Accept token_exchange JWTs on the policy store API

What does this MR do and why?

It adds Authn::TokenExchange::JwtValidationService and Authn::Tokens::TokenExchangeUser, wired into EE::Gitlab::Auth::AuthFinders#find_oauth_access_token, so a token_exchange JWT (minted by POST /api/v4/token_exchange) authenticates a request the same way a personal access token or OAuth token does today.

GET /organizations/:id/security/policy_store's own logic is unchanged. API::Govern::Policies gains one line, allow_access_with_scope :read_security_policies, if: ->(request) { (request.get? || request.head?) && request.path.match?(%r{/organizations/\d+/security/policy_store(/\d+)?\z}) }, so a token minted for this integration authenticates only on the organization-scoped policy_store list/show routes, not everywhere read_api is accepted. A new gitlab-policy-platform audience on the issuance endpoint's SUPPORTED_AUDIENCES is what lets a caller request such a token in the first place.

Design decisions

The intended subject for 19.6 is the organization's instance admin bot, not a human exchanging their own token. This MR was originally scoped for a CD service account caller; it was reopened to back the application settings enforcement spike instead, which evaluates policies as the admin bot. See work item 633577, "Can the instance admin bot be the token subject?". The CD service account question from work item 626823 is tracked separately and isn't blocking this experiment.

TokenExchangeUser#scopes grants read_security_policies, not read_api, so a token minted for this integration is rejected (403) everywhere else, including plain reads like GET /api/v4/user. This is the same narrow-scope pattern already used in production for read_virtual_registry, mcp_orbit, and self_rotate.

TokenExchangeUser does not define granular?, deliberately: Ability.allowed? on current_user remains the sole enforcement point for this credential type, the same as it is for a full-scope OAuth token today.

The verifier resolves the user from the token's gitlab.local_id claim, not sub, keeping the trusted input surface the same shape IamOauthToken already uses for its own user_id claim.

TokenExchangeUser includes Authn::Tokens::Concerns::DoorkeeperCompatible, the same concern StatelessAccessToken and IamOauthToken use, so expired?, accessible?, acceptable?, and scopes come from shared code rather than being hand-rolled here. expires_at is wired from the token's own exp claim. It doesn't define has_attribute?, so PersonalAccessTokens::LastUsedService no-ops on it via respond_to?.

How to set up and validate locally

Requires an Ultimate licence.

  1. Enable the feature flags on the rails console
Feature.enable(:security_policies_v2)
Feature.enable(:security_policy_store_token_exchange_auth)

Feature.enabled?(:security_policy_store_token_exchange_auth, :instance) # => true

Issuance (POST /api/v4/token_exchange) has no feature flag of its own. security_policy_store_token_exchange_auth gates only the consumption side, whether TokenExchangeUser accepts the resulting JWT.

  1. As an administrator, go to Admin > Settings > Security and compliance and turn on the policy store experiment
  2. Check whether a CloudConnector signing key already exists, and create one if not
bundle exec rake cloud_connector:keys:list
bundle exec rake cloud_connector:keys:create
  1. Seed a policy and make yourself an organization owner, on the rails console
organization = Organizations::Organization.find(1)
user = User.find_by_username('root')

Organizations::OrganizationUser.find_or_create_by!(organization: organization, user: user) do |organization_user|
  organization_user.access_level = Gitlab::Access::OWNER
end

Gitlab::PolicyStore.create(
  organization_id: organization.id,
  name: 'Block deployments on critical findings',
  trigger_type: 'deployment_requested',
  rules: [{ type: 'custom', value: "package governance\n\nimport rego.v1\n\ndefault allow := true\n" }]
)
  1. Create a personal access token for yourself with the api scope, then exchange it for a JWT scoped to the policy store audience
curl --request POST \
  --header "PRIVATE-TOKEN: <your PAT>" \
  --header "Content-Type: application/json" \
  --data '{"audience": "gitlab-policy-platform"}' \
  --url "<your GDK URL>/api/v4/token_exchange"
  1. Call the policy store list endpoint with the exchanged JWT, not the PAT
curl --header "Authorization: Bearer <the JWT from step 5>" \
  --url "<your GDK URL>/api/v4/organizations/1/security/policy_store"
  1. Verify the response carries the seeded policy, because the credential presented is the JWT
  2. Disable security_policy_store_token_exchange_auth and repeat step 6: verify it now fails with 401, because there is no PAT in the request for it to fall back to
  3. Try the same JWT against an unrelated endpoint, for example curl --header "Authorization: Bearer <the JWT>" --url "<your GDK URL>/api/v4/user": verify it fails with 403 insufficient_scope, because the token only carries read_security_policies

References

Edited by Marcos Rocha

Merge request reports

Loading
Loading