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 UUIDv7 entity_id); ParseOrganizationEntityID is the single encoding of the UUIDv7 shape rule, shared with provisioning.
  • internal/authz: resolveTuples appends 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 new gitlab_artifact_registry_authz_organization_ancestor_skipped_total counter, with a reason label distinguishing entity_id_not_uuidv7 (today's entire Organizations v1 population: legacy numeric anchors predating the UUID rule) from entity_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 per docs/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 into RegisterMetrics, and the shared reason closed set in internal/metrics/cardinality.go gains the two values.
  • internal/gitlabapi: validateAnchorShape locks the owner anchor to the one triple that exists today, platform == "gitlab", entity_type == "organization", entity_id a 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. The organization_id request field, FillOrganizationID, and ErrNamespaceOrganizationIDTaken are removed; Matches is 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's namespace_id.
  • internal/datastore: CreateNamespaceInput returns to six fields; the insert and projections stop naming organization_id (the column itself, and the jet type regeneration, follow in the drop MR).
  • OpenAPI (gitlab-v1.yaml), the Bruno collection, and docs/dev/observability.md follow; 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 ms

Timings: 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 ms

Timings: planning 0.224ms, execution 2.453ms, total 2.677ms.

Related to #449 (closed)

Edited by João Pereira

Merge request reports

Loading
Loading