forked from EduCraft/curriculum-project-hub
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user