GraphQL wrapper in Rails for the IAM Relationships API (gRPC) - role assignments for Artifact Registry
Why
The closed-by-default role assignment model agreed for Artifact Registry (AR) / Organizations needs a way for clients to grant and read role assignments without proxying through the AR binaries. The Relationships API is exposed over gRPC only, authenticated with a short-lived JWT, and intended to be called from internal services (note).
Proxying role-assignment calls through AR would add latency for no added value (note). GitLab Rails exposes a thin GraphQL wrapper over the gRPC Relationships API. This lets the (API-only, for 19.2 closed beta) role-assignment flow work for all users while keeping Rails as the place that mints the JWT.
See discussion: #593455 (note_3414941106), #593455 (note_3415640177).
What has been delivered (write path only)
This issue now only covers the write side of the wrapper. Lookup and delete are tracked separately (see Out of scope).
ArtifactRegistryRoleGrantGraphQL mutation (ee/app/graphql/mutations/authz/artifact_registry/role/grant.rb): grants a single(assignee, role, resource)tuple.ArtifactRegistryRoleBulkGrantGraphQL mutation (ee/app/graphql/mutations/authz/artifact_registry/role/bulk_grant.rb): grants an array of assignments in a single all-or-nothing request, capped atTypes::BaseArgument::MAX_ARRAY_SIZE. ReturnsgrantedRoleCounton success.Authz::ArtifactRegistry::GrantRoleAssignmentsService(ee/app/services/authz/artifact_registry/grant_role_assignments_service.rb): shared by both mutations (single grant passes a one-element array). Validates caller/organization, validates every assignment (assignee exists and is in the caller's organization,resource_idis a UUID,roleis known), rejects duplicate(assignee, resource)pairs within a batch, mints the JWT, and performs the gRPC write.Authn::IamService::RelationshipsClient(lib/authn/iam_service/relationships_client.rb), built onAuthn::IamService::BaseClient: wrapsupdate.v1.UpdateService#write_relationshipsover gRPC, attaching the JWT asBearermetadata. RaisesRequestErrorfor both gRPC (GRPC::BadStatus) and configuration (Authn::IamDataAccessService::ConfigurationError) failures.- JWT minting via
Authn::TokenExchange::TokenIssuer(ee/lib/authn/token_exchange/token_issuer.rb), audiencegitlab-iam-data-access. - Guarded by the
artifact_registry_role_assignmentfeature flag.
Actual layering (supersedes the original illustrative sketch below):
GraphQL mutation (ArtifactRegistryRoleGrant / ArtifactRegistryRoleBulkGrant)
│ user PAT / session
▼
Authz::ArtifactRegistry::GrantRoleAssignmentsService
│ validates caller, organization, assignments; mints JWT
│ (Authn::TokenExchange::TokenIssuer, audience: gitlab-iam-data-access)
▼
Authn::IamService::RelationshipsClient (< BaseClient)
│ metadata: { 'authorization' => "Bearer <jwt>" }
▼
IAM Update API (gRPC, update.v1.UpdateService#write_relationships)⚠️ Known gap: resource must be verified to belong to the caller's organization
GrantRoleAssignmentsService currently validates that resource_id is a well-formed UUID and that the assignee is in the caller's organization — but it never confirms the target AR resource (namespace or repository) actually belongs to that organization. IAM's relationships store treats Object.id as an opaque UUID with no org binding, so nothing on the IAM side can catch a caller granting a role on a resource from a different org. Today, the service will happily write that grant.
Organizations live in Rails; only Artifact Registry knows which org an AR resource belongs to. AR#181 — Internal API: resource ancestry lookup endpoint tracks AR adding a service-to-service endpoint for this check, but the exact contract is still under discussion there (it has already changed once, from a slug-scoped design to a namespace-UUID-scoped one, and whether the namespaces type needs an AR call at all depends on the org → namespace mapping in #603023 (closed)). Rather than build against a contract that is still in flux, reviewing and agreeing the request/response contract with the AR team is in scope for this issue, before implementing the Rails-side call.
This issue is blocked on AR#181 (and transitively on #603023 (closed)) landing before the ownership check can be implemented. See also AR#221, which cross-links this same dependency from the AR repo.
Tasks
- Review and agree the AR verification/ancestry-lookup contract (endpoint shape, request/response, namespace vs. repository scoping, auth) with the AR team on AR#181 before implementing against it.
- Confirm whether namespace-scoped assignments can be verified purely locally (via Rails' own org → namespace mapping, #603023 (closed)) or still require an AR call.
- Implement the Rails-side client/check in
GrantRoleAssignmentsService(or a collaborator) once the contract is confirmed, batching by namespace rather than calling per resource. - Fail closed on verification errors/timeouts (treat as a write failure, not an implicit pass).
How
Follow the existing KAS client as the reference pattern for the gRPC wrapper itself: Rails mints a short-lived JWT and attaches it as Bearer metadata on each gRPC call (lib/gitlab/kas/client.rb). See "What has been delivered" above for the actual layering that shipped, which additionally needs the AR resource-ownership check described above inserted before the IAM write, once its contract is reviewed and finalized.
Out of scope
- Lookup and delete endpoints for role assignments — tracked in separate follow-up issues #605676 (closed) (lookup) and #605677 (closed) (delete).
- The user management UI, the role <> permission mapping, and the Relationships API service itself (tracked under the Authentication team epic &21743, e.g. Relationships API - Lookup + Update (#599076) and Auth for relationships API writes (#599078)).
- The AR-side ancestry/verification endpoint implementation itself — tracked in AR#181. This issue only covers reviewing that contract from the Rails-wrapper side and building the Rails-side caller once it's settled.
Acceptance criteria
Happy path
- A caller with the feature flag enabled, granting a role to a same-organization assignee on a well-formed resource, succeeds: the mutation returns an empty
errorsarray. (covered byArtifactRegistryRoleGrant) - A successful bulk grant returns
grantedRoleCountequal to the number of assignments submitted, and an emptyerrorsarray. (covered byArtifactRegistryRoleBulkGrant) - A bulk grant containing multiple valid assignments across different assignees, resources, and roles writes all of them in a single IAM call and succeeds as one unit.
- A bulk grant at exactly
MAX_ARRAY_SIZEassignments succeeds. - The gRPC write attaches a JWT minted with the documented audience (
gitlab-iam-data-access) asBearermetadata on the call. - Granting the same
(assignee, resource)pair again in a separate request, with a different role, succeeds and updates the role (IAM upserts one ASSIGNMENT per(subject, object); the in-batch duplicate check only applies within a single request). - Once the resource-ownership check lands: granting a role on a resource that does belong to the caller's organization still succeeds (the check does not produce false positives for legitimate same-org grants).
Error handling
- An unauthenticated caller, or a caller with the
artifact_registry_role_assignmentfeature flag disabled, receives a top-level "resource not available" GraphQL error, andGrantRoleAssignmentsServiceis never invoked. - A missing/undeterminable organization, or an organization that is not the caller's own, returns a clear service error and performs no write.
- An
assignee_idthat does not resolve to a user, or resolves to a user in a different organization, returns a generic "assignee could not be found" error (does not reveal whether the user exists in another organization). - A
resource_idthat is not a valid UUID returns a validation error naming the offending assignment. - A
resource_idthat does not belong to the caller's organization (per whatever ownership check the reviewed contract lands on) is rejected with a generic "resource could not be found" error before any IAM write, and does not reveal whether the resource exists in another org. - A failure or timeout in the resource-ownership check is treated as a write failure (fails closed), not as an implicit pass, and is reported distinctly from an IAM/gRPC failure so on-call can tell which downstream dependency failed.
- An unrecognized
rolevalue returns a validation error naming the unknown role. - An empty
assignmentsarray returns a validation error rather than a silent no-op success. - For bulk grants, validation collects and returns an error for every invalid assignment (not just the first), each identified by its position and resource, in a single response.
- Duplicate
(assignee, resource)pairs within the same batch are rejected with a clear message identifying the duplicates, and nothing is written. - The write is all-or-nothing for the checks that exist today: if any assignment in a batch is invalid, duplicated, or IAM rejects the write, no assignments from that batch are persisted. Once the ownership check lands, a failing resource-ownership verification must also block the whole batch.
- gRPC/backend failures (
GRPC::BadStatus, IAM data access configuration errors) are caught byRelationshipsClient/GrantRoleAssignmentsService, reported viaGitlab::ErrorTracking, and surfaced to the client as a mutationerrorsentry rather than an unhandled exception or 500. - Bulk requests exceeding
MAX_ARRAY_SIZEassignments are rejected with a GraphQL argument validation error before the service is invoked. - All errors above are returned via the mutations'
errorsfield (or as top-level GraphQL errors for auth/feature-flag failures), never as a raw exception message or stack trace.
(Items marked [x] are already covered by the delivered code/specs; unchecked bold items depend on the resource-ownership check above and are not yet implemented.)