refactor(namespaces): read the organization ancestor from the anchor entity_id
Read the organization ancestor from the anchor entity_id
Context
ADR-022: the organization anchor carries organizations.uuid, not the numeric id (merged) makes a gitlab-platform namespace anchor to its organization by organizations.uuid: entity_id carries the organization's own UUIDv7 when entity_type is organization. That leaves namespaces.organization_id, added by feat(authz): source the organization ancestor from the resolved namespace (!1391), duplicating the anchor, so this MR reverses !1391 (merged): authorization sources the organization ancestor from the resolved target namespace's anchor entity_id, never from the caller JWT, and provisioning stops accepting organization_id. Supersedes docs(plans): amend S02/S10 to send organization_id at provisioning (!1498).
This is step 1 of the two-release column drop (docs/dev/database-migrations.md, Removing columns): the code here runs with the column still present and merely stops referencing it. A follow-up MR (branch jdrpereira/drop-namespaces-organization-id-column) drops the column once this is deployed.
What changes
internal/namespace:Resolution.OrganizationUUID()derives the ancestor from the anchor (entity_type == "organization"and a canonical UUIDv7entity_id);ParseOrganizationEntityIDis the single encoding of the UUIDv7 shape rule, shared with provisioning.internal/authz:resolveTuplesappends the organization ancestor from the anchor. An anchor that yields no ancestor degrades (fail-closed while organization-level policy is allow-only) and meters the newgitlab_artifact_registry_authz_organization_ancestor_skipped_totalcounter, with areasonlabel distinguishingentity_id_not_uuidv7(today's entire Organizations v1 population: legacy numeric anchors predating the UUID rule) fromentity_type_not_organization(anchors naming no organization, written outside provisioning). The skip is counter-only, no log line: it fires unsampled once per authorized request across the legacy population, so perdocs/dev/usage-data.md's convention the counter is the durable signal and the census of affected namespaces is a namespaces-table query. A registration test pins the counter intoRegisterMetrics, and the sharedreasonclosed set ininternal/metrics/cardinality.gogains the two values.internal/gitlabapi:validateAnchorShapelocks the owner anchor to the one triple that exists today,platform == "gitlab",entity_type == "organization",entity_ida canonical UUIDv7, refusing anything else with 422 (three distinct messages, checked in that order). Locked rather than platform-scoped because relaxing the set later is backward compatible while tightening it is not. Theorganization_idrequest field,FillOrganizationID, andErrNamespaceOrganizationIDTakenare removed;Matchesis exact equality on the six caller-owned fields again. A shape-refused body that disagrees with its anchor's stored row answers 422 (not 409) and leaves a WARN with the stored row'snamespace_id.internal/datastore:CreateNamespaceInputreturns to six fields; the insert and projections stop namingorganization_id(the column itself, and the jet type regeneration, follow in the drop MR).- OpenAPI (
gitlab-v1.yaml), the Bruno collection, anddocs/dev/observability.mdfollow; spec S33's provisioning contract now states the locked triple and the relax-later rationale under the surface's additive-changes rule.
Anchor replay stays exempt from the shape rules: an exact replay of a stored anchor answers 200 even when the stored values predate the rules, because replay is the caller's only recovery path after a lost response (S33).
S23 usage events for UUID-anchored namespaces stay behind the existing counter pending #639: Organization attribution in Snowplow events once AR only holds the organization UUID.
Compatibility note
Removing the organization_id request field is a pre-client change, not a breaking one: ADR-009's backward-compatibility rule is scoped to the platform versions the surface serves, and none exist yet (the field's intended sender, !1498 (closed), was closed as superseded; no caller ever sent it). S33's conventions sentence is aligned with that scoping in this MR.
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 container (matching GL_PG_CURR_VERSION), with synthesized seed data rolled back per query and the container torn down after the run. Collected locally with the db-review-prep skill before push, so there are no CI job links yet. The container carried this branch's migration head, where namespaces.organization_id still exists: the plans double as proof the new statements run against the un-dropped schema.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.NamespaceStore.FindByAnchor |
Limit -> Index Scan | unique_namespaces_platform_and_entity_type_and_entity_id |
1 / 1 | 8.30 | 0.013ms | 3 / 0 | n/a |
datastore.insertNamespace |
Insert | n/a | 1 / 1 | 0.01 | 0.457ms | 22 / 0 | n/a |
datastore.NamespaceStore.FindByAnchor
Summary: Plan matches the method's intent: a single-row probe on the partial anchor unique index, with the projection no longer selecting organization_id. Actual rows match the estimate (1 / 1) and execution stays under 30us at 5000 seeded namespaces. No anomalies.
Seed shape: namespaces=5000
Rendered SQL:
SELECT namespaces.id AS "namespaces.id",
namespaces.created_at AS "namespaces.created_at",
namespaces.slug AS "namespaces.slug",
namespaces.platform AS "namespaces.platform",
namespaces.entity_type AS "namespaces.entity_type",
namespaces.entity_id AS "namespaces.entity_id",
namespaces.billing_entity_type AS "namespaces.billing_entity_type",
namespaces.billing_entity_id AS "namespaces.billing_entity_id",
namespaces.delivery_mode_override AS "namespaces.delivery_mode_override",
namespaces.suspended_at AS "namespaces.suspended_at",
namespaces.disabled_at AS "namespaces.disabled_at",
namespaces.blocked_at AS "namespaces.blocked_at",
namespaces.deleted_at AS "namespaces.deleted_at",
namespaces.purged_at AS "namespaces.purged_at"
FROM public.namespaces
WHERE (((namespaces.platform = $1::text) AND (namespaces.entity_type = $2::text)) AND (namespaces.entity_id = $3::text)) AND (namespaces.purged_at IS NULL)
LIMIT $4;Bound args: ['gitlab', 'organization', 'review-prep-entity-002500', 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=174) (actual time=0.013..0.013 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using unique_namespaces_platform_and_entity_type_and_entity_id on namespaces (cost=0.28..8.30 rows=1 width=174) (actual time=0.012..0.012 rows=1 loops=1)
Index Cond: ((platform = 'gitlab'::text) AND (entity_type = 'organization'::text) AND (entity_id = 'review-prep-entity-002500'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=82
Planning Time: 0.285 ms
Execution Time: 0.027 msTimings: planning 0.285ms, execution 0.027ms, total 0.312ms.
datastore.insertNamespace
Summary: Single-row insert whose column list and RETURNING projection no longer name organization_id; the column's stored NULL default keeps the statement valid against the un-dropped schema. Execution cost is dominated by the existing trg_namespaces_create_statistics trigger (1.844ms of 2.453ms). No anomalies.
Seed shape: namespaces=0 (write target; INSERT seeds nothing per the skill's row-count rule)
Rendered SQL:
INSERT INTO public.namespaces (id, slug, platform, entity_type, entity_id, billing_entity_type, billing_entity_id)
VALUES ($1::uuid, $2::text, $3::text, $4::text, $5::text, $6::text, $7::text)
RETURNING namespaces.id AS "namespaces.id",
namespaces.created_at AS "namespaces.created_at",
namespaces.slug AS "namespaces.slug",
namespaces.platform AS "namespaces.platform",
namespaces.entity_type AS "namespaces.entity_type",
namespaces.entity_id AS "namespaces.entity_id",
namespaces.billing_entity_type AS "namespaces.billing_entity_type",
namespaces.billing_entity_id AS "namespaces.billing_entity_id",
namespaces.delivery_mode_override AS "namespaces.delivery_mode_override",
namespaces.suspended_at AS "namespaces.suspended_at",
namespaces.disabled_at AS "namespaces.disabled_at",
namespaces.blocked_at AS "namespaces.blocked_at",
namespaces.deleted_at AS "namespaces.deleted_at",
namespaces.purged_at AS "namespaces.purged_at";Bound args: ['0198c1a4-53e2-7bbc-9f00-3a1d5c2b7e11', 'review-prep-insert', 'gitlab', 'organization', '0198c1a4-53e2-7bbc-9f00-3a1d5c2b7e10', 'organization', '84']
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Insert on namespaces (cost=0.00..0.01 rows=1 width=274) (actual time=0.456..0.457 rows=1 loops=1)
Buffers: shared hit=22 dirtied=1 written=1
-> Result (cost=0.00..0.01 rows=1 width=274) (actual time=0.007..0.007 rows=1 loops=1)
Planning:
Buffers: shared hit=4
Planning Time: 0.224 ms
Trigger trg_namespaces_create_statistics: time=1.844 calls=1
Execution Time: 2.453 msTimings: planning 0.224ms, execution 2.453ms, total 2.677ms.
Related to #449 (closed)