- Generic org/team/member data model (renamed from club-specific naming) - Auth, organizations, invitations with mail + Matrix provisioning - Teams with automatic Matrix room creation, sync-job retry, reconciliation - Departments, seasons, roles (RBAC with privilege-escalation guard), audit log - Dashboard, members list, org switcher on the frontend - Synapse setup docs (Docker + bare-metal) and generalized PRD/architecture docs Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TBf3yfnnHzMQ5qhLV2285A
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { db } from "../db/index.js";
|
|
import { organizationRoles, orgRoleEnum, members } from "../db/schema.js";
|
|
import { findMembership, requesterIsOrganizationAdmin } from "../lib/authz.js";
|
|
import { logAudit } from "../lib/audit.js";
|
|
|
|
const listQuerySchema = z.object({
|
|
organizationId: z.string().uuid(),
|
|
memberId: z.string().uuid().optional(),
|
|
});
|
|
|
|
const assignRoleSchema = z.object({
|
|
organizationId: z.string().uuid(),
|
|
memberId: z.string().uuid(),
|
|
role: z.enum(orgRoleEnum.enumValues),
|
|
departmentId: z.string().uuid().optional(),
|
|
});
|
|
|
|
async function requesterHasRole(userId: string, organizationId: string, role: string): Promise<boolean> {
|
|
const membership = await findMembership(userId, organizationId);
|
|
if (!membership) return false;
|
|
const [row] = await db
|
|
.select({ role: organizationRoles.role })
|
|
.from(organizationRoles)
|
|
.where(
|
|
and(
|
|
eq(organizationRoles.memberId, membership.id),
|
|
eq(organizationRoles.organizationId, organizationId),
|
|
eq(organizationRoles.role, role as (typeof orgRoleEnum.enumValues)[number]),
|
|
),
|
|
)
|
|
.limit(1);
|
|
return !!row;
|
|
}
|
|
|
|
export async function roleRoutes(app: FastifyInstance) {
|
|
app.get("/api/organization-roles", { preHandler: app.authenticate }, async (request, reply) => {
|
|
const parsed = listQuerySchema.safeParse(request.query);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
}
|
|
const { organizationId, memberId } = parsed.data;
|
|
|
|
const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId);
|
|
if (!isAdmin) {
|
|
return reply.code(403).send({ error: "Forbidden" });
|
|
}
|
|
|
|
const conditions = memberId
|
|
? and(eq(organizationRoles.organizationId, organizationId), eq(organizationRoles.memberId, memberId))
|
|
: eq(organizationRoles.organizationId, organizationId);
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: organizationRoles.id,
|
|
memberId: organizationRoles.memberId,
|
|
role: organizationRoles.role,
|
|
departmentId: organizationRoles.departmentId,
|
|
firstName: members.firstName,
|
|
lastName: members.lastName,
|
|
})
|
|
.from(organizationRoles)
|
|
.innerJoin(members, eq(organizationRoles.memberId, members.id))
|
|
.where(conditions);
|
|
|
|
return { roles: rows };
|
|
});
|
|
|
|
app.post("/api/organization-roles", { preHandler: app.authenticate }, async (request, reply) => {
|
|
const parsed = assignRoleSchema.safeParse(request.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
|
}
|
|
const { organizationId, memberId, role, departmentId } = parsed.data;
|
|
|
|
const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, organizationId);
|
|
if (!isAdmin) {
|
|
return reply.code(403).send({ error: "Forbidden" });
|
|
}
|
|
|
|
if (role === "ORG_ADMIN") {
|
|
const requesterIsFullAdmin = await requesterHasRole(request.user.sub, organizationId, "ORG_ADMIN");
|
|
if (!requesterIsFullAdmin) {
|
|
return reply.code(403).send({ error: "Only an ORG_ADMIN can grant the ORG_ADMIN role" });
|
|
}
|
|
}
|
|
|
|
const [targetMember] = await db
|
|
.select({ id: members.id })
|
|
.from(members)
|
|
.where(and(eq(members.id, memberId), eq(members.organizationId, organizationId)))
|
|
.limit(1);
|
|
if (!targetMember) {
|
|
return reply.code(404).send({ error: "Member not found in this organization" });
|
|
}
|
|
|
|
const [assignment] = await db
|
|
.insert(organizationRoles)
|
|
.values({ organizationId, memberId, role, departmentId })
|
|
.returning();
|
|
|
|
await logAudit({
|
|
organizationId,
|
|
actorUserId: request.user.sub,
|
|
action: "role.assigned",
|
|
targetType: "member",
|
|
targetId: memberId,
|
|
metadata: { role, departmentId },
|
|
});
|
|
|
|
return reply.code(201).send(assignment);
|
|
});
|
|
|
|
app.delete(
|
|
"/api/organization-roles/:id",
|
|
{ preHandler: app.authenticate },
|
|
async (request, reply) => {
|
|
const { id } = request.params as { id: string };
|
|
|
|
const [assignment] = await db
|
|
.select()
|
|
.from(organizationRoles)
|
|
.where(eq(organizationRoles.id, id))
|
|
.limit(1);
|
|
if (!assignment) {
|
|
return reply.code(404).send({ error: "Role assignment not found" });
|
|
}
|
|
|
|
const isAdmin = await requesterIsOrganizationAdmin(request.user.sub, assignment.organizationId);
|
|
if (!isAdmin) {
|
|
return reply.code(403).send({ error: "Forbidden" });
|
|
}
|
|
|
|
if (assignment.role === "ORG_ADMIN") {
|
|
const requesterIsFullAdmin = await requesterHasRole(
|
|
request.user.sub,
|
|
assignment.organizationId,
|
|
"ORG_ADMIN",
|
|
);
|
|
if (!requesterIsFullAdmin) {
|
|
return reply.code(403).send({ error: "Only an ORG_ADMIN can revoke the ORG_ADMIN role" });
|
|
}
|
|
}
|
|
|
|
await db.delete(organizationRoles).where(eq(organizationRoles.id, id));
|
|
|
|
await logAudit({
|
|
organizationId: assignment.organizationId,
|
|
actorUserId: request.user.sub,
|
|
action: "role.revoked",
|
|
targetType: "member",
|
|
targetId: assignment.memberId,
|
|
metadata: { role: assignment.role },
|
|
});
|
|
|
|
return reply.code(204).send();
|
|
},
|
|
);
|
|
}
|