feat(orgs): deepen Org/Workspace RBAC — role hierarchy, permissions, delete endpoints
- types.ts: add ROLE_HIERARCHY (owner>admin>member>viewer), ROLE_PERMISSIONS matrix (11 permissions), hasPermission() and canManageRole() helpers - repository.ts: add deleteMembership, getUserMembership, deleteOrganization, deleteWorkspace - routes.ts: 4 new endpoints — DELETE /orgs/:id (owner only), DELETE /orgs/:id/workspaces/:wsId, DELETE /orgs/:id/memberships/:mbrId (RBAC enforced), GET /orgs/:id/permissions - RBAC enforcement: role update checks actor outranks target, cannot remove owner, cannot assign role >= own level - routes.test.ts: 6 new tests (8 total) — owner-only delete, member removal RBAC, permissions endpoint, non-member handling - repository.test.ts: 1 existing test unchanged
This commit is contained in:
parent
1efbb9340d
commit
0195cde1c0
@ -126,3 +126,26 @@ export async function updateMembership(
|
|||||||
if (!updated) throw new NotFoundError(`Membership '${id}' not found`);
|
if (!updated) throw new NotFoundError(`Membership '${id}' not found`);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteMembership(id: string, orgId: string): Promise<void> {
|
||||||
|
await membershipCollection().delete(id, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserMembership(
|
||||||
|
orgId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<MembershipDoc | null> {
|
||||||
|
const results = await membershipCollection().findMany({
|
||||||
|
filter: { orgId, userId, scope: 'org' },
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOrganization(id: string, productId: string): Promise<void> {
|
||||||
|
await orgCollection().delete(id, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkspace(id: string, orgId: string): Promise<void> {
|
||||||
|
await workspaceCollection().delete(id, orgId);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,13 +6,18 @@ const repoMock = {
|
|||||||
listOrganizations: vi.fn(),
|
listOrganizations: vi.fn(),
|
||||||
getOrganization: vi.fn(),
|
getOrganization: vi.fn(),
|
||||||
updateOrganization: vi.fn(),
|
updateOrganization: vi.fn(),
|
||||||
|
deleteOrganization: vi.fn(),
|
||||||
createWorkspace: vi.fn(),
|
createWorkspace: vi.fn(),
|
||||||
listWorkspaces: vi.fn(),
|
listWorkspaces: vi.fn(),
|
||||||
getWorkspace: vi.fn(),
|
getWorkspace: vi.fn(),
|
||||||
updateWorkspace: vi.fn(),
|
updateWorkspace: vi.fn(),
|
||||||
|
deleteWorkspace: vi.fn(),
|
||||||
createMembership: vi.fn(),
|
createMembership: vi.fn(),
|
||||||
listMemberships: vi.fn(),
|
listMemberships: vi.fn(),
|
||||||
|
getMembership: vi.fn(),
|
||||||
updateMembership: vi.fn(),
|
updateMembership: vi.fn(),
|
||||||
|
deleteMembership: vi.fn(),
|
||||||
|
getUserMembership: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('./repository.js', () => repoMock);
|
vi.mock('./repository.js', () => repoMock);
|
||||||
@ -77,4 +82,104 @@ describe('orgRoutes', () => {
|
|||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
expect(repoMock.createMembership).not.toHaveBeenCalled();
|
expect(repoMock.createMembership).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /orgs/:id only allows owner', async () => {
|
||||||
|
repoMock.getOrganization.mockResolvedValue({
|
||||||
|
id: 'org_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
ownerUserId: 'someone_else',
|
||||||
|
});
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/orgs/org_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(repoMock.deleteOrganization).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /orgs/:id succeeds for owner', async () => {
|
||||||
|
repoMock.getOrganization.mockResolvedValue({
|
||||||
|
id: 'org_1',
|
||||||
|
productId: 'lysnrai',
|
||||||
|
ownerUserId: 'admin_1',
|
||||||
|
});
|
||||||
|
repoMock.deleteOrganization.mockResolvedValue(undefined);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/orgs/org_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /orgs/:id/memberships/:membershipId prevents removing owner', async () => {
|
||||||
|
repoMock.getMembership.mockResolvedValue({ id: 'mbr_1', role: 'owner', orgId: 'org_1' });
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/orgs/org_1/memberships/mbr_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(repoMock.deleteMembership).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /orgs/:id/memberships/:membershipId succeeds when actor outranks target', async () => {
|
||||||
|
repoMock.getMembership.mockResolvedValue({ id: 'mbr_2', role: 'member', orgId: 'org_1' });
|
||||||
|
repoMock.getUserMembership.mockResolvedValue({ id: 'mbr_1', role: 'admin', orgId: 'org_1' });
|
||||||
|
repoMock.deleteMembership.mockResolvedValue(undefined);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/orgs/org_1/memberships/mbr_2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /orgs/:id/permissions returns role permissions', async () => {
|
||||||
|
repoMock.getUserMembership.mockResolvedValue({
|
||||||
|
id: 'mbr_1',
|
||||||
|
role: 'admin',
|
||||||
|
orgId: 'org_1',
|
||||||
|
userId: 'user_1',
|
||||||
|
});
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/orgs/org_1/permissions?userId=user_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.role).toBe('admin');
|
||||||
|
expect(body.permissions).toContain('org:read');
|
||||||
|
expect(body.permissions).toContain('member:invite');
|
||||||
|
expect(body.permissions).not.toContain('org:delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /orgs/:id/permissions returns empty for non-member', async () => {
|
||||||
|
repoMock.getUserMembership.mockResolvedValue(null);
|
||||||
|
const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' });
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/orgs/org_1/permissions?userId=unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.role).toBeNull();
|
||||||
|
expect(body.permissions).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import {
|
|||||||
UpdateOrganizationSchema,
|
UpdateOrganizationSchema,
|
||||||
UpdateWorkspaceSchema,
|
UpdateWorkspaceSchema,
|
||||||
WorkspaceDoc,
|
WorkspaceDoc,
|
||||||
|
hasPermission,
|
||||||
|
canManageRole,
|
||||||
|
type Permission,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): {
|
function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): {
|
||||||
@ -183,12 +186,106 @@ export async function orgRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/orgs/:id/memberships/:membershipId', async req => {
|
app.patch('/orgs/:id/memberships/:membershipId', async req => {
|
||||||
requireAdmin(req);
|
const access = requireAdmin(req);
|
||||||
const { id, membershipId } = req.params as { id: string; membershipId: string };
|
const { id, membershipId } = req.params as { id: string; membershipId: string };
|
||||||
const parsed = UpdateMembershipSchema.safeParse(req.body);
|
const parsed = UpdateMembershipSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; '));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RBAC: check actor can manage the target role
|
||||||
|
if (parsed.data.role) {
|
||||||
|
const actorMembership = await repo.getUserMembership(id, access.userId);
|
||||||
|
const targetMembership = await repo.getMembership(membershipId, id);
|
||||||
|
const actorRole = actorMembership?.role ?? 'viewer';
|
||||||
|
if (!canManageRole(actorRole, targetMembership.role)) {
|
||||||
|
throw new ForbiddenError('Cannot modify a member with equal or higher role');
|
||||||
|
}
|
||||||
|
if (!canManageRole(actorRole, parsed.data.role)) {
|
||||||
|
throw new ForbiddenError('Cannot assign a role equal to or higher than your own');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return repo.updateMembership(membershipId, id, parsed.data);
|
return repo.updateMembership(membershipId, id, parsed.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Delete organization (owner only) ────────────────────
|
||||||
|
app.delete('/orgs/:id', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const org = await repo.getOrganization(id, access.productId);
|
||||||
|
if (org.ownerUserId !== access.userId) {
|
||||||
|
throw new ForbiddenError('Only the organization owner can delete it');
|
||||||
|
}
|
||||||
|
await repo.deleteOrganization(id, access.productId);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete workspace ────────────────────────────────────
|
||||||
|
app.delete('/orgs/:id/workspaces/:workspaceId', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id, workspaceId } = req.params as { id: string; workspaceId: string };
|
||||||
|
await repo.getOrganization(id, access.productId);
|
||||||
|
await repo.getWorkspace(workspaceId, id);
|
||||||
|
await repo.deleteWorkspace(workspaceId, id);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Remove membership ──────────────────────────────────
|
||||||
|
app.delete('/orgs/:id/memberships/:membershipId', async req => {
|
||||||
|
const access = requireAdmin(req);
|
||||||
|
const { id, membershipId } = req.params as { id: string; membershipId: string };
|
||||||
|
const targetMembership = await repo.getMembership(membershipId, id);
|
||||||
|
|
||||||
|
// Cannot remove an owner
|
||||||
|
if (targetMembership.role === 'owner') {
|
||||||
|
throw new ForbiddenError('Cannot remove the organization owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// RBAC: actor must outrank target
|
||||||
|
const actorMembership = await repo.getUserMembership(id, access.userId);
|
||||||
|
const actorRole = actorMembership?.role ?? 'viewer';
|
||||||
|
if (!canManageRole(actorRole, targetMembership.role)) {
|
||||||
|
throw new ForbiddenError('Cannot remove a member with equal or higher role');
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.deleteMembership(membershipId, id);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Check permissions for a user ───────────────────────
|
||||||
|
app.get('/orgs/:id/permissions', async req => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const userId = (req.query as { userId?: string }).userId;
|
||||||
|
if (!userId) throw new BadRequestError('userId query parameter required');
|
||||||
|
|
||||||
|
const membership = await repo.getUserMembership(id, userId);
|
||||||
|
if (!membership) {
|
||||||
|
return { userId, orgId: id, role: null, permissions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = (
|
||||||
|
[
|
||||||
|
'org:read',
|
||||||
|
'org:update',
|
||||||
|
'org:delete',
|
||||||
|
'workspace:create',
|
||||||
|
'workspace:read',
|
||||||
|
'workspace:update',
|
||||||
|
'workspace:archive',
|
||||||
|
'member:invite',
|
||||||
|
'member:read',
|
||||||
|
'member:update_role',
|
||||||
|
'member:remove',
|
||||||
|
] as Permission[]
|
||||||
|
).filter(p => hasPermission(membership.role, p));
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
orgId: id,
|
||||||
|
role: membership.role,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,3 +114,63 @@ export const ListMembershipsQuerySchema = z.object({
|
|||||||
|
|
||||||
export type ListOrganizationsQuery = z.infer<typeof ListOrganizationsQuerySchema>;
|
export type ListOrganizationsQuery = z.infer<typeof ListOrganizationsQuerySchema>;
|
||||||
export type ListMembershipsQuery = z.infer<typeof ListMembershipsQuerySchema>;
|
export type ListMembershipsQuery = z.infer<typeof ListMembershipsQuerySchema>;
|
||||||
|
|
||||||
|
// ── RBAC Role Hierarchy ─────────────────────────────────────
|
||||||
|
|
||||||
|
export const ROLE_HIERARCHY: Record<string, number> = {
|
||||||
|
owner: 40,
|
||||||
|
admin: 30,
|
||||||
|
member: 20,
|
||||||
|
viewer: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Permission =
|
||||||
|
| 'org:read'
|
||||||
|
| 'org:update'
|
||||||
|
| 'org:delete'
|
||||||
|
| 'workspace:create'
|
||||||
|
| 'workspace:read'
|
||||||
|
| 'workspace:update'
|
||||||
|
| 'workspace:archive'
|
||||||
|
| 'member:invite'
|
||||||
|
| 'member:read'
|
||||||
|
| 'member:update_role'
|
||||||
|
| 'member:remove';
|
||||||
|
|
||||||
|
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||||
|
owner: [
|
||||||
|
'org:read',
|
||||||
|
'org:update',
|
||||||
|
'org:delete',
|
||||||
|
'workspace:create',
|
||||||
|
'workspace:read',
|
||||||
|
'workspace:update',
|
||||||
|
'workspace:archive',
|
||||||
|
'member:invite',
|
||||||
|
'member:read',
|
||||||
|
'member:update_role',
|
||||||
|
'member:remove',
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
'org:read',
|
||||||
|
'org:update',
|
||||||
|
'workspace:create',
|
||||||
|
'workspace:read',
|
||||||
|
'workspace:update',
|
||||||
|
'workspace:archive',
|
||||||
|
'member:invite',
|
||||||
|
'member:read',
|
||||||
|
'member:update_role',
|
||||||
|
'member:remove',
|
||||||
|
],
|
||||||
|
member: ['org:read', 'workspace:read', 'member:read'],
|
||||||
|
viewer: ['org:read', 'workspace:read', 'member:read'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasPermission(role: string, permission: Permission): boolean {
|
||||||
|
return (ROLE_PERMISSIONS[role] ?? []).includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageRole(actorRole: string, targetRole: string): boolean {
|
||||||
|
return (ROLE_HIERARCHY[actorRole] ?? 0) > (ROLE_HIERARCHY[targetRole] ?? 0);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user