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.
232 lines
7.6 KiB
TypeScript
232 lines
7.6 KiB
TypeScript
/**
|
|
* HTTP guards for org admin APIs (ADR-0021).
|
|
*
|
|
* Platform admin is a separate control plane — not modeled here.
|
|
*/
|
|
import type { Organization, OrganizationMemberRole, PrismaClient, User } from "@prisma/client";
|
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
import {
|
|
SESSION_COOKIE_NAME,
|
|
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;
|
|
readonly user: User;
|
|
readonly feishuOpenId: string;
|
|
readonly authenticationOrganizationId?: string | undefined;
|
|
}
|
|
|
|
export interface OrgAuthContext extends AuthContext {
|
|
readonly organization: Organization;
|
|
readonly membershipRole: OrganizationMemberRole;
|
|
}
|
|
|
|
export interface GuardDeps {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
}
|
|
|
|
export class HttpError extends Error {
|
|
readonly statusCode: number;
|
|
readonly code: string;
|
|
|
|
constructor(statusCode: number, code: string, message: string) {
|
|
super(message);
|
|
this.name = "HttpError";
|
|
this.statusCode = statusCode;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export async function requireSession(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
deps: GuardDeps,
|
|
): Promise<AuthContext | null> {
|
|
const raw = request.cookies[SESSION_COOKIE_NAME];
|
|
if (raw === undefined || raw === "") {
|
|
await sendError(reply, 401, "unauthenticated", "login required");
|
|
return null;
|
|
}
|
|
const session = verifySession(raw, deps.sessionSecret);
|
|
if (session === null) {
|
|
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
|
|
return null;
|
|
}
|
|
if ("feishuIdentityId" in session) {
|
|
const identity = await deps.prisma.feishuUserIdentity.findUnique({
|
|
where: { id: session.feishuIdentityId },
|
|
include: {
|
|
user: true,
|
|
connection: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
|
organization: { select: { id: true, status: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (identity === null || identity.userId !== session.userId ||
|
|
identity.connectionId !== session.feishuConnectionId ||
|
|
identity.connection.organization.id !== session.feishuOrganizationId ||
|
|
identity.connection.status !== "ACTIVE" ||
|
|
identity.connection.activeSecretVersion === null ||
|
|
identity.connection.activeSecretVersion.connectionId !== identity.connection.id ||
|
|
identity.connection.activeSecretVersion.retiredAt !== null ||
|
|
identity.connection.organization.status !== "ACTIVE") {
|
|
await sendError(reply, 401, "unauthenticated", "session identity not found or inactive");
|
|
return null;
|
|
}
|
|
return {
|
|
session,
|
|
user: identity.user,
|
|
feishuOpenId: identity.openId,
|
|
authenticationOrganizationId: identity.connection.organization.id,
|
|
};
|
|
}
|
|
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
|
|
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
|
|
await sendError(reply, 401, "unauthenticated", "session user not found");
|
|
return null;
|
|
}
|
|
return { session, user, feishuOpenId: user.feishuOpenId };
|
|
}
|
|
|
|
export async function requireOrgRole(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
deps: GuardDeps,
|
|
options: {
|
|
readonly orgSlug: string;
|
|
readonly roles?: readonly OrganizationMemberRole[];
|
|
},
|
|
): Promise<OrgAuthContext | null> {
|
|
const auth = await requireSession(request, reply, deps);
|
|
if (auth === null) return null;
|
|
|
|
const allowed = options.roles ?? ORG_ADMIN_ROLES;
|
|
const organization = await deps.prisma.organization.findUnique({
|
|
where: { slug: options.orgSlug },
|
|
});
|
|
if (organization === null) {
|
|
await sendError(reply, 404, "org_not_found", `organization not found: ${options.orgSlug}`);
|
|
return null;
|
|
}
|
|
if (organization.status !== "ACTIVE") {
|
|
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
|
|
return null;
|
|
}
|
|
if (auth.authenticationOrganizationId !== undefined &&
|
|
auth.authenticationOrganizationId !== organization.id) {
|
|
await sendError(reply, 403, "forbidden", "session is scoped to a different organization");
|
|
return null;
|
|
}
|
|
|
|
const membership = await deps.prisma.organizationMembership.findFirst({
|
|
where: {
|
|
organizationId: organization.id,
|
|
userId: auth.user.id,
|
|
revokedAt: null,
|
|
},
|
|
select: { role: true },
|
|
});
|
|
if (membership === null) {
|
|
await sendError(reply, 403, "forbidden", "not a member of this organization");
|
|
return null;
|
|
}
|
|
if (!allowed.includes(membership.role)) {
|
|
await sendError(reply, 403, "forbidden", `requires role: ${allowed.join("|")}`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...auth,
|
|
organization,
|
|
membershipRole: membership.role,
|
|
};
|
|
}
|
|
|
|
export async function requireOrgProject(
|
|
deps: GuardDeps,
|
|
organizationId: string,
|
|
projectId: string,
|
|
): Promise<{ readonly id: string; readonly organizationId: string; readonly name: string }> {
|
|
const project = await deps.prisma.project.findUnique({
|
|
where: { id: projectId },
|
|
select: { id: true, organizationId: true, name: true, archivedAt: true },
|
|
});
|
|
if (project === null || project.organizationId !== organizationId) {
|
|
throw new HttpError(404, "project_not_found", `project not found: ${projectId}`);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
export async function sendError(
|
|
reply: FastifyReply,
|
|
statusCode: number,
|
|
code: string,
|
|
message: string,
|
|
): 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 };
|
|
}
|