feat(authz): source the organization ancestor from the resolved namespace

Summary

resolveTuples needs the target repository's organization as a third relationship-ancestor object to fetch the ADR-021 Organization Administrator bootstrap tuple (#449 (closed)). !1380 (closed) sourced it from the caller's own identity.Subject.OriginID instead of the target, which a human review correctly rejected as a cross-organization privilege escalation: nothing tied the caller's organization to the target's, so an Organization Administrator of org A could reach org B's repository (IAM legitimately returns the caller's real owner tuple on org A, and GLAZ trusts the host to have already filtered relationships to the object and its ancestors). !1380 (closed) has since been reverted to the safe interim state; this MR supersedes it with the corrected approach rather than continuing on that branch.

This sources the ancestor from the resolved target instead, which needs the namespace row to carry its own organization UUID.

Changes

  • New migration: nullable organization_id uuid on namespaces, no default, no backfill (NULL = unknown, skip the ancestor) -- mirrors the packument_rebuild_token precedent -- plus a unique index partial on purged_at IS NULL, mirroring the anchor tuple's own unique index, so a purged namespace's organization_id does not block its owner from re-onboarding with a new row.
  • namespace.Resolution grows a nilable OrganizationID, threaded through namespaceColumns() and Resolver.ResolveRepository.
  • POST /api/gitlab/v1/namespaces (internal/gitlabapi) accepts an optional organization_id, validated as a canonical non-nil UUID (mirroring resolveTuples' and parsePathID's precedent) and never returned in the response, like the billing anchor. OpenAPI, Bruno, and the S33 spec's contract description move with it.
  • resolveTuples appends the ancestor only when res.OrganizationID is non-nil, sourced from the target being resolved -- never from identity.

What's still open (tracked, not blocking this MR)

Every namespace has organization_id = NULL until the GitLab monolith side sends one at provisioning. That's a follow-up outside this repo: organizations.uuid already carries a UUIDv7 and is very likely the same value that becomes a member's JWT Subject.OriginID, so no monolith-side schema change is needed -- just a provision_namespace_service.rb change, plus confirming that identity assumption with whoever owns the Cloud Connector/IAM token-issuance path. Until that ships, this degrades safely to the current repository/namespace-only lookup, never to a cross-tenant grant.

