fix: enforce active organization boundary

This commit is contained in:
2026-07-10 18:42:12 +08:00
parent f07f280b8f
commit d730e51c3d
24 changed files with 1686 additions and 460 deletions
@@ -1,7 +1,7 @@
# Enforce Organization status at the shared authorization seam # Enforce Organization status at the shared authorization seam
Type: task Type: task
Status: open Status: resolved
## Question ## Question
@@ -9,3 +9,42 @@ Make non-ACTIVE Organizations fail closed for project authorization,
onboarding, queued execution, and Feishu triggers through one shared seam, with onboarding, queued execution, and Feishu triggers through one shared seam, with
negative tests proving a SUSPENDED or ARCHIVED tenant cannot create, bind, negative tests proving a SUSPENDED or ARCHIVED tenant cannot create, bind,
trigger, resume, or mutate work while operator recovery remains explicit. trigger, resume, or mutate work while operator recovery remains explicit.
## Answer
Organization lifecycle is now enforced at both authorization and mutation
linearization points:
- Project and project-group authorization resolves the owning Organization and
denies every customer action before principal resolution when status is not
`ACTIVE`. The legacy role helper follows the same rule.
- Every customer mutation service (project/folder/binding/policy, explorer,
members, teams, team grants, external-directory sync and slash-session
mutation) verifies `ACTIVE` while holding a PostgreSQL `FOR SHARE` lock on
the Organization row. A concurrent status `UPDATE` therefore orders before
the operation (which fails closed) or after its transaction commits.
- Agent session/run/project-lock admission is one transaction behind that same
lifecycle lock. Queued triggers reauthorize when drained. Slash-command races
return an explicit user-facing rejection instead of becoming deduplicated,
silent failures.
- Feishu attachments download into a private
`HUB_PROJECT_WORKSPACE_ROOT/.cph-staging` batch outside every project. A
durably committed ACTIVE run/project-lock admission is the linearization
point; publication then uses exclusive same-filesystem hard links rather than
copying bytes under a database lock. Failure compensates inode-tracked links,
fails the admitted run and releases its lock. Startup removes abandoned
staging batches and refuses staging symlinks.
- Project creation holds the lifecycle lock across workspace allocation and DB
creation. A later transaction failure removes the new leaf workspace; cleanup
failure is surfaced together with the original error as `AggregateError`.
- External-directory sync uses short lifecycle-serialized transactions per
unit rather than holding the Organization row lock across an unbounded sync.
No customer API can reactivate or otherwise change Organization lifecycle
state. Operator recovery remains a separate platform-control-plane task.
Evidence includes deterministic status/update lock ordering, direct-service
negative tests for both `SUSPENDED` and `ARCHIVED`, queued-trigger and
post-authorization races, DB-triggered workspace compensation (including
cleanup failure), full local Hub tests, and the real Linux attachment trigger
path.
@@ -47,6 +47,7 @@ rollback and recovery, and no known critical security or data-integrity gaps.
- [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit. - [Provision a runnable non-root Hub service](issues/10-provision-nonroot-service.md) — install a strictly validated service identity and persistent layout, reject release/workspace overlap, validate complete production configuration and real bind settings, and execute Hub/Prisma/cph/bubblewrap probes as the service user under `no_new_privs` before writing the unit.
- [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity. - [Confine the agent runtime and protect service credentials](issues/16-confine-agent-and-protect-credentials.md) — replace the Agent environment, deny host reads/writes from root, keep every writable SDK path inside the canonical project, require bubblewrap plus socat, emit bounded correlated diagnostics, and prove the boundary with the real Linux SDK under a capability-free non-root identity.
- [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat. - [Close MCP context and file-delivery escape paths](issues/17-close-mcp-data-egress-escapes.md) — use no-follow project-rooted file operations and delivery snapshots, remove Git-root delivery, and bind every Feishu MCP read to the run's project and chat.
- [Enforce Organization status at the shared authorization seam](issues/22-enforce-organization-status.md) — deny non-ACTIVE tenants before principal resolution, serialize every customer mutation and run admission against Organization lifecycle updates, reauthorize queued work, and stage/publish attachments with crash recovery and explicit filesystem compensation.
## Fog ## Fog
+197
View File
@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { isAbsolute, join, resolve } from "node:path";
import {
linkWorkspaceFileNoFollow,
removeWorkspaceFileIfUnchangedNoFollow,
type WorkspaceFileWriteResult,
} from "../security/workspaceFiles.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
export interface MessageResourceStageRequest {
readonly fileKey: string;
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
}
export interface StagedMessageResource {
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
readonly stagedPath: string;
}
export interface StagedMessageResourceBatch {
readonly stagingRoot: string;
readonly resources: readonly StagedMessageResource[];
readonly publishedResources: PublishedMessageResource[];
}
export interface PublishedMessageResource extends WorkspaceFileWriteResult {
readonly resourceType: "image" | "file";
readonly workspaceRelativePath: string;
}
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
export async function stageMessageResources(
rt: FeishuRuntime,
messageId: string,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
): Promise<StagedMessageResourceBatch | null> {
if (requests.length === 0) return null;
const stagingBase = await requirePrivateStagingBase(workspaceRoot);
const stagingRoot = join(stagingBase, randomUUID());
const resources: StagedMessageResource[] = [];
try {
await mkdir(stagingRoot, { mode: 0o700 });
for (const [index, request] of requests.entries()) {
const stagedPath = await downloadMessageFile(
rt,
messageId,
request.fileKey,
workspaceRoot,
stagingRoot,
`resource-${index}`,
request.resourceType,
);
resources.push({
resourceType: request.resourceType,
workspaceRelativePath: request.workspaceRelativePath,
stagedPath,
});
}
return { stagingRoot, resources, publishedResources: [] };
} catch (error) {
try {
await rm(stagingRoot, { recursive: true, force: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`Feishu resource staging and cleanup both failed: ${stagingRoot}`,
{ cause: error },
);
}
throw error;
}
}
/**
* Publish with O(1) same-filesystem links only after ACTIVE run/project-lock
* admission has committed durably; that commit is the lifecycle linearization point.
*/
export async function publishStagedMessageResources(
batch: StagedMessageResourceBatch,
workspaceRoot: string,
workspaceDir: string,
): Promise<readonly PublishedMessageResource[]> {
for (const resource of batch.resources) {
const result = await linkWorkspaceFileNoFollow(
workspaceRoot,
workspaceDir,
resource.workspaceRelativePath,
resource.stagedPath,
);
batch.publishedResources.push({
...result,
resourceType: resource.resourceType,
workspaceRelativePath: resource.workspaceRelativePath,
});
}
return batch.publishedResources;
}
export async function removeStagedMessageResources(batch: StagedMessageResourceBatch): Promise<void> {
await rm(batch.stagingRoot, { recursive: true, force: true });
}
/** No trigger can be live during startup, so every prior staging batch is abandoned. */
export async function removeAbandonedMessageResourceStages(workspaceRoot: string): Promise<number> {
const canonicalRoot = await canonicalWorkspaceRoot(workspaceRoot, true);
if (canonicalRoot === null) return 0;
const stagingBase = join(canonicalRoot, ".cph-staging");
let metadata;
try {
metadata = await lstat(stagingBase);
} catch (error) {
if (isErrno(error, "ENOENT")) return 0;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Feishu staging base is not a real directory: ${stagingBase}`);
}
const entries = await readdir(stagingBase);
const removed = await Promise.allSettled(entries.map((entry) =>
rm(join(stagingBase, entry), { recursive: true, force: true })
));
const failures = removed.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(failures, `failed to remove abandoned Feishu staging batches: ${stagingBase}`);
}
return entries.length;
}
export async function compensatePublishedMessageResources(
resources: readonly PublishedMessageResource[],
workspaceRoot: string,
workspaceDir: string,
): Promise<void> {
const results = await Promise.allSettled(resources.map((resource) =>
removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot,
workspaceDir,
resource.workspaceRelativePath,
resource.device,
resource.inode,
)
));
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(failures, "failed to compensate one or more published Feishu resources");
}
}
async function requirePrivateStagingBase(workspaceRoot: string): Promise<string> {
const canonicalRoot = await canonicalWorkspaceRoot(workspaceRoot, false);
if (canonicalRoot === null) throw new Error(`workspace root not found: ${workspaceRoot}`);
const stagingBase = join(canonicalRoot, ".cph-staging");
try {
await mkdir(stagingBase, { mode: 0o700 });
} catch (error) {
if (!isErrno(error, "EEXIST")) throw error;
}
const metadata = await lstat(stagingBase);
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`Feishu staging base is not a real directory: ${stagingBase}`);
}
const canonicalStagingBase = await realpath(stagingBase);
const expectedStagingBase = join(await realpath(canonicalRoot), ".cph-staging");
if (canonicalStagingBase !== expectedStagingBase) {
throw new Error(`Feishu staging base escapes workspace root: ${stagingBase}`);
}
await chmod(stagingBase, 0o700);
return stagingBase;
}
async function canonicalWorkspaceRoot(
workspaceRoot: string,
allowMissing: boolean,
): Promise<string | null> {
if (!isAbsolute(workspaceRoot)) throw new Error(`workspace root must be absolute: ${workspaceRoot}`);
const configuredRoot = resolve(workspaceRoot);
let metadata;
try {
metadata = await lstat(configuredRoot);
} catch (error) {
if (allowMissing && isErrno(error, "ENOENT")) return null;
throw error;
}
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
throw new Error(`workspace root is not a real directory: ${workspaceRoot}`);
}
await realpath(configuredRoot);
return configuredRoot;
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+20 -8
View File
@@ -3,6 +3,7 @@ import type { FastifyBaseLogger } from "fastify";
import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js"; import { decimalToNumberOrNull, formatInteger, formatUsd } from "../agent/cost.js";
import type { ModelRegistry, RoleEntry } from "../agent/models.js"; import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js"; import type { RuntimeSettings } from "../settings/runtime.js";
import { lockActiveProjectOrganization } from "../org/status.js";
import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js"; import type { TriggerQueue } from "./triggerQueue.js";
@@ -87,10 +88,13 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"不会清空当前项目的等待队列。", "不会清空当前项目的等待队列。",
], ],
run: async ({ projectId, chatId, rt, sendOptions }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({ await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null }, where: { projectId, archivedAt: null },
data: { archivedAt: new Date() }, data: { archivedAt: new Date() },
}); });
});
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions); await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
}, },
}); });
@@ -104,19 +108,24 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"没有可恢复会话时只回复提示,不会创建 agent run。", "没有可恢复会话时只回复提示,不会创建 agent run。",
], ],
run: async ({ projectId, chatId, rt, sendOptions }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({ const resumed = await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
const latest = await tx.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } }, where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" }, orderBy: { archivedAt: "desc" },
select: { id: true }, select: { id: true },
}); });
if (latest === null) { if (latest === null) return false;
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions); await tx.agentSession.update({
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id }, where: { id: latest.id },
data: { archivedAt: null }, data: { archivedAt: null },
}); });
return true;
});
if (!resumed) {
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return;
}
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions); await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
}, },
}); });
@@ -175,10 +184,13 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"清空当前项目已经排队、尚未开始的触发请求。", "清空当前项目已经排队、尚未开始的触发请求。",
], ],
run: async ({ projectId, chatId, rt, sendOptions }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({ await deps.prisma.$transaction(async (tx) => {
await lockActiveProjectOrganization(tx, projectId);
await tx.agentSession.updateMany({
where: { projectId, archivedAt: null }, where: { projectId, archivedAt: null },
data: { archivedAt: new Date() }, data: { archivedAt: new Date() },
}); });
});
const cleared = deps.triggerQueue.clear(projectId); const cleared = deps.triggerQueue.clear(projectId);
if (cleared > 0) { if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
+179 -105
View File
@@ -9,6 +9,8 @@
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock, * ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads. * provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
*/ */
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -20,7 +22,6 @@ import {
reactToMessage, reactToMessage,
addReaction, addReaction,
removeReaction, removeReaction,
downloadMessageFile,
parsePostMessage, parsePostMessage,
resolveCard, resolveCard,
resolveSenderName, resolveSenderName,
@@ -31,17 +32,26 @@ import {
} from "./client.js"; } from "./client.js";
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 { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js"; import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js"; import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js"; import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js"; import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js"; import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js"; import { SenderNameCache } from "./senderCache.js";
import {
compensatePublishedMessageResources,
publishStagedMessageResources,
removeStagedMessageResources,
stageMessageResources,
type MessageResourceStageRequest,
type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
@@ -169,7 +179,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// slashes are left as literal text (extractRole returns null). Falls back // slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully. // to "draft" when no role is named — the registry degrades gracefully.
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: parsedAgentPrompt } = extractRole(cleanPrompt, models);
const roleId = parsedRole ?? "draft"; const roleId = parsedRole ?? "draft";
// ADR-0019: role trigger is the second gate after project agent.trigger. // ADR-0019: role trigger is the second gate after project agent.trigger.
// Unconfigured roles remain open for back-compat; configured roles require // Unconfigured roles remain open for back-compat; configured roles require
@@ -213,24 +223,36 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
rt, rt,
senderOpenId, senderOpenId,
}); });
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot);
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// Linearization point for org lifecycle + session/run/lock admission.
// FOR SHARE on the Organization row conflicts with a concurrent status
// UPDATE, so either the run is admitted while ACTIVE or suspension wins
// and no session/run/lock/audit row is committed.
let admission: {
readonly session: { readonly id: string; readonly metadata: Prisma.JsonValue };
readonly run: { readonly id: string };
};
try {
admission = await deps.prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, roleDecision.organizationId);
// ADR-0017: provider+role+model-bound session. Reuse if one exists for // ADR-0017: provider+role+model-bound session. Reuse if one exists for
// this (project, provider, role, model) tuple; otherwise create. Role is // this tuple; otherwise create. Role remains part of the key.
// part of the key because role prompts/tool surfaces can differ even when const existingSession = await tx.agentSession.findFirst({
// the underlying model is the same.
const existingSession = await deps.prisma.agentSession.findFirst({
where: { projectId, provider: providerId, roleId, model, archivedAt: null }, where: { projectId, provider: providerId, roleId, model, archivedAt: null },
select: { id: true, metadata: true }, select: { id: true, metadata: true },
}); });
const session = const session = existingSession !== null
existingSession !== null ? await tx.agentSession.update({
? await deps.prisma.agentSession.update({
where: { id: existingSession.id }, where: { id: existingSession.id },
data: { provider: providerId, roleId, model, updatedAt: new Date() }, data: { provider: providerId, roleId, model, updatedAt: new Date() },
select: { id: true, metadata: true }, select: { id: true, metadata: true },
}) })
: await deps.prisma.agentSession.create({ : await tx.agentSession.create({
data: { data: {
projectId, projectId,
provider: providerId, provider: providerId,
@@ -241,9 +263,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}, },
select: { id: true, metadata: true }, select: { id: true, metadata: true },
}); });
const sessionMetadata = readSessionMetadata(session.metadata); const run = await tx.agentRun.create({
const run = await deps.prisma.agentRun.create({
data: { data: {
projectId, projectId,
sessionId: session.id, sessionId: session.id,
@@ -257,36 +277,94 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}, },
select: { id: true }, select: { id: true },
}); });
await tx.projectAgentLock.create({
data: { projectId, runId: run.id, holderUserId: null },
});
return { session, run };
});
} catch (error) {
if (stagedResources !== null) {
await compensateFailedResourceAdmission(
error,
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
}
if (error instanceof InactiveOrganizationError) {
deps.logger.info(
{ projectId, organizationId: error.organizationId, status: error.status },
"feishu trigger: organization became inactive before run admission",
);
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
return "skipped";
}
if (!isPrismaUniqueConstraintError(error)) throw error;
const current = await currentLockRunId(deps.prisma, projectId);
if (current === null) throw error;
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const { session, run } = admission;
if (stagedResources !== null) {
try {
await publishStagedMessageResources(
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
await removeStagedMessageResources(stagedResources);
} catch (error) {
let publicationFailure: unknown = error;
try {
await compensateFailedResourceAdmission(
error,
stagedResources,
projectWorkspaceRoot,
project.workspaceDir,
);
} catch (cleanupError) {
publicationFailure = cleanupError;
}
try {
await deps.prisma.$transaction(async (tx) => {
await tx.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "attachment publication failed", finishedAt: new Date() },
});
await tx.projectAgentLock.deleteMany({ where: { runId: run.id } });
});
} catch (finalizationError) {
publicationFailure = new AggregateError(
[publicationFailure, finalizationError],
`attachment publication and run finalization both failed: ${run.id}`,
{ cause: publicationFailure },
);
}
await writeAudit(deps.prisma, { await writeAudit(deps.prisma, {
runId: run.id, runId: run.id,
projectId, projectId,
action: "run.attachment_publish_failed",
metadata: { error: String(publicationFailure) },
});
await sendText(rt, chatId, "附件处理失败,未启动运行。", sendOptions);
throw publicationFailure;
}
}
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
action: "run.created", action: "run.created",
metadata: { metadata: {
roleId, roleId,
model, model,
prompt: agentPrompt.slice(0, 200), prompt: agentPrompt.slice(0, 200),
sender: await senderAuditMetadata(rt, senderOpenId), sender: senderMetadata,
feishuTriggerContext, feishuTriggerContext,
}, },
}); });
const sessionMetadata = readSessionMetadata(session.metadata);
// ADR-0002: acquire the project lock for this run.
try {
await acquireLock(deps.prisma, projectId, run.id, null);
} catch {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: "lock race", finishedAt: new Date() },
});
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
action: "run.lock_race",
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
});
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const projectCtx: ProjectContext = { const projectCtx: ProjectContext = {
projectId, projectId,
@@ -771,30 +849,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} else if (msg.message_type === "post") { } else if (msg.message_type === "post") {
const parsed = parsePostMessage(msg.content); const parsed = parsePostMessage(msg.content);
cleanPrompt = extractPostPrompt(msg, parsed.text); cleanPrompt = extractPostPrompt(msg, parsed.text);
if (cleanPrompt === null) return;
if (parsed.imageKeys.length > 0 || parsed.fileKeys.length > 0) {
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) return;
const downloadedResources = await downloadPostMessageFiles({
rt,
msg,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
imageKeys: parsed.imageKeys,
fileKeys: parsed.fileKeys,
});
cleanPrompt = appendDownloadedResourcePaths(cleanPrompt, downloadedResources);
}
} else if (msg.message_type === "file" || msg.message_type === "image") { } else if (msg.message_type === "file" || msg.message_type === "image") {
// Download the file/image to the workspace, build a prompt around it.
const project = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { workspaceDir: true },
});
if (project === null) return;
let content: z.infer<typeof FileMessageContentSchema>; let content: z.infer<typeof FileMessageContentSchema>;
try { try {
content = FileMessageContentSchema.parse(JSON.parse(msg.content)); content = FileMessageContentSchema.parse(JSON.parse(msg.content));
@@ -807,17 +862,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} }
const key = content.file_key ?? content.image_key ?? ""; const key = content.file_key ?? content.image_key ?? "";
if (key !== "") { if (key !== "") {
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; cleanPrompt = "用户发来一个文件";
const savePath = await downloadMessageFile(
rt,
msg.message_id,
key,
projectWorkspaceRoot,
project.workspaceDir,
`.cph/inbox/${fileName}`,
msg.message_type,
);
cleanPrompt = `用户发来一个文件: ${savePath}`;
} }
} }
if (cleanPrompt === null) return; if (cleanPrompt === null) return;
@@ -839,7 +884,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (invocation !== null) { if (invocation !== null) {
const slashCommand = slashCommands.get(invocation.name); const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) { if (slashCommand !== undefined) {
try {
await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) }); await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
} catch (error) {
if (!(error instanceof InactiveOrganizationError)) throw error;
deps.logger.info(
{ projectId, organizationId: error.organizationId, status: error.status, command: invocation.name },
"feishu slash command: organization became inactive before mutation",
);
await sendText(rt, chatId, "组织当前不可用,拒绝操作。", sendOptionsForTriggerMessage(msg));
}
return; return;
} }
} }
@@ -1308,60 +1362,80 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
interface DownloadedMessageResource { function isPrismaUniqueConstraintError(error: unknown): boolean {
readonly resourceType: "image" | "file"; return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
readonly path: string;
} }
async function downloadPostMessageFiles(args: { async function stageTriggerMessageResources(
readonly rt: FeishuRuntime; rt: FeishuRuntime,
readonly msg: MessageReceiveEvent["message"]; msg: MessageReceiveEvent["message"],
readonly workspaceRoot: string; workspaceRoot: string,
readonly workspaceDir: string; ): Promise<StagedMessageResourceBatch | null> {
readonly imageKeys: readonly string[]; const requests: MessageResourceStageRequest[] = [];
readonly fileKeys: readonly string[]; if (msg.message_type === "post") {
}): Promise<readonly DownloadedMessageResource[]> { const parsed = parsePostMessage(msg.content);
const timestamp = Date.now(); for (const imageKey of parsed.imageKeys) {
const downloadedResources: DownloadedMessageResource[] = []; requests.push({
for (const [index, imageKey] of args.imageKeys.entries()) { fileKey: imageKey,
const savePath = await downloadMessageFile( resourceType: "image",
args.rt, workspaceRelativePath: `.cph/inbox/post-image-${randomUUID()}.png`,
args.msg.message_id, });
imageKey,
args.workspaceRoot,
args.workspaceDir,
`.cph/inbox/post-image-${timestamp}-${index}.png`,
"image",
);
downloadedResources.push({ resourceType: "image", path: savePath });
} }
for (const [index, fileKey] of args.fileKeys.entries()) { for (const fileKey of parsed.fileKeys) {
const savePath = await downloadMessageFile( requests.push({
args.rt,
args.msg.message_id,
fileKey, fileKey,
args.workspaceRoot, resourceType: "file",
args.workspaceDir, workspaceRelativePath: `.cph/inbox/post-file-${randomUUID()}.bin`,
`.cph/inbox/post-file-${timestamp}-${index}.bin`, });
"file",
);
downloadedResources.push({ resourceType: "file", path: savePath });
} }
return downloadedResources; } else if (msg.message_type === "file" || msg.message_type === "image") {
const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
const fileKey = content.file_key ?? content.image_key;
if (fileKey !== undefined && fileKey !== "") {
requests.push({
fileKey,
resourceType: msg.message_type,
workspaceRelativePath: `.cph/inbox/${msg.message_type}-${randomUUID()}.${msg.message_type === "image" ? "png" : "bin"}`,
});
}
}
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
} }
function appendDownloadedResourcePaths( function appendStagedResourcePaths(
prompt: string, prompt: string,
resources: readonly DownloadedMessageResource[], batch: StagedMessageResourceBatch | null,
workspaceDir: string,
): string { ): string {
const resources = batch?.resources ?? [];
if (resources.length === 0) return prompt; if (resources.length === 0) return prompt;
const paths = resources.map((resource) => { const paths = resources.map((resource) => {
const label = resource.resourceType === "image" ? "图片" : "文件"; const label = resource.resourceType === "image" ? "图片" : "文件";
return `- ${label}: ${resource.path}`; return `- ${label}: ${join(workspaceDir, resource.workspaceRelativePath)}`;
}); });
return `${prompt}\n\n消息附件已下载到项目工作区,可直接读取以下本地文件:\n${paths.join("\n")}`; return `${prompt}\n\n消息附件已下载到项目工作区,可直接读取以下本地文件:\n${paths.join("\n")}`;
} }
async function compensateFailedResourceAdmission(
primaryError: unknown,
batch: StagedMessageResourceBatch,
workspaceRoot: string,
workspaceDir: string,
): Promise<void> {
const cleanup = await Promise.allSettled([
removeStagedMessageResources(batch),
compensatePublishedMessageResources(batch.publishedResources, workspaceRoot, workspaceDir),
]);
const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
if (failures.length > 0) {
throw new AggregateError(
[primaryError, ...failures],
"Agent admission failed and Feishu resource compensation was incomplete",
{ cause: primaryError },
);
}
}
/** /**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`). * Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped. * Returns the role id and the remaining prompt with the command stripped.
+6 -3
View File
@@ -3,6 +3,7 @@ import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { makeTriggerHandler } from "./feishu/trigger.js"; import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
import { triggerQueue } from "./feishu/triggerQueue.js"; import { triggerQueue } from "./feishu/triggerQueue.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js"; import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { readServerBinding } from "./settings/server.js"; import { readServerBinding } from "./settings/server.js";
@@ -24,6 +25,11 @@ function booleanEnv(name: string, fallback: boolean): boolean {
/** Start the Hub after every import and runtime dependency has loaded. */ /** Start the Hub after every import and runtime dependency has loaded. */
export async function startHub(): Promise<void> { export async function startHub(): Promise<void> {
requireEnv("DATABASE_URL"); requireEnv("DATABASE_URL");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const app = Fastify({ logger: true });
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
const runtimeSettings = createEnvRuntimeSettings(); const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter"); await runtimeSettings.provider("openrouter");
@@ -31,13 +37,10 @@ export async function startHub(): Promise<void> {
const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
const sessionSecret = requireEnv("HUB_SESSION_SECRET"); const sessionSecret = requireEnv("HUB_SESSION_SECRET");
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding(); const bind = readServerBinding();
const app = Fastify({ logger: true });
// Startup reset: clear stale locks + mark dead runs as FAILED. // Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({}); await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({ await prisma.agentRun.updateMany({
+9 -2
View File
@@ -11,6 +11,7 @@ import {
createProjectFromOrgAdmin, createProjectFromOrgAdmin,
moveProjectToFolder, moveProjectToFolder,
} from "../projectOnboarding.js"; } from "../projectOnboarding.js";
import { lockActiveOrganization } from "./status.js";
export interface ExplorerFolderNode { export interface ExplorerFolderNode {
readonly id: string; readonly id: string;
@@ -120,6 +121,7 @@ export async function renameFolder(
}, },
) { ) {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
if (input.parentId !== undefined && input.parentId !== null) { if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) { if (input.parentId === folder.id) {
@@ -148,6 +150,7 @@ export async function archiveFolder(
input: { readonly organizationId: string; readonly folderId: string }, input: { readonly organizationId: string; readonly folderId: string },
): Promise<{ readonly archived: true; readonly folderId: string }> { ): Promise<{ readonly archived: true; readonly folderId: string }> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const folder = await requireActiveFolder(tx, input.folderId, input.organizationId); const folder = await requireActiveFolder(tx, input.folderId, input.organizationId);
const childFolders = await tx.folder.count({ const childFolders = await tx.folder.count({
where: { parentId: folder.id, archivedAt: null }, where: { parentId: folder.id, archivedAt: null },
@@ -195,12 +198,15 @@ export async function renameProject(
}, },
) { ) {
const name = requireNonEmpty(input.name, "project name"); const name = requireNonEmpty(input.name, "project name");
const project = await requireActiveProject(prisma, input.projectId, input.organizationId); return prisma.$transaction(async (tx) => {
return prisma.project.update({ await lockActiveOrganization(tx, input.organizationId);
const project = await requireActiveProject(tx, input.projectId, input.organizationId);
return tx.project.update({
where: { id: project.id }, where: { id: project.id },
data: { name }, data: { name },
select: { id: true, name: true, folderId: true }, select: { id: true, name: true, folderId: true },
}); });
});
} }
export async function moveOrgProjectToFolder( export async function moveOrgProjectToFolder(
@@ -223,6 +229,7 @@ export async function archiveProject(
input: { readonly organizationId: string; readonly projectId: string }, input: { readonly organizationId: string; readonly projectId: string },
): Promise<{ readonly archived: true; readonly projectId: string }> { ): Promise<{ readonly archived: true; readonly projectId: string }> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const project = await requireActiveProject(tx, input.projectId, input.organizationId); const project = await requireActiveProject(tx, input.projectId, input.organizationId);
const activeBinding = await tx.projectGroupBinding.findFirst({ const activeBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null }, where: { projectId: project.id, archivedAt: null },
+25 -23
View File
@@ -7,7 +7,8 @@
* 3. Cannot revoke or demote the last remaining OWNER. * 3. Cannot revoke or demote the last remaining OWNER.
* 4. ADMIN cannot modify OWNER memberships. * 4. ADMIN cannot modify OWNER memberships.
*/ */
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client"; import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js";
export interface OrgMemberRow { export interface OrgMemberRow {
readonly userId: string; readonly userId: string;
@@ -56,7 +57,9 @@ export async function addOrgMember(
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId"); const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false); assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
const user = await prisma.user.upsert({ const { user, membership } = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({
where: { feishuOpenId: openId }, where: { feishuOpenId: openId },
create: { create: {
feishuOpenId: openId, feishuOpenId: openId,
@@ -68,21 +71,19 @@ export async function addOrgMember(
: {}), : {}),
}, },
}); });
const existing = await tx.organizationMembership.findFirst({
const existing = await prisma.organizationMembership.findFirst({
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null }, where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
}); });
if (existing !== null) { if (existing !== null) throw new Error(`user ${user.id} is already an active member`);
throw new Error(`user ${user.id} is already an active member`); const membership = await tx.organizationMembership.create({
}
const membership = await prisma.organizationMembership.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
userId: user.id, userId: user.id,
role: input.role, role: input.role,
}, },
}); });
return { user, membership };
});
return { return {
userId: user.id, userId: user.id,
@@ -104,7 +105,9 @@ export async function setOrgMemberRole(
readonly role: OrganizationMemberRole; readonly role: OrganizationMemberRole;
}, },
): Promise<OrgMemberRow> { ): Promise<OrgMemberRow> {
const membership = await prisma.organizationMembership.findFirst({ return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
userId: input.targetUserId, userId: input.targetUserId,
@@ -119,19 +122,17 @@ export async function setOrgMemberRole(
}, },
}, },
}); });
if (membership === null) { if (membership === null) throw new Error(`member not found: ${input.targetUserId}`);
throw new Error(`member not found: ${input.targetUserId}`);
}
assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER"); assertCanAssignRole(input.actorRole, input.role, membership.role === "OWNER");
if (membership.role === "OWNER" && input.actorRole !== "OWNER") { if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot modify OWNER membership as ADMIN"); throw new Error("cannot modify OWNER membership as ADMIN");
} }
if (membership.role === "OWNER" && input.role !== "OWNER") { if (membership.role === "OWNER" && input.role !== "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId); await assertNotLastOwner(tx, input.organizationId, input.targetUserId);
} }
const updated = await prisma.organizationMembership.update({ const updated = await tx.organizationMembership.update({
where: { id: membership.id }, where: { id: membership.id },
data: { role: input.role }, data: { role: input.role },
}); });
@@ -144,6 +145,7 @@ export async function setOrgMemberRole(
role: updated.role, role: updated.role,
createdAt: membership.createdAt.toISOString(), createdAt: membership.createdAt.toISOString(),
}; };
});
} }
export async function revokeOrgMember( export async function revokeOrgMember(
@@ -155,7 +157,9 @@ export async function revokeOrgMember(
readonly targetUserId: string; readonly targetUserId: string;
}, },
): Promise<{ readonly revoked: true; readonly userId: string }> { ): Promise<{ readonly revoked: true; readonly userId: string }> {
const membership = await prisma.organizationMembership.findFirst({ await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const membership = await tx.organizationMembership.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
userId: input.targetUserId, userId: input.targetUserId,
@@ -163,20 +167,18 @@ export async function revokeOrgMember(
}, },
select: { id: true, role: true }, select: { id: true, role: true },
}); });
if (membership === null) { if (membership === null) throw new Error(`member not found: ${input.targetUserId}`);
throw new Error(`member not found: ${input.targetUserId}`);
}
if (membership.role === "OWNER" && input.actorRole !== "OWNER") { if (membership.role === "OWNER" && input.actorRole !== "OWNER") {
throw new Error("cannot revoke OWNER membership as ADMIN"); throw new Error("cannot revoke OWNER membership as ADMIN");
} }
if (membership.role === "OWNER") { if (membership.role === "OWNER") {
await assertNotLastOwner(prisma, input.organizationId, input.targetUserId); await assertNotLastOwner(tx, input.organizationId, input.targetUserId);
} }
await tx.organizationMembership.update({
await prisma.organizationMembership.update({
where: { id: membership.id }, where: { id: membership.id },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
});
return { revoked: true as const, userId: input.targetUserId }; return { revoked: true as const, userId: input.targetUserId };
} }
@@ -194,7 +196,7 @@ function assertCanAssignRole(
} }
async function assertNotLastOwner( async function assertNotLastOwner(
prisma: PrismaClient, prisma: PrismaClient | Prisma.TransactionClient,
organizationId: string, organizationId: string,
targetUserId: string, targetUserId: string,
): Promise<void> { ): Promise<void> {
+62
View File
@@ -0,0 +1,62 @@
import type { OrganizationStatus, Prisma } from "@prisma/client";
export class InactiveOrganizationError extends Error {
readonly organizationId: string;
readonly status: Exclude<OrganizationStatus, "ACTIVE">;
constructor(organizationId: string, status: Exclude<OrganizationStatus, "ACTIVE">) {
super(`organization ${organizationId} is ${status}; customer operations require ACTIVE`);
this.name = "InactiveOrganizationError";
this.organizationId = organizationId;
this.status = status;
}
}
export function inactiveOrganizationReason(
organizationId: string,
status: OrganizationStatus,
): string | undefined {
if (status === "ACTIVE") return undefined;
return `organization ${organizationId} is ${status}; customer operations require ACTIVE`;
}
export function requireActiveOrganizationStatus(
organizationId: string,
status: OrganizationStatus,
): void {
if (status !== "ACTIVE") throw new InactiveOrganizationError(organizationId, status);
}
/**
* Lock the Organization row for a customer operation and verify ACTIVE.
* PostgreSQL UPDATE takes a conflicting row lock, so lifecycle transitions
* serialize with the entire transaction that called this function.
*/
export async function lockActiveOrganization(
prisma: Prisma.TransactionClient,
organizationId: string,
): Promise<void> {
const rows = await prisma.$queryRaw<Array<{ readonly id: string; readonly status: OrganizationStatus }>>`
SELECT "id", "status"::text AS "status"
FROM "Organization"
WHERE "id" = ${organizationId}
FOR SHARE
`;
const organization = rows[0];
if (organization === undefined) throw new Error(`organization not found: ${organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
}
/** Resolve a Project's tenant inside the transaction, then take the lifecycle lock. */
export async function lockActiveProjectOrganization(
prisma: Prisma.TransactionClient,
projectId: string,
): Promise<string> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { organizationId: true },
});
if (project === null) throw new Error(`project not found: ${projectId}`);
await lockActiveOrganization(prisma, project.organizationId);
return project.organizationId;
}
+35 -30
View File
@@ -5,6 +5,7 @@
* grants that use this team as principal (product pin). * grants that use this team as principal (product pin).
*/ */
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js";
export interface TeamRow { export interface TeamRow {
readonly id: string; readonly id: string;
@@ -59,14 +60,14 @@ export async function createTeam(
): Promise<TeamRow> { ): Promise<TeamRow> {
const slug = sanitizeSlug(input.slug); const slug = sanitizeSlug(input.slug);
const name = requireNonEmpty(input.name, "name"); const name = requireNonEmpty(input.name, "name");
const existing = await prisma.team.findFirst({ const team = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const existing = await tx.team.findFirst({
where: { organizationId: input.organizationId, slug, archivedAt: null }, where: { organizationId: input.organizationId, slug, archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (existing !== null) { if (existing !== null) throw new Error(`team slug already exists: ${slug}`);
throw new Error(`team slug already exists: ${slug}`); return tx.team.create({
}
const team = await prisma.team.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
slug, slug,
@@ -74,6 +75,7 @@ export async function createTeam(
...(input.description !== undefined ? { description: input.description } : {}), ...(input.description !== undefined ? { description: input.description } : {}),
}, },
}); });
});
return { return {
id: team.id, id: team.id,
slug: team.slug, slug: team.slug,
@@ -93,8 +95,10 @@ export async function updateTeam(
readonly description?: string | null | undefined; readonly description?: string | null | undefined;
}, },
): Promise<TeamRow> { ): Promise<TeamRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId); const updated = await prisma.$transaction(async (tx) => {
const updated = await prisma.team.update({ await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
return tx.team.update({
where: { id: team.id }, where: { id: team.id },
data: { data: {
...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}), ...(input.name !== undefined ? { name: requireNonEmpty(input.name, "name") } : {}),
@@ -109,6 +113,7 @@ export async function updateTeam(
_count: { select: { memberships: { where: { revokedAt: null } } } }, _count: { select: { memberships: { where: { revokedAt: null } } } },
}, },
}); });
});
return { return {
id: updated.id, id: updated.id,
slug: updated.slug, slug: updated.slug,
@@ -127,6 +132,7 @@ export async function archiveTeam(
input: { readonly organizationId: string; readonly teamId: string }, input: { readonly organizationId: string; readonly teamId: string },
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> { ): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const now = new Date(); const now = new Date();
await tx.team.update({ await tx.team.update({
@@ -183,10 +189,12 @@ export async function addTeamMember(
readonly feishuOpenId?: string | undefined; readonly feishuOpenId?: string | undefined;
}, },
): Promise<TeamMemberRow> { ): Promise<TeamMemberRow> {
const team = await requireActiveTeam(prisma, input.teamId, input.organizationId); const result = await prisma.$transaction(async (tx) => {
const user = await resolveUser(prisma, input); await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const user = await resolveUser(tx, input);
// User should be an org member to join a team (product pin for pilot). // User should be an org member to join a team (product pin for pilot).
const membership = await prisma.organizationMembership.findFirst({ const membership = await tx.organizationMembership.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
userId: user.id, userId: user.id,
@@ -194,23 +202,19 @@ export async function addTeamMember(
}, },
select: { id: true }, select: { id: true },
}); });
if (membership === null) { if (membership === null) throw new Error(`user ${user.id} is not an active member of the organization`);
throw new Error(`user ${user.id} is not an active member of the organization`); const existing = await tx.teamMembership.findFirst({
}
const existing = await prisma.teamMembership.findFirst({
where: { teamId: team.id, userId: user.id, revokedAt: null }, where: { teamId: team.id, userId: user.id, revokedAt: null },
}); });
if (existing !== null) { if (existing !== null) throw new Error(`user ${user.id} is already on team ${team.id}`);
throw new Error(`user ${user.id} is already on team ${team.id}`); const row = await tx.teamMembership.create({ data: { teamId: team.id, userId: user.id } });
} return { user, row };
const row = await prisma.teamMembership.create({
data: { teamId: team.id, userId: user.id },
}); });
return { return {
userId: user.id, userId: result.user.id,
feishuOpenId: user.feishuOpenId, feishuOpenId: result.user.feishuOpenId,
displayName: user.displayName, displayName: result.user.displayName,
createdAt: row.createdAt.toISOString(), createdAt: result.row.createdAt.toISOString(),
}; };
} }
@@ -222,23 +226,24 @@ export async function revokeTeamMember(
readonly userId: string; readonly userId: string;
}, },
): Promise<{ readonly revoked: true; readonly userId: string }> { ): Promise<{ readonly revoked: true; readonly userId: string }> {
await requireActiveTeam(prisma, input.teamId, input.organizationId); await prisma.$transaction(async (tx) => {
const membership = await prisma.teamMembership.findFirst({ await lockActiveOrganization(tx, input.organizationId);
await requireActiveTeam(tx, input.teamId, input.organizationId);
const membership = await tx.teamMembership.findFirst({
where: { teamId: input.teamId, userId: input.userId, revokedAt: null }, where: { teamId: input.teamId, userId: input.userId, revokedAt: null },
select: { id: true }, select: { id: true },
}); });
if (membership === null) { if (membership === null) throw new Error(`team member not found: ${input.userId}`);
throw new Error(`team member not found: ${input.userId}`); await tx.teamMembership.update({
}
await prisma.teamMembership.update({
where: { id: membership.id }, where: { id: membership.id },
data: { revokedAt: new Date() }, data: { revokedAt: new Date() },
}); });
});
return { revoked: true as const, userId: input.userId }; return { revoked: true as const, userId: input.userId };
} }
async function resolveUser( async function resolveUser(
prisma: PrismaClient, prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined }, input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> { ): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
if (input.userId !== undefined && input.userId !== "") { if (input.userId !== undefined && input.userId !== "") {
+8
View File
@@ -8,6 +8,7 @@
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permissions/authorizer.js"; import { createPermissionAuthorizer } from "./permissions/authorizer.js";
import { inactiveOrganizationReason } from "./org/status.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";
@@ -73,6 +74,13 @@ export async function canTriggerRole(
principal: string, principal: string,
): Promise<PermissionResult> { ): Promise<PermissionResult> {
try { try {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { organization: { select: { id: true, status: true } } },
});
if (project === null) return { allowed: false, reason: `project not found: ${projectId}` };
const inactiveReason = inactiveOrganizationReason(project.organization.id, project.organization.status);
if (inactiveReason !== undefined) return { allowed: false, reason: inactiveReason };
const anyGrant = await prisma.roleTriggerGrant.findFirst({ const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId }, where: { projectId, roleId },
select: { id: true }, select: { id: true },
+32 -8
View File
@@ -1,5 +1,6 @@
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client"; import type { OrganizationStatus, PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js"; import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
import { inactiveOrganizationReason } from "../org/status.js";
export type AuthorizationAction = export type AuthorizationAction =
| "project.read" | "project.read"
@@ -61,8 +62,16 @@ 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 organizationId = await this.organizationForResource(req.resource); const organization = await this.organizationForResource(req.resource);
const resolution = await this.resolver.resolveActor(req.actor, { organizationId }); const inactiveReason = inactiveOrganizationReason(organization.id, organization.status);
if (inactiveReason !== undefined) {
return this.decision(req, inactivePrincipalResolution(req.actor, organization.id), {
allowed: false,
reason: inactiveReason,
requiredRole: defaultRequiredRole(req.action),
});
}
const resolution = await this.resolver.resolveActor(req.actor, { organizationId: organization.id });
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, {
@@ -221,27 +230,29 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
}; };
} }
private async organizationForResource(resource: AuthorizationResource): Promise<string> { private async organizationForResource(
resource: AuthorizationResource,
): Promise<{ readonly id: string; readonly status: OrganizationStatus }> {
switch (resource.type) { switch (resource.type) {
case "PROJECT": { case "PROJECT": {
const project = await this.prisma.project.findUnique({ const project = await this.prisma.project.findUnique({
where: { id: resource.id }, where: { id: resource.id },
select: { organizationId: true }, select: { organization: { select: { id: true, status: true } } },
}); });
if (project === null) { if (project === null) {
throw new Error(`authorization resource missing: PROJECT ${resource.id}`); throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
} }
return project.organizationId; return project.organization;
} }
case "PROJECT_GROUP": { case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findFirst({ const binding = await this.prisma.projectGroupBinding.findFirst({
where: { chatId: resource.id, archivedAt: null }, where: { chatId: resource.id, archivedAt: null },
select: { project: { select: { organizationId: true } } }, select: { project: { select: { organization: { select: { id: true, status: true } } } } },
}); });
if (binding === null) { if (binding === null) {
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`); throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
} }
return binding.project.organizationId; return binding.project.organization;
} }
case "ARTIFACT": case "ARTIFACT":
throw new Error("authorization for ARTIFACT resources requires an organization resolver"); throw new Error("authorization for ARTIFACT resources requires an organization resolver");
@@ -293,3 +304,16 @@ function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalT
principalId: principal.id, principalId: principal.id,
})); }));
} }
function inactivePrincipalResolution(actor: ActorInput, organizationId: string): PrincipalResolution {
const principals: PrincipalRef[] = [{ type: "USER", id: actor.feishuOpenId }];
if (actor.chatId !== undefined && actor.chatId !== "") {
principals.push({ type: "FEISHU_CHAT", id: actor.chatId });
}
return {
actor,
organizationId,
principals,
resolved: [],
};
}
+27 -12
View File
@@ -1,4 +1,5 @@
import type { PrismaClient, PrincipalType } from "@prisma/client"; import type { Prisma, PrismaClient, PrincipalType } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export type ExternalPrincipalType = Extract< export type ExternalPrincipalType = Extract<
PrincipalType, PrincipalType,
@@ -33,7 +34,9 @@ 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({ const connection = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
return tx.externalDirectoryConnection.upsert({
where: { where: {
organizationId_provider_source: { organizationId_provider_source: {
organizationId: input.organizationId, organizationId: input.organizationId,
@@ -53,10 +56,16 @@ export async function syncExternalPrincipalMemberships(
}, },
select: { id: true }, select: { id: true },
}); });
});
// Each membership is a short lifecycle-serialized unit. A large directory
// must not hold the Organization row lock (or one interactive transaction)
// for the duration of the entire external sync.
for (const item of input.memberships) { for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType); assertExternalPrincipalType(item.principalType);
const user = await prisma.user.upsert({ const userId = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({
where: { feishuOpenId: item.feishuOpenId }, where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName }, update: { displayName: item.displayName },
create: { create: {
@@ -66,9 +75,8 @@ export async function syncExternalPrincipalMemberships(
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } }, organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
}, },
}); });
await ensureOrganizationMembership(prisma, input.organizationId, user.id); await ensureOrganizationMembership(tx, input.organizationId, user.id);
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id)); const existing = await tx.externalPrincipalMembership.findFirst({
const existing = await prisma.externalPrincipalMembership.findFirst({
where: { where: {
userId: user.id, userId: user.id,
principalType: item.principalType, principalType: item.principalType,
@@ -79,7 +87,7 @@ export async function syncExternalPrincipalMemberships(
select: { id: true }, select: { id: true },
}); });
if (existing === null) { if (existing === null) {
await prisma.externalPrincipalMembership.create({ await tx.externalPrincipalMembership.create({
data: { data: {
userId: user.id, userId: user.id,
connectionId: connection.id, connectionId: connection.id,
@@ -89,17 +97,22 @@ export async function syncExternalPrincipalMemberships(
}, },
}); });
} else { } else {
await prisma.externalPrincipalMembership.update({ await tx.externalPrincipalMembership.update({
where: { id: existing.id }, where: { id: existing.id },
data: { syncedAt }, data: { syncedAt },
}); });
} }
return user.id;
});
incomingKeys.add(externalMembershipKey(userId, item.principalType, item.principalId, connection.id));
synced++; synced++;
} }
let revoked = 0; let revoked = 0;
if (input.replaceSource === true) { if (input.replaceSource === true) {
const active = await prisma.externalPrincipalMembership.findMany({ revoked = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const active = await tx.externalPrincipalMembership.findMany({
where: { connectionId: connection.id, revokedAt: null }, where: { connectionId: connection.id, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true }, select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
}); });
@@ -112,19 +125,21 @@ export async function syncExternalPrincipalMemberships(
))) )))
.map((membership) => membership.id); .map((membership) => membership.id);
if (revokeIds.length > 0) { if (revokeIds.length > 0) {
const result = await prisma.externalPrincipalMembership.updateMany({ const result = await tx.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } }, where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt }, data: { revokedAt: syncedAt },
}); });
revoked = result.count; return result.count;
} }
return 0;
});
} }
return { synced, revoked }; return { synced, revoked };
} }
async function ensureOrganizationMembership( async function ensureOrganizationMembership(
prisma: PrismaClient, prisma: PrismaClient | Prisma.TransactionClient,
organizationId: string, organizationId: string,
userId: string, userId: string,
): Promise<void> { ): Promise<void> {
+3
View File
@@ -1,4 +1,5 @@
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export interface GrantTeamProjectAccessInput { export interface GrantTeamProjectAccessInput {
readonly projectId: string; readonly projectId: string;
@@ -30,6 +31,7 @@ export async function grantTeamProjectAccess(
): Promise<ProjectTeamAccessEntry> { ): Promise<ProjectTeamAccessEntry> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input); const { project, team } = await resolveProjectAndTeam(tx, input);
await lockActiveOrganization(tx, project.organizationId);
const now = new Date(); const now = new Date();
const existing = await tx.permissionGrant.findFirst({ const existing = await tx.permissionGrant.findFirst({
where: { where: {
@@ -87,6 +89,7 @@ export async function revokeTeamProjectAccess(
): Promise<number> { ): Promise<number> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input); const { project, team } = await resolveProjectAndTeam(tx, input);
await lockActiveOrganization(tx, project.organizationId);
const result = await tx.permissionGrant.updateMany({ const result = await tx.permissionGrant.updateMany({
where: { where: {
resourceType: "PROJECT", resourceType: "PROJECT",
+44 -16
View File
@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises"; import { mkdir, rm } from "node:fs/promises";
import { relative, resolve } from "node:path"; import { dirname, relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
import { createPermissionAuthorizer } from "./permission.js"; import { createPermissionAuthorizer } from "./permission.js";
import { writeAudit } from "./audit.js"; import { writeAudit } from "./audit.js";
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
export interface OrganizationProjectPolicy { export interface OrganizationProjectPolicy {
readonly organizationId: string; readonly organizationId: string;
@@ -72,8 +73,9 @@ export async function setMembersCanCreateProjects(
prisma: PrismaClient, prisma: PrismaClient,
input: { readonly organizationId: string; readonly enabled: boolean }, input: { readonly organizationId: string; readonly enabled: boolean },
): Promise<OrganizationProjectPolicy> { ): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExists(prisma, input.organizationId); return prisma.$transaction(async (tx) => {
const settings = await prisma.organizationProjectSettings.upsert({ await requireActiveOrganizationTx(tx, input.organizationId);
return tx.organizationProjectSettings.upsert({
where: { organizationId: input.organizationId }, where: { organizationId: input.organizationId },
update: { membersCanCreateProjects: input.enabled }, update: { membersCanCreateProjects: input.enabled },
create: { create: {
@@ -82,13 +84,13 @@ export async function setMembersCanCreateProjects(
}, },
select: { organizationId: true, membersCanCreateProjects: true }, select: { organizationId: true, membersCanCreateProjects: true },
}); });
return settings; });
} }
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> { export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
const name = requireNonEmpty(input.name, "folder name"); const name = requireNonEmpty(input.name, "folder name");
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await ensureOrganizationExistsTx(tx, input.organizationId); await requireActiveOrganizationTx(tx, input.organizationId);
if (input.parentId !== undefined) { if (input.parentId !== undefined) {
await assertFolderInOrganization(tx, input.parentId, input.organizationId); await assertFolderInOrganization(tx, input.parentId, input.organizationId);
} }
@@ -113,6 +115,7 @@ export async function moveProjectToFolder(
select: { id: true, organizationId: true }, select: { id: true, organizationId: true },
}); });
if (project === null) throw new Error(`project not found: ${input.projectId}`); if (project === null) throw new Error(`project not found: ${input.projectId}`);
await requireActiveOrganizationTx(tx, project.organizationId);
if (input.folderId !== null) { if (input.folderId !== null) {
await assertFolderInOrganization(tx, input.folderId, project.organizationId); await assertFolderInOrganization(tx, input.folderId, project.organizationId);
} }
@@ -188,6 +191,7 @@ export async function bindFeishuChatToProject(
if (project === null) throw new Error(`project not found: ${input.projectId}`); if (project === null) throw new Error(`project not found: ${input.projectId}`);
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await requireActiveOrganizationTx(tx, project.organizationId);
const activeProjectBinding = await tx.projectGroupBinding.findFirst({ const activeProjectBinding = await tx.projectGroupBinding.findFirst({
where: { projectId: project.id, archivedAt: null }, where: { projectId: project.id, archivedAt: null },
select: { chatId: true }, select: { chatId: true },
@@ -248,6 +252,7 @@ export async function archiveFeishuChatBinding(
if (binding === null) return { archived: false }; if (binding === null) return { archived: false };
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId); const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await requireActiveOrganizationTx(tx, binding.project.organizationId);
await tx.projectGroupBinding.update({ await tx.projectGroupBinding.update({
where: { id: binding.id }, where: { id: binding.id },
data: { archivedAt: new Date() }, data: { archivedAt: new Date() },
@@ -287,9 +292,10 @@ async function createManagedProject(
): Promise<ProjectOnboardingResult> { ): Promise<ProjectOnboardingResult> {
const organization = await prisma.organization.findUnique({ const organization = await prisma.organization.findUnique({
where: { id: input.organizationId }, where: { id: input.organizationId },
select: { id: true, slug: true }, select: { id: true, slug: true, status: true },
}); });
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
const projectId = createProjectId(); const projectId = createProjectId();
const workspaceDir = projectWorkspaceDir({ const workspaceDir = projectWorkspaceDir({
@@ -297,13 +303,18 @@ async function createManagedProject(
organizationSlug: organization.slug, organizationSlug: organization.slug,
projectId, projectId,
}); });
await mkdir(workspaceDir, { recursive: true }); let workspaceCreated = false;
let project: { readonly id: string; readonly organizationId: string; readonly folderId: string | null; readonly workspaceDir: string };
const project = await prisma.$transaction(async (tx) => { try {
project = await prisma.$transaction(async (tx) => {
await ensureOrganizationProjectSettingsTx(tx, organization.id); await ensureOrganizationProjectSettingsTx(tx, organization.id);
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id; const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
await assertFolderInOrganization(tx, folderId, organization.id); await assertFolderInOrganization(tx, folderId, organization.id);
await mkdir(dirname(workspaceDir), { recursive: true });
await mkdir(workspaceDir);
workspaceCreated = true;
const created = await tx.project.create({ const created = await tx.project.create({
data: { data: {
id: projectId, id: projectId,
@@ -339,6 +350,19 @@ async function createManagedProject(
} }
return created; return created;
}); });
} catch (error) {
if (workspaceCreated) {
try {
await rm(workspaceDir, { recursive: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`project creation failed and workspace cleanup also failed: ${workspaceDir}`,
);
}
}
throw error;
}
await writeAudit(prisma, { await writeAudit(prisma, {
projectId: project.id, projectId: project.id,
@@ -382,6 +406,7 @@ async function requireActiveOrgMember(
organizationId: string, organizationId: string,
feishuOpenId: string, feishuOpenId: string,
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> { ): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
await requireActiveOrganization(prisma, organizationId);
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { feishuOpenId }, where: { feishuOpenId },
select: { select: {
@@ -407,7 +432,7 @@ async function ensureOrganizationProjectSettingsTx(
prisma: Prisma.TransactionClient, prisma: Prisma.TransactionClient,
organizationId: string, organizationId: string,
): Promise<OrganizationProjectPolicy> { ): Promise<OrganizationProjectPolicy> {
await ensureOrganizationExistsTx(prisma, organizationId); await requireActiveOrganizationTx(prisma, organizationId);
return prisma.organizationProjectSettings.upsert({ return prisma.organizationProjectSettings.upsert({
where: { organizationId }, where: { organizationId },
update: {}, update: {},
@@ -445,14 +470,17 @@ async function assertFolderInOrganization(
} }
} }
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> { async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } }); const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { id: true, status: true },
});
if (organization === null) throw new Error(`organization not found: ${organizationId}`); if (organization === null) throw new Error(`organization not found: ${organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
} }
async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> { async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } }); await lockActiveOrganization(prisma, organizationId);
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
} }
async function replaceProjectGrant( async function replaceProjectGrant(
+125 -3
View File
@@ -1,5 +1,5 @@
import { constants } from "node:fs"; import { constants } from "node:fs";
import { lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises"; import { link, lstat, mkdir, open, realpath, unlink, type FileHandle } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { isAbsolute, join, relative, resolve, sep } from "node:path";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
@@ -21,6 +21,12 @@ export interface WorkspaceFileSnapshot {
readonly data: Buffer; readonly data: Buffer;
} }
export interface WorkspaceFileWriteResult {
readonly path: string;
readonly device: number;
readonly inode: number;
}
interface OpenDirectory { interface OpenDirectory {
readonly handle: FileHandle; readonly handle: FileHandle;
readonly displayPath: string; readonly displayPath: string;
@@ -72,6 +78,21 @@ export async function writeNewWorkspaceFileNoFollow(
requestedPath: string, requestedPath: string,
source: Readable, source: Readable,
): Promise<string> { ): Promise<string> {
return (await writeNewWorkspaceFileNoFollowTracked(
workspaceRoot,
workspaceDir,
requestedPath,
source,
)).path;
}
/** Create one inbound file and return its identity for race-safe compensation. */
export async function writeNewWorkspaceFileNoFollowTracked(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
source: Readable,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath); const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!; const name = components.at(-1)!;
@@ -81,7 +102,7 @@ export async function writeNewWorkspaceFileNoFollow(
let file: FileHandle | undefined; let file: FileHandle | undefined;
let device: number | undefined; let device: number | undefined;
let inode: number | undefined; let inode: number | undefined;
let result: string | undefined; let result: WorkspaceFileWriteResult | undefined;
let failure: unknown; let failure: unknown;
const cleanupFailures: unknown[] = []; const cleanupFailures: unknown[] = [];
try { try {
@@ -104,7 +125,11 @@ export async function writeNewWorkspaceFileNoFollow(
} }
await file.sync(); await file.sync();
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath); await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
result = join(workspace, ...components); result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) { } catch (error) {
failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely"); failure = boundaryError(error, requestedPath, "cannot create inbound workspace file safely");
if (device !== undefined && inode !== undefined) { if (device !== undefined && inode !== undefined) {
@@ -124,6 +149,103 @@ export async function writeNewWorkspaceFileNoFollow(
return result!; return result!;
} }
/** Remove only the exact file created by a tracked no-follow write. */
export async function removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
expectedDevice: number,
expectedInode: number,
): Promise<void> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), false, requestedPath);
let failure: unknown;
try {
await unlinkIfSameFile(parent, name, expectedDevice, expectedInode);
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot compensate inbound workspace file safely");
}
await finishWithCleanup(
failure,
[parent.handle],
`inbound workspace compensation cleanup failed: ${requestedPath}`,
);
}
/**
* Publish a protected same-filesystem staging file with one exclusive hard-link
* operation. The target path is traversed without following workspace symlinks.
*/
export async function linkWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
stagedPath: string,
): Promise<WorkspaceFileWriteResult> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
const name = components.at(-1)!;
const parent = await openDirectoryTree(workspace, components.slice(0, -1), true, requestedPath);
let source: FileHandle | undefined;
let result: WorkspaceFileWriteResult | undefined;
let failure: unknown;
const cleanupFailures: unknown[] = [];
try {
source = await open(stagedPath, constants.O_RDONLY | constants.O_NOFOLLOW);
const metadata = await source.stat();
if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`staged resource is not a regular file: ${stagedPath}`, stagedPath);
}
const targetPath = childPath(parent, name);
await link(stagedPath, targetPath);
try {
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
} catch (error) {
await unlinkIfSameFile(parent, name, metadata.dev, metadata.ino).catch((cleanupError) => {
cleanupFailures.push(cleanupError);
});
throw error;
}
result = {
path: join(workspace, ...components),
device: metadata.dev,
inode: metadata.ino,
};
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot publish staged workspace file safely");
}
try {
await finishWithCleanup(
failure,
[source, parent.handle],
`staged workspace publication cleanup failed: ${requestedPath}`,
cleanupFailures,
);
} catch (error) {
if (result !== undefined) {
try {
await removeWorkspaceFileIfUnchangedNoFollow(
workspaceRoot,
workspaceDir,
requestedPath,
result.device,
result.inode,
);
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
`staged workspace publication and compensation both failed: ${requestedPath}`,
{ cause: error },
);
}
}
throw error;
}
return result!;
}
async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> { async function canonicalWorkspace(workspaceRoot: string, workspaceDir: string): Promise<string> {
if (process.platform !== "linux") { if (process.platform !== "linux") {
throw new WorkspaceFileBoundaryError( throw new WorkspaceFileBoundaryError(
@@ -7,6 +7,13 @@ import { join } from "node:path";
import Fastify from "fastify"; import Fastify from "fastify";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js"; import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
archiveFolder,
archiveProject,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../src/org/explorer.js";
import { import {
mintSessionToken, mintSessionToken,
sessionCookieHeader, sessionCookieHeader,
@@ -221,4 +228,59 @@ describe("admin explorer API", () => {
await app.close(); await app.close();
} }
}); });
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"blocks direct explorer mutations when the organization is %s",
async (status) => {
await Promise.all([
prisma.folder.create({ data: { id: `folder-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename" } }),
prisma.folder.create({ data: { id: `folder-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive" } }),
prisma.folder.create({ data: { id: `folder-target-${status}`, organizationId: DEFAULT_ORG_ID, name: "Target" } }),
prisma.project.create({
data: { id: `project-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename", workspaceDir: `/tmp/rename-${status}` },
}),
prisma.project.create({
data: { id: `project-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive", workspaceDir: `/tmp/archive-${status}` },
}),
prisma.project.create({
data: { id: `project-move-${status}`, organizationId: DEFAULT_ORG_ID, name: "Move", workspaceDir: `/tmp/move-${status}` },
}),
]);
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const expected = `organization ${DEFAULT_ORG_ID} is ${status}`;
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: `folder-rename-${status}`,
name: "Changed",
})).rejects.toThrow(expected);
await expect(archiveFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: `folder-archive-${status}`,
})).rejects.toThrow(expected);
await expect(renameProject(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-rename-${status}`,
name: "Changed",
})).rejects.toThrow(expected);
await expect(archiveProject(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-archive-${status}`,
})).rejects.toThrow(expected);
await expect(moveOrgProjectToFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-move-${status}`,
folderId: `folder-target-${status}`,
})).rejects.toThrow(expected);
await expect(prisma.folder.findUniqueOrThrow({ where: { id: `folder-rename-${status}` } })).resolves.toMatchObject({
name: "Rename",
archivedAt: null,
});
await expect(prisma.project.findUniqueOrThrow({ where: { id: `project-rename-${status}` } })).resolves.toMatchObject({
name: "Rename",
archivedAt: null,
});
},
);
}); });
@@ -9,6 +9,8 @@ import {
sessionCookieHeader, sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js"; } from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { addOrgMember } from "../../src/org/members.js";
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
const SESSION_SECRET = "integration-test-session-secret"; const SESSION_SECRET = "integration-test-session-secret";
@@ -158,4 +160,49 @@ describe("admin members + teams API", () => {
await app.close(); await app.close();
} }
}); });
it("blocks direct member and team mutations for a suspended organization", async () => {
await seedOwner();
await prisma.user.create({ data: { id: "u-direct-member", feishuOpenId: "ou_direct", displayName: "Direct" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-direct-member", role: "MEMBER" },
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "existing", name: "Existing" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const expected = `organization ${DEFAULT_ORG_ID} is SUSPENDED`;
await expect(addOrgMember(prisma, {
organizationId: DEFAULT_ORG_ID,
actorRole: "OWNER",
feishuOpenId: "ou_blocked",
role: "MEMBER",
})).rejects.toThrow(expected);
await expect(createTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
slug: "blocked",
name: "Blocked",
})).rejects.toThrow(expected);
await expect(updateTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
name: "Changed",
})).rejects.toThrow(expected);
await expect(addTeamMember(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
userId: "u-direct-member",
})).rejects.toThrow(expected);
await expect(archiveTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
})).rejects.toThrow(expected);
await expect(prisma.user.findUnique({ where: { feishuOpenId: "ou_blocked" } })).resolves.toBeNull();
await expect(prisma.team.findUniqueOrThrow({ where: { id: team.id } })).resolves.toMatchObject({
name: "Existing",
archivedAt: null,
});
});
}); });
+100
View File
@@ -7,6 +7,7 @@ import {
PrismaPrincipalResolver, PrismaPrincipalResolver,
syncExternalPrincipalMemberships, syncExternalPrincipalMemberships,
} from "../../src/permission.js"; } from "../../src/permission.js";
import { lockActiveOrganization } from "../../src/org/status.js";
describe("ADR-0019 authorization", () => { describe("ADR-0019 authorization", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -96,6 +97,75 @@ describe("ADR-0019 authorization", () => {
}); });
}); });
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"denies every project authorization when the organization is %s",
async (status) => {
await prisma.project.create({
data: { id: `p-org-${status}`, organizationId: DEFAULT_ORG_ID, name: status, workspaceDir: `/tmp/org-${status}` },
});
await prisma.user.create({
data: {
id: `u-org-${status}`,
feishuOpenId: `ou_org_${status}`,
displayName: status,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: `p-org-${status}`,
principalType: "USER",
principalId: `ou_org_${status}`,
role: "MANAGE",
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
for (const action of ["project.read", "project.edit", "collaborator.manage", "agent.trigger", "agent.cancel"] as const) {
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: `ou_org_${status}` },
action,
resource: { type: "PROJECT", id: `p-org-${status}` },
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain(`organization ${DEFAULT_ORG_ID} is ${status}`);
}
},
);
it("serializes a lifecycle transition behind an admitted customer transaction", async () => {
let releaseOperation!: () => void;
const operationHeld = new Promise<void>((resolve) => {
releaseOperation = resolve;
});
let markLocked!: () => void;
const rowLocked = new Promise<void>((resolve) => {
markLocked = resolve;
});
const operation = prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, DEFAULT_ORG_ID);
markLocked();
await operationHeld;
});
await rowLocked;
let transitionCommitted = false;
const transition = prisma.organization.update({
where: { id: DEFAULT_ORG_ID },
data: { status: "SUSPENDED" },
}).then(() => {
transitionCommitted = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(transitionCommitted).toBe(false);
releaseOperation();
await operation;
await transition;
expect(transitionCommitted).toBe(true);
});
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", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" }, data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
@@ -177,6 +247,36 @@ describe("ADR-0019 authorization", () => {
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" }); expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
}); });
it("blocks directory sync and team grants for an archived organization", async () => {
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "archived-team", name: "Archived Team" },
});
await prisma.project.create({
data: { id: "p-archived-grant", organizationId: DEFAULT_ORG_ID, name: "Archived", workspaceDir: "/tmp/archived" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ARCHIVED" } });
const expected = `organization ${DEFAULT_ORG_ID} is ARCHIVED`;
await expect(syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "blocked-sync",
memberships: [{
feishuOpenId: "ou_blocked_sync",
displayName: "Blocked",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-blocked",
}],
})).rejects.toThrow(expected);
await expect(grantTeamProjectAccess(prisma, {
projectId: "p-archived-grant",
teamId: team.id,
role: "EDIT",
})).rejects.toThrow(expected);
await expect(prisma.externalDirectoryConnection.count()).resolves.toBe(0);
await expect(prisma.permissionGrant.count({ where: { resourceId: "p-archived-grant" } })).resolves.toBe(0);
});
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", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" }, data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
+125 -3
View File
@@ -1,7 +1,7 @@
import { mkdtemp, rm, stat } from "node:fs/promises"; import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import { import {
archiveFeishuChatBinding, archiveFeishuChatBinding,
@@ -9,17 +9,21 @@ import {
createFolder, createFolder,
createProjectFromFeishuChat, createProjectFromFeishuChat,
createProjectFromOrgAdmin, createProjectFromOrgAdmin,
moveProjectToFolder,
setMembersCanCreateProjects, setMembersCanCreateProjects,
} from "../../src/projectOnboarding.js"; } from "../../src/projectOnboarding.js";
const workspaceRoots: string[] = []; const workspaceRoots: string[] = [];
const itAsNonRoot = typeof process.getuid === "function" && process.getuid() === 0 ? it.skip : it;
describe("ADR-0021 project onboarding", () => { describe("ADR-0021 project onboarding", () => {
beforeEach(async () => { beforeEach(async () => {
await dropPermissionSettingsFailureTrigger();
await resetDb(); await resetDb();
}); });
afterEach(async () => { afterEach(async () => {
await dropPermissionSettingsFailureTrigger();
while (workspaceRoots.length > 0) { while (workspaceRoots.length > 0) {
const root = workspaceRoots.pop(); const root = workspaceRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true }); if (root !== undefined) await rm(root, { recursive: true, force: true });
@@ -27,6 +31,7 @@ describe("ADR-0021 project onboarding", () => {
}); });
afterAll(async () => { afterAll(async () => {
await dropPermissionSettingsFailureTrigger();
await prisma.$disconnect(); await prisma.$disconnect();
}); });
@@ -111,6 +116,98 @@ describe("ADR-0021 project onboarding", () => {
})).rejects.toThrow(/members cannot create projects/); })).rejects.toThrow(/members cannot create projects/);
}); });
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"blocks project creation and organization mutations when the org is %s",
async (status) => {
await seedUser(`u-${status}`, `ou_${status}`, "OWNER");
const folder = await createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: `Before ${status}`,
});
const workspaceRoot = await tempWorkspaceRoot();
const project = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: `ou_${status}`,
name: `Existing ${status}`,
workspaceRoot,
});
const organizationWorkspace = dirname(project.workspaceDir);
const workspacesBefore = (await readdir(organizationWorkspace)).sort();
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
await expect(createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: `ou_${status}`,
name: "Blocked",
workspaceRoot,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(readdir(organizationWorkspace)).resolves.toEqual(workspacesBefore);
await expect(bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: `ou_${status}`,
chatId: `chat-${status}`,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: "Blocked folder",
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(setMembersCanCreateProjects(prisma, {
organizationId: DEFAULT_ORG_ID,
enabled: false,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(moveProjectToFolder(prisma, {
projectId: project.projectId,
folderId: folder.id,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
},
);
it("removes a newly created workspace when the database transaction later fails", async () => {
await seedUser("u-compensate", "ou_compensate", "ADMIN");
await installPermissionSettingsFailureTrigger(0);
const workspaceRoot = await tempWorkspaceRoot();
await expect(createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_compensate",
name: "Must Roll Back",
workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/);
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]);
await expect(prisma.project.count()).resolves.toBe(0);
});
itAsNonRoot("surfaces both the transaction and workspace cleanup failures", async () => {
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default");
const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure",
name: "Cleanup Must Fail Loudly",
workspaceRoot,
});
const outcome = pending.then(
() => null,
(error: unknown) => error,
);
await vi.waitFor(async () => {
expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 });
await chmod(organizationWorkspace, 0o500);
try {
const error = await outcome;
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toHaveLength(2);
await expect(prisma.project.count()).resolves.toBe(0);
} finally {
await chmod(organizationWorkspace, 0o700);
}
});
it("binds an existing project once, archives the binding, then allows a new active binding", async () => { it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
await seedUser("u-owner", "ou_owner", "OWNER"); await seedUser("u-owner", "ou_owner", "OWNER");
const workspaceRoot = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
@@ -176,3 +273,28 @@ async function tempWorkspaceRoot(): Promise<string> {
workspaceRoots.push(root); workspaceRoots.push(root);
return root; return root;
} }
async function installPermissionSettingsFailureTrigger(delaySeconds: number): Promise<void> {
await dropPermissionSettingsFailureTrigger();
await prisma.$executeRawUnsafe(`
CREATE FUNCTION cph_test_fail_permission_settings() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_sleep(${delaySeconds});
RAISE EXCEPTION 'forced permission settings failure';
END;
$$
`);
await prisma.$executeRawUnsafe(`
CREATE TRIGGER cph_test_fail_permission_settings
BEFORE INSERT ON "PermissionSettings"
FOR EACH ROW EXECUTE FUNCTION cph_test_fail_permission_settings()
`);
}
async function dropPermissionSettingsFailureTrigger(): Promise<void> {
await prisma.$executeRawUnsafe(
'DROP TRIGGER IF EXISTS cph_test_fail_permission_settings ON "PermissionSettings"',
);
await prisma.$executeRawUnsafe('DROP FUNCTION IF EXISTS cph_test_fail_permission_settings()');
}
@@ -68,4 +68,16 @@ describe("canTriggerRole (integration, per-role gate)", () => {
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a"); const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
expect(onP2.allowed).toBe(false); expect(onP2.allowed).toBe(false);
}); });
it("denies an otherwise open role when the organization is suspended", async () => {
const project = await prisma.project.create({
data: { id: "p-role-suspended", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const result = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(result.allowed).toBe(false);
expect(result.reason).toContain(`organization ${DEFAULT_ORG_ID} is SUSPENDED`);
});
}); });
+240 -3
View File
@@ -1,10 +1,11 @@
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js"; import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
@@ -164,7 +165,9 @@ describe("trigger full lifecycle (integration)", () => {
}); });
itOnLinux("includes downloaded post image paths in the agent prompt", async () => { itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
const workspaceDir = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
await seedProject("proj-post-image", "chat-post-image"); await seedProject("proj-post-image", "chat-post-image");
await prisma.project.update({ await prisma.project.update({
where: { id: "proj-post-image" }, where: { id: "proj-post-image" },
@@ -198,7 +201,7 @@ describe("trigger full lifecycle (integration)", () => {
settings, settings,
logger: silentLogger, logger: silentLogger,
runAgent, runAgent,
projectWorkspaceRoot: dirname(workspaceDir), projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
}); });
@@ -212,6 +215,10 @@ describe("trigger full lifecycle (integration)", () => {
params: { type: "image" }, params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" }, path: { message_id: event.message.message_id, file_key: "img-key-1" },
}); });
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
}); });
it("sends an onboarding card when an unbound chat mentions the bot", async () => { it("sends an onboarding card when an unbound chat mentions the bot", async () => {
@@ -612,6 +619,115 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0); expect(runs).toHaveLength(0);
}); });
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s",
async (status) => {
await seedProject(`proj-org-${status}`, `chat-org-${status}`);
const session = await prisma.agentSession.create({
data: {
projectId: `proj-org-${status}`,
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.count()).toBe(0);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
},
);
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects direct session mutation when the organization is %s",
async (status) => {
await seedProject(`proj-direct-${status}`, `chat-direct-${status}`);
const session = await prisma.agentSession.create({
data: {
projectId: `proj-direct-${status}`,
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const commands = createSlashCommandRegistry({
prisma,
settings,
logger: silentLogger,
triggerQueue: new TriggerQueue(),
});
const resume = commands.get("resume");
expect(resume).toBeDefined();
await expect(resume!.run({
invocation: { name: "resume", args: [] },
projectId: `proj-direct-${status}`,
chatId: `chat-direct-${status}`,
rt,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
},
);
it("reports a lifecycle race that rejects a slash-command mutation", async () => {
await seedProject("proj-slash-race", "chat-slash-race");
const session = await prisma.agentSession.create({
data: {
projectId: "proj-slash-race",
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
authorizer: {
async can(request) {
return {
allowed: true,
reason: "authorized before concurrent suspension",
action: request.action,
resource: request.resource,
actor: request.actor,
organizationId: DEFAULT_ORG_ID,
principals: [{ type: "USER", id: "ou_test_user" }],
requiredRole: "EDIT",
effectiveRole: "EDIT",
};
},
},
});
await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。");
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
});
it("queues a text trigger when project is already locked (ADR-0002)", async () => { it("queues a text trigger when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3"); await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock. // Manually create a lock by inserting a run + lock.
@@ -678,6 +794,127 @@ describe("trigger full lifecycle (integration)", () => {
}); });
}); });
it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => {
await seedProject("proj-queue-suspended", "chat-queue-suspended");
const firstRun = deferred<RunResult>();
const queuedRunAgent: TestRunner = async (req) => {
runAgentCalls.push(req);
return firstRun.promise;
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent: queuedRunAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第一个请求"), rt);
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第二个请求"), rt);
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
await vi.waitFor(() => expect(rt.sentTexts).toContain("无权限使用角色 draft。"));
expect(runAgentCalls).toHaveLength(1);
await expect(prisma.agentRun.count()).resolves.toBe(1);
});
it("rechecks ACTIVE atomically when suspension lands after the first authorization", async () => {
await seedProject("proj-admission-race", "chat-admission-race");
const providerEntered = deferred<void>();
const releaseProvider = deferred<void>();
const baseProvider = settings.provider.bind(settings);
const pausedSettings: RuntimeSettings = {
...settings,
async provider(providerId, context) {
providerEntered.resolve();
await releaseProvider.promise;
return baseProvider(providerId, context);
},
};
const trigger = makeTriggerHandler({
prisma,
settings: pausedSettings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
const pendingTrigger = trigger(makeEvent("chat-admission-race", "@_user_1 竞态请求"), rt);
await providerEntered.promise;
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
releaseProvider.resolve();
await pendingTrigger;
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
expect(runAgentCalls).toHaveLength(0);
await expect(prisma.agentSession.count()).resolves.toBe(0);
await expect(prisma.agentRun.count()).resolves.toBe(0);
await expect(prisma.projectAgentLock.count()).resolves.toBe(0);
await expect(prisma.auditEntry.count({ where: { action: "run.created" } })).resolves.toBe(0);
});
itOnLinux("does not publish a staged attachment when suspension wins admission", async () => {
const workspaceRoot = await tempWorkspaceRoot();
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
await seedProject("proj-attachment-race", "chat-attachment-race");
await prisma.project.update({
where: { id: "proj-attachment-race" },
data: { workspaceDir },
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
message: {
...baseEvent.message,
message_type: "post",
content: JSON.stringify({
zh_cn: {
content: [[
{ tag: "text", text: "@_user_1 附件竞态" },
{ tag: "img", image_key: "img-race" },
]],
},
}),
},
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
});
const pendingTrigger = trigger(event, rt);
await resourceEntered.promise;
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
releaseResource.resolve();
await pendingTrigger;
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
expect(runAgentCalls).toHaveLength(0);
await expect(prisma.agentRun.count()).resolves.toBe(0);
await expect(readdir(join(workspaceDir, ".cph", "inbox"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
});
it("does not create a run for unbound chats and asks unknown users to log in", async () => { it("does not create a run for unbound chats and asks unknown users to log in", async () => {
await seedProject("proj-4", "chat-4"); await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
+6 -1
View File
@@ -250,7 +250,7 @@ function mockPrisma(): PrismaClient {
let lock: { projectId: string; runId: string } | null = null; let lock: { projectId: string; runId: string } | null = null;
const session = { id: "session-1", metadata: {} }; const session = { id: "session-1", metadata: {} };
return { const client = {
feishuEventReceipt: { feishuEventReceipt: {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })), create: vi.fn(async () => ({ id: "receipt-1" })),
@@ -286,6 +286,11 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "audit-1" })), create: vi.fn(async () => ({ id: "audit-1" })),
}, },
} as unknown as PrismaClient; } as unknown as PrismaClient;
Object.assign(client, {
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
});
return client;
} }
function silentLogger(): FeishuRuntime["logger"] { function silentLogger(): FeishuRuntime["logger"] {
+39
View File
@@ -0,0 +1,39 @@
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages } from "../../src/feishu/resourceStaging.js";
const roots: string[] = [];
describe("Feishu resource staging recovery", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("removes every abandoned batch during startup recovery", async () => {
const root = await tempRoot();
const batch = join(root, ".cph-staging", "abandoned-batch");
await mkdir(batch, { recursive: true });
await writeFile(join(batch, "resource-0"), "sensitive attachment");
await expect(removeAbandonedMessageResourceStages(root)).resolves.toBe(1);
await expect(readdir(join(root, ".cph-staging"))).resolves.toEqual([]);
});
it("refuses to traverse a staging-base symlink", async () => {
const root = await tempRoot();
const outside = await tempRoot();
await symlink(outside, join(root, ".cph-staging"));
await expect(removeAbandonedMessageResourceStages(root)).rejects.toThrow(
/staging base is not a real directory/,
);
});
});
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-resource-staging-test-"));
roots.push(root);
return root;
}