Skip to content

Commit 1f88774

Browse files
authored
feat(identity): add Atlas migrations and schema loader (#1342)
* feat(identity): add Atlas migrations and schema loader for identity service - Create services/identity/atlas/atlas.hcl with local, ci, and production environments pointing to the identity migrations directory - Create services/identity/migrations/20260302000001_initial.sql with identity, role_assignment, and invitation tables, CHECK constraints for status enums, and a partial unique index enforcing one active role per identity per role type - Run atlas migrate hash to generate atlas.sum integrity file - Update utilities/atlas-loader to support --schema=identity, loading IdentityEntity, RoleAssignmentEntity, and InvitationEntity * feat(identity): register identity service database in migration runner Add identity service entry to ServiceDatabases mapping so that the migration runner can provision meridian_identity database and apply the initial schema migration. * fix(identity): add foreign key constraints to role_assignment and invitation tables Add FK constraints from role_assignment(identity_id, granted_by, revoked_by) and invitation(identity_id, invited_by) referencing identity(id) to enforce referential integrity at the database level. Uses ON DELETE RESTRICT to prevent orphaned records when an identity is deleted. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 50a367f commit 1f88774

5 files changed

Lines changed: 156 additions & 1 deletion

File tree

internal/migrations/runner.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ var ServiceDatabases = map[string]ServiceDatabase{
6969
"tenant": {Database: "meridian_platform", User: "meridian_platform_user", Password: ""},
7070
"current-account": {Database: "meridian_current_account", User: "meridian_current_account_user", Password: ""},
7171
"financial-accounting": {Database: "meridian_financial_accounting", User: "meridian_financial_accounting_user", Password: ""},
72+
"identity": {Database: "meridian_identity", User: "meridian_identity_user", Password: ""},
7273
"position-keeping": {Database: "meridian_position_keeping", User: "meridian_position_keeping_user", Password: ""},
7374
"payment-order": {Database: "meridian_payment_order", User: "meridian_payment_order_user", Password: ""},
7475
"party": {Database: "meridian_party", User: "meridian_party_user", Password: ""},

services/identity/atlas/atlas.hcl

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Atlas configuration for Identity Service
2+
// Manages platform identities (user accounts), role assignments, and invitations
3+
// Uses database-per-service architecture with unqualified table names
4+
//
5+
// Multi-tenant support:
6+
// - Migrations use unqualified table names (no schema prefix)
7+
// - For multi-org mode: search_path routes to organization schemas (org_{tenant_id})
8+
// - For local development: uses default public schema in service-specific database
9+
10+
data "external_schema" "gorm" {
11+
program = [
12+
"go",
13+
"run",
14+
"-mod=mod",
15+
"./utilities/atlas-loader",
16+
"--schema=identity"
17+
]
18+
}
19+
20+
env "local" {
21+
// Service-specific migration directory
22+
migration {
23+
dir = "file://services/identity/migrations"
24+
}
25+
26+
// Dev database - uses default public schema
27+
dev = "docker://postgres/16/dev"
28+
29+
// Source schema from GORM models via external loader
30+
src = data.external_schema.gorm.url
31+
32+
// Lint configuration to catch dangerous changes
33+
lint {
34+
destructive {
35+
error = true
36+
}
37+
data_depend {
38+
error = true
39+
}
40+
incompatible {
41+
error = true
42+
}
43+
}
44+
}
45+
46+
env "ci" {
47+
migration {
48+
dir = "file://services/identity/migrations"
49+
}
50+
51+
dev = "docker://postgres/16/dev"
52+
53+
src = data.external_schema.gorm.url
54+
55+
lint {
56+
destructive {
57+
error = true
58+
}
59+
data_depend {
60+
error = true
61+
}
62+
incompatible {
63+
error = true
64+
}
65+
}
66+
}
67+
68+
env "production" {
69+
// Production environment - apply only, never diff
70+
// URL points to service-specific database (meridian_identity)
71+
// For multi-org: URL includes search_path for org schema
72+
url = getenv("DATABASE_URL")
73+
74+
migration {
75+
dir = "file://services/identity/migrations"
76+
}
77+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
-- Identity Service Schema
2+
-- Uses UNQUALIFIED table names to support multi-organization routing via search_path.
3+
-- For local dev: search_path routes to default schema
4+
-- For multi-org: org schemas created by provisioning, search_path routes to org schema
5+
6+
-- Create "identity" table (singular, unqualified - uses search_path for schema routing)
7+
CREATE TABLE "identity" (
8+
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
9+
"email" character varying(255) NOT NULL,
10+
"status" character varying(30) NOT NULL DEFAULT 'PENDING_INVITE',
11+
"password_hash" character varying(255) NOT NULL DEFAULT '',
12+
"external_idp" character varying(100) NOT NULL DEFAULT '',
13+
"external_sub" character varying(255) NOT NULL DEFAULT '',
14+
"failed_attempts" bigint NOT NULL DEFAULT 0,
15+
"version" bigint NOT NULL DEFAULT 1,
16+
"created_at" timestamptz NOT NULL DEFAULT now(),
17+
"updated_at" timestamptz NOT NULL DEFAULT now(),
18+
"deleted_at" timestamptz NULL,
19+
PRIMARY KEY ("id"),
20+
CONSTRAINT "chk_identity_status" CHECK (status IN ('PENDING_INVITE', 'ACTIVE', 'SUSPENDED', 'LOCKED'))
21+
);
22+
-- Indexes for identity
23+
CREATE UNIQUE INDEX "idx_identity_email" ON "identity" ("email") WHERE (deleted_at IS NULL);
24+
CREATE INDEX "idx_identity_deleted_at" ON "identity" ("deleted_at");
25+
26+
-- Create "role_assignment" table
27+
CREATE TABLE "role_assignment" (
28+
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
29+
"identity_id" uuid NOT NULL,
30+
"granted_by" uuid NOT NULL,
31+
"role" character varying(50) NOT NULL,
32+
"expires_at" timestamptz NULL,
33+
"revoked_at" timestamptz NULL,
34+
"revoked_by" uuid NULL,
35+
"created_at" timestamptz NOT NULL DEFAULT now(),
36+
"updated_at" timestamptz NOT NULL DEFAULT now(),
37+
PRIMARY KEY ("id"),
38+
CONSTRAINT "chk_role_assignment_role" CHECK (role IN ('VIEWER', 'OPERATOR', 'ADMIN', 'TENANT_OWNER', 'PLATFORM')),
39+
CONSTRAINT "fk_role_assignment_identity" FOREIGN KEY ("identity_id") REFERENCES "identity" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT,
40+
CONSTRAINT "fk_role_assignment_granted_by" FOREIGN KEY ("granted_by") REFERENCES "identity" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT,
41+
CONSTRAINT "fk_role_assignment_revoked_by" FOREIGN KEY ("revoked_by") REFERENCES "identity" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT
42+
);
43+
-- Indexes for role_assignment
44+
CREATE INDEX "idx_role_assignment_identity" ON "role_assignment" ("identity_id");
45+
-- Partial unique index enforces one active role per (identity, role) pair.
46+
-- CockroachDB: partial index on existing columns in a separate statement is safe.
47+
CREATE UNIQUE INDEX "idx_role_assignment_active" ON "role_assignment" ("identity_id", "role") WHERE (revoked_at IS NULL);
48+
49+
-- Create "invitation" table
50+
CREATE TABLE "invitation" (
51+
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
52+
"identity_id" uuid NOT NULL,
53+
"invited_by" uuid NOT NULL,
54+
"token_hash" character varying(64) NOT NULL,
55+
"expires_at" timestamptz NOT NULL,
56+
"status" character varying(20) NOT NULL DEFAULT 'PENDING',
57+
"created_at" timestamptz NOT NULL DEFAULT now(),
58+
"updated_at" timestamptz NOT NULL DEFAULT now(),
59+
PRIMARY KEY ("id"),
60+
CONSTRAINT "chk_invitation_status" CHECK (status IN ('PENDING', 'ACCEPTED')),
61+
CONSTRAINT "fk_invitation_identity" FOREIGN KEY ("identity_id") REFERENCES "identity" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT,
62+
CONSTRAINT "fk_invitation_invited_by" FOREIGN KEY ("invited_by") REFERENCES "identity" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT
63+
);
64+
-- Indexes for invitation
65+
CREATE INDEX "idx_invitation_identity" ON "invitation" ("identity_id");
66+
CREATE UNIQUE INDEX "idx_invitation_token_hash" ON "invitation" ("token_hash");
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
h1:Tn/5cytIH9dlz3yIx//SIVmDVeh83jHJ9Z5V1R7Jz+w=
2+
20260302000001_initial.sql h1:SsQN4cGTrm/6qs12GD9YXoSO6QyKTIpNZrM+4rRBWJA=

utilities/atlas-loader/main.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"ariga.io/atlas-provider-gorm/gormschema"
1111
capersistence "github.com/meridianhub/meridian/services/current-account/adapters/persistence"
1212
fapersistence "github.com/meridianhub/meridian/services/financial-accounting/adapters/persistence"
13+
identitypersistence "github.com/meridianhub/meridian/services/identity/adapters/persistence"
1314
partypersistence "github.com/meridianhub/meridian/services/party/adapters/persistence"
1415
popersistence "github.com/meridianhub/meridian/services/payment-order/adapters/persistence"
1516
tenantpersistence "github.com/meridianhub/meridian/services/tenant/adapters/persistence"
@@ -23,14 +24,15 @@ const (
2324
schemaCurrentAccount = "current_account"
2425
schemaPositionKeeping = "position_keeping"
2526
schemaFinancialAccounting = "financial_accounting"
27+
schemaIdentity = "identity"
2628
schemaParty = "party"
2729
schemaPaymentOrder = "payment_order"
2830
schemaPlatform = "platform"
2931
)
3032

3133
func main() {
3234
// Parse schema filter flag
33-
schemaFilter := flag.String("schema", "", "Filter models by schema (current_account, position_keeping, financial_accounting, party, payment_order, platform)")
35+
schemaFilter := flag.String("schema", "", "Filter models by schema (current_account, position_keeping, financial_accounting, identity, party, payment_order, platform)")
3436
flag.Parse()
3537

3638
// Determine which models to load based on schema filter
@@ -63,6 +65,13 @@ func main() {
6365
&fapersistence.FinancialBookingLogEntity{},
6466
&fapersistence.LedgerPostingEntity{},
6567
}
68+
case schemaIdentity:
69+
// Identity service for platform user accounts, roles, and invitations
70+
modelList = []interface{}{
71+
&identitypersistence.IdentityEntity{},
72+
&identitypersistence.RoleAssignmentEntity{},
73+
&identitypersistence.InvitationEntity{},
74+
}
6675
case schemaParty:
6776
// Party service for customer/organization identity management
6877
modelList = []interface{}{

0 commit comments

Comments
 (0)