Test plan

  • New migration validated against a real local Postgres (via mise run db:setup/db:dump-structure, not hand-edited): squawk lint clean, jet model and structure.sql regenerated for real.
  • Datastore: Matches (organization_id supplied/matching, differs), round-trip persistence, replay-mismatch, and a dedicated unique_namespaces_organization_id violation test.
  • internal/gitlabapi: happy path (organization_id persists, never returned) and 422 for non-UUID / non-canonical / nil-UUID values.
  • internal/authz: unit coverage on resolveTuples' object list plus a full end-to-end test proving the ADR-021 bootstrap grant succeeds once the resolution carries an OrganizationID -- provably safe this time, since it's tied to the resolved target, not the caller.
  • api/openapi/gitlab-v1.yaml validated with redocly lint.
  • Full repo go build, go vet, golangci-lint run ./... (0 issues), non-integration go test ./..., and the integration-tagged suites for internal/datastore and internal/namespace (real Postgres via testcontainers) all pass.
  • No configuration-reference update: no internal/config/** or config.example.yaml change.
  • No e2e format-scenario-catalog update: this is an internal authz/provisioning-contract change, not a new user-facing format scenario.

Related to #449 (closed)

Database Review Evidence

Migrations

Note

Timings are from CI (db:migrate matrix, goose verbose) against an empty database, in apply / rollback order per PG version. Production-scale validation via Database Lab is not yet available. See Database review evidence for the matrix rationale and how to read the numbers.

Migration PG 16 PG 17 PG 18
20260811120000_add_namespaces_organization_id.sql OK (5.62ms / 6.44ms) OK (7.11ms / 9.93ms) OK (8.76ms / 14.65ms)

Migration notes: no anomalies. ADD COLUMN is metadata-only and the unique index builds CONCURRENTLY; all three PG versions apply and roll back in single-digit milliseconds against an empty database, with no version-specific regression (max/second-max is 1.23x on apply, 1.47x on rollback) and no up/down asymmetry worth flagging at this scale.

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.insertNamespace[.OrganizationIDNil] Insert n/a 1 / 1 0.01 0.245ms 26 / 3 n/a
datastore.insertNamespace[.OrganizationIDPresent] Insert n/a 1 / 1 0.01 0.270ms 51 / 1 n/a
datastore.FillOrganizationID Update n/a (Seq Scan) 1 / 1 2.62 0.339ms 109 / 0 n/a
datastore.insertNamespace[.OrganizationIDNil]

Summary: Plan matches the method's intent: a single-row Insert with no index lookup, since a bare INSERT ... RETURNING never scans. nullableUUIDExpr's uuid.Nil branch renders organization_id as a literal NULL rather than a bound placeholder, so this branch's arg list stays at 7 rather than 8. No anomalies.

Seed shape: (no pre-existing rows; write-target INSERT seeds none)

Rendered SQL:

INSERT INTO namespaces (id, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL)
RETURNING id, created_at, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id,
          delivery_mode_override, suspended_at, disabled_at, blocked_at, deleted_at, purged_at, organization_id

Bound args: [<generated uuid>, 'review-prep-ns-slug', 'gitlab', 'group', 'review-prep-entity-1', 'namespace', 'review-prep-billing-1']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Insert on namespaces  (cost=0.00..0.01 rows=1 width=274) (actual time=0.245..0.245 rows=1 loops=1)
  Buffers: shared hit=26 read=3 dirtied=7 written=4
  ->  Result  (cost=0.00..0.01 rows=1 width=274) (actual time=0.001..0.001 rows=1 loops=1)
Planning:
  Buffers: shared hit=11
Planning Time: 0.090 ms
Execution Time: 0.290 ms

Timings: planning 0.090ms, execution 0.290ms, total 0.380ms.

datastore.insertNamespace[.OrganizationIDPresent]

Summary: Same Insert plan shape as the nil branch, with organization_id now bound as an 8th placeholder ($8::uuid) instead of an inlined literal, since nullableUUIDExpr takes its non-nil branch. Confirms the two branches differ only in how the value reaches Postgres, not in plan shape. No anomalies.

Seed shape: (no pre-existing rows; write-target INSERT seeds none)

Rendered SQL:

INSERT INTO namespaces (id, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id, organization_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id,
          delivery_mode_override, suspended_at, disabled_at, blocked_at, deleted_at, purged_at, organization_id

Bound args: [<generated uuid>, 'review-prep-ns-slug', 'gitlab', 'group', 'review-prep-entity-1', 'namespace', 'review-prep-billing-1', <generated organization uuid>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Insert on namespaces  (cost=0.00..0.01 rows=1 width=274) (actual time=0.270..0.270 rows=1 loops=1)
  Buffers: shared hit=51 read=1 dirtied=2 written=1
  ->  Result  (cost=0.00..0.01 rows=1 width=274) (actual time=0.001..0.001 rows=1 loops=1)
Planning:
  Buffers: shared hit=11
Planning Time: 0.118 ms
Execution Time: 0.302 ms

Timings: planning 0.118ms, execution 0.302ms, total 0.420ms.

datastore.FillOrganizationID

Summary: Update drives from a Seq Scan filtered on id = $2 AND organization_id IS NULL, matching the one-way-fill guard's WHERE clause exactly. Seq Scan is expected and appropriate at this write target's 50-row seed cardinality (1 target + 49 siblings, per this mode's seeding rule); id is the primary key (pk_namespaces), so production cardinality resolves via Index Scan instead. No anomalies at this size.

Seed shape: namespaces=50

Rendered SQL:

UPDATE namespaces
SET organization_id = $1
WHERE (namespaces.id = $2) AND (namespaces.organization_id IS NULL)
RETURNING id, created_at, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id,
          delivery_mode_override, suspended_at, disabled_at, blocked_at, deleted_at, purged_at, organization_id

Bound args: [<generated organization uuid>, <seeded target row id>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Update on namespaces  (cost=0.00..2.62 rows=1 width=22) (actual time=0.336..0.339 rows=1 loops=1)
  Buffers: shared hit=109
  ->  Seq Scan on namespaces  (cost=0.00..2.62 rows=1 width=22) (actual time=0.003..0.006 rows=1 loops=1)
        Filter: ((organization_id IS NULL) AND (id = '40d74a1f-42fe-47bd-9ad9-09d29d10d324'::uuid))
        Rows Removed by Filter: 49
        Buffers: shared hit=2
Planning:
  Buffers: shared hit=50
Planning Time: 0.143 ms
Execution Time: 0.380 ms

Timings: planning 0.143ms, execution 0.380ms, total 0.523ms.

Edited by Bojan Marjanovic

Merge request reports

Loading
Loading