Files
curriculum-project-hub/hub/src/admin/routes/accessRoutes.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

98 lines
3.3 KiB
TypeScript

import type { PermissionRole, PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
grantTeamProjectAccess,
listProjectTeamAccess,
revokeTeamProjectAccess,
} from "../../permissions/projectTeamAccess.js";
import { requireProjectPermission, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
export async function registerAccessRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/projects/:projectId/team-access", 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;
return { access: await listProjectTeamAccess(config.prisma, projectId) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
const body = request.body as {
teamId?: unknown;
teamSlug?: unknown;
role?: unknown;
};
const role = parseRole(body.role);
if (role === null) {
return reply.status(400).send({
error: { code: "bad_request", message: "role must be READ|EDIT|MANAGE" },
});
}
const entry = await grantTeamProjectAccess(config.prisma, {
projectId,
role,
createdByUserId: auth.user.id,
...(typeof body.teamId === "string" ? { teamId: body.teamId } : {}),
...(typeof body.teamSlug === "string" ? { teamSlug: body.teamSlug } : {}),
});
return entry;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete(
"/api/org/:orgSlug/projects/:projectId/team-access/:teamId",
async (request, reply) => {
try {
const { orgSlug, projectId, teamId } = request.params as {
orgSlug: string;
projectId: string;
teamId: string;
};
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
return { revoked: count };
} catch (err) {
return handleRouteError(reply, err);
}
},
);
}
function parseRole(value: unknown): PermissionRole | null {
if (typeof value !== "string") return null;
return (ROLES as readonly string[]).includes(value) ? (value as PermissionRole) : null;
}