diff --git a/services/platform-service/src/modules/orgs/repository.ts b/services/platform-service/src/modules/orgs/repository.ts index 81f454b8..1e850516 100644 --- a/services/platform-service/src/modules/orgs/repository.ts +++ b/services/platform-service/src/modules/orgs/repository.ts @@ -126,3 +126,26 @@ export async function updateMembership( if (!updated) throw new NotFoundError(`Membership '${id}' not found`); return updated; } + +export async function deleteMembership(id: string, orgId: string): Promise { + await membershipCollection().delete(id, orgId); +} + +export async function getUserMembership( + orgId: string, + userId: string +): Promise { + 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 { + await orgCollection().delete(id, productId); +} + +export async function deleteWorkspace(id: string, orgId: string): Promise { + await workspaceCollection().delete(id, orgId); +} diff --git a/services/platform-service/src/modules/orgs/routes.test.ts b/services/platform-service/src/modules/orgs/routes.test.ts index ec6f3ae4..9bef7313 100644 --- a/services/platform-service/src/modules/orgs/routes.test.ts +++ b/services/platform-service/src/modules/orgs/routes.test.ts @@ -6,13 +6,18 @@ const repoMock = { listOrganizations: vi.fn(), getOrganization: vi.fn(), updateOrganization: vi.fn(), + deleteOrganization: vi.fn(), createWorkspace: vi.fn(), listWorkspaces: vi.fn(), getWorkspace: vi.fn(), updateWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), createMembership: vi.fn(), listMemberships: vi.fn(), + getMembership: vi.fn(), updateMembership: vi.fn(), + deleteMembership: vi.fn(), + getUserMembership: vi.fn(), }; vi.mock('./repository.js', () => repoMock); @@ -77,4 +82,104 @@ describe('orgRoutes', () => { expect(res.statusCode).toBe(400); 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([]); + }); }); diff --git a/services/platform-service/src/modules/orgs/routes.ts b/services/platform-service/src/modules/orgs/routes.ts index 2da41914..5d2a0a43 100644 --- a/services/platform-service/src/modules/orgs/routes.ts +++ b/services/platform-service/src/modules/orgs/routes.ts @@ -14,6 +14,9 @@ import { UpdateOrganizationSchema, UpdateWorkspaceSchema, WorkspaceDoc, + hasPermission, + canManageRole, + type Permission, } from './types.js'; 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 => { - requireAdmin(req); + const access = requireAdmin(req); const { id, membershipId } = req.params as { id: string; membershipId: string }; const parsed = UpdateMembershipSchema.safeParse(req.body); if (!parsed.success) { 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); }); + + // ── 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, + }; + }); } diff --git a/services/platform-service/src/modules/orgs/types.ts b/services/platform-service/src/modules/orgs/types.ts index e249d85b..cc4c5855 100644 --- a/services/platform-service/src/modules/orgs/types.ts +++ b/services/platform-service/src/modules/orgs/types.ts @@ -114,3 +114,63 @@ export const ListMembershipsQuerySchema = z.object({ export type ListOrganizationsQuery = z.infer; export type ListMembershipsQuery = z.infer; + +// ── RBAC Role Hierarchy ───────────────────────────────────── + +export const ROLE_HIERARCHY: Record = { + 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 = { + 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); +}