Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e0a0c70
feat: add RBAC contracts and persistence
ColeMurray Aug 31, 2026
46b620d
feat: enforce workspace permissions at the HTTP boundary
ColeMurray Aug 31, 2026
68789f0
feat: enforce session authorization and revoke stale sockets
ColeMurray Aug 31, 2026
9c9403d
feat: enforce automation ownership and execution authority
ColeMurray Aug 31, 2026
1333903
feat: add workspace access administration
ColeMurray Aug 31, 2026
4866d41
fix(rbac): address foundation review feedback
ColeMurray Aug 31, 2026
ab4dc1b
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
34e6d03
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
ba3774c
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
9f6f939
Merge branch 'rbac-automation-authorization' into rbac-workspace-sett…
ColeMurray Aug 31, 2026
675a554
fix(rbac): preserve merge batch result contract
ColeMurray Aug 31, 2026
cf5e5e5
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
0c33bb8
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
2644c82
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
45fd9fe
Merge branch 'rbac-automation-authorization' into rbac-workspace-sett…
ColeMurray Aug 31, 2026
326ffe7
Merge remote-tracking branch 'origin/main' into HEAD
ColeMurray Aug 31, 2026
4258709
fix: address workspace administration review feedback
ColeMurray Aug 31, 2026
39583dc
test: remove stale RBAC route import
ColeMurray Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions packages/control-plane/src/routes/rbac.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { AuthorizationError, AuthorizationService } from "../authorization/service";
import { isCanonicalUserId } from "@open-inspect/shared/user-id";
import {
replaceMemberRoleInputSchema,
replaceMemberStatusInputSchema,
} from "@open-inspect/shared/rbac";
import { ZodError } from "zod";
import {
AuthorizationError,
AuthorizationService,
RbacConflictError,
} from "../authorization/service";
import type { Env } from "../types";
import type { Route } from "./shared";
import {
Expand All @@ -7,6 +17,7 @@ import {
defineRoutes,
error,
json,
parseJsonBody,
requirePermission,
type UserRouteContext,
} from "./shared";
Expand All @@ -22,9 +33,21 @@ function rbacErrorResponse(cause: unknown): Response {
cause.status
);
}
if (cause instanceof RbacConflictError) {
return json({ error: cause.message, code: "rbac_conflict" }, 409);
}
if (cause instanceof ZodError) return error("Invalid request body", 400);
return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
}

function decodePathSegment(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

async function handleGetCurrentAuthorization(
_request: Request,
_env: Env,
Expand Down Expand Up @@ -61,10 +84,11 @@ async function handleGetRole(
): Promise<Response> {
const service = new AuthorizationService(ctx.db);
try {
const role = await service.getRole(decodeURIComponent(match.groups!.id));
const roleId = decodePathSegment(match.groups!.id);
if (roleId === null) return error("Invalid role ID", 400);
const role = await service.getRole(roleId);
return role ? json(role) : error("Role not found", 404);
} catch (cause) {
if (cause instanceof URIError) return error("Invalid role ID", 400);
return rbacErrorResponse(cause);
}
}
Expand All @@ -83,6 +107,60 @@ async function handleListMembers(
}
}

async function handleReplaceMemberRole(
request: Request,
_env: Env,
match: RegExpMatchArray,
ctx: UserRouteContext
): Promise<Response> {
const targetUserId = decodePathSegment(match.groups!.id);
if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
return error("Invalid user ID", 400);
}
const body = await parseJsonBody<unknown>(request);
if (body instanceof Response) return body;
const service = new AuthorizationService(ctx.db);
try {
const parsed = replaceMemberRoleInputSchema.parse(body);
await service.replaceMemberRole({
targetUserId,
roleId: parsed.roleId,
actorUserId: ctx.principal.userId,
requestId: ctx.request_id,
});
return new Response(null, { status: 204 });
} catch (cause) {
return rbacErrorResponse(cause);
}
}

async function handleReplaceMemberStatus(
request: Request,
_env: Env,
match: RegExpMatchArray,
ctx: UserRouteContext
): Promise<Response> {
const targetUserId = decodePathSegment(match.groups!.id);
if (targetUserId === null || !isCanonicalUserId(targetUserId)) {
return error("Invalid user ID", 400);
}
const body = await parseJsonBody<unknown>(request);
if (body instanceof Response) return body;
const service = new AuthorizationService(ctx.db);
try {
const parsed = replaceMemberStatusInputSchema.parse(body);
await service.replaceMemberStatus({
targetUserId,
suspended: parsed.suspended,
actorUserId: ctx.principal.userId,
requestId: ctx.request_id,
});
return new Response(null, { status: 204 });
} catch (cause) {
return rbacErrorResponse(cause);
}
}

export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
{
method: "GET",
Expand Down Expand Up @@ -112,4 +190,18 @@ export const rbacRoutes: Route[] = defineRoutes(SCM_AGNOSTIC_HUMAN_USER_ROUTE, [
cacheControl: "private, no-store",
handler: handleListMembers,
},
{
method: "PUT",
pattern: /^\/members\/(?<id>[^/]+)\/role$/,
authorization: requirePermission("workspace.members.manage"),
cacheControl: "private, no-store",
handler: handleReplaceMemberRole,
},
{
method: "PUT",
pattern: /^\/members\/(?<id>[^/]+)\/status$/,
authorization: requirePermission("workspace.members.manage"),
cacheControl: "private, no-store",
handler: handleReplaceMemberStatus,
},
]);
Loading
Loading