forked from bai/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
||||
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
|
||||
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
|
||||
|
||||
## 纪律
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
|
||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
|
||||
): 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<string> {
|
||||
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(
|
||||
|
||||
@@ -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<string>();
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<PrincipalResolution>;
|
||||
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
|
||||
}
|
||||
|
||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
|
||||
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
|
||||
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(),
|
||||
|
||||
@@ -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<ProjectTeamAccessEntry> {
|
||||
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<number> {
|
||||
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<readonly ProjectTeamAccessEntry[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
"PermissionGrant",
|
||||
"RoleTriggerGrant",
|
||||
"ExternalPrincipalMembership",
|
||||
"ExternalDirectoryConnection",
|
||||
"TeamExternalBinding",
|
||||
"TeamMembership",
|
||||
"Team",
|
||||
@@ -42,12 +44,30 @@ export async function resetDb(): Promise<void> {
|
||||
"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<void> {
|
||||
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",
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
-/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user