From cfd11b2155bf06afbcc9063dd1367b0ae235126e Mon Sep 17 00:00:00 2001 From: Kilian Boute Date: Wed, 12 Aug 2026 13:51:11 +0000 Subject: [PATCH 1/2] feat(tenant): shared canonical-first organisationId ownership filter Add a models-free, strings-only home for the organisationId migration's canonical-first dual-read filter so hub-api and the pipeline services build the exact same $or shape instead of each hardcoding it: - CanonicalOrganisationField constant ("organisationId"). - MissingCanonicalOrganisation() predicate (reusable by index/backfill tooling). - CanonicalFirstOwnership(organisationId, legacyField, legacyValue). - TenantField{Legacy} per-collection descriptor with .Scope(org, legacyValue). Callers resolve the ids via their own models helpers and pass strings, keeping this package a dependency-light leaf. Additive; no existing consumer is affected until it bumps the dependency and delegates its local helper here. --- pkg/database/tenant.go | 58 +++++++++++++++++++++++++++++++++++++ pkg/database/tenant_test.go | 39 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 pkg/database/tenant.go create mode 100644 pkg/database/tenant_test.go diff --git a/pkg/database/tenant.go b/pkg/database/tenant.go new file mode 100644 index 0000000..1333712 --- /dev/null +++ b/pkg/database/tenant.go @@ -0,0 +1,58 @@ +package database + +import "go.mongodb.org/mongo-driver/bson" + +// CanonicalOrganisationField is the canonical, string-hex tenant ownership key +// written to every organisation-owned document during the organisationId +// migration. Reads scope on this field first and only fall back to a legacy +// owner field for documents that predate the migration. +const CanonicalOrganisationField = "organisationId" + +// MissingCanonicalOrganisation matches documents that have not yet been stamped +// with the canonical organisationId. It is the predicate that distinguishes +// pre-migration (legacy-owned) documents from migrated ones, and is exported so +// index and backfill tooling can reuse the exact same shape. +func MissingCanonicalOrganisation() bson.M { + return bson.M{CanonicalOrganisationField: bson.M{"$exists": false}} +} + +// CanonicalFirstOwnership returns a tenant-isolation filter that matches: +// +// - documents already owned by the canonical organisationId, OR +// - legacy documents that predate the migration (no organisationId yet) whose +// legacy owner field equals legacyValue. +// +// This is the single "dual-read" shape shared across services during the +// organisationId migration. Callers resolve organisationId and legacyValue with +// their own models helpers (e.g. models.GetOrganisationId / +// models.GetUserIdFromAccountOrMaster) and pass the resolved strings here, so +// this package stays a dependency-light leaf with no models import. +// +// Once every document has been backfilled with organisationId, the legacy +// branch can be dropped and this collapses to a single equality on +// organisationId. +func CanonicalFirstOwnership(organisationId, legacyField, legacyValue string) bson.M { + return bson.M{"$or": []bson.M{ + {CanonicalOrganisationField: organisationId}, + {"$and": []bson.M{ + MissingCanonicalOrganisation(), + {legacyField: legacyValue}, + }}, + }} +} + +// TenantField is a per-collection descriptor: the name of the legacy owner field +// for one collection is declared once, then scopes are built from a resolved +// identity. It lets each service register its collections' legacy fields in a +// single place instead of re-hardcoding the field string at every read site. +type TenantField struct { + // Legacy is the pre-migration owner field for the collection + // (e.g. "user_id", "master_user_id", "userid", "owner_id"). + Legacy string +} + +// Scope returns the canonical-first ownership filter for this collection from an +// already-resolved canonical organisationId and legacy owner value. +func (t TenantField) Scope(organisationId, legacyValue string) bson.M { + return CanonicalFirstOwnership(organisationId, t.Legacy, legacyValue) +} diff --git a/pkg/database/tenant_test.go b/pkg/database/tenant_test.go new file mode 100644 index 0000000..8159712 --- /dev/null +++ b/pkg/database/tenant_test.go @@ -0,0 +1,39 @@ +package database + +import ( + "reflect" + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestCanonicalFirstOwnership(t *testing.T) { + got := CanonicalFirstOwnership("org-123", "master_user_id", "legacy-abc") + want := bson.M{"$or": []bson.M{ + {"organisationId": "org-123"}, + {"$and": []bson.M{ + {"organisationId": bson.M{"$exists": false}}, + {"master_user_id": "legacy-abc"}, + }}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("CanonicalFirstOwnership mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestTenantFieldScope(t *testing.T) { + tenant := TenantField{Legacy: "user_id"} + got := tenant.Scope("org-9", "legacy-9") + want := CanonicalFirstOwnership("org-9", "user_id", "legacy-9") + if !reflect.DeepEqual(got, want) { + t.Fatalf("TenantField.Scope mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestMissingCanonicalOrganisation(t *testing.T) { + got := MissingCanonicalOrganisation() + want := bson.M{"organisationId": bson.M{"$exists": false}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("MissingCanonicalOrganisation mismatch:\n got: %#v\nwant: %#v", got, want) + } +} From 8600f8ed97726d9c628d4beb08339327a53ca7f7 Mon Sep 17 00:00:00 2001 From: Kilian Boute Date: Wed, 12 Aug 2026 13:51:11 +0000 Subject: [PATCH 2/2] feat(tenant): shared canonical-first organisationId ownership filter Add a models-free, strings-only home for the organisationId migration's canonical-first dual-read filter so hub-api and the pipeline services build the exact same $or shape instead of each hardcoding it: - CanonicalOrganisationField constant ("organisationId"). - MissingCanonicalOrganisation() predicate (reusable by index/backfill tooling). - CanonicalFirstOwnership(organisationId, legacyField, legacyValue). - TenantField{Legacy} per-collection descriptor with .Scope(org, legacyValue). Callers resolve the ids via their own models helpers and pass strings, keeping this package a dependency-light leaf. Additive; no existing consumer is affected until it bumps the dependency and delegates its local helper here. --- pkg/database/tenant.go | 65 +++++++++++++++++++++++++++++++++++++ pkg/database/tenant_test.go | 43 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 pkg/database/tenant.go create mode 100644 pkg/database/tenant_test.go diff --git a/pkg/database/tenant.go b/pkg/database/tenant.go new file mode 100644 index 0000000..c4fe685 --- /dev/null +++ b/pkg/database/tenant.go @@ -0,0 +1,65 @@ +package database + +import "go.mongodb.org/mongo-driver/bson" + +// CanonicalOrganisationField is the canonical, string-hex tenant ownership key +// written to every organisation-owned document during the organisationId +// migration. Reads scope on this field first and only fall back to a legacy +// owner field for documents that predate the migration. +const CanonicalOrganisationField = "organisationId" + +// MissingCanonicalOrganisation matches documents that have not yet been stamped +// with the canonical organisationId. A document counts as missing when the field +// is absent, an empty string, or null, so partially-written or cleared legacy +// documents still fall back to the legacy owner clause during the migration. It +// is the predicate that distinguishes pre-migration (legacy-owned) documents +// from migrated ones, and is exported so index and backfill tooling can reuse +// the exact same shape. +func MissingCanonicalOrganisation() bson.M { + return bson.M{"$or": []bson.M{ + {CanonicalOrganisationField: bson.M{"$exists": false}}, + {CanonicalOrganisationField: ""}, + {CanonicalOrganisationField: nil}, + }} +} + +// CanonicalFirstOwnership returns a tenant-isolation filter that matches: +// +// - documents already owned by the canonical organisationId, OR +// - legacy documents that predate the migration (no organisationId yet) whose +// legacy owner field equals legacyValue. +// +// This is the single "dual-read" shape shared across services during the +// organisationId migration. Callers resolve organisationId and legacyValue with +// their own models helpers (e.g. models.GetOrganisationId / +// models.GetUserIdFromAccountOrMaster) and pass the resolved strings here, so +// this package stays a dependency-light leaf with no models import. +// +// Once every document has been backfilled with organisationId, the legacy +// branch can be dropped and this collapses to a single equality on +// organisationId. +func CanonicalFirstOwnership(organisationId, legacyField, legacyValue string) bson.M { + return bson.M{"$or": []bson.M{ + {CanonicalOrganisationField: organisationId}, + {"$and": []bson.M{ + MissingCanonicalOrganisation(), + {legacyField: legacyValue}, + }}, + }} +} + +// TenantField is a per-collection descriptor: the name of the legacy owner field +// for one collection is declared once, then scopes are built from a resolved +// identity. It lets each service register its collections' legacy fields in a +// single place instead of re-hardcoding the field string at every read site. +type TenantField struct { + // Legacy is the pre-migration owner field for the collection + // (e.g. "user_id", "master_user_id", "userid", "owner_id"). + Legacy string +} + +// Scope returns the canonical-first ownership filter for this collection from an +// already-resolved canonical organisationId and legacy owner value. +func (t TenantField) Scope(organisationId, legacyValue string) bson.M { + return CanonicalFirstOwnership(organisationId, t.Legacy, legacyValue) +} diff --git a/pkg/database/tenant_test.go b/pkg/database/tenant_test.go new file mode 100644 index 0000000..8badb56 --- /dev/null +++ b/pkg/database/tenant_test.go @@ -0,0 +1,43 @@ +package database + +import ( + "reflect" + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestCanonicalFirstOwnership(t *testing.T) { + got := CanonicalFirstOwnership("org-123", "master_user_id", "legacy-abc") + want := bson.M{"$or": []bson.M{ + {"organisationId": "org-123"}, + {"$and": []bson.M{ + MissingCanonicalOrganisation(), + {"master_user_id": "legacy-abc"}, + }}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("CanonicalFirstOwnership mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestTenantFieldScope(t *testing.T) { + tenant := TenantField{Legacy: "user_id"} + got := tenant.Scope("org-9", "legacy-9") + want := CanonicalFirstOwnership("org-9", "user_id", "legacy-9") + if !reflect.DeepEqual(got, want) { + t.Fatalf("TenantField.Scope mismatch:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestMissingCanonicalOrganisation(t *testing.T) { + got := MissingCanonicalOrganisation() + want := bson.M{"$or": []bson.M{ + {"organisationId": bson.M{"$exists": false}}, + {"organisationId": ""}, + {"organisationId": nil}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("MissingCanonicalOrganisation mismatch:\n got: %#v\nwant: %#v", got, want) + } +}