forked from bai/curriculum-project-hub
feat(hub): 支持教师团队权限
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
# ADR 0019: Resolve Actors to Principal Sets for Team Permissions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0004 deliberately chose a Feishu Docs-like permission model:
|
||||||
|
`grant(resource, principal, role)` plus separate resource settings. The first
|
||||||
|
Hub implementation stored `principal` as an opaque string and the Feishu trigger
|
||||||
|
used the sender's `open_id` directly. That was enough for individual teacher
|
||||||
|
grants, but not for production collaboration:
|
||||||
|
|
||||||
|
- a curriculum team needs one grant to apply to many teachers;
|
||||||
|
- Feishu departments, user groups, and project chats should be grantable
|
||||||
|
principals;
|
||||||
|
- role-specific agent grants (`/review`, `/draft`) must compose with project
|
||||||
|
permissions consistently;
|
||||||
|
- audit/debug output must explain which principal and grant allowed or denied a
|
||||||
|
run.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce typed principals and resolve every actor to a principal set before
|
||||||
|
authorization.
|
||||||
|
|
||||||
|
### Principal types
|
||||||
|
|
||||||
|
`PrincipalType` is:
|
||||||
|
|
||||||
|
- `USER`: a Feishu user, identified by `feishuOpenId`.
|
||||||
|
- `TEAM`: a Hub-managed teacher team.
|
||||||
|
- `FEISHU_CHAT`: a Feishu chat/group.
|
||||||
|
- `FEISHU_DEPARTMENT`: a Feishu department.
|
||||||
|
- `FEISHU_USER_GROUP`: a Feishu user group.
|
||||||
|
- `APP`: an integration or bot principal.
|
||||||
|
|
||||||
|
`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
|
||||||
|
`principalId`. Legacy opaque principal strings are backfilled as
|
||||||
|
`USER/<old principal>`.
|
||||||
|
|
||||||
|
### Team membership
|
||||||
|
|
||||||
|
Hub teams are first-class. A user can be a direct active member of a team. A
|
||||||
|
team can also be bound to external Feishu principals: chat, department, or user
|
||||||
|
group. If an actor resolves to any bound external principal, the actor also
|
||||||
|
resolves to that Hub team.
|
||||||
|
|
||||||
|
Membership changes are effective immediately because authorization resolves the
|
||||||
|
principal set at request time.
|
||||||
|
|
||||||
|
### External Feishu principal sync
|
||||||
|
|
||||||
|
Feishu department, user group, and chat memberships are not scattered through
|
||||||
|
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
|
||||||
|
read by `PrincipalResolver`. The Feishu message context can also contribute the
|
||||||
|
current `FEISHU_CHAT` principal directly because receiving a group event is
|
||||||
|
already contextual proof that the actor is speaking in that group.
|
||||||
|
|
||||||
|
### Grant merging
|
||||||
|
|
||||||
|
Authorization evaluates all active grants matching any resolved principal for
|
||||||
|
the requested resource. There is no explicit deny in this ADR. Among matching
|
||||||
|
grants, the highest role wins:
|
||||||
|
|
||||||
|
```text
|
||||||
|
READ < EDIT < MANAGE
|
||||||
|
```
|
||||||
|
|
||||||
|
Action thresholds:
|
||||||
|
|
||||||
|
- project read requires `READ`.
|
||||||
|
- project edit requires `EDIT`.
|
||||||
|
- collaborator management requires `MANAGE`.
|
||||||
|
- agent trigger requires `EDIT`.
|
||||||
|
- normal agent cancel requires `MANAGE`.
|
||||||
|
|
||||||
|
`PermissionSettings` never grants access beyond grants. Settings only constrain
|
||||||
|
an already-granted capability. For `agentTrigger`:
|
||||||
|
|
||||||
|
- missing setting or `ROLE` means role-derived grants decide;
|
||||||
|
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
|
||||||
|
- `DISABLED` denies the action.
|
||||||
|
|
||||||
|
Other settings keep their existing string storage, but the same rule applies:
|
||||||
|
settings are policy constraints, not positive grants.
|
||||||
|
|
||||||
|
### Role-trigger composition
|
||||||
|
|
||||||
|
Role-trigger grants are a second gate after the project-level `agent.trigger`
|
||||||
|
gate:
|
||||||
|
|
||||||
|
1. The actor must pass project `agent.trigger` for the project.
|
||||||
|
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
|
||||||
|
the role is open for backward compatibility.
|
||||||
|
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
|
||||||
|
role is configured. The actor must match an active role grant by any resolved
|
||||||
|
principal.
|
||||||
|
|
||||||
|
This keeps `/review` restrictions orthogonal to ordinary edit access while
|
||||||
|
preventing a fully revoked role from silently reopening.
|
||||||
|
|
||||||
|
### Module seam
|
||||||
|
|
||||||
|
Callers use a single authorization module:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
authorizer.can({
|
||||||
|
actor,
|
||||||
|
action,
|
||||||
|
resource,
|
||||||
|
roleId,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller does not know how users, teams, Feishu departments, user groups, or
|
||||||
|
chats expand. `PrincipalResolver` owns that implementation, and
|
||||||
|
`PermissionAuthorizer` owns grant/settings/role-trigger composition.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Teacher teams become production-grade principals rather than UI sugar.
|
||||||
|
- Feishu external organization concepts can be synchronized and granted without
|
||||||
|
leaking Feishu-specific checks into the trigger path.
|
||||||
|
- A single audit decision can report actor, principal set, matched grant, and
|
||||||
|
matched role grant.
|
||||||
|
- Backward compatibility is explicit: old principal strings become user
|
||||||
|
principals, and unconfigured role grants remain open.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
-- ADR-0019: typed principals, Hub teams, and external Feishu principal sync.
|
||||||
|
|
||||||
|
CREATE TYPE "PrincipalType" AS ENUM (
|
||||||
|
'USER',
|
||||||
|
'TEAM',
|
||||||
|
'FEISHU_CHAT',
|
||||||
|
'FEISHU_DEPARTMENT',
|
||||||
|
'FEISHU_USER_GROUP',
|
||||||
|
'APP'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Team_slug_key" ON "Team"("slug");
|
||||||
|
CREATE INDEX "Team_archivedAt_idx" ON "Team"("archivedAt");
|
||||||
|
|
||||||
|
CREATE TABLE "TeamMembership" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"teamId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "TeamMembership_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "TeamMembership_teamId_revokedAt_idx" ON "TeamMembership"("teamId", "revokedAt");
|
||||||
|
CREATE INDEX "TeamMembership_userId_revokedAt_idx" ON "TeamMembership"("userId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "TeamMembership_teamId_userId_revokedAt_key" ON "TeamMembership"("teamId", "userId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "TeamMembership_active_key" ON "TeamMembership"("teamId", "userId") WHERE "revokedAt" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "TeamMembership" ADD CONSTRAINT "TeamMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "TeamExternalBinding" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"teamId" TEXT NOT NULL,
|
||||||
|
"principalType" "PrincipalType" NOT NULL,
|
||||||
|
"principalId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "TeamExternalBinding_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "TeamExternalBinding_principalType_principalId_revokedAt_idx" ON "TeamExternalBinding"("principalType", "principalId", "revokedAt");
|
||||||
|
CREATE INDEX "TeamExternalBinding_teamId_revokedAt_idx" ON "TeamExternalBinding"("teamId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "TeamExternalBinding_active_key" ON "TeamExternalBinding"("teamId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "TeamExternalBinding" ADD CONSTRAINT "TeamExternalBinding_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE "ExternalPrincipalMembership" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"principalType" "PrincipalType" NOT NULL,
|
||||||
|
"principalId" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ExternalPrincipalMembership_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ExternalPrincipalMembership_userId_revokedAt_idx" ON "ExternalPrincipalMembership"("userId", "revokedAt");
|
||||||
|
CREATE INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt_idx" ON "ExternalPrincipalMembership"("principalType", "principalId", "revokedAt");
|
||||||
|
CREATE INDEX "ExternalPrincipalMembership_source_syncedAt_idx" ON "ExternalPrincipalMembership"("source", "syncedAt");
|
||||||
|
CREATE UNIQUE INDEX "ExternalPrincipalMembership_userId_principalType_principalId_source_revokedAt_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "ExternalPrincipalMembership_active_key" ON "ExternalPrincipalMembership"("userId", "principalType", "principalId", "source") WHERE "revokedAt" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "ExternalPrincipalMembership" ADD CONSTRAINT "ExternalPrincipalMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- PermissionGrant: backfill legacy opaque principal strings as USER principals.
|
||||||
|
ALTER TABLE "PermissionGrant" DROP CONSTRAINT IF EXISTS "PermissionGrant_resourceId_fkey";
|
||||||
|
DROP INDEX IF EXISTS "PermissionGrant_resourceType_resourceId_principal_role_revo_key";
|
||||||
|
DROP INDEX IF EXISTS "PermissionGrant_principal_revokedAt_idx";
|
||||||
|
|
||||||
|
ALTER TABLE "PermissionGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
|
||||||
|
ALTER TABLE "PermissionGrant" ADD COLUMN "principalId" TEXT;
|
||||||
|
UPDATE "PermissionGrant" SET "principalId" = "principal";
|
||||||
|
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalId" SET NOT NULL;
|
||||||
|
ALTER TABLE "PermissionGrant" ALTER COLUMN "principalType" DROP DEFAULT;
|
||||||
|
ALTER TABLE "PermissionGrant" DROP COLUMN "principal";
|
||||||
|
|
||||||
|
WITH ranked_permission_grants AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY "resourceType", "resourceId", "principalType", "principalId", "role"
|
||||||
|
ORDER BY "createdAt", "id"
|
||||||
|
) AS rn
|
||||||
|
FROM "PermissionGrant"
|
||||||
|
WHERE "revokedAt" IS NULL
|
||||||
|
)
|
||||||
|
DELETE FROM "PermissionGrant"
|
||||||
|
WHERE "id" IN (
|
||||||
|
SELECT "id" FROM ranked_permission_grants WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "PermissionGrant_principalType_principalId_revokedAt_idx" ON "PermissionGrant"("principalType", "principalId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "PermissionGrant_active_key" ON "PermissionGrant"("resourceType", "resourceId", "principalType", "principalId", "role") WHERE "revokedAt" IS NULL;
|
||||||
|
|
||||||
|
-- PermissionSettings resource is polymorphic; do not FK resourceId to Project.
|
||||||
|
ALTER TABLE "PermissionSettings" DROP CONSTRAINT IF EXISTS "PermissionSettings_resourceId_fkey";
|
||||||
|
|
||||||
|
-- RoleTriggerGrant: same backfill as PermissionGrant.
|
||||||
|
DROP INDEX IF EXISTS "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key";
|
||||||
|
DROP INDEX IF EXISTS "RoleTriggerGrant_principal_revokedAt_idx";
|
||||||
|
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalType" "PrincipalType" NOT NULL DEFAULT 'USER';
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ADD COLUMN "principalId" TEXT;
|
||||||
|
UPDATE "RoleTriggerGrant" SET "principalId" = "principal";
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalId" SET NOT NULL;
|
||||||
|
ALTER TABLE "RoleTriggerGrant" ALTER COLUMN "principalType" DROP DEFAULT;
|
||||||
|
ALTER TABLE "RoleTriggerGrant" DROP COLUMN "principal";
|
||||||
|
|
||||||
|
WITH ranked_role_trigger_grants AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY "projectId", "roleId", "principalType", "principalId"
|
||||||
|
ORDER BY "createdAt", "id"
|
||||||
|
) AS rn
|
||||||
|
FROM "RoleTriggerGrant"
|
||||||
|
WHERE "revokedAt" IS NULL
|
||||||
|
)
|
||||||
|
DELETE FROM "RoleTriggerGrant"
|
||||||
|
WHERE "id" IN (
|
||||||
|
SELECT "id" FROM ranked_role_trigger_grants WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "RoleTriggerGrant_principalType_principalId_revokedAt_idx" ON "RoleTriggerGrant"("principalType", "principalId", "revokedAt");
|
||||||
|
CREATE UNIQUE INDEX "RoleTriggerGrant_active_key" ON "RoleTriggerGrant"("projectId", "roleId", "principalType", "principalId") WHERE "revokedAt" IS NULL;
|
||||||
+96
-21
@@ -39,6 +39,8 @@ model User {
|
|||||||
requestedRuns AgentRun[] @relation("runRequester")
|
requestedRuns AgentRun[] @relation("runRequester")
|
||||||
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
heldLocks ProjectAgentLock[] @relation("lockHolder")
|
||||||
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
|
||||||
|
teamMemberships TeamMembership[]
|
||||||
|
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")
|
||||||
@@ -64,6 +66,86 @@ enum PlatformRole {
|
|||||||
TEACHER
|
TEACHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ADR-0019: typed principals for permission grants and actor resolution.
|
||||||
|
/// USER is identified by Feishu open_id; TEAM by Hub Team.id; Feishu external
|
||||||
|
/// principals by their Feishu ids.
|
||||||
|
enum PrincipalType {
|
||||||
|
USER
|
||||||
|
TEAM
|
||||||
|
FEISHU_CHAT
|
||||||
|
FEISHU_DEPARTMENT
|
||||||
|
FEISHU_USER_GROUP
|
||||||
|
APP
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hub-managed teacher team. A team is a first-class permission principal.
|
||||||
|
model Team {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
|
||||||
|
memberships TeamMembership[]
|
||||||
|
externalBindings TeamExternalBinding[]
|
||||||
|
|
||||||
|
@@index([archivedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct Hub team membership. External Feishu groups can also map to teams via
|
||||||
|
/// TeamExternalBinding; both sources are resolved at authorization time.
|
||||||
|
model TeamMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, userId, revokedAt])
|
||||||
|
@@index([teamId, revokedAt])
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a Hub team to an external Feishu principal. When an actor resolves to
|
||||||
|
/// the external principal, the actor also resolves to this team.
|
||||||
|
model TeamExternalBinding {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
teamId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([teamId, principalType, principalId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
@@index([teamId, revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locally synchronized Feishu external principal membership.
|
||||||
|
model ExternalPrincipalMembership {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
|
source String
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, principalType, principalId, source, revokedAt])
|
||||||
|
@@index([userId, revokedAt])
|
||||||
|
@@index([principalType, principalId, revokedAt])
|
||||||
|
@@index([source, syncedAt])
|
||||||
|
}
|
||||||
|
|
||||||
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
// --- Project & Feishu binding (ADR-0001) ---------------------------------
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
@@ -80,8 +162,6 @@ model Project {
|
|||||||
agentSessions AgentSession[]
|
agentSessions AgentSession[]
|
||||||
agentRuns AgentRun[]
|
agentRuns AgentRun[]
|
||||||
agentLock ProjectAgentLock?
|
agentLock ProjectAgentLock?
|
||||||
permissionGrants PermissionGrant[] @relation("projectGrants")
|
|
||||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
|
||||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||||
auditEntries AuditEntry[] @relation("projectAudit")
|
auditEntries AuditEntry[] @relation("projectAudit")
|
||||||
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||||
@@ -224,32 +304,29 @@ enum PermissionResourceType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// ADR-0004 PermissionGrant: resource × principal × role.
|
/// ADR-0004 PermissionGrant: resource × principal × role.
|
||||||
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
|
/// ADR-0019 replaces the old opaque `principal` with typed
|
||||||
/// The compound unique covers "one active grant per (resource, principal, role)"
|
/// `principalType/principalId` so user/team/Feishu principals compose through
|
||||||
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
|
/// the same authorization path.
|
||||||
/// row, not a conflict.
|
|
||||||
model PermissionGrant {
|
model PermissionGrant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resourceType PermissionResourceType
|
resourceType PermissionResourceType
|
||||||
resourceId String
|
resourceId String
|
||||||
principal String
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
role PermissionRole
|
role PermissionRole
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
|
||||||
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
|
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@unique([resourceType, resourceId, principal, role, revokedAt])
|
|
||||||
@@index([resourceType, resourceId, revokedAt])
|
@@index([resourceType, resourceId, revokedAt])
|
||||||
@@index([principal, revokedAt])
|
@@index([principalType, principalId, revokedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
|
||||||
/// opaque string column each; the enum/value-domain is decided by admin policy,
|
/// opaque string column each. ADR-0019 pins `agentTrigger` values used by the
|
||||||
/// not by this schema. `resourceType`+`resourceId` identify the resource.
|
/// authorizer: ROLE, MANAGE_ONLY, DISABLED.
|
||||||
/// Related to Project only when resourceType=PROJECT (null otherwise).
|
|
||||||
model PermissionSettings {
|
model PermissionSettings {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
resourceType PermissionResourceType
|
resourceType PermissionResourceType
|
||||||
@@ -263,8 +340,6 @@ model PermissionSettings {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([resourceType, resourceId])
|
@@unique([resourceType, resourceId])
|
||||||
@@index([resourceType, resourceId])
|
@@index([resourceType, resourceId])
|
||||||
}
|
}
|
||||||
@@ -276,15 +351,16 @@ model PermissionSettings {
|
|||||||
/// /review vs /draft). Two gates in series — both must pass.
|
/// /review vs /draft). Two gates in series — both must pass.
|
||||||
///
|
///
|
||||||
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
|
||||||
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
|
/// data, not a code enum). ADR-0019 makes the principal typed. A project-scoped
|
||||||
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
|
/// row grants the role on that project;
|
||||||
/// 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
|
||||||
principal String
|
principalType PrincipalType
|
||||||
|
principalId String
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
@@ -292,9 +368,8 @@ model RoleTriggerGrant {
|
|||||||
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([projectId, roleId, principal, revokedAt])
|
|
||||||
@@index([projectId, roleId, revokedAt])
|
@@index([projectId, roleId, revokedAt])
|
||||||
@@index([principal, revokedAt])
|
@@index([principalType, principalId, revokedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
// --- Audit (ADR Audit, content OPEN) -------------------------------------
|
||||||
|
|||||||
+64
-15
@@ -15,7 +15,7 @@ import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMe
|
|||||||
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
||||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
import { PatchableTextStream } from "./textStream.js";
|
import { PatchableTextStream } from "./textStream.js";
|
||||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||||
@@ -25,6 +25,7 @@ interface TriggerDeps {
|
|||||||
readonly settings: RuntimeSettings;
|
readonly settings: RuntimeSettings;
|
||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||||
|
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,7 @@ interface TriggerDeps {
|
|||||||
* agent run is the long leg); the lock guarantees one run per project at a time.
|
* agent run is the long leg); the lock guarantees one run per project at a time.
|
||||||
*/
|
*/
|
||||||
export function makeTriggerHandler(deps: TriggerDeps) {
|
export function makeTriggerHandler(deps: TriggerDeps) {
|
||||||
|
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
|
||||||
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
||||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
@@ -77,8 +79,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectId = binding.projectId;
|
const projectId = binding.projectId;
|
||||||
// ADR-0004: triggerAgent requires edit on the project. principal=sender
|
// ADR-0019: triggerAgent is authorized through actor → principal-set
|
||||||
// open_id (sub-typology OPEN — pragmatic choice, see permission.ts).
|
// resolution, then grant/settings composition.
|
||||||
// A missing open_id is treated as an untrusted event: deny explicitly
|
// A missing open_id is treated as an untrusted event: deny explicitly
|
||||||
// rather than skipping the check (fail closed).
|
// rather than skipping the check (fail closed).
|
||||||
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
const senderOpenId = event.sender.sender_id.open_id ?? "";
|
||||||
@@ -88,10 +90,20 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
|
await sendText(rt, chatId, "无法识别发送者,拒绝触发。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId);
|
const actor = { feishuOpenId: senderOpenId, chatId };
|
||||||
if (!perm.allowed) {
|
const triggerDecision = await authorizer.can({
|
||||||
deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied");
|
actor,
|
||||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.denied", metadata: { reason: perm.reason } });
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: projectId },
|
||||||
|
});
|
||||||
|
if (!triggerDecision.allowed) {
|
||||||
|
deps.logger.info({ projectId, senderOpenId, reason: triggerDecision.reason }, "feishu trigger: permission denied");
|
||||||
|
await writeAudit(deps.prisma, {
|
||||||
|
projectId,
|
||||||
|
...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
|
||||||
|
action: "trigger.denied",
|
||||||
|
metadata: { decision: auditDecision(triggerDecision) },
|
||||||
|
});
|
||||||
await sendText(rt, chatId, "无权限触发。");
|
await sendText(rt, chatId, "无权限触发。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,13 +198,23 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
const models = await deps.settings.modelRegistry({ projectId });
|
const models = await deps.settings.modelRegistry({ projectId });
|
||||||
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models);
|
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models);
|
||||||
const roleId = parsedRole ?? "draft";
|
const roleId = parsedRole ?? "draft";
|
||||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
// ADR-0019: role trigger is the second gate after project agent.trigger.
|
||||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
// Unconfigured roles remain open for back-compat; configured roles require
|
||||||
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
|
// a matching active RoleTriggerGrant for any resolved principal.
|
||||||
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
|
const roleDecision = await authorizer.can({
|
||||||
if (!rolePerm.allowed) {
|
actor,
|
||||||
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
|
action: "role.trigger",
|
||||||
await writeAudit(deps.prisma, { projectId, actorUserId: senderOpenId, action: "trigger.role_denied", metadata: { roleId, reason: rolePerm.reason } });
|
resource: { type: "PROJECT", id: projectId },
|
||||||
|
roleId,
|
||||||
|
});
|
||||||
|
if (!roleDecision.allowed) {
|
||||||
|
deps.logger.info({ projectId, roleId, senderOpenId, reason: roleDecision.reason }, "feishu trigger: role permission denied");
|
||||||
|
await writeAudit(deps.prisma, {
|
||||||
|
projectId,
|
||||||
|
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
|
||||||
|
action: "trigger.role_denied",
|
||||||
|
metadata: { roleId, decision: auditDecision(roleDecision) },
|
||||||
|
});
|
||||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,7 +257,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
requestedByUserId: roleDecision.actorUserId ?? null,
|
||||||
entrypoint: "FEISHU",
|
entrypoint: "FEISHU",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
prompt: agentPrompt,
|
prompt: agentPrompt,
|
||||||
@@ -395,6 +417,33 @@ function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
allowed: decision.allowed,
|
||||||
|
reason: decision.reason,
|
||||||
|
action: decision.action,
|
||||||
|
requiredRole: decision.requiredRole,
|
||||||
|
effectiveRole: decision.effectiveRole ?? null,
|
||||||
|
principals: decision.principals.map((principal) => ({
|
||||||
|
type: principal.type,
|
||||||
|
id: principal.id,
|
||||||
|
})),
|
||||||
|
matchedGrant: decision.matchedGrant === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: decision.matchedGrant.id,
|
||||||
|
principal: decision.matchedGrant.principal,
|
||||||
|
role: decision.matchedGrant.role,
|
||||||
|
},
|
||||||
|
matchedRoleGrant: decision.matchedRoleGrant === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: decision.matchedRoleGrant.id,
|
||||||
|
principal: decision.matchedRoleGrant.principal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
|
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
|
||||||
const fileDeliveryPrompt =
|
const fileDeliveryPrompt =
|
||||||
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
|
||||||
|
|||||||
+39
-74
@@ -1,89 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* Permission gate — ADR-0004 in code, project-scope.
|
* Permission compatibility exports.
|
||||||
*
|
*
|
||||||
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
|
* ADR-0019 moves production authorization behind PrincipalResolver +
|
||||||
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
|
* PermissionAuthorizer. The wrappers below preserve the old helper names for
|
||||||
* `Role.can_mono`). This module checks that for the run's requester against
|
* tests and incremental callers while routing project trigger checks through
|
||||||
* the project resource.
|
* the new authorizer.
|
||||||
*
|
|
||||||
* Scope decision (project-wise): the resource is `project`, not
|
|
||||||
* `project_group` — the run and lock scope to the project (ADR-0002), so the
|
|
||||||
* capability is checked against the project. per-artifact permission is `OPEN`
|
|
||||||
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
|
|
||||||
*
|
|
||||||
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
|
|
||||||
* use the sender's Feishu open_id as the principal string literal — a pragmatic
|
|
||||||
* choice for the first cut, not a spec decision. When a real principal model
|
|
||||||
* lands, this is the single seam to change.
|
|
||||||
*
|
|
||||||
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
|
|
||||||
* capabilities from settings policy knobs but doesn't pin the composition
|
|
||||||
* rule). This gate implements the role half only; settings composition is
|
|
||||||
* deferred and clearly marked.
|
|
||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { PermissionRole } from "@prisma/client";
|
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||||
|
|
||||||
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||||
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||||
|
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||||
|
export type {
|
||||||
|
AuthorizationAction,
|
||||||
|
AuthorizationDecision,
|
||||||
|
AuthorizationRequest,
|
||||||
|
AuthorizationResource,
|
||||||
|
PermissionAuthorizer,
|
||||||
|
} from "./permissions/authorizer.js";
|
||||||
|
export type {
|
||||||
|
ActorInput,
|
||||||
|
PrincipalRef,
|
||||||
|
PrincipalResolution,
|
||||||
|
PrincipalResolver,
|
||||||
|
ResolvedPrincipal,
|
||||||
|
} from "./permissions/principals.js";
|
||||||
|
export type {
|
||||||
|
ExternalPrincipalMembershipInput,
|
||||||
|
ExternalPrincipalType,
|
||||||
|
SyncExternalPrincipalMembershipsInput,
|
||||||
|
SyncExternalPrincipalMembershipsResult,
|
||||||
|
} from "./permissions/externalSync.js";
|
||||||
|
|
||||||
export interface PermissionResult {
|
export interface PermissionResult {
|
||||||
readonly allowed: boolean;
|
readonly allowed: boolean;
|
||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether `principal` may trigger an agent run on `projectId`.
|
|
||||||
*
|
|
||||||
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
|
|
||||||
* caller replies with the reason. Never throws — a DB error is a deny with the
|
|
||||||
* error as reason, so the run is not started on a broken state.
|
|
||||||
*/
|
|
||||||
export async function canTriggerAgent(
|
export async function canTriggerAgent(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
principal: string,
|
principal: string,
|
||||||
): Promise<PermissionResult> {
|
): Promise<PermissionResult> {
|
||||||
try {
|
try {
|
||||||
// Look for an active (non-revoked) grant on the project resource for this
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
// principal, with a role that grants edit or higher.
|
actor: { feishuOpenId: principal },
|
||||||
const grant = await prisma.permissionGrant.findFirst({
|
action: "agent.trigger",
|
||||||
where: {
|
resource: { type: "PROJECT", id: projectId },
|
||||||
resourceType: "PROJECT",
|
|
||||||
resourceId: projectId,
|
|
||||||
principal,
|
|
||||||
role: { in: ["EDIT", "MANAGE"] },
|
|
||||||
revokedAt: null,
|
|
||||||
},
|
|
||||||
select: { id: true, role: true },
|
|
||||||
});
|
});
|
||||||
if (grant !== null) {
|
return { allowed: decision.allowed, reason: decision.reason };
|
||||||
return { allowed: true, reason: `granted by role ${grant.role}` };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
|
* Role-only legacy helper. New trigger code uses `role.trigger` on
|
||||||
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
|
* PermissionAuthorizer so project edit+ and role grant composition happen in
|
||||||
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
|
* one decision.
|
||||||
* Two gates in series — both must pass.
|
|
||||||
*
|
|
||||||
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
|
|
||||||
* (regardless of principal), the role is unconfigured on this project →
|
|
||||||
* **allow** (back-compat: a project that hasn't configured per-role grants
|
|
||||||
* doesn't get locked down). Once any grant exists for that role on the
|
|
||||||
* project, only principals with an active grant may trigger it. "Grants exist
|
|
||||||
* but this principal has none" → deny (admin explicitly restricted the role).
|
|
||||||
*
|
|
||||||
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
|
|
||||||
* same opaque string ADR-0004 uses (sender open_id here; principal
|
|
||||||
* sub-typology OPEN).
|
|
||||||
*/
|
*/
|
||||||
export async function canTriggerRole(
|
export async function canTriggerRole(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
@@ -92,10 +67,6 @@ export async function canTriggerRole(
|
|||||||
principal: string,
|
principal: string,
|
||||||
): Promise<PermissionResult> {
|
): Promise<PermissionResult> {
|
||||||
try {
|
try {
|
||||||
// Any grant record (active OR revoked) for this (project, role)? If none,
|
|
||||||
// the role is unconfigured → allow (back-compat: no per-role restriction).
|
|
||||||
// A revoked grant still counts as "configured" — revoking one person must
|
|
||||||
// not silently reopen the role to everyone.
|
|
||||||
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||||
where: { projectId, roleId },
|
where: { projectId, roleId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -103,9 +74,8 @@ export async function canTriggerRole(
|
|||||||
if (anyGrant === null) {
|
if (anyGrant === null) {
|
||||||
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||||
}
|
}
|
||||||
// Grants exist — this principal must hold one.
|
|
||||||
const grant = await prisma.roleTriggerGrant.findFirst({
|
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||||
where: { projectId, roleId, principal, revokedAt: null },
|
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (grant !== null) {
|
if (grant !== null) {
|
||||||
@@ -119,8 +89,3 @@ export async function canTriggerRole(
|
|||||||
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
|
||||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
|
||||||
return EDIT_OR_HIGHER.has(role);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
|
||||||
|
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
|
||||||
|
|
||||||
|
export type AuthorizationAction =
|
||||||
|
| "project.read"
|
||||||
|
| "project.edit"
|
||||||
|
| "collaborator.manage"
|
||||||
|
| "agent.trigger"
|
||||||
|
| "agent.cancel"
|
||||||
|
| "role.trigger";
|
||||||
|
|
||||||
|
export interface AuthorizationResource {
|
||||||
|
readonly type: PermissionResourceType;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationRequest {
|
||||||
|
readonly actor: ActorInput;
|
||||||
|
readonly action: AuthorizationAction;
|
||||||
|
readonly resource: AuthorizationResource;
|
||||||
|
readonly roleId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchedPermissionGrant {
|
||||||
|
readonly id: string;
|
||||||
|
readonly principal: PrincipalRef;
|
||||||
|
readonly role: PermissionRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchedRoleTriggerGrant {
|
||||||
|
readonly id: string;
|
||||||
|
readonly principal: PrincipalRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationDecision {
|
||||||
|
readonly allowed: boolean;
|
||||||
|
readonly reason: string;
|
||||||
|
readonly action: AuthorizationAction;
|
||||||
|
readonly resource: AuthorizationResource;
|
||||||
|
readonly actor: ActorInput;
|
||||||
|
readonly actorUserId?: string | undefined;
|
||||||
|
readonly principals: readonly PrincipalRef[];
|
||||||
|
readonly requiredRole: PermissionRole;
|
||||||
|
readonly effectiveRole?: PermissionRole | undefined;
|
||||||
|
readonly matchedGrant?: MatchedPermissionGrant | undefined;
|
||||||
|
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionAuthorizer {
|
||||||
|
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
|
||||||
|
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||||
|
throw new Error("role.trigger authorization requires roleId");
|
||||||
|
}
|
||||||
|
const resolution = await this.resolver.resolveActor(req.actor);
|
||||||
|
const threshold = await this.requiredRole(req);
|
||||||
|
if (threshold === "DISABLED") {
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: false,
|
||||||
|
reason: "permission setting disables this action",
|
||||||
|
requiredRole: defaultRequiredRole(req.action),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
|
||||||
|
if (grant === undefined) {
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: false,
|
||||||
|
reason: `no active ${threshold}+ grant for resolved principals`,
|
||||||
|
requiredRole: threshold,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.action !== "role.trigger") {
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: true,
|
||||||
|
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
|
||||||
|
requiredRole: threshold,
|
||||||
|
effectiveRole: grant.role,
|
||||||
|
matchedGrant: grant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
|
||||||
|
if (roleGate.status === "open") {
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: true,
|
||||||
|
reason: `role ${req.roleId} unconfigured on project (open)`,
|
||||||
|
requiredRole: threshold,
|
||||||
|
effectiveRole: grant.role,
|
||||||
|
matchedGrant: grant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roleGate.grant !== undefined) {
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: true,
|
||||||
|
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
|
||||||
|
requiredRole: threshold,
|
||||||
|
effectiveRole: grant.role,
|
||||||
|
matchedGrant: grant,
|
||||||
|
matchedRoleGrant: roleGate.grant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.decision(req, resolution, {
|
||||||
|
allowed: false,
|
||||||
|
reason: `no active role ${req.roleId} grant for resolved principals`,
|
||||||
|
requiredRole: threshold,
|
||||||
|
effectiveRole: grant.role,
|
||||||
|
matchedGrant: grant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
|
||||||
|
let required = defaultRequiredRole(req.action);
|
||||||
|
if ((req.action === "agent.trigger" || req.action === "role.trigger") && req.resource.type === "PROJECT") {
|
||||||
|
const settings = await this.prisma.permissionSettings.findFirst({
|
||||||
|
where: { resourceType: req.resource.type, resourceId: req.resource.id },
|
||||||
|
select: { agentTrigger: true },
|
||||||
|
});
|
||||||
|
const policy = normalizePolicy(settings?.agentTrigger);
|
||||||
|
if (policy === "DISABLED") return "DISABLED";
|
||||||
|
if (policy === "MANAGE_ONLY") required = "MANAGE";
|
||||||
|
}
|
||||||
|
return required;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bestGrant(
|
||||||
|
resource: AuthorizationResource,
|
||||||
|
principals: readonly PrincipalRef[],
|
||||||
|
threshold: PermissionRole,
|
||||||
|
): Promise<MatchedPermissionGrant | undefined> {
|
||||||
|
const grants = await this.prisma.permissionGrant.findMany({
|
||||||
|
where: {
|
||||||
|
resourceType: resource.type,
|
||||||
|
resourceId: resource.id,
|
||||||
|
revokedAt: null,
|
||||||
|
OR: principalWhere(principals),
|
||||||
|
},
|
||||||
|
select: { id: true, principalType: true, principalId: true, role: true },
|
||||||
|
});
|
||||||
|
const sufficient = grants
|
||||||
|
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
|
||||||
|
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
|
||||||
|
if (sufficient === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
id: sufficient.id,
|
||||||
|
principal: { type: sufficient.principalType, id: sufficient.principalId },
|
||||||
|
role: sufficient.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roleTriggerGrant(
|
||||||
|
resource: AuthorizationResource,
|
||||||
|
roleId: string,
|
||||||
|
principals: readonly PrincipalRef[],
|
||||||
|
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
|
||||||
|
if (resource.type !== "PROJECT") {
|
||||||
|
return { status: "configured" };
|
||||||
|
}
|
||||||
|
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
|
||||||
|
where: { projectId: resource.id, roleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (anyGrant === null) return { status: "open" };
|
||||||
|
|
||||||
|
const grant = await this.prisma.roleTriggerGrant.findFirst({
|
||||||
|
where: {
|
||||||
|
projectId: resource.id,
|
||||||
|
roleId,
|
||||||
|
revokedAt: null,
|
||||||
|
OR: principalWhere(principals),
|
||||||
|
},
|
||||||
|
select: { id: true, principalType: true, principalId: true },
|
||||||
|
});
|
||||||
|
if (grant === null) return { status: "configured" };
|
||||||
|
return {
|
||||||
|
status: "configured",
|
||||||
|
grant: {
|
||||||
|
id: grant.id,
|
||||||
|
principal: { type: grant.principalType, id: grant.principalId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private decision(
|
||||||
|
req: AuthorizationRequest,
|
||||||
|
resolution: PrincipalResolution,
|
||||||
|
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
|
||||||
|
): AuthorizationDecision {
|
||||||
|
return {
|
||||||
|
action: req.action,
|
||||||
|
resource: req.resource,
|
||||||
|
actor: req.actor,
|
||||||
|
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
||||||
|
principals: resolution.principals,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPermissionAuthorizer(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
resolver?: PrincipalResolver,
|
||||||
|
): PermissionAuthorizer {
|
||||||
|
return new PrismaPermissionAuthorizer(prisma, resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||||
|
return roleRank(role) >= roleRank("EDIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleRank(role: PermissionRole): number {
|
||||||
|
switch (role) {
|
||||||
|
case "READ": return 1;
|
||||||
|
case "EDIT": return 2;
|
||||||
|
case "MANAGE": return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
|
||||||
|
switch (action) {
|
||||||
|
case "project.read": return "READ";
|
||||||
|
case "project.edit": return "EDIT";
|
||||||
|
case "agent.trigger": return "EDIT";
|
||||||
|
case "role.trigger": return "EDIT";
|
||||||
|
case "collaborator.manage": return "MANAGE";
|
||||||
|
case "agent.cancel": return "MANAGE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
|
||||||
|
if (policy === undefined || policy.trim() === "") return "ROLE";
|
||||||
|
const normalized = policy.trim().toUpperCase();
|
||||||
|
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
|
||||||
|
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
|
||||||
|
return "ROLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
|
||||||
|
return principals.map((principal) => ({
|
||||||
|
principalType: principal.type,
|
||||||
|
principalId: principal.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||||
|
|
||||||
|
export type ExternalPrincipalType = Extract<
|
||||||
|
PrincipalType,
|
||||||
|
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ExternalPrincipalMembershipInput {
|
||||||
|
readonly feishuOpenId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly principalType: ExternalPrincipalType;
|
||||||
|
readonly principalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncExternalPrincipalMembershipsInput {
|
||||||
|
readonly source: string;
|
||||||
|
readonly replaceSource?: boolean | undefined;
|
||||||
|
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncExternalPrincipalMembershipsResult {
|
||||||
|
readonly synced: number;
|
||||||
|
readonly revoked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncExternalPrincipalMemberships(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: SyncExternalPrincipalMembershipsInput,
|
||||||
|
): Promise<SyncExternalPrincipalMembershipsResult> {
|
||||||
|
const syncedAt = new Date();
|
||||||
|
const incomingKeys = new Set<string>();
|
||||||
|
let synced = 0;
|
||||||
|
|
||||||
|
for (const item of input.memberships) {
|
||||||
|
assertExternalPrincipalType(item.principalType);
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { feishuOpenId: item.feishuOpenId },
|
||||||
|
update: { displayName: item.displayName },
|
||||||
|
create: {
|
||||||
|
feishuOpenId: item.feishuOpenId,
|
||||||
|
displayName: item.displayName,
|
||||||
|
platformRoles: { create: { role: "TEACHER" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId));
|
||||||
|
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
principalType: item.principalType,
|
||||||
|
principalId: item.principalId,
|
||||||
|
source: input.source,
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing === null) {
|
||||||
|
await prisma.externalPrincipalMembership.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
principalType: item.principalType,
|
||||||
|
principalId: item.principalId,
|
||||||
|
source: input.source,
|
||||||
|
syncedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.externalPrincipalMembership.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { syncedAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
synced++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let revoked = 0;
|
||||||
|
if (input.replaceSource === true) {
|
||||||
|
const active = await prisma.externalPrincipalMembership.findMany({
|
||||||
|
where: { source: input.source, revokedAt: null },
|
||||||
|
select: { id: true, userId: true, principalType: true, principalId: true },
|
||||||
|
});
|
||||||
|
const revokeIds = active
|
||||||
|
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||||
|
membership.userId,
|
||||||
|
membership.principalType,
|
||||||
|
membership.principalId,
|
||||||
|
)))
|
||||||
|
.map((membership) => membership.id);
|
||||||
|
if (revokeIds.length > 0) {
|
||||||
|
const result = await prisma.externalPrincipalMembership.updateMany({
|
||||||
|
where: { id: { in: revokeIds } },
|
||||||
|
data: { revokedAt: syncedAt },
|
||||||
|
});
|
||||||
|
revoked = result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { synced, revoked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
|
||||||
|
return `${userId}:${type}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
|
||||||
|
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
|
||||||
|
throw new Error(`unsupported external principal type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface PrincipalRef {
|
||||||
|
readonly type: PrincipalType;
|
||||||
|
readonly id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActorInput {
|
||||||
|
readonly feishuOpenId: string;
|
||||||
|
readonly chatId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrincipalSource =
|
||||||
|
| "actor-user"
|
||||||
|
| "context-chat"
|
||||||
|
| "team-membership"
|
||||||
|
| "external-membership"
|
||||||
|
| "team-external-binding";
|
||||||
|
|
||||||
|
export interface ResolvedPrincipal {
|
||||||
|
readonly principal: PrincipalRef;
|
||||||
|
readonly source: PrincipalSource;
|
||||||
|
readonly via?: PrincipalRef | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrincipalResolution {
|
||||||
|
readonly actor: ActorInput;
|
||||||
|
readonly userId?: string | undefined;
|
||||||
|
readonly principals: readonly PrincipalRef[];
|
||||||
|
readonly resolved: readonly ResolvedPrincipal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrincipalResolver {
|
||||||
|
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
|
||||||
|
const resolved = new PrincipalCollector();
|
||||||
|
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||||
|
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||||
|
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { feishuOpenId: actor.feishuOpenId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user !== null) {
|
||||||
|
const memberships = await this.prisma.teamMembership.findMany({
|
||||||
|
where: { userId: user.id, revokedAt: null, team: { archivedAt: null } },
|
||||||
|
select: { teamId: true },
|
||||||
|
});
|
||||||
|
for (const membership of memberships) {
|
||||||
|
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
|
||||||
|
where: { userId: user.id, revokedAt: null },
|
||||||
|
select: { principalType: true, principalId: true },
|
||||||
|
});
|
||||||
|
for (const membership of externalMemberships) {
|
||||||
|
resolved.add(
|
||||||
|
{ type: membership.principalType, id: membership.principalId },
|
||||||
|
"external-membership",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
|
||||||
|
if (externalPrincipals.length > 0) {
|
||||||
|
const bindings = await this.prisma.teamExternalBinding.findMany({
|
||||||
|
where: {
|
||||||
|
revokedAt: null,
|
||||||
|
team: { archivedAt: null },
|
||||||
|
OR: externalPrincipals.map((principal) => ({
|
||||||
|
principalType: principal.type,
|
||||||
|
principalId: principal.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
select: { teamId: true, principalType: true, principalId: true },
|
||||||
|
});
|
||||||
|
for (const binding of bindings) {
|
||||||
|
resolved.add(
|
||||||
|
{ type: "TEAM", id: binding.teamId },
|
||||||
|
"team-external-binding",
|
||||||
|
{ type: binding.principalType, id: binding.principalId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actor,
|
||||||
|
...(user !== null ? { userId: user.id } : {}),
|
||||||
|
principals: resolved.principals(),
|
||||||
|
resolved: resolved.entries(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function principalKey(principal: PrincipalRef): string {
|
||||||
|
return `${principal.type}:${principal.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalPrincipal(type: PrincipalType): boolean {
|
||||||
|
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrincipalCollector {
|
||||||
|
private readonly refs = new Map<string, PrincipalRef>();
|
||||||
|
private readonly provenance: ResolvedPrincipal[] = [];
|
||||||
|
|
||||||
|
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
|
||||||
|
const key = principalKey(principal);
|
||||||
|
if (!this.refs.has(key)) {
|
||||||
|
this.refs.set(key, principal);
|
||||||
|
}
|
||||||
|
this.provenance.push({
|
||||||
|
principal,
|
||||||
|
source,
|
||||||
|
...(via !== undefined ? { via } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
principals(): readonly PrincipalRef[] {
|
||||||
|
return [...this.refs.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
entries(): readonly ResolvedPrincipal[] {
|
||||||
|
return this.provenance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma, resetDb } from "./helpers.js";
|
||||||
|
import { createPermissionAuthorizer, PrismaPrincipalResolver, syncExternalPrincipalMemberships } from "../../src/permission.js";
|
||||||
|
|
||||||
|
describe("ADR-0019 authorization", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves actor principals from user, chat, direct teams, external memberships, and external team bindings", async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
|
||||||
|
});
|
||||||
|
const directTeam = await prisma.team.create({
|
||||||
|
data: { slug: "direct-auth", name: "Direct Team" },
|
||||||
|
});
|
||||||
|
const departmentTeam = await prisma.team.create({
|
||||||
|
data: { slug: "department-auth", name: "Department Team" },
|
||||||
|
});
|
||||||
|
const chatTeam = await prisma.team.create({
|
||||||
|
data: { slug: "chat-auth", name: "Chat Team" },
|
||||||
|
});
|
||||||
|
await prisma.teamMembership.create({
|
||||||
|
data: { teamId: directTeam.id, userId: user.id },
|
||||||
|
});
|
||||||
|
await prisma.externalPrincipalMembership.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
|
principalId: "dep-physics",
|
||||||
|
source: "test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.teamExternalBinding.create({
|
||||||
|
data: { teamId: departmentTeam.id, principalType: "FEISHU_DEPARTMENT", principalId: "dep-physics" },
|
||||||
|
});
|
||||||
|
await prisma.teamExternalBinding.create({
|
||||||
|
data: { teamId: chatTeam.id, principalType: "FEISHU_CHAT", principalId: "chat-auth" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
|
||||||
|
feishuOpenId: "ou_auth_1",
|
||||||
|
chatId: "chat-auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolution.userId).toBe(user.id);
|
||||||
|
expect(resolution.principals).toEqual(expect.arrayContaining([
|
||||||
|
{ type: "USER", id: "ou_auth_1" },
|
||||||
|
{ type: "FEISHU_CHAT", id: "chat-auth" },
|
||||||
|
{ type: "FEISHU_DEPARTMENT", id: "dep-physics" },
|
||||||
|
{ type: "TEAM", id: directTeam.id },
|
||||||
|
{ type: "TEAM", id: departmentTeam.id },
|
||||||
|
{ type: "TEAM", id: chatTeam.id },
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows agent trigger through a team project grant", async () => {
|
||||||
|
const { team } = await seedUserTeamProject("auth-team-grant");
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-team-grant",
|
||||||
|
principalType: "TEAM",
|
||||||
|
principalId: team.id,
|
||||||
|
role: "EDIT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_team_grant" },
|
||||||
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-team-grant" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.matchedGrant).toMatchObject({
|
||||||
|
principal: { type: "TEAM", id: team.id },
|
||||||
|
role: "EDIT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses external Feishu principal memberships as grantable principals", async () => {
|
||||||
|
await prisma.project.create({
|
||||||
|
data: { id: "p-auth-ext", name: "External", workspaceDir: "/tmp/auth-ext" },
|
||||||
|
});
|
||||||
|
await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
source: "feishu-test",
|
||||||
|
memberships: [
|
||||||
|
{
|
||||||
|
feishuOpenId: "ou_auth_ext",
|
||||||
|
displayName: "Teacher Ext",
|
||||||
|
principalType: "FEISHU_USER_GROUP",
|
||||||
|
principalId: "ug-lesson-design",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-ext",
|
||||||
|
principalType: "FEISHU_USER_GROUP",
|
||||||
|
principalId: "ug-lesson-design",
|
||||||
|
role: "EDIT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_ext" },
|
||||||
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-ext" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(true);
|
||||||
|
expect(decision.matchedGrant?.principal).toEqual({
|
||||||
|
type: "FEISHU_USER_GROUP",
|
||||||
|
id: "ug-lesson-design",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes missing external memberships when replacing a sync source", async () => {
|
||||||
|
await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
source: "feishu-directory",
|
||||||
|
memberships: [
|
||||||
|
{
|
||||||
|
feishuOpenId: "ou_sync_a",
|
||||||
|
displayName: "Teacher A",
|
||||||
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
|
principalId: "dep-a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feishuOpenId: "ou_sync_b",
|
||||||
|
displayName: "Teacher B",
|
||||||
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
|
principalId: "dep-b",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await syncExternalPrincipalMemberships(prisma, {
|
||||||
|
source: "feishu-directory",
|
||||||
|
replaceSource: true,
|
||||||
|
memberships: [
|
||||||
|
{
|
||||||
|
feishuOpenId: "ou_sync_a",
|
||||||
|
displayName: "Teacher A",
|
||||||
|
principalType: "FEISHU_DEPARTMENT",
|
||||||
|
principalId: "dep-a",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ synced: 1, revoked: 1 });
|
||||||
|
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor({ feishuOpenId: "ou_sync_b" });
|
||||||
|
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
||||||
|
await prisma.project.create({
|
||||||
|
data: { id: "p-auth-policy", name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
||||||
|
});
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
|
||||||
|
});
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-policy",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_auth_policy",
|
||||||
|
role: "EDIT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.permissionSettings.create({
|
||||||
|
data: defaultProjectSettings("p-auth-policy", { agentTrigger: "MANAGE_ONLY" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const editDecision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_policy" },
|
||||||
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-policy" },
|
||||||
|
});
|
||||||
|
expect(editDecision.allowed).toBe(false);
|
||||||
|
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-policy",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_auth_policy",
|
||||||
|
role: "MANAGE",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manageDecision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_policy" },
|
||||||
|
action: "agent.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-policy" },
|
||||||
|
});
|
||||||
|
expect(manageDecision.allowed).toBe(true);
|
||||||
|
expect(manageDecision.requiredRole).toBe("MANAGE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires project agent.trigger and configured role trigger grant for role.trigger", async () => {
|
||||||
|
const { team } = await seedUserTeamProject("auth-role");
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-role",
|
||||||
|
principalType: "TEAM",
|
||||||
|
principalId: team.id,
|
||||||
|
role: "EDIT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: {
|
||||||
|
projectId: "p-auth-role",
|
||||||
|
roleId: "review",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_someone_else",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const denied = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_role" },
|
||||||
|
action: "role.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-role" },
|
||||||
|
roleId: "review",
|
||||||
|
});
|
||||||
|
expect(denied.allowed).toBe(false);
|
||||||
|
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: {
|
||||||
|
projectId: "p-auth-role",
|
||||||
|
roleId: "review",
|
||||||
|
principalType: "TEAM",
|
||||||
|
principalId: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowed = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_role" },
|
||||||
|
action: "role.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-role" },
|
||||||
|
roleId: "review",
|
||||||
|
});
|
||||||
|
expect(allowed.allowed).toBe(true);
|
||||||
|
expect(allowed.matchedRoleGrant?.principal).toEqual({ type: "TEAM", id: team.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a role configured when all role grants are revoked", async () => {
|
||||||
|
await prisma.project.create({
|
||||||
|
data: { id: "p-auth-revoked-role", name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
|
||||||
|
});
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
|
||||||
|
});
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-auth-revoked-role",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_auth_revoked",
|
||||||
|
role: "EDIT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.roleTriggerGrant.create({
|
||||||
|
data: {
|
||||||
|
projectId: "p-auth-revoked-role",
|
||||||
|
roleId: "review",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_auth_revoked",
|
||||||
|
revokedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decision = await createPermissionAuthorizer(prisma).can({
|
||||||
|
actor: { feishuOpenId: "ou_auth_revoked" },
|
||||||
|
action: "role.trigger",
|
||||||
|
resource: { type: "PROJECT", id: "p-auth-revoked-role" },
|
||||||
|
roleId: "review",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decision.allowed).toBe(false);
|
||||||
|
expect(decision.reason).toContain("no active role review grant");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedUserTeamProject(suffix: string) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: `u-${suffix}`,
|
||||||
|
feishuOpenId: `ou_${suffix.replaceAll("-", "_")}`,
|
||||||
|
displayName: "Teacher Team",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const team = await prisma.team.create({
|
||||||
|
data: { slug: `team-${suffix}`, name: `Team ${suffix}` },
|
||||||
|
});
|
||||||
|
await prisma.teamMembership.create({
|
||||||
|
data: { teamId: team.id, userId: user.id },
|
||||||
|
});
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: { id: `p-${suffix}`, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
|
||||||
|
});
|
||||||
|
return { user, team, project };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProjectSettings(projectId: string, overrides: { agentTrigger?: string } = {}) {
|
||||||
|
return {
|
||||||
|
resourceType: "PROJECT" as const,
|
||||||
|
resourceId: projectId,
|
||||||
|
externalShare: "ROLE",
|
||||||
|
comment: "ROLE",
|
||||||
|
copyDownload: "ROLE",
|
||||||
|
collaboratorMgmt: "ROLE",
|
||||||
|
agentTrigger: overrides.agentTrigger ?? "ROLE",
|
||||||
|
agentCancel: "ROLE",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ export async function resetDb(): Promise<void> {
|
|||||||
"PermissionSettings",
|
"PermissionSettings",
|
||||||
"PermissionGrant",
|
"PermissionGrant",
|
||||||
"RoleTriggerGrant",
|
"RoleTriggerGrant",
|
||||||
|
"ExternalPrincipalMembership",
|
||||||
|
"TeamExternalBinding",
|
||||||
|
"TeamMembership",
|
||||||
|
"Team",
|
||||||
"ProjectAgentLock",
|
"ProjectAgentLock",
|
||||||
"AgentRun",
|
"AgentRun",
|
||||||
"AgentSession",
|
"AgentSession",
|
||||||
@@ -277,7 +281,8 @@ export async function seedProject(
|
|||||||
create: {
|
create: {
|
||||||
resourceType: "PROJECT",
|
resourceType: "PROJECT",
|
||||||
resourceId: projectId,
|
resourceId: projectId,
|
||||||
principal,
|
principalType: "USER",
|
||||||
|
principalId: principal,
|
||||||
role,
|
role,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
||||||
});
|
});
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
});
|
});
|
||||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||||
expect(r.allowed).toBe(true);
|
expect(r.allowed).toBe(true);
|
||||||
@@ -36,7 +36,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
});
|
});
|
||||||
// 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({
|
||||||
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
});
|
});
|
||||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
|
||||||
expect(r.allowed).toBe(false);
|
expect(r.allowed).toBe(false);
|
||||||
@@ -47,7 +47,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
|
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
|
||||||
});
|
});
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() },
|
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
|
||||||
expect(r.allowed).toBe(false);
|
expect(r.allowed).toBe(false);
|
||||||
@@ -57,11 +57,11 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
|||||||
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", 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", name: "T", workspaceDir: "/tmp/x" } });
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: p1.id, roleId: "review", principal: "ou_a" },
|
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||||
});
|
});
|
||||||
// p2 has the role configured for someone else; ou_a has no grant there.
|
// p2 has the role configured for someone else; ou_a has no grant there.
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: p2.id, roleId: "review", principal: "ou_other" },
|
data: { projectId: p2.id, roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||||
});
|
});
|
||||||
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
|
||||||
expect(onP1.allowed).toBe(true);
|
expect(onP1.allowed).toBe(true);
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
await seedProject("proj-10", "chat-10");
|
await seedProject("proj-10", "chat-10");
|
||||||
// Someone else holds review; ou_test_user does not.
|
// Someone else holds review; ou_test_user does not.
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
data: { projectId: "proj-10", roleId: "review", principalType: "USER", principalId: "ou_other" },
|
||||||
});
|
});
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
it("allows /review when sender holds the role grant", async () => {
|
it("allows /review when sender holds the role grant", async () => {
|
||||||
await seedProject("proj-11", "chat-11");
|
await seedProject("proj-11", "chat-11");
|
||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
data: { projectId: "proj-11", roleId: "review", principalType: "USER", principalId: "ou_test_user" },
|
||||||
});
|
});
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user