From 2b7aa3294f2b3e8ae3b67e9f570fe1ac69b5ae3b Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Thu, 9 Jul 2026 23:37:20 +0800 Subject: [PATCH] feat: add organization tenant model --- AGENTS.md | 2 + CLAUDE.md | 2 + .../0020-organization-is-saas-tenant-root.md | 92 ++++++++ .../migration.sql | 145 ++++++++++++ hub/prisma/schema.prisma | 216 +++++++++++------ hub/src/permission.ts | 6 + hub/src/permissions/authorizer.ts | 34 ++- hub/src/permissions/externalSync.ts | 55 ++++- hub/src/permissions/principals.ts | 27 ++- hub/src/permissions/projectTeamAccess.ts | 218 ++++++++++++++++++ hub/test/integration/authorization.test.ts | 109 +++++++-- hub/test/integration/helpers.ts | 22 ++ hub/test/integration/lock.test.ts | 10 +- hub/test/integration/role-permission.test.ts | 14 +- hub/test/unit/feishu-approval.test.ts | 1 + hub/test/unit/feishu-reactions.test.ts | 1 + spec/Spec/Prelude.lean | 4 + spec/Spec/System.lean | 4 +- spec/Spec/System/Organization.lean | 51 ++++ spec/Spec/System/PermissionGrant.lean | 3 +- 20 files changed, 900 insertions(+), 116 deletions(-) create mode 100644 docs/adr/0020-organization-is-saas-tenant-root.md create mode 100644 hub/prisma/migrations/20260709180000_organization_tenant_root/migration.sql create mode 100644 hub/src/permissions/projectTeamAccess.ts create mode 100644 spec/Spec/System/Organization.lean diff --git a/AGENTS.md b/AGENTS.md index 79041fe..76d999f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 +- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` + 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 ## 纪律 diff --git a/CLAUDE.md b/CLAUDE.md index a076d38..1a1bde1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。 - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 +- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` + 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 ## 纪律 diff --git a/docs/adr/0020-organization-is-saas-tenant-root.md b/docs/adr/0020-organization-is-saas-tenant-root.md new file mode 100644 index 0000000..68b0df6 --- /dev/null +++ b/docs/adr/0020-organization-is-saas-tenant-root.md @@ -0,0 +1,92 @@ +# ADR 0020: Organization Is The SaaS Tenant Root + +## Status + +Accepted. + +## Context + +The Hub is moving from a single-organization deployment assumption toward a +SaaS shape. The existing permission model already supports team principals via +`PermissionGrant(PROJECT, TEAM, role)`, but without a tenant root the data model +cannot safely answer: + +- which customer owns a project; +- which organization a team belongs to; +- whether a team grant crosses customer boundaries; +- which external directory sync produced a Feishu department/user-group + membership; +- where future org admin, billing, audit, and platform break-glass controls + attach. + +Retrofitting this after projects, teams, runs, and audit records grow would +force tenant assumptions into every caller. + +## Decision + +Introduce `Organization` as the SaaS tenant root. + +Customer-side collaboration data is organization-scoped: + +```text +Organization + OrganizationMembership + Team + TeamMembership + Project + ExternalDirectoryConnection + PermissionGrant +``` + +`User` remains global. A user can belong to multiple organizations through +`OrganizationMembership`. Project-level authorization still uses +`PermissionGrant` and `PermissionAuthorizer`; the authorizer derives the +organization from the requested resource, then resolves teams and external +principals in that organization scope. + +`Team` belongs to exactly one organization. `Project` belongs to exactly one +organization. A `PermissionGrant` that grants a `TEAM` principal on a `PROJECT` +resource is valid only when the team and project are in the same organization. +This invariant is pinned in `Spec.System.Organization`. + +External directory membership is scoped through `ExternalDirectoryConnection`: + +```text +ExternalDirectoryConnection( + organizationId, + provider, + providerTenantId, + source +) +``` + +`ExternalPrincipalMembership` points at a connection rather than carrying a +free-floating `source` string. `source` names a sync pipeline; it is not the +tenant identity. + +The SaaS platform control plane is separate from customer project permissions. +`READ/EDIT/MANAGE` remains the customer-side project permission lattice. +Platform staff access, tenant suspension, billing, and break-glass access are a +separate control plane and must not be modeled as project `MANAGE`. + +## Consequences + +- Existing single-org data is backfilled into a default organization during + migration. +- `Team.slug` becomes organization-scoped instead of globally unique. +- Actor resolution is organization-aware: direct teams and synchronized external + principals only expand inside the resource's organization. +- Direct `USER` grants remain possible because a global user can collaborate + across organizations when explicitly granted. +- Future org admin UI can be built on this model without changing the + authorization core. + +## Open Questions / Deferred + +- Full platform admin UI, billing, limits, and tenant suspension workflows are + deferred. +- Whether direct `USER` project grants should require active + `OrganizationMembership` is deferred; explicit direct grants remain allowed + for now. +- Provider-specific directory semantics beyond Feishu are deferred, but the + connection model leaves room for them. diff --git a/hub/prisma/migrations/20260709180000_organization_tenant_root/migration.sql b/hub/prisma/migrations/20260709180000_organization_tenant_root/migration.sql new file mode 100644 index 0000000..9185e88 --- /dev/null +++ b/hub/prisma/migrations/20260709180000_organization_tenant_root/migration.sql @@ -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; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 651e501..03b20df 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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]) diff --git a/hub/src/permission.ts b/hub/src/permission.ts index 3863b71..284a016 100644 --- a/hub/src/permission.ts +++ b/hub/src/permission.ts @@ -12,6 +12,7 @@ import { createPermissionAuthorizer } from "./permissions/authorizer.js"; export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js"; export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js"; export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js"; +export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js"; export type { AuthorizationAction, AuthorizationDecision, @@ -32,6 +33,11 @@ export type { SyncExternalPrincipalMembershipsInput, SyncExternalPrincipalMembershipsResult, } from "./permissions/externalSync.js"; +export type { + GrantTeamProjectAccessInput, + ProjectTeamAccessEntry, + RevokeTeamProjectAccessInput, +} from "./permissions/projectTeamAccess.js"; export interface PermissionResult { readonly allowed: boolean; diff --git a/hub/src/permissions/authorizer.ts b/hub/src/permissions/authorizer.ts index 2aeb2f5..15bcca6 100644 --- a/hub/src/permissions/authorizer.ts +++ b/hub/src/permissions/authorizer.ts @@ -38,6 +38,7 @@ export interface AuthorizationDecision { readonly action: AuthorizationAction; readonly resource: AuthorizationResource; readonly actor: ActorInput; + readonly organizationId: string; readonly actorUserId?: string | undefined; readonly principals: readonly PrincipalRef[]; readonly requiredRole: PermissionRole; @@ -60,7 +61,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer { if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) { throw new Error("role.trigger authorization requires roleId"); } - const resolution = await this.resolver.resolveActor(req.actor); + const organizationId = await this.organizationForResource(req.resource); + const resolution = await this.resolver.resolveActor(req.actor, { organizationId }); const threshold = await this.requiredRole(req); if (threshold === "DISABLED") { return this.decision(req, resolution, { @@ -206,17 +208,45 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer { private decision( req: AuthorizationRequest, resolution: PrincipalResolution, - result: Omit, + result: Omit, ): AuthorizationDecision { return { action: req.action, resource: req.resource, actor: req.actor, + organizationId: resolution.organizationId, ...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}), principals: resolution.principals, ...result, }; } + + private async organizationForResource(resource: AuthorizationResource): Promise { + switch (resource.type) { + case "PROJECT": { + const project = await this.prisma.project.findUnique({ + where: { id: resource.id }, + select: { organizationId: true }, + }); + if (project === null) { + throw new Error(`authorization resource missing: PROJECT ${resource.id}`); + } + return project.organizationId; + } + case "PROJECT_GROUP": { + const binding = await this.prisma.projectGroupBinding.findUnique({ + where: { chatId: resource.id }, + select: { project: { select: { organizationId: true } } }, + }); + if (binding === null) { + throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`); + } + return binding.project.organizationId; + } + case "ARTIFACT": + throw new Error("authorization for ARTIFACT resources requires an organization resolver"); + } + } } export function createPermissionAuthorizer( diff --git a/hub/src/permissions/externalSync.ts b/hub/src/permissions/externalSync.ts index 10d87a6..520aee3 100644 --- a/hub/src/permissions/externalSync.ts +++ b/hub/src/permissions/externalSync.ts @@ -13,6 +13,9 @@ export interface ExternalPrincipalMembershipInput { } export interface SyncExternalPrincipalMembershipsInput { + readonly organizationId: string; + readonly provider?: string | undefined; + readonly providerTenantId?: string | undefined; readonly source: string; readonly replaceSource?: boolean | undefined; readonly memberships: readonly ExternalPrincipalMembershipInput[]; @@ -30,6 +33,26 @@ export async function syncExternalPrincipalMemberships( const syncedAt = new Date(); const incomingKeys = new Set(); let synced = 0; + const connection = await prisma.externalDirectoryConnection.upsert({ + where: { + organizationId_provider_source: { + organizationId: input.organizationId, + provider: input.provider ?? "FEISHU", + source: input.source, + }, + }, + update: { + revokedAt: null, + ...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}), + }, + create: { + organizationId: input.organizationId, + provider: input.provider ?? "FEISHU", + ...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}), + source: input.source, + }, + select: { id: true }, + }); for (const item of input.memberships) { assertExternalPrincipalType(item.principalType); @@ -40,15 +63,17 @@ export async function syncExternalPrincipalMemberships( feishuOpenId: item.feishuOpenId, displayName: item.displayName, platformRoles: { create: { role: "TEACHER" } }, + organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } }, }, }); - incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId)); + await ensureOrganizationMembership(prisma, input.organizationId, user.id); + incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id)); const existing = await prisma.externalPrincipalMembership.findFirst({ where: { userId: user.id, principalType: item.principalType, principalId: item.principalId, - source: input.source, + connectionId: connection.id, revokedAt: null, }, select: { id: true }, @@ -57,9 +82,9 @@ export async function syncExternalPrincipalMemberships( await prisma.externalPrincipalMembership.create({ data: { userId: user.id, + connectionId: connection.id, principalType: item.principalType, principalId: item.principalId, - source: input.source, syncedAt, }, }); @@ -75,14 +100,15 @@ export async function syncExternalPrincipalMemberships( let revoked = 0; if (input.replaceSource === true) { const active = await prisma.externalPrincipalMembership.findMany({ - where: { source: input.source, revokedAt: null }, - select: { id: true, userId: true, principalType: true, principalId: true }, + where: { connectionId: connection.id, revokedAt: null }, + select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true }, }); const revokeIds = active .filter((membership) => !incomingKeys.has(externalMembershipKey( membership.userId, membership.principalType, membership.principalId, + membership.connectionId, ))) .map((membership) => membership.id); if (revokeIds.length > 0) { @@ -97,8 +123,23 @@ export async function syncExternalPrincipalMemberships( return { synced, revoked }; } -function externalMembershipKey(userId: string, type: PrincipalType, id: string): string { - return `${userId}:${type}:${id}`; +async function ensureOrganizationMembership( + prisma: PrismaClient, + organizationId: string, + userId: string, +): Promise { + const active = await prisma.organizationMembership.findFirst({ + where: { organizationId, userId, revokedAt: null }, + select: { id: true }, + }); + if (active !== null) return; + await prisma.organizationMembership.create({ + data: { organizationId, userId, role: "MEMBER" }, + }); +} + +function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string { + return `${connectionId}:${userId}:${type}:${id}`; } function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType { diff --git a/hub/src/permissions/principals.ts b/hub/src/permissions/principals.ts index 07303b7..d392bdc 100644 --- a/hub/src/permissions/principals.ts +++ b/hub/src/permissions/principals.ts @@ -10,6 +10,10 @@ export interface ActorInput { readonly chatId?: string | undefined; } +export interface PrincipalResolutionScope { + readonly organizationId: string; +} + export type PrincipalSource = | "actor-user" | "context-chat" @@ -25,19 +29,20 @@ export interface ResolvedPrincipal { export interface PrincipalResolution { readonly actor: ActorInput; + readonly organizationId: string; readonly userId?: string | undefined; readonly principals: readonly PrincipalRef[]; readonly resolved: readonly ResolvedPrincipal[]; } export interface PrincipalResolver { - resolveActor(actor: ActorInput): Promise; + resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise; } export class PrismaPrincipalResolver implements PrincipalResolver { constructor(private readonly prisma: PrismaClient) {} - async resolveActor(actor: ActorInput): Promise { + async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise { const resolved = new PrincipalCollector(); resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user"); if (actor.chatId !== undefined && actor.chatId !== "") { @@ -51,7 +56,11 @@ export class PrismaPrincipalResolver implements PrincipalResolver { if (user !== null) { const memberships = await this.prisma.teamMembership.findMany({ - where: { userId: user.id, revokedAt: null, team: { archivedAt: null } }, + where: { + userId: user.id, + revokedAt: null, + team: { organizationId: scope.organizationId, archivedAt: null }, + }, select: { teamId: true }, }); for (const membership of memberships) { @@ -59,7 +68,14 @@ export class PrismaPrincipalResolver implements PrincipalResolver { } const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({ - where: { userId: user.id, revokedAt: null }, + where: { + userId: user.id, + revokedAt: null, + connection: { + organizationId: scope.organizationId, + revokedAt: null, + }, + }, select: { principalType: true, principalId: true }, }); for (const membership of externalMemberships) { @@ -75,7 +91,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver { const bindings = await this.prisma.teamExternalBinding.findMany({ where: { revokedAt: null, - team: { archivedAt: null }, + team: { organizationId: scope.organizationId, archivedAt: null }, OR: externalPrincipals.map((principal) => ({ principalType: principal.type, principalId: principal.id, @@ -94,6 +110,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver { return { actor, + organizationId: scope.organizationId, ...(user !== null ? { userId: user.id } : {}), principals: resolved.principals(), resolved: resolved.entries(), diff --git a/hub/src/permissions/projectTeamAccess.ts b/hub/src/permissions/projectTeamAccess.ts new file mode 100644 index 0000000..7fe313f --- /dev/null +++ b/hub/src/permissions/projectTeamAccess.ts @@ -0,0 +1,218 @@ +import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client"; + +export interface GrantTeamProjectAccessInput { + readonly projectId: string; + readonly teamId?: string | undefined; + readonly teamSlug?: string | undefined; + readonly role: PermissionRole; + readonly createdByUserId?: string | undefined; +} + +export interface RevokeTeamProjectAccessInput { + readonly projectId: string; + readonly teamId?: string | undefined; + readonly teamSlug?: string | undefined; +} + +export interface ProjectTeamAccessEntry { + readonly grantId: string; + readonly projectId: string; + readonly organizationId: string; + readonly teamId: string; + readonly teamSlug: string; + readonly teamName: string; + readonly role: PermissionRole; +} + +export async function grantTeamProjectAccess( + prisma: PrismaClient, + input: GrantTeamProjectAccessInput, +): Promise { + return prisma.$transaction(async (tx) => { + const { project, team } = await resolveProjectAndTeam(tx, input); + const now = new Date(); + const existing = await tx.permissionGrant.findFirst({ + where: { + resourceType: "PROJECT", + resourceId: project.id, + principalType: "TEAM", + principalId: team.id, + revokedAt: null, + }, + select: { id: true, role: true }, + }); + if (existing?.role === input.role) { + return entryFromGrant({ + grantId: existing.id, + projectId: project.id, + organizationId: project.organizationId, + team, + role: existing.role, + }); + } + await tx.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: project.id, + principalType: "TEAM", + principalId: team.id, + revokedAt: null, + }, + data: { revokedAt: now }, + }); + const grant = await tx.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: project.id, + principalType: "TEAM", + principalId: team.id, + role: input.role, + ...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}), + }, + select: { id: true, role: true }, + }); + return entryFromGrant({ + grantId: grant.id, + projectId: project.id, + organizationId: project.organizationId, + team, + role: grant.role, + }); + }); +} + +export async function revokeTeamProjectAccess( + prisma: PrismaClient, + input: RevokeTeamProjectAccessInput, +): Promise { + return prisma.$transaction(async (tx) => { + const { project, team } = await resolveProjectAndTeam(tx, input); + const result = await tx.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: project.id, + principalType: "TEAM", + principalId: team.id, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + return result.count; + }); +} + +export async function listProjectTeamAccess( + prisma: PrismaClient, + projectId: string, +): Promise { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true, organizationId: true }, + }); + if (project === null) { + throw new Error(`project not found: ${projectId}`); + } + const grants = await prisma.permissionGrant.findMany({ + where: { + resourceType: "PROJECT", + resourceId: projectId, + principalType: "TEAM", + revokedAt: null, + }, + select: { id: true, principalId: true, role: true }, + orderBy: { createdAt: "asc" }, + }); + if (grants.length === 0) return []; + + const teams = await prisma.team.findMany({ + where: { + organizationId: project.organizationId, + id: { in: grants.map((grant) => grant.principalId) }, + archivedAt: null, + }, + select: { id: true, slug: true, name: true }, + }); + const teamsById = new Map(teams.map((team) => [team.id, team])); + const missing = grants.filter((grant) => !teamsById.has(grant.principalId)); + if (missing.length > 0) { + throw new Error( + `project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`, + ); + } + + return grants.map((grant) => entryFromGrant({ + grantId: grant.id, + projectId: project.id, + organizationId: project.organizationId, + team: teamsById.get(grant.principalId)!, + role: grant.role, + })); +} + +type ProjectForAccess = { + readonly id: string; + readonly organizationId: string; +}; + +type TeamForAccess = { + readonly id: string; + readonly slug: string; + readonly name: string; +}; + +async function resolveProjectAndTeam( + prisma: PrismaClient | Prisma.TransactionClient, + input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined }, +): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> { + if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) { + throw new Error("granting team project access requires teamId or teamSlug"); + } + if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") { + throw new Error("granting team project access accepts only one of teamId or teamSlug"); + } + + const project = await prisma.project.findUnique({ + where: { id: input.projectId }, + select: { id: true, organizationId: true }, + }); + if (project === null) { + throw new Error(`project not found: ${input.projectId}`); + } + + const team = await prisma.team.findFirst({ + where: + input.teamId !== undefined && input.teamId !== "" + ? { id: input.teamId, archivedAt: null } + : { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null }, + select: { id: true, slug: true, name: true, organizationId: true }, + }); + if (team === null) { + const label = input.teamId ?? input.teamSlug; + throw new Error(`active team not found for project ${input.projectId}: ${label}`); + } + if (team.organizationId !== project.organizationId) { + throw new Error( + `cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`, + ); + } + + return { project, team }; +} + +function entryFromGrant(input: { + readonly grantId: string; + readonly projectId: string; + readonly organizationId: string; + readonly team: TeamForAccess; + readonly role: PermissionRole; +}): ProjectTeamAccessEntry { + return { + grantId: input.grantId, + projectId: input.projectId, + organizationId: input.organizationId, + teamId: input.team.id, + teamSlug: input.team.slug, + teamName: input.team.name, + role: input.role, + }; +} diff --git a/hub/test/integration/authorization.test.ts b/hub/test/integration/authorization.test.ts index 74a5e84..e33f92b 100644 --- a/hub/test/integration/authorization.test.ts +++ b/hub/test/integration/authorization.test.ts @@ -1,6 +1,12 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { prisma, resetDb } from "./helpers.js"; -import { createPermissionAuthorizer, PrismaPrincipalResolver, syncExternalPrincipalMemberships } from "../../src/permission.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; +import { + createPermissionAuthorizer, + grantTeamProjectAccess, + listProjectTeamAccess, + PrismaPrincipalResolver, + syncExternalPrincipalMemberships, +} from "../../src/permission.js"; describe("ADR-0019 authorization", () => { beforeEach(async () => { @@ -16,23 +22,30 @@ describe("ADR-0019 authorization", () => { data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" }, }); const directTeam = await prisma.team.create({ - data: { slug: "direct-auth", name: "Direct Team" }, + data: { organizationId: DEFAULT_ORG_ID, slug: "direct-auth", name: "Direct Team" }, }); const departmentTeam = await prisma.team.create({ - data: { slug: "department-auth", name: "Department Team" }, + data: { organizationId: DEFAULT_ORG_ID, slug: "department-auth", name: "Department Team" }, }); const chatTeam = await prisma.team.create({ - data: { slug: "chat-auth", name: "Chat Team" }, + data: { organizationId: DEFAULT_ORG_ID, slug: "chat-auth", name: "Chat Team" }, }); await prisma.teamMembership.create({ data: { teamId: directTeam.id, userId: user.id }, }); + const connection = await prisma.externalDirectoryConnection.create({ + data: { + organizationId: DEFAULT_ORG_ID, + provider: "FEISHU", + source: "test", + }, + }); await prisma.externalPrincipalMembership.create({ data: { userId: user.id, + connectionId: connection.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics", - source: "test", }, }); await prisma.teamExternalBinding.create({ @@ -45,7 +58,7 @@ describe("ADR-0019 authorization", () => { const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({ feishuOpenId: "ou_auth_1", chatId: "chat-auth", - }); + }, { organizationId: DEFAULT_ORG_ID }); expect(resolution.userId).toBe(user.id); expect(resolution.principals).toEqual(expect.arrayContaining([ @@ -85,9 +98,10 @@ describe("ADR-0019 authorization", () => { it("uses external Feishu principal memberships as grantable principals", async () => { await prisma.project.create({ - data: { id: "p-auth-ext", name: "External", workspaceDir: "/tmp/auth-ext" }, + data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" }, }); await syncExternalPrincipalMemberships(prisma, { + organizationId: DEFAULT_ORG_ID, source: "feishu-test", memberships: [ { @@ -123,6 +137,7 @@ describe("ADR-0019 authorization", () => { it("revokes missing external memberships when replacing a sync source", async () => { await syncExternalPrincipalMemberships(prisma, { + organizationId: DEFAULT_ORG_ID, source: "feishu-directory", memberships: [ { @@ -141,6 +156,7 @@ describe("ADR-0019 authorization", () => { }); const result = await syncExternalPrincipalMemberships(prisma, { + organizationId: DEFAULT_ORG_ID, source: "feishu-directory", replaceSource: true, memberships: [ @@ -154,13 +170,16 @@ describe("ADR-0019 authorization", () => { }); expect(result).toEqual({ synced: 1, revoked: 1 }); - const activeB = await new PrismaPrincipalResolver(prisma).resolveActor({ feishuOpenId: "ou_sync_b" }); + const activeB = await new PrismaPrincipalResolver(prisma).resolveActor( + { feishuOpenId: "ou_sync_b" }, + { organizationId: DEFAULT_ORG_ID }, + ); expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" }); }); it("lets PermissionSettings constrain agent trigger without granting by itself", async () => { await prisma.project.create({ - data: { id: "p-auth-policy", name: "Policy", workspaceDir: "/tmp/auth-policy" }, + data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" }, }); await prisma.user.create({ data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" }, @@ -185,12 +204,15 @@ describe("ADR-0019 authorization", () => { }); expect(editDecision.allowed).toBe(false); - await prisma.permissionGrant.create({ - data: { + await prisma.permissionGrant.updateMany({ + where: { resourceType: "PROJECT", resourceId: "p-auth-policy", principalType: "USER", principalId: "ou_auth_policy", + revokedAt: null, + }, + data: { role: "MANAGE", }, }); @@ -253,7 +275,7 @@ describe("ADR-0019 authorization", () => { it("keeps a role configured when all role grants are revoked", async () => { await prisma.project.create({ - data: { id: "p-auth-revoked-role", name: "Revoked", workspaceDir: "/tmp/auth-revoked" }, + data: { id: "p-auth-revoked-role", organizationId: DEFAULT_ORG_ID, name: "Revoked", workspaceDir: "/tmp/auth-revoked" }, }); await prisma.user.create({ data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" }, @@ -288,7 +310,7 @@ describe("ADR-0019 authorization", () => { expect(decision.reason).toContain("no active role review grant"); }); it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => { - await prisma.project.create({ data: { id: "p-cancel", name: "Cancel", workspaceDir: "/tmp/cancel" } }); + await prisma.project.create({ data: { id: "p-cancel", organizationId: DEFAULT_ORG_ID, name: "Cancel", workspaceDir: "/tmp/cancel" } }); await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } }); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" }, @@ -304,7 +326,7 @@ describe("ADR-0019 authorization", () => { }); it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => { - await prisma.project.create({ data: { id: "p-cancel-edit", name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } }); + await prisma.project.create({ data: { id: "p-cancel-edit", organizationId: DEFAULT_ORG_ID, name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } }); await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } }); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" }, @@ -319,7 +341,7 @@ describe("ADR-0019 authorization", () => { }); it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => { - await prisma.project.create({ data: { id: "p-cancel-off", name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } }); + await prisma.project.create({ data: { id: "p-cancel-off", organizationId: DEFAULT_ORG_ID, name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } }); await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } }); await prisma.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" }, @@ -336,6 +358,57 @@ describe("ADR-0019 authorization", () => { expect(decision.allowed).toBe(false); expect(decision.reason).toContain("disables this action"); }); + + it("scopes team slug and project team access by organization", async () => { + await seedTestOrganization("org-other", "other"); + await prisma.team.create({ + data: { organizationId: "org-other", slug: "curriculum", name: "Other Curriculum" }, + }); + const team = await prisma.team.create({ + data: { organizationId: DEFAULT_ORG_ID, slug: "curriculum", name: "Default Curriculum" }, + }); + await prisma.project.create({ + data: { id: "p-team-access", organizationId: DEFAULT_ORG_ID, name: "Team Access", workspaceDir: "/tmp/team-access" }, + }); + + const entry = await grantTeamProjectAccess(prisma, { + projectId: "p-team-access", + teamSlug: "curriculum", + role: "EDIT", + }); + + expect(entry).toMatchObject({ + projectId: "p-team-access", + organizationId: DEFAULT_ORG_ID, + teamId: team.id, + teamSlug: "curriculum", + role: "EDIT", + }); + await grantTeamProjectAccess(prisma, { + projectId: "p-team-access", + teamSlug: "curriculum", + role: "MANAGE", + }); + const entries = await listProjectTeamAccess(prisma, "p-team-access"); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ teamId: team.id, role: "MANAGE" }); + }); + + it("refuses cross-organization team project grants", async () => { + await seedTestOrganization("org-other-deny", "other-deny"); + const otherTeam = await prisma.team.create({ + data: { organizationId: "org-other-deny", slug: "review", name: "Other Review" }, + }); + await prisma.project.create({ + data: { id: "p-cross-org", organizationId: DEFAULT_ORG_ID, name: "Cross Org", workspaceDir: "/tmp/cross-org" }, + }); + + await expect(grantTeamProjectAccess(prisma, { + projectId: "p-cross-org", + teamId: otherTeam.id, + role: "EDIT", + })).rejects.toThrow(/cross-organization team grant refused/); + }); }); async function seedUserTeamProject(suffix: string) { @@ -347,13 +420,13 @@ async function seedUserTeamProject(suffix: string) { }, }); const team = await prisma.team.create({ - data: { slug: `team-${suffix}`, name: `Team ${suffix}` }, + data: { organizationId: DEFAULT_ORG_ID, slug: `team-${suffix}`, name: `Team ${suffix}` }, }); await prisma.teamMembership.create({ data: { teamId: team.id, userId: user.id }, }); const project = await prisma.project.create({ - data: { id: `p-${suffix}`, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` }, + data: { id: `p-${suffix}`, organizationId: DEFAULT_ORG_ID, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` }, }); return { user, team, project }; } diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index c1a18e6..1fe8bd6 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -19,6 +19,7 @@ import type { FeishuRuntime } from "../../src/feishu/client.js"; import type { ModelFactory } from "../../src/agent/runner.js"; export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; +export const DEFAULT_ORG_ID = "org_test_default"; export const prisma = new PrismaClient({ datasources: { db: { url: TEST_DATABASE_URL } }, @@ -35,6 +36,7 @@ export async function resetDb(): Promise { "PermissionGrant", "RoleTriggerGrant", "ExternalPrincipalMembership", + "ExternalDirectoryConnection", "TeamExternalBinding", "TeamMembership", "Team", @@ -42,12 +44,30 @@ export async function resetDb(): Promise { "AgentRun", "AgentSession", "ProjectGroupBinding", + "OrganizationMembership", "PlatformRoleAssignment", "User", "Project", + "Organization", ]; // Truncate with CASCADE to wipe dependent rows in one shot. await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`); + await seedTestOrganization(); +} + +export async function seedTestOrganization( + id: string = DEFAULT_ORG_ID, + slug: string = "test-default", +): Promise { + await prisma.organization.upsert({ + where: { id }, + update: {}, + create: { + id, + slug, + name: "Test Default Organization", + }, + }); } /** A logger that discards everything (tests don't need fastify's pino). */ @@ -340,6 +360,7 @@ export async function seedProject( await prisma.project.create({ data: { id: projectId, + organizationId: DEFAULT_ORG_ID, name: `Test ${projectId}`, workspaceDir: `/tmp/test-${projectId}`, }, @@ -350,6 +371,7 @@ export async function seedProject( feishuOpenId: principal, displayName: "Test User", platformRoles: { create: { role: "TEACHER" } }, + organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } }, permissionGrants: { create: { resourceType: "PROJECT", diff --git a/hub/test/integration/lock.test.ts b/hub/test/integration/lock.test.ts index cb9cbb5..ec958b9 100644 --- a/hub/test/integration/lock.test.ts +++ b/hub/test/integration/lock.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest"; -import { prisma, resetDb } from "./helpers.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js"; describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { @@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { it("acquireLock + currentLockRunId round-trip", async () => { const project = await prisma.project.create({ - data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" }, + data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" }, }); const run = await prisma.agentRun.create({ data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, @@ -26,7 +26,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { it("acquireLock fails when project already locked (exclusivity)", async () => { const project = await prisma.project.create({ - data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" }, + data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" }, }); const run1 = await prisma.agentRun.create({ data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, @@ -41,7 +41,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => { const project = await prisma.project.create({ - data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" }, + data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" }, }); // Create a run that is already COMPLETED (terminal), then manually insert a lock. const run = await prisma.agentRun.create({ @@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => { it("releaseLock is idempotent", async () => { const project = await prisma.project.create({ - data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" }, + data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" }, }); const run = await prisma.agentRun.create({ data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} }, diff --git a/hub/test/integration/role-permission.test.ts b/hub/test/integration/role-permission.test.ts index f51f680..5435386 100644 --- a/hub/test/integration/role-permission.test.ts +++ b/hub/test/integration/role-permission.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterAll } from "vitest"; -import { prisma, resetDb } from "./helpers.js"; +import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; import { canTriggerRole } from "../../src/permission.js"; describe("canTriggerRole (integration, per-role gate)", () => { @@ -13,7 +13,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { it("allows when role is unconfigured on the project (back-compat: open)", async () => { const project = await prisma.project.create({ - data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" }, + data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" }, }); const r = await canTriggerRole(prisma, project.id, "draft", "ou_a"); expect(r.allowed).toBe(true); @@ -21,7 +21,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { it("allows when principal holds an active grant for the role", async () => { const project = await prisma.project.create({ - data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" }, + data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" }, }); await prisma.roleTriggerGrant.create({ data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" }, @@ -32,7 +32,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { it("denies when grants exist for the role but principal has none", async () => { const project = await prisma.project.create({ - data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" }, + data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" }, }); // Someone else has the role; ou_b does not. await prisma.roleTriggerGrant.create({ @@ -44,7 +44,7 @@ describe("canTriggerRole (integration, per-role gate)", () => { it("denies when the principal's grant was revoked", async () => { const project = await prisma.project.create({ - data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" }, + data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" }, }); await prisma.roleTriggerGrant.create({ data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() }, @@ -54,8 +54,8 @@ describe("canTriggerRole (integration, per-role gate)", () => { }); it("scopes grants per-project (grant on p1 does not allow on p2)", async () => { - const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } }); - const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } }); + const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } }); + const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } }); await prisma.roleTriggerGrant.create({ data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" }, }); diff --git a/hub/test/unit/feishu-approval.test.ts b/hub/test/unit/feishu-approval.test.ts index b3da1c3..40f05ba 100644 --- a/hub/test/unit/feishu-approval.test.ts +++ b/hub/test/unit/feishu-approval.test.ts @@ -166,6 +166,7 @@ function allowAllAuthorizer(): PermissionAuthorizer { action: req.action, resource: req.resource, actor: req.actor, + organizationId: "org_test_default", actorUserId: "user-1", principals: [{ type: "USER", id: "ou_user" }], requiredRole: "EDIT", diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index f10abc8..19b4cf3 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -235,6 +235,7 @@ function allowAllAuthorizer(): PermissionAuthorizer { action: req.action, resource: req.resource, actor: req.actor, + organizationId: "org_test_default", actorUserId: "user-1", principals: [{ type: "USER", id: "ou_user" }], requiredRole: "EDIT", diff --git a/spec/Spec/Prelude.lean b/spec/Spec/Prelude.lean index c21d688..2d6671f 100644 --- a/spec/Spec/Prelude.lean +++ b/spec/Spec/Prelude.lean @@ -13,6 +13,10 @@ namespace Spec.System structure Identifiers where /-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/ ProjectId : Type + /-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/ + OrganizationId : Type + /-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/ + TeamId : Type /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/ RunId : Type diff --git a/spec/Spec/System.lean b/spec/Spec/System.lean index 1b7f6bc..b48fecf 100644 --- a/spec/Spec/System.lean +++ b/spec/Spec/System.lean @@ -1,4 +1,5 @@ import Spec.System.ProjectGroup +import Spec.System.Organization import Spec.System.Run import Spec.System.Lock import Spec.System.Memory @@ -14,6 +15,7 @@ import Spec.System.Audit **语义分歧点**: - `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。 +- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。 - `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 - `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 - `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。 @@ -24,5 +26,5 @@ import Spec.System.Audit (ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。 - `Audit` —— 有意从简(内容多为 plumbing,OPEN)。 -标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018。 +标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020。 -/ diff --git a/spec/Spec/System/Organization.lean b/spec/Spec/System/Organization.lean new file mode 100644 index 0000000..3343dd6 --- /dev/null +++ b/spec/Spec/System/Organization.lean @@ -0,0 +1,51 @@ +import Spec.Prelude + +/-! +# Organization —— SaaS tenant root (ADR-0020) + +ADR-0020:Hub 长期按 SaaS 形态演进,`Organization` 是客户侧 tenant root。 +`Project` 与 `Team` 都必须归属且只归属一个 organization;team 对 project 的授权 +必须在同一 organization 内。平台运营控制面(platform staff / break-glass / 账单) +不复用 project 的 `read/edit/manage` 角色格,而是另一个控制面。 + +本模块只钉死会造成实现分歧的不变量:tenant root 存在、project/team 单归属、team grant +不得跨 org。用户身份、外部目录 provider 细节、平台控制面角色集合仍为 `OPEN`。 +-/ + +namespace Spec.System + +variable (I : Identifiers) + +/-- 课程项目的租户归属(`PINNED`, ADR-0020):每个 project 必须有一个 organization。 -/ +structure ProjectTenancy where + /-- 被归属的课程项目(`PINNED`, ADR-0020)。 -/ + project : I.ProjectId + /-- project 所属 organization(`PINNED`, ADR-0020)。 -/ + organization : I.OrganizationId + +/-- Hub team 的租户归属(`PINNED`, ADR-0020):team 是 org-scoped principal。 -/ +structure TeamTenancy where + /-- 被归属的 Hub team(`PINNED`, ADR-0020)。 -/ + team : I.TeamId + /-- team 所属 organization(`PINNED`, ADR-0020)。 -/ + organization : I.OrganizationId + +/-- Team 对 project 的授权作用域(`PINNED`, ADR-0020):`PermissionGrant` 可以把 +TEAM principal 授给 PROJECT resource,但该 grant 必须同 org。role 本身仍由 +`PermissionGrant`/`Permission` 定义,这里仅刻画 tenant well-scopedness。 -/ +structure TeamProjectGrantScope where + /-- 被授权的 project(`PINNED`, ADR-0020)。 -/ + project : I.ProjectId + /-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/ + team : I.TeamId + +/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization +(`PINNED`, ADR-0020)。`projectOrg`/`teamOrg` 由平台提供(表示 `OPEN`);本谓词钉死 +跨 org team grant 必须被拒绝。 -/ +def TeamProjectGrantScope.WellScoped + (grant : TeamProjectGrantScope I) + (projectOrg : I.ProjectId → Option I.OrganizationId) + (teamOrg : I.TeamId → Option I.OrganizationId) : Prop := + ∃ o, projectOrg grant.project = some o ∧ teamOrg grant.team = some o + +end Spec.System diff --git a/spec/Spec/System/PermissionGrant.lean b/spec/Spec/System/PermissionGrant.lean index 9869049..6718180 100644 --- a/spec/Spec/System/PermissionGrant.lean +++ b/spec/Spec/System/PermissionGrant.lean @@ -12,7 +12,8 @@ ADR-0004 的"飞书云文档式"权限:**grant**(`resource × principal × role` principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本 层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`—— ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现 -须 surface,不得默认。 +须 surface,不得默认。ADR-0020 另行钉死 TEAM principal 授权 PROJECT resource 时必须 +同 organization;该 tenant well-scopedness 见 `Spec.System.Organization`。 -/ namespace Spec.System