Skip to content

Workload identity: a last, read-only rule for every project of a self-managed issuer - #634

Merged
schmitch merged 1 commit into
mainfrom
claude/conditional-project-publishing-m19j9r
Oct 28, 2026
Merged

schmitch merged 1 commit into
mainfrom
claude/conditional-project-publishing-m19j9r

Conversation

@schmitch

Copy link
Copy Markdown
Member

Fixes #626

A workload_identity rule had to pin a workload identity claim. An administrator of a self-managed GitLab who wanted every CI job of the instance to restore from Forge therefore had to list namespace or project IDs and keep the list current. A top-level group does not cover its subgroups, and a project created later got 401 until the realm was edited, which revoked every key the realm had issued.

The rule

{ "role": "gitlab-ci-read", "issuer_wide": true, "conditions": [] }
  • No identity condition needed. Conditions are still AND-ed: context conditions such as ref_protected = true narrow the rule, and none is required.
  • Refused for public issuers. Validation rejects it for https://gitlab.com and https://token.actions.githubusercontent.com, in any spelling of the URL (Url-normalized). rule_matches also refuses it when the token's iss is one of them, the same defense in depth that covers a malformed row.
  • Last in the order. Rules are first-match, so validation requires every issuer-wide rule to come after every other rule.
  • Capped at read. Principal::Workload carries issuer_wide. Authz::effective_with intersects the role's grants with a read-only scope (level: read, no admin), the same way a read-only token scope narrows a user's grants. The flag is stored on the exchanged key (workload_api_key.issuer_wide, migration 0005) and in registry tokens (fp.wide, left out when false). The cap therefore holds with the CI token, with an exchanged key and with registry tokens.
  • Writes stay pinned to a workload identity. A rule for a project placed ahead of the issuer-wide one picks a role of its own, which may write. A trusted publisher still adds write for its own project on top of the issuer-wide read. validate_trusted_publisher_conditions is unchanged and still requires identity claims.
  • Existing realms keep their keys. issuer_wide is #[serde(default, skip_serializing_if = …)], so a realm without such a rule normalizes to the same bytes and keeps its config digest. A test pins the digest.
  • Rolling upgrade fails closed. An older instance cannot parse a realm that has issuer_wide (deny_unknown_fields). It refuses that issuer's tokens, and the keys and registry tokens issued under that realm fail its realm check.

Web UI

The Rules panel of the realm form (RealmForm.tsx) has a per-rule switch, Every project of this issuer:

  • The switch is off and disabled for the GitHub Actions and GitLab.com issuers.
  • While it is on, a warning names the issuer and the role and says that every account that can run a pipeline gets the role, in a personal namespace or a fork too, and that open sign-up makes a self-managed GitLab as public as GitLab.com.
  • Turning it on drops the rule's blank condition rows.
  • Add rule inserts the new rule ahead of the issuer-wide rules.
  • wifRuleHasIdentityScope accepts an issuer-wide rule for a self-managed issuer, and the form explains an issuer-wide rule placed ahead of another rule.

Docs

Tests

  • wif.rs unit tests:
    • the rule is accepted for a self-managed issuer, with or without conditions;
    • it is refused for both public issuers (also as https://GitLab.com/ and :443) and when it is not last;
    • matching is first-match, and a malformed row with a public issuer fails closed;
    • an existing realm's normalized config and SHA-256 digest are unchanged.
  • trusted_publishers.rs integration test, issuer_wide_rule_reads_and_only_pinned_rules_or_publishers_write:
    • a realm with an issuer-wide rule ahead of a pinned rule is refused (400);
    • the pinned project pushes through its role;
    • another project reads but gets 403 on push with both the CI token and an exchanged key, although its role holds write;
    • a registry token minted from the key carries wide and only pull;
    • a trusted publisher still adds write for its packages only.
    • With the cap disabled, the test fails with 201 instead of 403.
  • crates/db/tests/workload_api_key.rs: a key keeps its issuer_wide.
  • authz.rs: the read-only scope caps write and delete.
  • images/token.rs: the registry token round-trips wide.
  • Web: toRealmConfig sends issuer_wide only when on and seeding reads it back. Route tests cover creating a realm with the switch, the insertion order, the ordering error, and the disabled switch for GitLab.com.

Run locally:

  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo xtask changelog --check, cargo xtask docs --check.
  • Rust tests: forge-server lib, trusted_publishers, auth_realms, realm_seed, registry; forge-db workload_api_key and migrations; forge-core lib.
  • Web: pnpm run tsc and the full vitest suite (476 + 3 new).

Generated by Claude Code

…-managed issuer

A workload_identity rule had to pin a workload identity claim, so an
administrator of a self-managed GitLab who wanted every CI job of the
instance to restore listed namespace or project IDs and kept the list
current: a top-level group does not cover its subgroups, and a project
created later got 401 until the realm was edited, which revoked every key
it had issued.

A rule can now trust every workload of the realm's issuer:

    { "role": "gitlab-ci-read", "issuer_wide": true, "conditions": [] }

- It needs no identity condition; conditions such as ref_protected only
  narrow it. Validation refuses it for GitHub Actions and GitLab.com,
  whose tokens anyone can mint, and rule matching refuses it there too.
- Rules are first-match, so issuer-wide rules must come after every other
  rule.
- A workload it matched carries issuer_wide on its principal, and the
  authz seam caps its role's grants at read, as a read-only token scope
  would: a role given write never hands it to every project. The flag
  travels with the exchanged key (workload_api_key.issuer_wide, migration
  0005) and with the registry tokens minted from either credential.
- Writes stay pinned to a workload identity: a rule for a project ahead
  of the issuer-wide one picks a role of its own, and a trusted publisher
  still adds write for its own project on top of the issuer-wide read.
- The field is left out of the normalized config while unset, so realms
  without such a rule keep their config digest and their keys.

The realm form's Rules panel has a per-rule "Every project of this
issuer" switch, off and disabled for the GitHub Actions and GitLab.com
issuers, with a warning naming who gets the role. A rule added while one
is on goes ahead of it. The security model in trusted-publishing.md and
the GitLab CI guide describe it.

Fixes #626

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e6DJZBnEKyFNGDPWaFtJj
@schmitch
schmitch enabled auto-merge October 28, 2026 14:32
@schmitch
schmitch added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit da5d7ac Oct 28, 2026
19 checks passed
@schmitch
schmitch deleted the claude/conditional-project-publishing-m19j9r branch October 28, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Workload identity: let a self-managed GitLab realm give every project of the instance read access

2 participants