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
+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,