forked from EduCraft/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
-- ADR-0020: organization is the SaaS tenant root.
|
||||
-- Backfill all existing data into one default organization so old databases
|
||||
-- upgrade without a manual bootstrap step.
|
||||
|
||||
CREATE TYPE "OrganizationStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'ARCHIVED');
|
||||
CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
|
||||
|
||||
CREATE TABLE "Organization" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "OrganizationStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
|
||||
CREATE INDEX "Organization_status_idx" ON "Organization"("status");
|
||||
|
||||
INSERT INTO "Organization" ("id", "slug", "name", "status")
|
||||
VALUES ('org_default', 'legacy-default', 'Legacy Default Organization', 'ACTIVE');
|
||||
|
||||
CREATE TABLE "OrganizationMembership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "OrganizationMembership" ("id", "organizationId", "userId", "role")
|
||||
SELECT
|
||||
'orgmem_default_' || md5("User"."id"),
|
||||
'org_default',
|
||||
"User"."id",
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM "PlatformRoleAssignment"
|
||||
WHERE "PlatformRoleAssignment"."userId" = "User"."id"
|
||||
AND "PlatformRoleAssignment"."role" = 'ADMIN'
|
||||
AND "PlatformRoleAssignment"."revokedAt" IS NULL
|
||||
)
|
||||
THEN 'OWNER'::"OrganizationMemberRole"
|
||||
ELSE 'MEMBER'::"OrganizationMemberRole"
|
||||
END
|
||||
FROM "User";
|
||||
|
||||
CREATE INDEX "OrganizationMembership_organizationId_revokedAt_idx" ON "OrganizationMembership"("organizationId", "revokedAt");
|
||||
CREATE INDEX "OrganizationMembership_userId_revokedAt_idx" ON "OrganizationMembership"("userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "OrganizationMembership_organizationId_userId_revokedAt_key" ON "OrganizationMembership"("organizationId", "userId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "OrganizationMembership_active_key" ON "OrganizationMembership"("organizationId", "userId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT;
|
||||
UPDATE "Project" SET "organizationId" = 'org_default';
|
||||
ALTER TABLE "Project" ALTER COLUMN "organizationId" SET NOT NULL;
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
|
||||
|
||||
ALTER TABLE "Team" ADD COLUMN "organizationId" TEXT;
|
||||
UPDATE "Team" SET "organizationId" = 'org_default';
|
||||
ALTER TABLE "Team" ALTER COLUMN "organizationId" SET NOT NULL;
|
||||
ALTER TABLE "Team" ADD CONSTRAINT "Team_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
DROP INDEX IF EXISTS "Team_slug_key";
|
||||
CREATE INDEX "Team_organizationId_archivedAt_idx" ON "Team"("organizationId", "archivedAt");
|
||||
CREATE INDEX "Team_organizationId_slug_archivedAt_idx" ON "Team"("organizationId", "slug", "archivedAt");
|
||||
CREATE UNIQUE INDEX "Team_active_slug_key" ON "Team"("organizationId", "slug") WHERE "archivedAt" IS NULL;
|
||||
|
||||
CREATE TABLE "ExternalDirectoryConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerTenantId" TEXT,
|
||||
"source" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "ExternalDirectoryConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "ExternalDirectoryConnection" ("id", "organizationId", "provider", "source")
|
||||
SELECT DISTINCT
|
||||
'extconn_default_' || md5("source"),
|
||||
'org_default',
|
||||
'FEISHU',
|
||||
"source"
|
||||
FROM "ExternalPrincipalMembership";
|
||||
|
||||
CREATE UNIQUE INDEX "ExternalDirectoryConnection_organizationId_provider_source_key" ON "ExternalDirectoryConnection"("organizationId", "provider", "source");
|
||||
CREATE INDEX "ExternalDirectoryConnection_organizationId_revokedAt_idx" ON "ExternalDirectoryConnection"("organizationId", "revokedAt");
|
||||
CREATE INDEX "ExternalDirectoryConnection_provider_providerTenantId_idx" ON "ExternalDirectoryConnection"("provider", "providerTenantId");
|
||||
ALTER TABLE "ExternalDirectoryConnection" ADD CONSTRAINT "ExternalDirectoryConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key";
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_active_key";
|
||||
DROP INDEX IF EXISTS "ExternalPrincipalMembership_source_syncedAt_idx";
|
||||
|
||||
ALTER TABLE "ExternalPrincipalMembership" ADD COLUMN "connectionId" TEXT;
|
||||
UPDATE "ExternalPrincipalMembership"
|
||||
SET "connectionId" = 'extconn_default_' || md5("source");
|
||||
ALTER TABLE "ExternalPrincipalMembership" ALTER COLUMN "connectionId" SET NOT NULL;
|
||||
ALTER TABLE "ExternalPrincipalMembership" DROP COLUMN "source";
|
||||
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "ExternalDirectoryConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "ExternalPrincipalMembership_connectionId_revokedAt_idx" ON "ExternalPrincipalMembership"("connectionId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_connectionId_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId", "revokedAt");
|
||||
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "connectionId") WHERE "revokedAt" IS NULL;
|
||||
|
||||
-- A principal should have one active role on a resource. Keep the strongest
|
||||
-- grant and revoke lower active grants to preserve history without ambiguity.
|
||||
DROP INDEX IF EXISTS "PermissionGrant_active_key";
|
||||
|
||||
WITH ranked_permission_grants AS (
|
||||
SELECT
|
||||
"id",
|
||||
row_number() OVER (
|
||||
PARTITION BY "resourceType", "resourceId", "principalType", "principalId"
|
||||
ORDER BY
|
||||
CASE "role"
|
||||
WHEN 'MANAGE' THEN 3
|
||||
WHEN 'EDIT' THEN 2
|
||||
WHEN 'READ' THEN 1
|
||||
END DESC,
|
||||
"createdAt" ASC,
|
||||
"id" ASC
|
||||
) AS rn
|
||||
FROM "PermissionGrant"
|
||||
WHERE "revokedAt" IS NULL
|
||||
)
|
||||
UPDATE "PermissionGrant"
|
||||
SET "revokedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "id" IN (
|
||||
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PermissionGrant_active_principal_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||
Reference in New Issue
Block a user