Files
curriculum-project-hub/hub/src/admin/routes/explorerRoutes.ts
T
ChickenPige0n 080efa70c5 feat(admin): gate project surfaces behind permission grants for members
The org admin SPA was org-admin only: every project route used
requireOrgRole, so a plain MEMBER could not reach the projects they held
a project grant on, and an org OWNER/ADMIN could mutate any project
without holding the project's `manage` grant. That contradicts ADR-0004
(spec `Permission.lean`): org role is not a project authorization root,
and the only out-of-role override is platform-admin force-release
(`RequiresAdmin`), not org admin.

Add `requireProjectPermission` (guards.ts): resolve any org member, bind
the project to their org, then check the PermissionGrant authorizer.
`allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read*
oversight only; mutations pinned to `collaborator.manage`
(grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org
admin still needs the project MANAGE grant to mutate access. The project
detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject`
so the SPA can render mutation controls only for entitled actors.

Add a member-facing project surface:
- `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the
  actor's principals and return the projects with a READ+ grant.
- The SPA routes members (non-admin) to the projects page instead of the
  admin overview, renders a member project shell on project routes, shows
  a `我的项目` list for members and the full folder explorer for admins.
- The project detail page gates rename/archive/bind/sessions behind org
  admin and the grant/revoke UI behind `actorCanManageProject`.
- The denied panel now points members at their authorized projects.

Update admin-members-teams integration test: seed the owner with a MANAGE
grant on the test project so the org-owner flow still passes the new
project-level gate on team-access grant/revoke.
2026-07-14 21:25:18 +08:00

264 lines
9.2 KiB
TypeScript

/**
* Org explorer HTTP routes under `/api/org/:orgSlug`.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
archiveFolder,
archiveOrgProjectChatBinding,
archiveProject,
createOrgFolder,
createOrgProject,
getOrgProjectDetail,
listMyProjects,
listOrgExplorer,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../org/explorer.js";
import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import { createPermissionAuthorizer } from "../../permissions/authorizer.js";
export interface ExplorerRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
}
export async function registerExplorerRoutes(
app: FastifyInstance,
config: ExplorerRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/explorer", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await listOrgExplorer(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/my-projects", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, {
orgSlug,
roles: ["OWNER", "ADMIN", "MEMBER"],
});
if (auth === null) return;
const projects = await listMyProjects(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.feishuOpenId,
});
return { projects };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
name?: unknown;
parentId?: unknown;
sortKey?: unknown;
};
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await createOrgFolder(config.prisma, {
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
});
return reply.status(201).send({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
name?: unknown;
sortKey?: unknown;
parentId?: unknown;
};
const folder = await renameFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return {
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; folderId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const result = await createOrgProject(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.feishuOpenId,
name: body.name,
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send(result);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
const detail = await getOrgProjectDetail(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
const manageDecision = await createPermissionAuthorizer(config.prisma).can({
actor: { feishuOpenId: auth.feishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
return {
...detail,
actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole),
actorCanManageProject: manageDecision.allowed,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
return await renameProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId/folder", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be string or null" },
});
}
return await moveOrgProjectToFolder(config.prisma, {
organizationId: auth.organization.id,
projectId,
folderId: body.folderId as string | null,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveOrgProjectChatBinding(config.prisma, {
organizationId: auth.organization.id,
projectId,
actorFeishuOpenId: auth.feishuOpenId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}