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:
saravanakumardb1 2026-03-20 00:36:02 -07:00
parent 1efbb9340d
commit 0195cde1c0
4 changed files with 286 additions and 1 deletions

View File

@ -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);
}

View File

@ -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([]);
});
}); });

View File

@ -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,
};
});
} }

View File

@ -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);
}