feat: add organization tenant model

This commit is contained in:
2026-07-09 23:37:20 +08:00
parent 5d315fff21
commit 2b7aa3294f
20 changed files with 900 additions and 116 deletions
+2
View File
@@ -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`)。
## 纪律 ## 纪律
+2
View File
@@ -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;
+82 -6
View File
@@ -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())
@@ -35,6 +83,7 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[] platformRoles PlatformRoleAssignment[]
organizationMemberships OrganizationMembership[]
createdProjects Project[] @relation("projectCreator") createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester") requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder") heldLocks ProjectAgentLock[] @relation("lockHolder")
@@ -81,17 +130,20 @@ 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
slug String
name String name String
description String? description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
memberships TeamMembership[] 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
@@ -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,6 +232,7 @@ model Project {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
archivedAt DateTime? archivedAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding? groupBinding ProjectGroupBinding?
agentSessions AgentSession[] agentSessions AgentSession[]
@@ -166,6 +242,7 @@ model Project {
auditEntries AuditEntry[] @relation("projectAudit") auditEntries AuditEntry[] @relation("projectAudit")
fileChanges AgentFileChange[] @relation("projectFileChanges") fileChanges AgentFileChange[] @relation("projectFileChanges")
@@index([organizationId, archivedAt])
@@index([archivedAt]) @@index([archivedAt])
} }
@@ -351,7 +428,6 @@ model PermissionSettings {
@@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.
+6
View File
@@ -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;
+32 -2
View File
@@ -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(
+48 -7
View File
@@ -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 {
+22 -5
View File
@@ -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(),
+218
View File
@@ -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,
};
}
+91 -18
View File
@@ -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 };
} }
+22
View File
@@ -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",
+5 -5
View File
@@ -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: {} },
+7 -7
View File
@@ -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" },
}); });
+1
View File
@@ -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",
+1
View File
@@ -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",
+4
View File
@@ -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
+3 -1
View File
@@ -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
-/ -/
+51
View File
@@ -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
+2 -1
View File
@@ -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