forked from EduCraft/curriculum-project-hub
080efa70c5
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.
98 lines
3.3 KiB
TypeScript
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;
|
|
}
|