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 uuidonnamespaces, no default, no backfill (NULL= unknown, skip the ancestor) -- mirrors thepackument_rebuild_tokenprecedent -- plus a unique index partial onpurged_at IS NULL, mirroring the anchor tuple's own unique index, so a purged namespace'sorganization_iddoes not block its owner from re-onboarding with a new row. namespace.Resolutiongrows a nilableOrganizationID, threaded throughnamespaceColumns()andResolver.ResolveRepository.POST /api/gitlab/v1/namespaces(internal/gitlabapi) accepts an optionalorganization_id, validated as a canonical non-nil UUID (mirroringresolveTuples' andparsePathID's precedent) and never returned in the response, like the billing anchor. OpenAPI, Bruno, and the S33 spec's contract description move with it.resolveTuplesappends the ancestor only whenres.OrganizationIDis 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):squawklint clean, jet model andstructure.sqlregenerated for real. - Datastore:
Matches(organization_id supplied/matching, differs), round-trip persistence, replay-mismatch, and a dedicatedunique_namespaces_organization_idviolation test. -
internal/gitlabapi: happy path (organization_id persists, never returned) and 422 for non-UUID / non-canonical / nil-UUID values. -
internal/authz: unit coverage onresolveTuples' object list plus a full end-to-end test proving the ADR-021 bootstrap grant succeeds once the resolution carries anOrganizationID-- provably safe this time, since it's tied to the resolved target, not the caller. -
api/openapi/gitlab-v1.yamlvalidated withredocly lint. - Full repo
go build,go vet,golangci-lint run ./...(0 issues), non-integrationgo test ./..., and the integration-tagged suites forinternal/datastoreandinternal/namespace(real Postgres via testcontainers) all pass. - No configuration-reference update: no
internal/config/**orconfig.example.yamlchange. - 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_idBound 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 msTimings: 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_idBound 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 msTimings: 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_idBound 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 msTimings: planning 0.143ms, execution 0.380ms, total 0.523ms.