forked from bai/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
- `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
- 其余部件(将来的 `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/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照。
|
||||||
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
|
- 其余部件(将来的 `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 ---------------------------------------------------
|
// --- 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).
|
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -34,16 +82,17 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
platformRoles PlatformRoleAssignment[]
|
platformRoles PlatformRoleAssignment[]
|
||||||
createdProjects Project[] @relation("projectCreator")
|
organizationMemberships OrganizationMembership[]
|
||||||
requestedRuns AgentRun[] @relation("runRequester")
|
createdProjects Project[] @relation("projectCreator")
|
||||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
requestedRuns AgentRun[] @relation("runRequester")
|
||||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
teamMemberships TeamMembership[]
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
|
teamMemberships TeamMembership[]
|
||||||
externalPrincipalMemberships ExternalPrincipalMembership[]
|
externalPrincipalMemberships ExternalPrincipalMembership[]
|
||||||
permissionGrants PermissionGrant[] @relation("grantCreator")
|
permissionGrants PermissionGrant[] @relation("grantCreator")
|
||||||
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
|
||||||
auditEntries AuditEntry[] @relation("auditActor")
|
auditEntries AuditEntry[] @relation("auditActor")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
|
/// 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.
|
/// Hub-managed teacher team. A team is a first-class permission principal.
|
||||||
model Team {
|
model Team {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
organizationId String
|
||||||
name String
|
slug String
|
||||||
description String?
|
name String
|
||||||
createdAt DateTime @default(now())
|
description String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
archivedAt DateTime?
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
memberships TeamMembership[]
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
memberships TeamMembership[]
|
||||||
externalBindings TeamExternalBinding[]
|
externalBindings TeamExternalBinding[]
|
||||||
|
|
||||||
@@index([archivedAt])
|
@@index([organizationId, archivedAt])
|
||||||
|
@@index([organizationId, slug, archivedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
||||||
/// TeamExternalBinding; both sources are resolved at authorization time.
|
/// TeamExternalBinding; both sources are resolved at authorization time.
|
||||||
model TeamMembership {
|
model TeamMembership {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
teamId String
|
teamId String
|
||||||
userId String
|
userId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
@@ -129,27 +181,50 @@ model TeamExternalBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Locally synchronized Feishu external principal membership.
|
/// 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 {
|
model ExternalPrincipalMembership {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
connectionId String
|
||||||
principalType PrincipalType
|
principalType PrincipalType
|
||||||
principalId String
|
principalId String
|
||||||
source String
|
|
||||||
syncedAt DateTime @default(now())
|
syncedAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
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([userId, revokedAt])
|
||||||
|
@@index([connectionId, revokedAt])
|
||||||
@@index([principalType, principalId, revokedAt])
|
@@index([principalType, principalId, revokedAt])
|
||||||
@@index([source, syncedAt])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
name String
|
name String
|
||||||
workspaceDir String
|
workspaceDir String
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
@@ -157,15 +232,17 @@ model Project {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
|
|
||||||
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
groupBinding ProjectGroupBinding?
|
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
agentSessions AgentSession[]
|
groupBinding ProjectGroupBinding?
|
||||||
agentRuns AgentRun[]
|
agentSessions AgentSession[]
|
||||||
agentLock ProjectAgentLock?
|
agentRuns AgentRun[]
|
||||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
agentLock ProjectAgentLock?
|
||||||
auditEntries AuditEntry[] @relation("projectAudit")
|
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||||
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
auditEntries AuditEntry[] @relation("projectAudit")
|
||||||
|
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||||
|
|
||||||
|
@@index([organizationId, archivedAt])
|
||||||
@@index([archivedAt])
|
@@index([archivedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +283,8 @@ model AgentSession {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
runs AgentRun[]
|
runs AgentRun[]
|
||||||
messages AgentMessage[] @relation("sessionMessages")
|
messages AgentMessage[] @relation("sessionMessages")
|
||||||
|
|
||||||
@@index([projectId, archivedAt])
|
@@index([projectId, archivedAt])
|
||||||
@@ -235,12 +312,12 @@ enum AgentEntrypoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AgentRun {
|
model AgentRun {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
sessionId String?
|
sessionId String?
|
||||||
requestedByUserId String?
|
requestedByUserId String?
|
||||||
entrypoint AgentEntrypoint
|
entrypoint AgentEntrypoint
|
||||||
status AgentRunStatus @default(ACTIVE)
|
status AgentRunStatus @default(ACTIVE)
|
||||||
prompt String
|
prompt String
|
||||||
model String
|
model String
|
||||||
provider String
|
provider String
|
||||||
@@ -248,21 +325,21 @@ model AgentRun {
|
|||||||
inputTokens Int?
|
inputTokens Int?
|
||||||
outputTokens Int?
|
outputTokens Int?
|
||||||
/// Provider/gateway-reported cost in USD. Null means this run has no trusted cost fact.
|
/// 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.
|
/// Semantic source of costUsd, e.g. provider_reported. Not a pricing-estimate fallback.
|
||||||
costSource String?
|
costSource String?
|
||||||
metadata Json
|
metadata Json
|
||||||
error String?
|
error String?
|
||||||
startedAt DateTime @default(now())
|
startedAt DateTime @default(now())
|
||||||
finishedAt DateTime?
|
finishedAt DateTime?
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
projectLock ProjectAgentLock?
|
projectLock ProjectAgentLock?
|
||||||
messages AgentMessage[] @relation("runMessages")
|
messages AgentMessage[] @relation("runMessages")
|
||||||
fileChanges AgentFileChange[] @relation("runFileChanges")
|
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||||
|
|
||||||
@@index([projectId, status])
|
@@index([projectId, status])
|
||||||
@@index([projectId, finishedAt])
|
@@index([projectId, finishedAt])
|
||||||
@@ -276,16 +353,16 @@ model AgentRun {
|
|||||||
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
/// 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.
|
/// app-level invariant checked on read/write, not a DB constraint.
|
||||||
model ProjectAgentLock {
|
model ProjectAgentLock {
|
||||||
projectId String @id
|
projectId String @id
|
||||||
runId String @unique
|
runId String @unique
|
||||||
holderUserId String?
|
holderUserId String?
|
||||||
acquiredAt DateTime @default(now())
|
acquiredAt DateTime @default(now())
|
||||||
heartbeatAt DateTime?
|
heartbeatAt DateTime?
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||||
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
@@ -315,14 +392,14 @@ enum PermissionResourceType {
|
|||||||
/// `principalType/principalId` so user/team/Feishu principals compose through
|
/// `principalType/principalId` so user/team/Feishu principals compose through
|
||||||
/// the same authorization path.
|
/// the same authorization path.
|
||||||
model PermissionGrant {
|
model PermissionGrant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resourceType PermissionResourceType
|
resourceType PermissionResourceType
|
||||||
resourceId String
|
resourceId String
|
||||||
principalType PrincipalType
|
principalType PrincipalType
|
||||||
principalId String
|
principalId String
|
||||||
role PermissionRole
|
role PermissionRole
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
|
||||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
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
|
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
|
||||||
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
||||||
model PermissionSettings {
|
model PermissionSettings {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resourceType PermissionResourceType
|
resourceType PermissionResourceType
|
||||||
resourceId String
|
resourceId String
|
||||||
externalShare String
|
externalShare String
|
||||||
@@ -344,14 +421,13 @@ model PermissionSettings {
|
|||||||
collaboratorMgmt String
|
collaboratorMgmt String
|
||||||
agentTrigger String
|
agentTrigger String
|
||||||
agentCancel String
|
agentCancel String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([resourceType, resourceId])
|
@@unique([resourceType, resourceId])
|
||||||
@@index([resourceType, resourceId])
|
@@index([resourceType, resourceId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
|
||||||
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
|
||||||
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
|
/// 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)".
|
/// the compound unique covers "one active grant per (project, principal, role)".
|
||||||
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
|
||||||
model RoleTriggerGrant {
|
model RoleTriggerGrant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
roleId String
|
roleId String
|
||||||
principalType PrincipalType
|
principalType PrincipalType
|
||||||
principalId String
|
principalId String
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
|
||||||
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
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:
|
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
|
||||||
/// session messages ≠ Feishu group chat history.
|
/// session messages ≠ Feishu group chat history.
|
||||||
model AgentMessage {
|
model AgentMessage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionId String
|
sessionId String
|
||||||
runId String?
|
runId String?
|
||||||
role String
|
role String
|
||||||
content String
|
content String
|
||||||
attachments Json
|
attachments Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
|
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([sessionId, createdAt])
|
||||||
@@index([runId])
|
@@index([runId])
|
||||||
@@ -447,11 +523,11 @@ model AgentMessage {
|
|||||||
/// that produced it; the tool's execute closure records via a sink the runner
|
/// that produced it; the tool's execute closure records via a sink the runner
|
||||||
/// injects into ToolContext (A-3 file-change capture).
|
/// injects into ToolContext (A-3 file-change capture).
|
||||||
model AgentFileChange {
|
model AgentFileChange {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
runId String
|
runId String
|
||||||
projectId String
|
projectId String
|
||||||
path String
|
path String
|
||||||
operation String
|
operation String
|
||||||
beforeHash String?
|
beforeHash String?
|
||||||
afterHash String?
|
afterHash String?
|
||||||
diff String?
|
diff String?
|
||||||
@@ -459,7 +535,7 @@ model AgentFileChange {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
|
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([runId])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
|||||||
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||||
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||||
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||||
|
export { grantTeamProjectAccess, listProjectTeamAccess, revokeTeamProjectAccess } from "./permissions/projectTeamAccess.js";
|
||||||
export type {
|
export type {
|
||||||
AuthorizationAction,
|
AuthorizationAction,
|
||||||
AuthorizationDecision,
|
AuthorizationDecision,
|
||||||
@@ -32,6 +33,11 @@ export type {
|
|||||||
SyncExternalPrincipalMembershipsInput,
|
SyncExternalPrincipalMembershipsInput,
|
||||||
SyncExternalPrincipalMembershipsResult,
|
SyncExternalPrincipalMembershipsResult,
|
||||||
} from "./permissions/externalSync.js";
|
} from "./permissions/externalSync.js";
|
||||||
|
export type {
|
||||||
|
GrantTeamProjectAccessInput,
|
||||||
|
ProjectTeamAccessEntry,
|
||||||
|
RevokeTeamProjectAccessInput,
|
||||||
|
} from "./permissions/projectTeamAccess.js";
|
||||||
|
|
||||||
export interface PermissionResult {
|
export interface PermissionResult {
|
||||||
readonly allowed: boolean;
|
readonly allowed: boolean;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface AuthorizationDecision {
|
|||||||
readonly action: AuthorizationAction;
|
readonly action: AuthorizationAction;
|
||||||
readonly resource: AuthorizationResource;
|
readonly resource: AuthorizationResource;
|
||||||
readonly actor: ActorInput;
|
readonly actor: ActorInput;
|
||||||
|
readonly organizationId: string;
|
||||||
readonly actorUserId?: string | undefined;
|
readonly actorUserId?: string | undefined;
|
||||||
readonly principals: readonly PrincipalRef[];
|
readonly principals: readonly PrincipalRef[];
|
||||||
readonly requiredRole: PermissionRole;
|
readonly requiredRole: PermissionRole;
|
||||||
@@ -60,7 +61,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
|||||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||||
throw new Error("role.trigger authorization requires 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);
|
const threshold = await this.requiredRole(req);
|
||||||
if (threshold === "DISABLED") {
|
if (threshold === "DISABLED") {
|
||||||
return this.decision(req, resolution, {
|
return this.decision(req, resolution, {
|
||||||
@@ -206,17 +208,45 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
|||||||
private decision(
|
private decision(
|
||||||
req: AuthorizationRequest,
|
req: AuthorizationRequest,
|
||||||
resolution: PrincipalResolution,
|
resolution: PrincipalResolution,
|
||||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
|
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
|
||||||
): AuthorizationDecision {
|
): AuthorizationDecision {
|
||||||
return {
|
return {
|
||||||
action: req.action,
|
action: req.action,
|
||||||
resource: req.resource,
|
resource: req.resource,
|
||||||
actor: req.actor,
|
actor: req.actor,
|
||||||
|
organizationId: resolution.organizationId,
|
||||||
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
||||||
principals: resolution.principals,
|
principals: resolution.principals,
|
||||||
...result,
|
...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(
|
export function createPermissionAuthorizer(
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface ExternalPrincipalMembershipInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncExternalPrincipalMembershipsInput {
|
export interface SyncExternalPrincipalMembershipsInput {
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly provider?: string | undefined;
|
||||||
|
readonly providerTenantId?: string | undefined;
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly replaceSource?: boolean | undefined;
|
readonly replaceSource?: boolean | undefined;
|
||||||
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
||||||
@@ -30,6 +33,26 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
const syncedAt = new Date();
|
const syncedAt = new Date();
|
||||||
const incomingKeys = new Set<string>();
|
const incomingKeys = new Set<string>();
|
||||||
let synced = 0;
|
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) {
|
for (const item of input.memberships) {
|
||||||
assertExternalPrincipalType(item.principalType);
|
assertExternalPrincipalType(item.principalType);
|
||||||
@@ -40,15 +63,17 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
feishuOpenId: item.feishuOpenId,
|
feishuOpenId: item.feishuOpenId,
|
||||||
displayName: item.displayName,
|
displayName: item.displayName,
|
||||||
platformRoles: { create: { role: "TEACHER" } },
|
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({
|
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
principalType: item.principalType,
|
principalType: item.principalType,
|
||||||
principalId: item.principalId,
|
principalId: item.principalId,
|
||||||
source: input.source,
|
connectionId: connection.id,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -57,9 +82,9 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
await prisma.externalPrincipalMembership.create({
|
await prisma.externalPrincipalMembership.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
connectionId: connection.id,
|
||||||
principalType: item.principalType,
|
principalType: item.principalType,
|
||||||
principalId: item.principalId,
|
principalId: item.principalId,
|
||||||
source: input.source,
|
|
||||||
syncedAt,
|
syncedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -75,14 +100,15 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
let revoked = 0;
|
let revoked = 0;
|
||||||
if (input.replaceSource === true) {
|
if (input.replaceSource === true) {
|
||||||
const active = await prisma.externalPrincipalMembership.findMany({
|
const active = await prisma.externalPrincipalMembership.findMany({
|
||||||
where: { source: input.source, revokedAt: null },
|
where: { connectionId: connection.id, revokedAt: null },
|
||||||
select: { id: true, userId: true, principalType: true, principalId: true },
|
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||||
});
|
});
|
||||||
const revokeIds = active
|
const revokeIds = active
|
||||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||||
membership.userId,
|
membership.userId,
|
||||||
membership.principalType,
|
membership.principalType,
|
||||||
membership.principalId,
|
membership.principalId,
|
||||||
|
membership.connectionId,
|
||||||
)))
|
)))
|
||||||
.map((membership) => membership.id);
|
.map((membership) => membership.id);
|
||||||
if (revokeIds.length > 0) {
|
if (revokeIds.length > 0) {
|
||||||
@@ -97,8 +123,23 @@ export async function syncExternalPrincipalMemberships(
|
|||||||
return { synced, revoked };
|
return { synced, revoked };
|
||||||
}
|
}
|
||||||
|
|
||||||
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
|
async function ensureOrganizationMembership(
|
||||||
return `${userId}:${type}:${id}`;
|
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 {
|
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface ActorInput {
|
|||||||
readonly chatId?: string | undefined;
|
readonly chatId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrincipalResolutionScope {
|
||||||
|
readonly organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type PrincipalSource =
|
export type PrincipalSource =
|
||||||
| "actor-user"
|
| "actor-user"
|
||||||
| "context-chat"
|
| "context-chat"
|
||||||
@@ -25,19 +29,20 @@ export interface ResolvedPrincipal {
|
|||||||
|
|
||||||
export interface PrincipalResolution {
|
export interface PrincipalResolution {
|
||||||
readonly actor: ActorInput;
|
readonly actor: ActorInput;
|
||||||
|
readonly organizationId: string;
|
||||||
readonly userId?: string | undefined;
|
readonly userId?: string | undefined;
|
||||||
readonly principals: readonly PrincipalRef[];
|
readonly principals: readonly PrincipalRef[];
|
||||||
readonly resolved: readonly ResolvedPrincipal[];
|
readonly resolved: readonly ResolvedPrincipal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrincipalResolver {
|
export interface PrincipalResolver {
|
||||||
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
|
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
|
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
|
||||||
const resolved = new PrincipalCollector();
|
const resolved = new PrincipalCollector();
|
||||||
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||||
@@ -51,7 +56,11 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
|||||||
|
|
||||||
if (user !== null) {
|
if (user !== null) {
|
||||||
const memberships = await this.prisma.teamMembership.findMany({
|
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 },
|
select: { teamId: true },
|
||||||
});
|
});
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
@@ -59,7 +68,14 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
|
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 },
|
select: { principalType: true, principalId: true },
|
||||||
});
|
});
|
||||||
for (const membership of externalMemberships) {
|
for (const membership of externalMemberships) {
|
||||||
@@ -75,7 +91,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
|||||||
const bindings = await this.prisma.teamExternalBinding.findMany({
|
const bindings = await this.prisma.teamExternalBinding.findMany({
|
||||||
where: {
|
where: {
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
team: { archivedAt: null },
|
team: { organizationId: scope.organizationId, archivedAt: null },
|
||||||
OR: externalPrincipals.map((principal) => ({
|
OR: externalPrincipals.map((principal) => ({
|
||||||
principalType: principal.type,
|
principalType: principal.type,
|
||||||
principalId: principal.id,
|
principalId: principal.id,
|
||||||
@@ -94,6 +110,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
actor,
|
actor,
|
||||||
|
organizationId: scope.organizationId,
|
||||||
...(user !== null ? { userId: user.id } : {}),
|
...(user !== null ? { userId: user.id } : {}),
|
||||||
principals: resolved.principals(),
|
principals: resolved.principals(),
|
||||||
resolved: resolved.entries(),
|
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 { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { prisma, resetDb } from "./helpers.js";
|
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||||
import { createPermissionAuthorizer, PrismaPrincipalResolver, syncExternalPrincipalMemberships } from "../../src/permission.js";
|
import {
|
||||||
|
createPermissionAuthorizer,
|
||||||
|
grantTeamProjectAccess,
|
||||||
|
listProjectTeamAccess,
|
||||||
|
PrismaPrincipalResolver,
|
||||||
|
syncExternalPrincipalMemberships,
|
||||||
|
} from "../../src/permission.js";
|
||||||
|
|
||||||
describe("ADR-0019 authorization", () => {
|
describe("ADR-0019 authorization", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -16,23 +22,30 @@ describe("ADR-0019 authorization", () => {
|
|||||||
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
|
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
|
||||||
});
|
});
|
||||||
const directTeam = await prisma.team.create({
|
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({
|
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({
|
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({
|
await prisma.teamMembership.create({
|
||||||
data: { teamId: directTeam.id, userId: user.id },
|
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({
|
await prisma.externalPrincipalMembership.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
connectionId: connection.id,
|
||||||
principalType: "FEISHU_DEPARTMENT",
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
principalId: "dep-physics",
|
principalId: "dep-physics",
|
||||||
source: "test",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.teamExternalBinding.create({
|
await prisma.teamExternalBinding.create({
|
||||||
@@ -45,7 +58,7 @@ describe("ADR-0019 authorization", () => {
|
|||||||
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
|
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
|
||||||
feishuOpenId: "ou_auth_1",
|
feishuOpenId: "ou_auth_1",
|
||||||
chatId: "chat-auth",
|
chatId: "chat-auth",
|
||||||
});
|
}, { organizationId: DEFAULT_ORG_ID });
|
||||||
|
|
||||||
expect(resolution.userId).toBe(user.id);
|
expect(resolution.userId).toBe(user.id);
|
||||||
expect(resolution.principals).toEqual(expect.arrayContaining([
|
expect(resolution.principals).toEqual(expect.arrayContaining([
|
||||||
@@ -85,9 +98,10 @@ describe("ADR-0019 authorization", () => {
|
|||||||
|
|
||||||
it("uses external Feishu principal memberships as grantable principals", async () => {
|
it("uses external Feishu principal memberships as grantable principals", async () => {
|
||||||
await prisma.project.create({
|
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, {
|
await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
source: "feishu-test",
|
source: "feishu-test",
|
||||||
memberships: [
|
memberships: [
|
||||||
{
|
{
|
||||||
@@ -123,6 +137,7 @@ describe("ADR-0019 authorization", () => {
|
|||||||
|
|
||||||
it("revokes missing external memberships when replacing a sync source", async () => {
|
it("revokes missing external memberships when replacing a sync source", async () => {
|
||||||
await syncExternalPrincipalMemberships(prisma, {
|
await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
source: "feishu-directory",
|
source: "feishu-directory",
|
||||||
memberships: [
|
memberships: [
|
||||||
{
|
{
|
||||||
@@ -141,6 +156,7 @@ describe("ADR-0019 authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await syncExternalPrincipalMemberships(prisma, {
|
const result = await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
source: "feishu-directory",
|
source: "feishu-directory",
|
||||||
replaceSource: true,
|
replaceSource: true,
|
||||||
memberships: [
|
memberships: [
|
||||||
@@ -154,13 +170,16 @@ describe("ADR-0019 authorization", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ synced: 1, revoked: 1 });
|
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" });
|
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
||||||
await prisma.project.create({
|
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({
|
await prisma.user.create({
|
||||||
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
|
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);
|
expect(editDecision.allowed).toBe(false);
|
||||||
|
|
||||||
await prisma.permissionGrant.create({
|
await prisma.permissionGrant.updateMany({
|
||||||
data: {
|
where: {
|
||||||
resourceType: "PROJECT",
|
resourceType: "PROJECT",
|
||||||
resourceId: "p-auth-policy",
|
resourceId: "p-auth-policy",
|
||||||
principalType: "USER",
|
principalType: "USER",
|
||||||
principalId: "ou_auth_policy",
|
principalId: "ou_auth_policy",
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
role: "MANAGE",
|
role: "MANAGE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -253,7 +275,7 @@ describe("ADR-0019 authorization", () => {
|
|||||||
|
|
||||||
it("keeps a role configured when all role grants are revoked", async () => {
|
it("keeps a role configured when all role grants are revoked", async () => {
|
||||||
await prisma.project.create({
|
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({
|
await prisma.user.create({
|
||||||
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
|
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");
|
expect(decision.reason).toContain("no active role review grant");
|
||||||
});
|
});
|
||||||
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
|
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.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
|
||||||
await prisma.permissionGrant.create({
|
await prisma.permissionGrant.create({
|
||||||
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
|
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 () => {
|
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.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
|
||||||
await prisma.permissionGrant.create({
|
await prisma.permissionGrant.create({
|
||||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
|
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 () => {
|
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.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
|
||||||
await prisma.permissionGrant.create({
|
await prisma.permissionGrant.create({
|
||||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
|
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.allowed).toBe(false);
|
||||||
expect(decision.reason).toContain("disables this action");
|
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) {
|
async function seedUserTeamProject(suffix: string) {
|
||||||
@@ -347,13 +420,13 @@ async function seedUserTeamProject(suffix: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const team = await prisma.team.create({
|
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({
|
await prisma.teamMembership.create({
|
||||||
data: { teamId: team.id, userId: user.id },
|
data: { teamId: team.id, userId: user.id },
|
||||||
});
|
});
|
||||||
const project = await prisma.project.create({
|
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 };
|
return { user, team, project };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
|
|||||||
import type { ModelFactory } from "../../src/agent/runner.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 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({
|
export const prisma = new PrismaClient({
|
||||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||||
@@ -35,6 +36,7 @@ export async function resetDb(): Promise<void> {
|
|||||||
"PermissionGrant",
|
"PermissionGrant",
|
||||||
"RoleTriggerGrant",
|
"RoleTriggerGrant",
|
||||||
"ExternalPrincipalMembership",
|
"ExternalPrincipalMembership",
|
||||||
|
"ExternalDirectoryConnection",
|
||||||
"TeamExternalBinding",
|
"TeamExternalBinding",
|
||||||
"TeamMembership",
|
"TeamMembership",
|
||||||
"Team",
|
"Team",
|
||||||
@@ -42,12 +44,30 @@ export async function resetDb(): Promise<void> {
|
|||||||
"AgentRun",
|
"AgentRun",
|
||||||
"AgentSession",
|
"AgentSession",
|
||||||
"ProjectGroupBinding",
|
"ProjectGroupBinding",
|
||||||
|
"OrganizationMembership",
|
||||||
"PlatformRoleAssignment",
|
"PlatformRoleAssignment",
|
||||||
"User",
|
"User",
|
||||||
"Project",
|
"Project",
|
||||||
|
"Organization",
|
||||||
];
|
];
|
||||||
// Truncate with CASCADE to wipe dependent rows in one shot.
|
// Truncate with CASCADE to wipe dependent rows in one shot.
|
||||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
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). */
|
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||||
@@ -340,6 +360,7 @@ export async function seedProject(
|
|||||||
await prisma.project.create({
|
await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
name: `Test ${projectId}`,
|
name: `Test ${projectId}`,
|
||||||
workspaceDir: `/tmp/test-${projectId}`,
|
workspaceDir: `/tmp/test-${projectId}`,
|
||||||
},
|
},
|
||||||
@@ -350,6 +371,7 @@ export async function seedProject(
|
|||||||
feishuOpenId: principal,
|
feishuOpenId: principal,
|
||||||
displayName: "Test User",
|
displayName: "Test User",
|
||||||
platformRoles: { create: { role: "TEACHER" } },
|
platformRoles: { create: { role: "TEACHER" } },
|
||||||
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
|
||||||
permissionGrants: {
|
permissionGrants: {
|
||||||
create: {
|
create: {
|
||||||
resourceType: "PROJECT",
|
resourceType: "PROJECT",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
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";
|
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
|
||||||
|
|
||||||
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||||
@@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
|||||||
|
|
||||||
it("acquireLock + currentLockRunId round-trip", async () => {
|
it("acquireLock + currentLockRunId round-trip", async () => {
|
||||||
const project = await prisma.project.create({
|
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({
|
const run = await prisma.agentRun.create({
|
||||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
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 () => {
|
it("acquireLock fails when project already locked (exclusivity)", async () => {
|
||||||
const project = await prisma.project.create({
|
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({
|
const run1 = await prisma.agentRun.create({
|
||||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
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 () => {
|
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
|
||||||
const project = await prisma.project.create({
|
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.
|
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
|
||||||
const run = await prisma.agentRun.create({
|
const run = await prisma.agentRun.create({
|
||||||
@@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
|||||||
|
|
||||||
it("releaseLock is idempotent", async () => {
|
it("releaseLock is idempotent", async () => {
|
||||||
const project = await prisma.project.create({
|
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({
|
const run = await prisma.agentRun.create({
|
||||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
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 { 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";
|
import { canTriggerRole } from "../../src/permission.js";
|
||||||
|
|
||||||
describe("canTriggerRole (integration, per-role gate)", () => {
|
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 () => {
|
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||||
const project = await prisma.project.create({
|
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");
|
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||||
expect(r.allowed).toBe(true);
|
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 () => {
|
it("allows when principal holds an active grant for the role", async () => {
|
||||||
const project = await prisma.project.create({
|
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({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
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 () => {
|
it("denies when grants exist for the role but principal has none", async () => {
|
||||||
const project = await prisma.project.create({
|
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.
|
// Someone else has the role; ou_b does not.
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
@@ -44,7 +44,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
|
|
||||||
it("denies when the principal's grant was revoked", async () => {
|
it("denies when the principal's grant was revoked", async () => {
|
||||||
const project = await prisma.project.create({
|
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({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
|
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 () => {
|
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 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", 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({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ function allowAllAuthorizer(): PermissionAuthorizer {
|
|||||||
action: req.action,
|
action: req.action,
|
||||||
resource: req.resource,
|
resource: req.resource,
|
||||||
actor: req.actor,
|
actor: req.actor,
|
||||||
|
organizationId: "org_test_default",
|
||||||
actorUserId: "user-1",
|
actorUserId: "user-1",
|
||||||
principals: [{ type: "USER", id: "ou_user" }],
|
principals: [{ type: "USER", id: "ou_user" }],
|
||||||
requiredRole: "EDIT",
|
requiredRole: "EDIT",
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ function allowAllAuthorizer(): PermissionAuthorizer {
|
|||||||
action: req.action,
|
action: req.action,
|
||||||
resource: req.resource,
|
resource: req.resource,
|
||||||
actor: req.actor,
|
actor: req.actor,
|
||||||
|
organizationId: "org_test_default",
|
||||||
actorUserId: "user-1",
|
actorUserId: "user-1",
|
||||||
principals: [{ type: "USER", id: "ou_user" }],
|
principals: [{ type: "USER", id: "ou_user" }],
|
||||||
requiredRole: "EDIT",
|
requiredRole: "EDIT",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ namespace Spec.System
|
|||||||
structure Identifiers where
|
structure Identifiers where
|
||||||
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
|
/-- 课程项目标识(`OPEN` 表示;聚合根,likec4 `Project`)。 -/
|
||||||
ProjectId : Type
|
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 无关,
|
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
|
||||||
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
|
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/
|
||||||
RunId : Type
|
RunId : Type
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Spec.System.ProjectGroup
|
import Spec.System.ProjectGroup
|
||||||
|
import Spec.System.Organization
|
||||||
import Spec.System.Run
|
import Spec.System.Run
|
||||||
import Spec.System.Lock
|
import Spec.System.Lock
|
||||||
import Spec.System.Memory
|
import Spec.System.Memory
|
||||||
@@ -14,6 +15,7 @@ import Spec.System.Audit
|
|||||||
**语义分歧点**:
|
**语义分歧点**:
|
||||||
|
|
||||||
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
|
||||||
|
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。
|
||||||
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
|
||||||
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
|
||||||
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
|
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。
|
||||||
@@ -24,5 +26,5 @@ import Spec.System.Audit
|
|||||||
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
|
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。
|
||||||
- `Audit` —— 有意从简(内容多为 plumbing,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 未定,非本
|
principal 子类型学(user/chat/department/…)与各 policy 值域均为 `OPEN`(ADR 未定,非本
|
||||||
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
|
层分歧点)。**role-capability 与 settings-policy 如何组合成最终授权决策**亦 `OPEN`——
|
||||||
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
|
ADR-0004 把二者列为分离的闸,但未明文规定组合规则(AND?settings 能否超出 role?),实现
|
||||||
须 surface,不得默认。
|
须 surface,不得默认。ADR-0020 另行钉死 TEAM principal 授权 PROJECT resource 时必须
|
||||||
|
同 organization;该 tenant well-scopedness 见 `Spec.System.Organization`。
|
||||||
-/
|
-/
|
||||||
|
|
||||||
namespace Spec.System
|
namespace Spec.System
|
||||||
|
|||||||
Reference in New Issue
Block a user