diff --git a/AGENTS.md b/AGENTS.md index 76d999f..9222db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 +- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 + 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 / + `Spec.System.ProjectWorkspace`)。 ## 纪律 diff --git a/CLAUDE.md b/CLAUDE.md index 1a1bde1..3fa92d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,9 @@ - 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。 - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。 +- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 + 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 / + `Spec.System.ProjectWorkspace`)。 ## 纪律 diff --git a/docs/adr/0001-project-group-is-collaboration-space.md b/docs/adr/0001-project-group-is-collaboration-space.md index 85de1df..0988451 100644 --- a/docs/adr/0001-project-group-is-collaboration-space.md +++ b/docs/adr/0001-project-group-is-collaboration-space.md @@ -26,4 +26,6 @@ The group can stay open while no Claude processing is active. A teacher leaving - Multi-open is natural: a teacher participates in multiple project groups. - Normal group discussion does not occupy the project. - "Exit project" should not be the normal action in the group workflow. -- Project rebinding or group archival needs explicit product rules, separate from run cancellation. +- ADR-0021 adds those explicit product rules: active project↔Feishu chat binding + is strict 1:1, while archived historical bindings are retained so org admins + can correct pilot setup mistakes without losing audit/session history. diff --git a/docs/adr/0021-org-admin-project-onboarding.md b/docs/adr/0021-org-admin-project-onboarding.md new file mode 100644 index 0000000..5fc0db1 --- /dev/null +++ b/docs/adr/0021-org-admin-project-onboarding.md @@ -0,0 +1,102 @@ +# ADR 0021: Org Admin Project Onboarding + +## Status + +Accepted. + +## Context + +ADR-0020 introduced `Organization` as the SaaS tenant root. The next product +surface is not only the Feishu bot trigger path, but also the control planes +around it: + +- platform staff need a private platform admin area to create and operate orgs; +- customer org owners/admins need an org admin area for teams, roles, model + provider configuration, projects, folders, sessions, and usage accounting; +- ordinary teachers should be able to start work from the natural Feishu group + flow without entering the web backend. + +Without a crisp project onboarding model, project creation, Feishu chat binding, +permissions, session history, and usage accounting would drift into separate +ad-hoc rules. + +## Decision + +Use one web app with two separate admin areas: + +```text +/admin/platform internal platform admins only +/admin/org/:orgSlug customer org OWNER/ADMIN only +``` + +The guards are intentionally separate: + +- `requirePlatformAdmin` for internal operators; +- `requireOrgRole` for org owner/admin backend access; +- `requireProjectPermission` for project-level actions. + +Platform admin is not an `OrganizationMembership` and is not represented by +project `PermissionGrant`. Platform admin audit remains separate from customer +project audit. + +Customer orgs are manually created by platform staff for the pilot. Platform +admins invite/allowlist other platform admins. Customer users authenticate with +the customer's Feishu app. The same customer-owned Feishu app may be used for +OAuth login, bot messages, and directory sync. App secrets are org-scoped +secrets and must be encrypted at rest; business code should access them through +a secret/connection resolver, not raw plaintext columns. + +Project management has two creation paths: + +- org owner/admin creates projects in the org web backend; +- ordinary org members may create a project from an unbound Feishu group when + `membersCanCreateProjects` is enabled for the org. + +In both paths the creator gets project `MANAGE`. A Feishu-created project is +immediately bound to the source chat, and that chat receives project `EDIT`. + +Feishu chat binding is strict 1:1: + +- one Feishu chat binds to at most one active project; +- one project binds to at most one Feishu chat; +- binding an existing unbound project from Feishu requires the clicking user to + have `MANAGE` on that project; +- binding creates an active `FEISHU_CHAT -> PROJECT EDIT` grant; +- binding mistakes are corrected by org owner/admin unbinding or archiving the + binding in the backend; historical sessions stay on their original project. + +Folders are transparent organization nodes for project navigation and usage +aggregation: + +- folders belong to one organization; +- projects may sit in folders; +- folders can be nested; +- folders are not permission resources; +- folder visibility, team policies, and inherited grants are deferred. + +Project permissions remain on `PROJECT` resources. Moving a project between +folders does not change grants. + +Usage accounting is org-wise and project/folder aggregatable. It is not payment +collection in the pilot because customers supply their own model provider API +keys and base URLs. + +## Consequences + +- `OrganizationProjectSettings.membersCanCreateProjects` gates Feishu group + project creation for ordinary members. +- `Folder` and `Project.folderId` support the file-manager-like project + explorer without creating a second ACL system. +- Service code should expose project creation and chat binding as reusable + backend operations so Feishu cards and future web APIs call the same rules. +- Org role/model/provider/billing panels can be added on top of the org tenant + root without changing project authorization. + +## Open Questions / Deferred + +- True one-click Feishu app provisioning is deferred; pilot uses guided setup + and readiness checks. +- Folder-level permissions are deferred until there is a concrete customer need. +- Self-serve org signup and payment collection are deferred beyond pilot. +- The exact platform admin identity store and audit schema are separate from + this ADR and should be modeled before exposing the platform admin panel. diff --git a/hub/prisma/migrations/20260710100000_org_admin_project_onboarding/migration.sql b/hub/prisma/migrations/20260710100000_org_admin_project_onboarding/migration.sql new file mode 100644 index 0000000..a3da3dd --- /dev/null +++ b/hub/prisma/migrations/20260710100000_org_admin_project_onboarding/migration.sql @@ -0,0 +1,92 @@ +-- ADR-0021: org admin project onboarding. +-- Folder is a transparent project explorer node; project remains the permission boundary. + +CREATE TABLE "OrganizationProjectSettings" ( + "organizationId" TEXT NOT NULL, + "membersCanCreateProjects" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationProjectSettings_pkey" PRIMARY KEY ("organizationId") +); + +ALTER TABLE "OrganizationProjectSettings" + ADD CONSTRAINT "OrganizationProjectSettings_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "OrganizationProjectSettings" ("organizationId", "membersCanCreateProjects", "createdAt", "updatedAt") +SELECT "id", true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "Organization" +ON CONFLICT ("organizationId") DO NOTHING; + +CREATE TABLE "Folder" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "parentId" TEXT, + "name" TEXT NOT NULL, + "sortKey" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Folder_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Folder" + ADD CONSTRAINT "Folder_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Folder" + ADD CONSTRAINT "Folder_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE INDEX "Folder_organizationId_parentId_archivedAt_idx" + ON "Folder"("organizationId", "parentId", "archivedAt"); + +CREATE INDEX "Folder_organizationId_parentId_sortKey_idx" + ON "Folder"("organizationId", "parentId", "sortKey"); + +CREATE UNIQUE INDEX "Folder_active_sibling_name_key" + ON "Folder"("organizationId", COALESCE("parentId", ''), lower("name")) + WHERE "archivedAt" IS NULL; + +INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "sortKey", "createdAt", "updatedAt") +SELECT 'folder_inbox_' || md5("id"), "id", NULL, 'Inbox', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "Organization"; + +ALTER TABLE "Project" ADD COLUMN "folderId" TEXT; + +UPDATE "Project" AS p +SET "folderId" = f."id" +FROM "Folder" AS f +WHERE f."organizationId" = p."organizationId" + AND f."parentId" IS NULL + AND f."name" = 'Inbox' + AND p."folderId" IS NULL; + +ALTER TABLE "Project" + ADD CONSTRAINT "Project_folderId_fkey" + FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "Project_folderId_archivedAt_idx" + ON "Project"("folderId", "archivedAt"); + +ALTER TABLE "ProjectGroupBinding" ADD COLUMN "archivedAt" TIMESTAMP(3); + +DROP INDEX IF EXISTS "ProjectGroupBinding_projectId_key"; +DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_key"; +DROP INDEX IF EXISTS "ProjectGroupBinding_chatId_idx"; + +CREATE INDEX "ProjectGroupBinding_projectId_archivedAt_idx" + ON "ProjectGroupBinding"("projectId", "archivedAt"); + +CREATE INDEX "ProjectGroupBinding_chatId_archivedAt_idx" + ON "ProjectGroupBinding"("chatId", "archivedAt"); + +CREATE UNIQUE INDEX "ProjectGroupBinding_active_project_key" + ON "ProjectGroupBinding"("projectId") + WHERE "archivedAt" IS NULL; + +CREATE UNIQUE INDEX "ProjectGroupBinding_active_chat_key" + ON "ProjectGroupBinding"("chatId") + WHERE "archivedAt" IS NULL; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 03b20df..e042037 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -36,6 +36,8 @@ model Organization { updatedAt DateTime @updatedAt memberships OrganizationMembership[] + projectSettings OrganizationProjectSettings? + folders Folder[] projects Project[] teams Team[] externalDirectoryConnections ExternalDirectoryConnection[] @@ -73,6 +75,17 @@ enum OrganizationMemberRole { MEMBER } +/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can +/// create projects from unbound chats only when membersCanCreateProjects=true. +model OrganizationProjectSettings { + organizationId String @id + membersCanCreateProjects Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) +} + /// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004). model User { id String @id @default(cuid()) @@ -222,9 +235,32 @@ model ExternalPrincipalMembership { // --- Project & Feishu binding (ADR-0001) --------------------------------- +/// ADR-0021: transparent project explorer folder. Folders are org-scoped +/// navigation/aggregation nodes, not permission resources; project grants stay +/// attached to PROJECT resources. +model Folder { + id String @id @default(cuid()) + organizationId String + parentId String? + name String + sortKey String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + parent Folder? @relation("folderTree", fields: [parentId], references: [id], onDelete: Restrict) + children Folder[] @relation("folderTree") + projects Project[] + + @@index([organizationId, parentId, archivedAt]) + @@index([organizationId, parentId, sortKey]) +} + model Project { id String @id @default(cuid()) organizationId String + folderId String? name String workspaceDir String createdByUserId String? @@ -232,37 +268,39 @@ model Project { updatedAt DateTime @updatedAt archivedAt DateTime? - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) - groupBinding ProjectGroupBinding? + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + groupBindings ProjectGroupBinding[] agentSessions AgentSession[] agentRuns AgentRun[] agentLock ProjectAgentLock? - roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") - auditEntries AuditEntry[] @relation("projectAudit") - fileChanges AgentFileChange[] @relation("projectFileChanges") + roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants") + auditEntries AuditEntry[] @relation("projectAudit") + fileChanges AgentFileChange[] @relation("projectFileChanges") @@index([organizationId, archivedAt]) + @@index([folderId, archivedAt]) @@index([archivedAt]) } -/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒ -/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat). -/// The "at most one binding per project" half is enforced by the 1:1 relation. -/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled -/// here; archival is a future policy, not a current column. +/// ADR-0001 + ADR-0021: active bindings are one project ↔ one Feishu chat +/// (1:1). Historical archived bindings are retained for audit; partial unique +/// indexes in migrations enforce one active binding per project and per chat. model ProjectGroupBinding { - id String @id @default(cuid()) - projectId String @unique - chatId String @unique + id String @id @default(cuid()) + projectId String + chatId String createdByUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) - @@index([chatId]) + @@index([projectId, archivedAt]) + @@index([chatId, archivedAt]) } // --- AgentRun, session, lock (ADR-0002, 0017) ----------------------------- diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 200cecb..0059e9f 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -535,8 +535,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id"); return; } - const binding = await deps.prisma.projectGroupBinding.findUnique({ - where: { chatId }, + const binding = await deps.prisma.projectGroupBinding.findFirst({ + where: { chatId, archivedAt: null }, select: { projectId: true }, }); if (binding === null) { @@ -618,8 +618,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { if (chatId === "") return; // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group. - const binding = await deps.prisma.projectGroupBinding.findUnique({ - where: { chatId }, + const binding = await deps.prisma.projectGroupBinding.findFirst({ + where: { chatId, archivedAt: null }, select: { projectId: true }, }); if (binding === null) { diff --git a/hub/src/permissions/authorizer.ts b/hub/src/permissions/authorizer.ts index 15bcca6..68f1ab7 100644 --- a/hub/src/permissions/authorizer.ts +++ b/hub/src/permissions/authorizer.ts @@ -234,8 +234,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer { return project.organizationId; } case "PROJECT_GROUP": { - const binding = await this.prisma.projectGroupBinding.findUnique({ - where: { chatId: resource.id }, + const binding = await this.prisma.projectGroupBinding.findFirst({ + where: { chatId: resource.id, archivedAt: null }, select: { project: { select: { organizationId: true } } }, }); if (binding === null) { diff --git a/hub/src/projectOnboarding.ts b/hub/src/projectOnboarding.ts new file mode 100644 index 0000000..1b52f42 --- /dev/null +++ b/hub/src/projectOnboarding.ts @@ -0,0 +1,537 @@ +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { relative, resolve } from "node:path"; +import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; +import { createPermissionAuthorizer } from "./permission.js"; +import { writeAudit } from "./audit.js"; + +export interface OrganizationProjectPolicy { + readonly organizationId: string; + readonly membersCanCreateProjects: boolean; +} + +export interface ProjectOnboardingResult { + readonly projectId: string; + readonly organizationId: string; + readonly folderId: string | null; + readonly workspaceDir: string; + readonly chatId?: string | undefined; +} + +export interface CreateOrgAdminProjectInput { + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly name: string; + readonly workspaceRoot: string; + readonly folderId?: string | undefined; + readonly sortKey?: string | undefined; +} + +export interface CreateFeishuChatProjectInput { + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly chatId: string; + readonly name: string; + readonly workspaceRoot: string; + readonly folderId?: string | undefined; + readonly sortKey?: string | undefined; +} + +export interface BindFeishuChatToProjectInput { + readonly projectId: string; + readonly actorFeishuOpenId: string; + readonly chatId: string; +} + +export interface ArchiveFeishuChatBindingInput { + readonly projectId?: string | undefined; + readonly chatId?: string | undefined; + readonly actorFeishuOpenId: string; +} + +export interface CreateFolderInput { + readonly organizationId: string; + readonly name: string; + readonly parentId?: string | undefined; + readonly sortKey?: string | undefined; +} + +export interface MoveProjectToFolderInput { + readonly projectId: string; + readonly folderId: string | null; +} + +export async function ensureOrganizationProjectSettings( + prisma: PrismaClient, + organizationId: string, +): Promise { + return prisma.$transaction(async (tx) => ensureOrganizationProjectSettingsTx(tx, organizationId)); +} + +export async function setMembersCanCreateProjects( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly enabled: boolean }, +): Promise { + await ensureOrganizationExists(prisma, input.organizationId); + const settings = await prisma.organizationProjectSettings.upsert({ + where: { organizationId: input.organizationId }, + update: { membersCanCreateProjects: input.enabled }, + create: { + organizationId: input.organizationId, + membersCanCreateProjects: input.enabled, + }, + select: { organizationId: true, membersCanCreateProjects: true }, + }); + return settings; +} + +export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise { + const name = requireNonEmpty(input.name, "folder name"); + return prisma.$transaction(async (tx) => { + await ensureOrganizationExistsTx(tx, input.organizationId); + if (input.parentId !== undefined) { + await assertFolderInOrganization(tx, input.parentId, input.organizationId); + } + return tx.folder.create({ + data: { + organizationId: input.organizationId, + name, + sortKey: input.sortKey ?? "", + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + }, + }); + }); +} + +export async function moveProjectToFolder( + prisma: PrismaClient, + input: MoveProjectToFolderInput, +): Promise<{ readonly projectId: string; readonly folderId: string | null }> { + return prisma.$transaction(async (tx) => { + const project = await tx.project.findUnique({ + where: { id: input.projectId }, + select: { id: true, organizationId: true }, + }); + if (project === null) throw new Error(`project not found: ${input.projectId}`); + if (input.folderId !== null) { + await assertFolderInOrganization(tx, input.folderId, project.organizationId); + } + const updated = await tx.project.update({ + where: { id: input.projectId }, + data: { folderId: input.folderId }, + select: { id: true, folderId: true }, + }); + return { projectId: updated.id, folderId: updated.folderId }; + }); +} + +export async function createProjectFromOrgAdmin( + prisma: PrismaClient, + input: CreateOrgAdminProjectInput, +): Promise { + const name = requireNonEmpty(input.name, "project name"); + const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId); + if (!isOrgAdminRole(actor.role)) { + throw new Error(`org admin project creation requires OWNER or ADMIN in organization ${input.organizationId}`); + } + return createManagedProject(prisma, { + organizationId: input.organizationId, + actorUserId: actor.userId, + actorFeishuOpenId: input.actorFeishuOpenId, + name, + workspaceRoot: input.workspaceRoot, + folderId: input.folderId, + sortKey: input.sortKey, + chatId: undefined, + }); +} + +export async function createProjectFromFeishuChat( + prisma: PrismaClient, + input: CreateFeishuChatProjectInput, +): Promise { + const chatId = requireNonEmpty(input.chatId, "chat id"); + const name = requireNonEmpty(input.name, "project name"); + const actor = await requireActiveOrgMember(prisma, input.organizationId, input.actorFeishuOpenId); + const settings = await ensureOrganizationProjectSettings(prisma, input.organizationId); + if (!settings.membersCanCreateProjects && !isOrgAdminRole(actor.role)) { + throw new Error(`members cannot create projects in organization ${input.organizationId}`); + } + const activeBinding = await prisma.projectGroupBinding.findFirst({ + where: { chatId, archivedAt: null }, + select: { projectId: true }, + }); + if (activeBinding !== null) { + throw new Error(`Feishu chat ${chatId} is already bound to project ${activeBinding.projectId}`); + } + return createManagedProject(prisma, { + organizationId: input.organizationId, + actorUserId: actor.userId, + actorFeishuOpenId: input.actorFeishuOpenId, + name, + workspaceRoot: input.workspaceRoot, + folderId: input.folderId, + sortKey: input.sortKey, + chatId, + }); +} + +export async function bindFeishuChatToProject( + prisma: PrismaClient, + input: BindFeishuChatToProjectInput, +): Promise { + const chatId = requireNonEmpty(input.chatId, "chat id"); + const project = await prisma.project.findUnique({ + where: { id: input.projectId }, + select: { id: true, organizationId: true, folderId: true, workspaceDir: true }, + }); + if (project === null) throw new Error(`project not found: ${input.projectId}`); + const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId); + await prisma.$transaction(async (tx) => { + const activeProjectBinding = await tx.projectGroupBinding.findFirst({ + where: { projectId: project.id, archivedAt: null }, + select: { chatId: true }, + }); + if (activeProjectBinding !== null) { + throw new Error(`project ${project.id} is already bound to Feishu chat ${activeProjectBinding.chatId}`); + } + const activeChatBinding = await tx.projectGroupBinding.findFirst({ + where: { chatId, archivedAt: null }, + select: { projectId: true }, + }); + if (activeChatBinding !== null) { + throw new Error(`Feishu chat ${chatId} is already bound to project ${activeChatBinding.projectId}`); + } + await tx.projectGroupBinding.create({ + data: { projectId: project.id, chatId, createdByUserId: actor.userId }, + }); + await replaceProjectGrant(tx, { + projectId: project.id, + principalType: "FEISHU_CHAT", + principalId: chatId, + role: "EDIT", + createdByUserId: actor.userId, + }); + }); + await writeAudit(prisma, { + projectId: project.id, + actorUserId: actor.userId, + action: "project.chat_bound", + metadata: { chatId, actorVia: actor.via }, + }); + return { + projectId: project.id, + organizationId: project.organizationId, + folderId: project.folderId, + workspaceDir: project.workspaceDir, + chatId, + }; +} + +export async function archiveFeishuChatBinding( + prisma: PrismaClient, + input: ArchiveFeishuChatBindingInput, +): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined; readonly chatId?: string | undefined }> { + if ((input.projectId === undefined || input.projectId === "") && (input.chatId === undefined || input.chatId === "")) { + throw new Error("archiveFeishuChatBinding requires projectId or chatId"); + } + if (input.projectId !== undefined && input.projectId !== "" && input.chatId !== undefined && input.chatId !== "") { + throw new Error("archiveFeishuChatBinding accepts only one of projectId or chatId"); + } + const binding = await prisma.projectGroupBinding.findFirst({ + where: { + archivedAt: null, + ...(input.projectId !== undefined && input.projectId !== "" ? { projectId: input.projectId } : { chatId: input.chatId! }), + }, + select: { id: true, projectId: true, chatId: true, project: { select: { organizationId: true } } }, + }); + if (binding === null) return { archived: false }; + const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId); + await prisma.$transaction(async (tx) => { + await tx.projectGroupBinding.update({ + where: { id: binding.id }, + data: { archivedAt: new Date() }, + }); + await tx.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: binding.projectId, + principalType: "FEISHU_CHAT", + principalId: binding.chatId, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + }); + await writeAudit(prisma, { + projectId: binding.projectId, + actorUserId: actor.userId, + action: "project.chat_binding_archived", + metadata: { chatId: binding.chatId, actorVia: actor.via }, + }); + return { archived: true, projectId: binding.projectId, chatId: binding.chatId }; +} + +async function createManagedProject( + prisma: PrismaClient, + input: { + readonly organizationId: string; + readonly actorUserId: string; + readonly actorFeishuOpenId: string; + readonly name: string; + readonly workspaceRoot: string; + readonly folderId: string | undefined; + readonly sortKey?: string | undefined; + readonly chatId: string | undefined; + }, +): Promise { + const organization = await prisma.organization.findUnique({ + where: { id: input.organizationId }, + select: { id: true, slug: true }, + }); + if (organization === null) throw new Error(`organization not found: ${input.organizationId}`); + + const projectId = createProjectId(); + const workspaceDir = projectWorkspaceDir({ + workspaceRoot: input.workspaceRoot, + organizationSlug: organization.slug, + projectId, + }); + await mkdir(workspaceDir, { recursive: true }); + + const project = await prisma.$transaction(async (tx) => { + await ensureOrganizationProjectSettingsTx(tx, organization.id); + const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id; + await assertFolderInOrganization(tx, folderId, organization.id); + + const created = await tx.project.create({ + data: { + id: projectId, + organizationId: organization.id, + folderId, + name: input.name, + workspaceDir, + createdByUserId: input.actorUserId, + }, + select: { id: true, organizationId: true, folderId: true, workspaceDir: true }, + }); + await tx.permissionSettings.create({ + data: defaultProjectPermissionSettings(created.id), + }); + await replaceProjectGrant(tx, { + projectId: created.id, + principalType: "USER", + principalId: input.actorFeishuOpenId, + role: "MANAGE", + createdByUserId: input.actorUserId, + }); + if (input.chatId !== undefined) { + await tx.projectGroupBinding.create({ + data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId }, + }); + await replaceProjectGrant(tx, { + projectId: created.id, + principalType: "FEISHU_CHAT", + principalId: input.chatId, + role: "EDIT", + createdByUserId: input.actorUserId, + }); + } + return created; + }); + + await writeAudit(prisma, { + projectId: project.id, + actorUserId: input.actorUserId, + action: input.chatId === undefined ? "project.created" : "project.created_from_feishu_chat", + metadata: { folderId: project.folderId, workspaceDir, chatId: input.chatId ?? null }, + }); + + return { + projectId: project.id, + organizationId: project.organizationId, + folderId: project.folderId, + workspaceDir: project.workspaceDir, + ...(input.chatId !== undefined ? { chatId: input.chatId } : {}), + }; +} + +async function requireProjectManager( + prisma: PrismaClient, + projectId: string, + organizationId: string, + actorFeishuOpenId: string, +): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> { + const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId); + if (isOrgAdminRole(actor.role)) { + return { ...actor, via: "org-admin" }; + } + const decision = await createPermissionAuthorizer(prisma).can({ + actor: { feishuOpenId: actorFeishuOpenId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: projectId }, + }); + if (!decision.allowed) { + throw new Error(`project ${projectId} requires MANAGE for ${actorFeishuOpenId}: ${decision.reason}`); + } + return { ...actor, via: "project-manage" }; +} + +async function requireActiveOrgMember( + prisma: PrismaClient, + organizationId: string, + feishuOpenId: string, +): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> { + const user = await prisma.user.findUnique({ + where: { feishuOpenId }, + select: { + id: true, + organizationMemberships: { + where: { organizationId, revokedAt: null }, + select: { role: true }, + take: 1, + }, + }, + }); + if (user === null) { + throw new Error(`Feishu user ${feishuOpenId} must log in before project onboarding`); + } + const membership = user.organizationMemberships[0]; + if (membership === undefined) { + throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`); + } + return { userId: user.id, role: membership.role }; +} + +async function ensureOrganizationProjectSettingsTx( + prisma: Prisma.TransactionClient, + organizationId: string, +): Promise { + await ensureOrganizationExistsTx(prisma, organizationId); + return prisma.organizationProjectSettings.upsert({ + where: { organizationId }, + update: {}, + create: { organizationId, membersCanCreateProjects: true }, + select: { organizationId: true, membersCanCreateProjects: true }, + }); +} + +async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationId: string): Promise<{ readonly id: string }> { + const existing = await prisma.folder.findFirst({ + where: { organizationId, parentId: null, name: "Inbox", archivedAt: null }, + select: { id: true }, + }); + if (existing !== null) return existing; + return prisma.folder.create({ + data: { organizationId, name: "Inbox", sortKey: "000000" }, + select: { id: true }, + }); +} + +async function assertFolderInOrganization( + prisma: Prisma.TransactionClient, + folderId: string, + organizationId: string, +): Promise { + const folder = await prisma.folder.findUnique({ + where: { id: folderId }, + select: { organizationId: true, archivedAt: true }, + }); + if (folder === null || folder.archivedAt !== null) { + throw new Error(`active folder not found: ${folderId}`); + } + if (folder.organizationId !== organizationId) { + throw new Error(`folder ${folderId} is in ${folder.organizationId}, not organization ${organizationId}`); + } +} + +async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise { + const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } }); + if (organization === null) throw new Error(`organization not found: ${organizationId}`); +} + +async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise { + const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } }); + if (organization === null) throw new Error(`organization not found: ${organizationId}`); +} + +async function replaceProjectGrant( + prisma: Prisma.TransactionClient, + input: { + readonly projectId: string; + readonly principalType: "USER" | "FEISHU_CHAT"; + readonly principalId: string; + readonly role: PermissionRole; + readonly createdByUserId: string; + }, +): Promise { + await prisma.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: input.projectId, + principalType: input.principalType, + principalId: input.principalId, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: input.projectId, + principalType: input.principalType, + principalId: input.principalId, + role: input.role, + createdByUserId: input.createdByUserId, + }, + }); +} + +function defaultProjectPermissionSettings(projectId: string): Prisma.PermissionSettingsCreateInput { + return { + resourceType: "PROJECT", + resourceId: projectId, + externalShare: "DISABLED", + comment: "ROLE", + copyDownload: "ROLE", + collaboratorMgmt: "MANAGE_ONLY", + agentTrigger: "ROLE", + agentCancel: "MANAGE_ONLY", + }; +} + +function isOrgAdminRole(role: OrganizationMemberRole): boolean { + return role === "OWNER" || role === "ADMIN"; +} + +function requireNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed === "") throw new Error(`${label} is required`); + return trimmed; +} + +function createProjectId(): string { + return `project_${randomUUID().replaceAll("-", "")}`; +} + +function projectWorkspaceDir(input: { + readonly workspaceRoot: string; + readonly organizationSlug: string; + readonly projectId: string; +}): string { + const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root")); + const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id")); + const rel = relative(root, dir); + if (rel === "" || rel.startsWith("..")) { + throw new Error(`allocated workspace escapes root: ${dir}`); + } + return dir; +} + +function safePathSegment(value: string, label: string): string { + const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_"); + if (segment === "." || segment === ".." || segment === "") { + throw new Error(`${label} is not a safe path segment`); + } + return segment; +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 1fe8bd6..80a3cb7 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -44,6 +44,8 @@ export async function resetDb(): Promise { "AgentRun", "AgentSession", "ProjectGroupBinding", + "Folder", + "OrganizationProjectSettings", "OrganizationMembership", "PlatformRoleAssignment", "User", @@ -68,6 +70,25 @@ export async function seedTestOrganization( name: "Test Default Organization", }, }); + await prisma.organizationProjectSettings.upsert({ + where: { organizationId: id }, + update: {}, + create: { organizationId: id, membersCanCreateProjects: true }, + }); + const inbox = await prisma.folder.findFirst({ + where: { organizationId: id, parentId: null, name: "Inbox", archivedAt: null }, + select: { id: true }, + }); + if (inbox === null) { + await prisma.folder.create({ + data: { + id: `folder_inbox_${id}`, + organizationId: id, + name: "Inbox", + sortKey: "000000", + }, + }); + } } /** A logger that discards everything (tests don't need fastify's pino). */ diff --git a/hub/test/integration/project-onboarding.test.ts b/hub/test/integration/project-onboarding.test.ts new file mode 100644 index 0000000..299c4fe --- /dev/null +++ b/hub/test/integration/project-onboarding.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js"; +import { + archiveFeishuChatBinding, + bindFeishuChatToProject, + createFolder, + createProjectFromFeishuChat, + createProjectFromOrgAdmin, + setMembersCanCreateProjects, +} from "../../src/projectOnboarding.js"; + +const workspaceRoots: string[] = []; + +describe("ADR-0021 project onboarding", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterEach(async () => { + while (workspaceRoots.length > 0) { + const root = workspaceRoots.pop(); + if (root !== undefined) await rm(root, { recursive: true, force: true }); + } + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("lets an org admin create an unbound project in a folder", async () => { + await seedUser("u-admin", "ou_admin", "ADMIN"); + const folder = await createFolder(prisma, { + organizationId: DEFAULT_ORG_ID, + name: "Grade 7", + sortKey: "010000", + }); + const workspaceRoot = await tempWorkspaceRoot(); + + const result = await createProjectFromOrgAdmin(prisma, { + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_admin", + name: "Newton Lesson", + folderId: folder.id, + workspaceRoot, + }); + + expect(result.folderId).toBe(folder.id); + expect(result.chatId).toBeUndefined(); + expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); + const grant = await prisma.permissionGrant.findFirst({ + where: { + resourceType: "PROJECT", + resourceId: result.projectId, + principalType: "USER", + principalId: "ou_admin", + revokedAt: null, + }, + select: { role: true }, + }); + expect(grant?.role).toBe("MANAGE"); + const settings = await prisma.permissionSettings.findUnique({ + where: { resourceType_resourceId: { resourceType: "PROJECT", resourceId: result.projectId } }, + }); + expect(settings?.agentTrigger).toBe("ROLE"); + }); + + it("lets an org member create and bind a project from an unbound Feishu chat", async () => { + await seedUser("u-member", "ou_member", "MEMBER"); + const workspaceRoot = await tempWorkspaceRoot(); + + const result = await createProjectFromFeishuChat(prisma, { + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_member", + chatId: "chat-onboard", + name: "Poetry Workshop", + workspaceRoot, + }); + + expect(result.chatId).toBe("chat-onboard"); + const binding = await prisma.projectGroupBinding.findFirst({ + where: { projectId: result.projectId, chatId: "chat-onboard", archivedAt: null }, + }); + expect(binding).not.toBeNull(); + const grants = await prisma.permissionGrant.findMany({ + where: { resourceType: "PROJECT", resourceId: result.projectId, revokedAt: null }, + select: { principalType: true, principalId: true, role: true }, + orderBy: { principalType: "asc" }, + }); + expect(grants).toEqual(expect.arrayContaining([ + { principalType: "USER", principalId: "ou_member", role: "MANAGE" }, + { principalType: "FEISHU_CHAT", principalId: "chat-onboard", role: "EDIT" }, + ])); + }); + + it("blocks ordinary Feishu project creation when the org setting is off", async () => { + await seedUser("u-member", "ou_member", "MEMBER"); + await setMembersCanCreateProjects(prisma, { + organizationId: DEFAULT_ORG_ID, + enabled: false, + }); + + await expect(createProjectFromFeishuChat(prisma, { + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_member", + chatId: "chat-denied", + name: "Denied Project", + workspaceRoot: await tempWorkspaceRoot(), + })).rejects.toThrow(/members cannot create projects/); + }); + + it("binds an existing project once, archives the binding, then allows a new active binding", async () => { + await seedUser("u-owner", "ou_owner", "OWNER"); + const workspaceRoot = await tempWorkspaceRoot(); + const project = await createProjectFromOrgAdmin(prisma, { + organizationId: DEFAULT_ORG_ID, + actorFeishuOpenId: "ou_owner", + name: "Reusable Project", + workspaceRoot, + }); + + await bindFeishuChatToProject(prisma, { + projectId: project.projectId, + actorFeishuOpenId: "ou_owner", + chatId: "chat-first", + }); + await expect(bindFeishuChatToProject(prisma, { + projectId: project.projectId, + actorFeishuOpenId: "ou_owner", + chatId: "chat-second", + })).rejects.toThrow(/already bound/); + + await expect(archiveFeishuChatBinding(prisma, { + projectId: project.projectId, + actorFeishuOpenId: "ou_owner", + })).resolves.toMatchObject({ archived: true, chatId: "chat-first" }); + await bindFeishuChatToProject(prisma, { + projectId: project.projectId, + actorFeishuOpenId: "ou_owner", + chatId: "chat-second", + }); + + const activeBindings = await prisma.projectGroupBinding.findMany({ + where: { projectId: project.projectId, archivedAt: null }, + select: { chatId: true }, + }); + expect(activeBindings).toEqual([{ chatId: "chat-second" }]); + const oldChatGrant = await prisma.permissionGrant.findFirst({ + where: { + resourceType: "PROJECT", + resourceId: project.projectId, + principalType: "FEISHU_CHAT", + principalId: "chat-first", + revokedAt: null, + }, + }); + expect(oldChatGrant).toBeNull(); + }); +}); + +async function seedUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { + await prisma.user.create({ + data: { + id, + feishuOpenId, + displayName: feishuOpenId, + organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, + }, + }); +} + +async function tempWorkspaceRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "cph-onboarding-")); + workspaceRoots.push(root); + return root; +} diff --git a/hub/test/unit/feishu-reactions.test.ts b/hub/test/unit/feishu-reactions.test.ts index 19b4cf3..87c3316 100644 --- a/hub/test/unit/feishu-reactions.test.ts +++ b/hub/test/unit/feishu-reactions.test.ts @@ -255,7 +255,7 @@ function mockPrisma(): PrismaClient { create: vi.fn(async () => ({ id: "receipt-1" })), }, projectGroupBinding: { - findUnique: vi.fn(async () => ({ projectId: "project-1" })), + findFirst: vi.fn(async () => ({ projectId: "project-1" })), }, project: { findUnique: vi.fn(async () => ({ workspaceDir: "/tmp/cph-project" })), diff --git a/spec/Spec/Prelude.lean b/spec/Spec/Prelude.lean index 2d6671f..a1d17fe 100644 --- a/spec/Spec/Prelude.lean +++ b/spec/Spec/Prelude.lean @@ -17,6 +17,8 @@ structure Identifiers where OrganizationId : Type /-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/ TeamId : Type + /-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/ + FolderId : Type /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/ RunId : Type diff --git a/spec/Spec/System.lean b/spec/Spec/System.lean index b48fecf..9e3a899 100644 --- a/spec/Spec/System.lean +++ b/spec/Spec/System.lean @@ -1,5 +1,6 @@ import Spec.System.ProjectGroup import Spec.System.Organization +import Spec.System.ProjectWorkspace import Spec.System.Run import Spec.System.Lock import Spec.System.Memory @@ -16,6 +17,8 @@ import Spec.System.Audit - `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。 - `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org。 +- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界 + (ADR-0021)。 - `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 - `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 - `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式。 @@ -26,5 +29,5 @@ import Spec.System.Audit (ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。 - `Audit` —— 有意从简(内容多为 plumbing,OPEN)。 -标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020。 +标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020, 0021。 -/ diff --git a/spec/Spec/System/ProjectGroup.lean b/spec/Spec/System/ProjectGroup.lean index 5492285..cb3cfdb 100644 --- a/spec/Spec/System/ProjectGroup.lean +++ b/spec/Spec/System/ProjectGroup.lean @@ -6,14 +6,12 @@ import Spec.Prelude ADR-0001 的核心:一个 project 对应一个**长生命周期**飞书项目群;群是协作空间,**不是锁 owner**(锁归 `AgentRun`,见 `Lock` / ADR-0002),不是临时处理 session。群可在无 Claude 处理时保持开启;教师离群/静音与项目权限、与 Claude 生命周期相互独立。本模块钉死 -project↔group 的一对一绑定——likec4 画得出"project has group",画不出"恰好一个、且群 -不持锁"。 +project↔group 的**active**一对一绑定——likec4 画得出"project has group",画不出"恰好一个、 +且群不持锁"。 -**群失效态与绑定快照(`OPEN`, ADR-0001 Consequences):** 群解散/归档/不可达时, -`GroupBinding` 快照应变为 `none`(解绑、允许 rebind)还是保持 `some` 但指向失效 chat -(悬挂、待管理员干预)未决策。ADR-0001 已点名 "rebinding or group archival needs -explicit product rules";本模块只刻画健康态 1:1 不变式,不臆造失效态语义。实现遇到 -群解散必须 surface,不得默认任一方。 +**绑定历史(`PINNED`, ADR-0021):** active binding 严格 1:1;实现可以保留 archived +historical binding rows 供审计/纠错,但 `GroupBinding` 谓词只刻画当前 active 快照。 +群解散/不可达的自动化处理仍为 `OPEN`;pilot 纠错由 org admin 显式归档绑定。 -/ namespace Spec.System @@ -27,13 +25,14 @@ structure ProjectGroup where group";chat 标识见 `Identifiers.ChatId`)。 -/ chat : I.ChatId -/-- 项目↔群绑定表(`PINNED` 每项目至多一个群, ADR-0001)。`ProjectId → Option ChatId` 的 -结构本身即"一个 project 至多绑一个群"(由 `Option` 自带);良构补另一半——单射。 -/ +/-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。 +`ProjectId → Option ChatId` 的结构本身即"一个 project 至多绑一个 active 群"(由 `Option` +自带);良构补另一半——单射。 -/ def GroupBinding := I.ProjectId → Option I.ChatId -/-- 绑定良构:**单射**——不同 project 不绑同一 chat(`PINNED` 1:1 的另一半, ADR-0001)。 -"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 project"。群 -rebinding/archival 是生命周期事件(ADR-0001 Consequences),不在本快照不变式内。 -/ +/-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半, +ADR-0001/0021)。"每 project 至多一个群"由 `Option` 结构自带;这条钉死"每群至多属于一个 +project"。archived historical bindings 不在本快照不变式内。 -/ def GroupBinding.WellFormed (b : GroupBinding I) : Prop := ∀ p₁ p₂ c, b p₁ = some c → b p₂ = some c → p₁ = p₂ diff --git a/spec/Spec/System/ProjectWorkspace.lean b/spec/Spec/System/ProjectWorkspace.lean new file mode 100644 index 0000000..8b672ec --- /dev/null +++ b/spec/Spec/System/ProjectWorkspace.lean @@ -0,0 +1,54 @@ +import Spec.Prelude + +/-! +# ProjectWorkspace —— project explorer 与飞书建项入口(ADR-0021) + +ADR-0021 把 org 后台里的"文件管理器式"项目管理收口为透明 folder + project: +folder 只负责导航、排序、层级与用量聚合,当前不是权限资源。project 仍是授权边界。 + +本模块只钉死会影响实现分歧的不变量:folder/project 同 org、folder 不参与权限、普通成员 +从飞书群创建 project 必须受 org policy 控制。folder visibility/team policy/继承授权仍为 +未来扩展,不得在当前实现中半隐式加入。 +-/ + +namespace Spec.System + +variable (I : Identifiers) + +/-- Project explorer folder 的租户归属(`PINNED`, ADR-0021):folder 是 org 内透明节点。 -/ +structure FolderTenancy where + /-- 被归属的 folder(`PINNED`, ADR-0021)。 -/ + folder : I.FolderId + /-- folder 所属 organization(`PINNED`, ADR-0021)。 -/ + organization : I.OrganizationId + +/-- Project 放入 folder 的作用域(`PINNED`, ADR-0021):project 可以位于某个 folder, +但二者必须同 org。folder 不改变 project 权限。 -/ +structure ProjectFolderPlacement where + /-- 被放置的 project(`PINNED`, ADR-0021)。 -/ + project : I.ProjectId + /-- project 所在 folder(`PINNED`, ADR-0021)。 -/ + folder : I.FolderId + +/-- Project-folder placement 是良构的 iff project 与 folder 解析到同一 organization +(`PINNED`, ADR-0021)。 -/ +def ProjectFolderPlacement.WellScoped + (placement : ProjectFolderPlacement I) + (projectOrg : I.ProjectId → Option I.OrganizationId) + (folderOrg : I.FolderId → Option I.OrganizationId) : Prop := + ∃ o, projectOrg placement.project = some o ∧ folderOrg placement.folder = some o + +/-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project +不改变 project 自身授权。未来 folder policy 若出现,必须新增显式语义而不是复用本谓词。 -/ +structure FolderTransparent where + /-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/ + current : True + +/-- 普通 org member 是否可从 Feishu 群自助创建 project 的 org policy(`PINNED`, ADR-0021)。 -/ +structure MemberProjectCreationPolicy where + /-- policy 所属 organization(`PINNED`, ADR-0021)。 -/ + organization : I.OrganizationId + /-- 为 true 时普通成员可从未绑定飞书群创建 project;owner/admin 可走后台入口。 -/ + membersCanCreateProjects : Bool + +end Spec.System