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).

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

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 errors array. (covered by ArtifactRegistryRoleGrant)
  • A successful bulk grant returns grantedRoleCount equal to the number of assignments submitted, and an empty errors array. (covered by ArtifactRegistryRoleBulkGrant)
  • 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_SIZE assignments succeeds.
  • The gRPC write attaches a JWT minted with the documented audience (gitlab-iam-data-access) as Bearer metadata 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_assignment feature flag disabled, receives a top-level "resource not available" GraphQL error, and GrantRoleAssignmentsService is 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_id that 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_id that is not a valid UUID returns a validation error naming the offending assignment.
  • A resource_id that 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 role value returns a validation error naming the unknown role.
  • An empty assignments array 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 by RelationshipsClient/GrantRoleAssignmentsService, reported via Gitlab::ErrorTracking, and surfaced to the client as a mutation errors entry rather than an unhandled exception or 500.
  • Bulk requests exceeding MAX_ARRAY_SIZE assignments are rejected with a GraphQL argument validation error before the service is invoked.
  • All errors above are returned via the mutations' errors field (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.)

Edited by Shilpa Kundapur