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.
- 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) # => trueIssuance (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.
- As an administrator, go to Admin > Settings > Security and compliance and turn on the policy store experiment
- 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- 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" }]
)- Create a personal access token for yourself with the
apiscope, 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"- 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"- Verify the response carries the seeded policy, because the credential presented is the JWT
- Disable
security_policy_store_token_exchange_authand repeat step 6: verify it now fails with401, because there is no PAT in the request for it to fall back to - 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 with403 insufficient_scope, because the token only carriesread_security_policies
References
- Part of https://gitlab.com/gitlab-org/gitlab/-/work_items/606971
- Relates to https://gitlab.com/gitlab-org/gitlab/-/work_items/607776 (open)
- Relates to https://gitlab.com/gitlab-org/gitlab/-/work_items/633577 (open, the caller-identity spike)
- Relates to https://gitlab.com/gitlab-org/gitlab/-/work_items/626823 (open, the deferred CD service account question)
- Follows the pattern merged in !236798 (merged) (merged)
- Built on !249148 (merged) (merged)