Accept StatelessAccessToken JWTs on the policy store API

What does this MR do and why?

It adds allow_access_with_scope :read_security_policies to API::Govern::Policies, so a token scoped to read_security_policies can authenticate GET /organizations/:id/security/policy_store without the broader read_api scope. The gate itself is unchanged: this only narrows which scope a credential needs.

That scope is reached through Authn::Tokens::StatelessAccessToken, GitLab's existing short-lived JWT already used by Duo Workflow, not a new token class. Gitlab::Auth::StatelessAccessTokenGate is generalized to resolve its feature flag from the token's scope instead of a hardcoded Duo flag, so a read_security_policies-scoped token checks security_policy_store_stateless_access_token_auth while Duo Workflow's tokens keep checking duo_workflow_use_token_issuer unchanged.

Design decisions

This supersedes !251202 (closed), which built the same policy-store authentication on Authn::TokenExchange::TokenIssuer and POST /api/v4/token_exchange, the pattern built for Artifact Registry. In review there, alipniagov and daniele-gitlab pointed out that StatelessAccessToken is the better fit here: Rails is the only service that verifies this token (GLAZ forwards it to the policy store unchecked), and StatelessAccessToken already reads scopes from gitlab.scopes, denylists revoked JTIs, and uses a glsat- prefix the auth finder already dispatches on, so none of the token_exchange endpoint, its job-token/organization rules, or a bespoke verifier are needed.

The admin bot cannot use this yet. GlobalPolicy denies access_api/access_git to internal users, which the admin bot is, so a request authenticated this way currently 403s before reaching the policy check. That exception, together with checking the token's gitlab.origin_id claim against the requested organization, needs its own MR and an AppSec review.

StatelessAccessTokenGate.enabled? also now rejects any StatelessAccessToken that carries neither read_security_policies nor a Duo Workflow scope, closing a gap where such a token previously passed whenever duo_workflow_use_token_issuer was enabled. None of the three Duo Workflow issuer call sites (CreateOauthAccessTokenService, CreateCompositeOauthAccessTokenService, Ai::FlowTriggers::RunService) pass custom scopes today, so none are affected.

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_stateless_access_token_auth)
  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. 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
  1. Seed a policy, on the rails console
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. Mint a StatelessAccessToken scoped to the policy store, on the rails console
token = Authn::Tokens::StatelessAccessToken.issue(
  user: user, organization: organization, ttl: 1.hour.to_i, scopes: ['read_security_policies']
).plaintext_token
  1. Call the policy store list endpoint with the token
curl --header "Authorization: Bearer <token from step 6>" \
  --url "<your GDK URL>/api/v4/organizations/1/security/policy_store"
  1. Verify the response carries the seeded policy
  2. Disable security_policy_store_stateless_access_token_auth and repeat step 7: verify it now fails with 401, because there is no personal access token in the request to fall back to
  3. Try the same token against an unrelated endpoint, for example curl --header "Authorization: Bearer <token>" --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