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.
This commit is contained in:
2026-07-14 21:25:18 +08:00
parent ab9dfad53a
commit 080efa70c5
9 changed files with 445 additions and 135 deletions
+19 -7
View File
@@ -5,7 +5,7 @@ import {
listProjectTeamAccess,
revokeTeamProjectAccess,
} from "../../permissions/projectTeamAccess.js";
import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { requireProjectPermission, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
@@ -19,9 +19,13 @@ export async function registerAccessRoutes(
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 requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
return { access: await listProjectTeamAccess(config.prisma, projectId) };
} catch (err) {
return handleRouteError(reply, err);
@@ -31,9 +35,13 @@ export async function registerAccessRoutes(
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 requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const body = request.body as {
teamId?: unknown;
teamSlug?: unknown;
@@ -67,9 +75,13 @@ export async function registerAccessRoutes(
projectId: string;
teamId: string;
};
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "collaborator.manage",
allowOrgAdminOversight: false,
});
if (auth === null) return;
await requireOrgProject(guardDeps, auth.organization.id, projectId);
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
return { revoked: count };
} catch (err) {