feat: add organization tenant model

This commit is contained in:
2026-07-09 23:37:20 +08:00
parent 5d315fff21
commit 2b7aa3294f
20 changed files with 900 additions and 116 deletions
@@ -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;
+146 -70
View File
@@ -25,6 +25,54 @@ datasource db {
// --- Platform identity ---------------------------------------------------
/// ADR-0020: SaaS tenant root. Every project and team belongs to exactly one
/// organization; platform operator access remains outside project roles.
model Organization {
id String @id @default(cuid())
slug String @unique
name String
status OrganizationStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships OrganizationMembership[]
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
@@index([status])
}
enum OrganizationStatus {
ACTIVE
SUSPENDED
ARCHIVED
}
/// Org-scoped platform role. Distinct from project PermissionRole and from
/// global PlatformRoleAssignment, which is reserved for SaaS/platform control.
model OrganizationMembership {
id String @id @default(cuid())
organizationId String
userId String
role OrganizationMemberRole @default(MEMBER)
createdAt DateTime @default(now())
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId, revokedAt])
@@index([organizationId, revokedAt])
@@index([userId, revokedAt])
}
enum OrganizationMemberRole {
OWNER
ADMIN
MEMBER
}
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
@@ -34,16 +82,17 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
@@ -80,27 +129,30 @@ enum PrincipalType {
/// Hub-managed teacher team. A team is a first-class permission principal.
model Team {
id String @id @default(cuid())
slug String @unique
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
id String @id @default(cuid())
organizationId String
slug String
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
memberships TeamMembership[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships TeamMembership[]
externalBindings TeamExternalBinding[]
@@index([archivedAt])
@@index([organizationId, archivedAt])
@@index([organizationId, slug, archivedAt])
}
/// Direct Hub team membership. External Feishu groups can also map to teams via
/// TeamExternalBinding; both sources are resolved at authorization time.
model TeamMembership {
id String @id @default(cuid())
id String @id @default(cuid())
teamId String
userId String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
revokedAt DateTime?
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@ -129,27 +181,50 @@ model TeamExternalBinding {
}
/// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection {
id String @id @default(cuid())
organizationId String
provider String
providerTenantId String?
source String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revokedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships ExternalPrincipalMembership[]
@@unique([organizationId, provider, source])
@@index([organizationId, revokedAt])
@@index([provider, providerTenantId])
}
/// Locally synchronized Feishu external principal membership, scoped through
/// an organization-owned ExternalDirectoryConnection.
model ExternalPrincipalMembership {
id String @id @default(cuid())
userId String
connectionId String
principalType PrincipalType
principalId String
source String
syncedAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection ExternalDirectoryConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, principalType, principalId, source, revokedAt])
@@unique([userId, principalType, principalId, connectionId, revokedAt])
@@index([userId, revokedAt])
@@index([connectionId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([source, syncedAt])
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
model Project {
id String @id @default(cuid())
organizationId String
name String
workspaceDir String
createdByUserId String?
@@ -157,15 +232,17 @@ model Project {
updatedAt DateTime @updatedAt
archivedAt DateTime?
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([archivedAt])
}
@@ -206,8 +283,8 @@ model AgentSession {
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
messages AgentMessage[] @relation("sessionMessages")
@@index([projectId, archivedAt])
@@ -235,12 +312,12 @@ enum AgentEntrypoint {
}
model AgentRun {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
sessionId String?
requestedByUserId String?
entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE)
status AgentRunStatus @default(ACTIVE)
prompt String
model String
provider String
@@ -248,21 +325,21 @@ model AgentRun {
inputTokens Int?
outputTokens Int?
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
costUsd Decimal? @db.Decimal(18, 8)
costUsd Decimal? @db.Decimal(18, 8)
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
costSource String?
metadata Json
error String?
startedAt DateTime @default(now())
startedAt DateTime @default(now())
finishedAt DateTime?
updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock?
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
messages AgentMessage[] @relation("runMessages")
fileChanges AgentFileChange[] @relation("runFileChanges")
@@index([projectId, status])
@@index([projectId, finishedAt])
@@ -276,16 +353,16 @@ model AgentRun {
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
/// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock {
projectId String @id
runId String @unique
projectId String @id
runId String @unique
holderUserId String?
acquiredAt DateTime @default(now())
acquiredAt DateTime @default(now())
heartbeatAt DateTime?
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
}
@@ -315,14 +392,14 @@ enum PermissionResourceType {
/// `principalType/principalId` so user/team/Feishu principals compose through
/// the same authorization path.
model PermissionGrant {
id String @id @default(cuid())
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principalType PrincipalType
principalId String
role PermissionRole
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
revokedAt DateTime?
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@ -335,7 +412,7 @@ model PermissionGrant {
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
model PermissionSettings {
id String @id @default(cuid())
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
externalShare String
@@ -344,14 +421,13 @@ model PermissionSettings {
collaboratorMgmt String
agentTrigger String
agentCancel String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceType, resourceId])
@@index([resourceType, resourceId])
}
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
@@ -363,13 +439,13 @@ model PermissionSettings {
/// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant {
id String @id @default(cuid())
id String @id @default(cuid())
projectId String
roleId String
principalType PrincipalType
principalId String
createdByUserId String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
revokedAt DateTime?
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
@@ -427,16 +503,16 @@ model FeishuEventReceipt {
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
/// session messages ≠ Feishu group chat history.
model AgentMessage {
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
id String @id @default(cuid())
sessionId String
runId String?
role String
content String
attachments Json
createdAt DateTime @default(now())
createdAt DateTime @default(now())
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
@@index([sessionId, createdAt])
@@index([runId])
@@ -447,11 +523,11 @@ model AgentMessage {
/// that produced it; the tool's execute closure records via a sink the runner
/// injects into ToolContext (A-3 file-change capture).
model AgentFileChange {
id String @id @default(cuid())
runId String
projectId String
path String
operation String
id String @id @default(cuid())
runId String
projectId String
path String
operation String
beforeHash String?
afterHash String?
diff String?
@@ -459,7 +535,7 @@ model AgentFileChange {
createdAt DateTime @default(now())
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
@@index([runId])
@@index([projectId])