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
+54
View File
@@ -10,8 +10,10 @@ import {
verifySession,
type SessionPayload,
} from "./session.js";
import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js";
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"];
export interface AuthContext {
readonly session: SessionPayload;
@@ -175,3 +177,55 @@ export async function sendError(
): Promise<void> {
await reply.status(statusCode).send({ error: { code, message } });
}
export interface ProjectAuthContext extends OrgAuthContext {
readonly projectId: string;
}
/**
* Resolve an org member (any role) viewing/mutating a project in their org, then
* enforce project-level permission via the PermissionGrant authorizer.
*
* `allowOrgAdminOversight=true` lets org OWNER/ADMIN through without a project
* grant — reserved for *read* oversight (listing/viewing). Mutations that the
* spec pins to project `manage` (e.g. `collaborator.manage`) must pass
* `allowOrgAdminOversight=false`: org role alone is not a project authorization
* root (spec `Permission.lean` / ADR-0004; the only out-of-role override is
* platform-admin force-release `RequiresAdmin`, not org admin).
*/
export async function requireProjectPermission(
request: FastifyRequest,
reply: FastifyReply,
deps: GuardDeps,
options: {
readonly orgSlug: string;
readonly projectId: string;
readonly action: AuthorizationAction;
readonly allowOrgAdminOversight: boolean;
},
): Promise<ProjectAuthContext | null> {
const auth = await requireOrgRole(request, reply, deps, {
orgSlug: options.orgSlug,
roles: ANY_ORG_ROLE,
});
if (auth === null) return null;
await requireOrgProject(deps, auth.organization.id, options.projectId);
if (options.allowOrgAdminOversight && ORG_ADMIN_ROLES.includes(auth.membershipRole)) {
return { ...auth, projectId: options.projectId };
}
const decision = await createPermissionAuthorizer(deps.prisma).can({
actor: { feishuOpenId: auth.feishuOpenId },
action: options.action,
resource: { type: "PROJECT", id: options.projectId },
});
if (!decision.allowed) {
await sendError(
reply,
403,
"forbidden",
`project ${options.projectId} requires ${decision.requiredRole} (${options.action}): ${decision.reason}`,
);
return null;
}
return { ...auth, projectId: options.projectId };
}
+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) {
+38 -3
View File
@@ -10,13 +10,15 @@ import {
createOrgFolder,
createOrgProject,
getOrgProjectDetail,
listMyProjects,
listOrgExplorer,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../org/explorer.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.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;
@@ -41,6 +43,24 @@ export async function registerExplorerRoutes(
}
});
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 };
@@ -144,12 +164,27 @@ export async function registerExplorerRoutes(
app.get("/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 });
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
return await getOrgProjectDetail(config.prisma, {
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);
}
+58
View File
@@ -12,6 +12,7 @@ import {
moveProjectToFolder,
} from "../projectOnboarding.js";
import { lockActiveOrganization } from "./status.js";
import { PrismaPrincipalResolver } from "../permissions/principals.js";
export interface ExplorerFolderNode {
readonly id: string;
@@ -335,6 +336,63 @@ export async function archiveOrgProjectChatBinding(
});
}
/**
* Projects an org member can see via their resolved principals' READ+ grants
* (spec `PermissionGrant` / ADR-0004). Used by the member-facing project list
* — org-admin oversight uses `listOrgExplorer` instead.
*/
export async function listMyProjects(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly actorFeishuOpenId: string },
): Promise<readonly ExplorerProjectNode[]> {
const resolver = new PrismaPrincipalResolver(prisma);
const resolution = await resolver.resolveActor(
{ feishuOpenId: input.actorFeishuOpenId },
{ organizationId: input.organizationId },
);
const principalKeys = resolution.principals.map((p) => `${p.type}:${p.id}`);
if (principalKeys.length === 0) return [];
const principalTypes = resolution.principals.map((p) => p.type);
const principalIds = resolution.principals.map((p) => p.id);
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
revokedAt: null,
principalType: { in: principalTypes },
principalId: { in: principalIds },
},
select: { resourceId: true },
distinct: ["resourceId"],
});
const projectIds = grants.map((g) => g.resourceId);
if (projectIds.length === 0) return [];
const projects = await prisma.project.findMany({
where: { id: { in: projectIds }, organizationId: input.organizationId, archivedAt: null },
select: {
id: true,
name: true,
folderId: true,
createdAt: true,
groupBindings: {
where: { archivedAt: null },
select: { chatId: true, createdAt: true },
take: 1,
},
},
orderBy: { name: "asc" },
});
return projects.map((p) => {
const binding = p.groupBindings[0];
return {
id: p.id,
name: p.name,
folderId: p.folderId,
createdAt: p.createdAt.toISOString(),
binding: binding === undefined ? null : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() },
};
});
}
async function requireActiveFolder(
prisma: PrismaClient | Prisma.TransactionClient,
folderId: string,