124 commits of main against 33 of agentic-teams. 22 files overlapped, 7 conflicted. Three needed real reconciliation rather than a side-pick: **The `volumes` collision.** Both branches independently added a `volumes` field to ContainerSpec meaning different things: main's is persistent storage keyed by a server-derived `claimName` that deliberately outlives an instance; mine is emptyDir scratch shared between an init container and the main container. Worse, each emitted the pod-level `volumes` list from its own helper, so spreading both would have silently kept only the last — a server with a PVC would have lost it the moment a session scratch volume appeared. Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now owns the whole pod-level list and emits both kinds; `buildPodExtras` is init containers only. fsGroup/securityContext stays tied to the PVC case, because an emptyDir is created writable by the container's own user and chowning it is work with no purpose. Five tests pin the coexistence so a later change cannot drop one kind again. **The extracted permission mapper.** I moved `mapUrlToPermission` out of main.ts into middleware/url-permission.ts so it could be tested; main kept it inline and kept editing it. Kept the extraction and ported main's one change across — the `/api/v1/audit/events/purge` mapping onto the existing `audit-purge` operation. Verified rule-by-rule that the extracted version is a superset: all 15 of main's rules plus the 5 agentic-teams ones. **The NUL byte in create.ts.** Both branches fixed the same defect independently, mine as `''` and main's as `'\0'`. Took main's. The rest were one-liners: .gitignore comment wording, and an add/add on proxy-long-running.test.ts where main's copy carries a progressive-delivery test mine lacked — took main's in full. Not changed, but worth flagging: main's generateDeploymentSpec hardcodes `automountServiceAccountToken: false` while generatePodSpec honours `spec.automountServiceAccountToken`. That asymmetry is main's own and predates this merge, so it is left exactly as-is rather than altered inside a merge commit. Verified: 3138 tests pass (237 files), build clean, prisma schema valid. Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file touched during conflict resolution was checked and introduces no new errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
/**
|
|
* HTTP method + URL → RBAC permission check.
|
|
*
|
|
* Extracted from main.ts so it can be tested directly: main.ts calls main() at
|
|
* module load, so nothing there is importable from a test, and an
|
|
* authorization mapping that cannot be tested is an authorization mapping
|
|
* nobody has checked.
|
|
*/
|
|
import { SET_OWN_PASSWORD_OPERATION } from '../bootstrap/self-password-permission.js';
|
|
import type { RbacAction } from '../services/index.js';
|
|
|
|
export type PermissionCheck =
|
|
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
|
| { kind: 'operation'; operation: string }
|
|
| { kind: 'skip' };
|
|
|
|
/**
|
|
* Map an HTTP method + URL to a permission check.
|
|
* Returns 'skip' for URLs that should not be RBAC-checked.
|
|
*/
|
|
export function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
|
|
if (!match) return { kind: 'skip' };
|
|
|
|
const segment = match[1] as string;
|
|
|
|
// Recent mcpd error logs — read-only operator view, gated by `logs`.
|
|
if (url.startsWith('/api/v1/logs')) {
|
|
return { kind: 'operation', operation: 'logs' };
|
|
}
|
|
|
|
// Self-service password change — gated by the `set-own-password` operation
|
|
// (a default, admin-revocable permission), NOT the broad edit:users that an
|
|
// admin reset of another user needs. Must precede the generic users mapping.
|
|
if (url.startsWith('/api/v1/users/me/password')) {
|
|
return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
|
|
}
|
|
|
|
// Git webhooks authenticate by HMAC signature, not by a user token — the
|
|
// forge has no mcpctl identity. Skipping RBAC here is safe only because the
|
|
// route itself fails closed without a configured signing secret.
|
|
if (url.startsWith('/api/v1/webhooks/git/')) return { kind: 'skip' };
|
|
|
|
// Operations (non-resource endpoints)
|
|
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
|
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
|
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
|
// Same operation guards the trace-event purge — both are bulk deletes of
|
|
// audit history and should not be separately grantable by accident.
|
|
if (url.startsWith('/api/v1/audit/events/purge')) return { kind: 'operation', operation: 'audit-purge' };
|
|
// /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
|
|
if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
|
|
// /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so
|
|
// only explicitly-granted callers can force it (the loop itself bypasses
|
|
// RBAC by calling the rotator in-process).
|
|
if (/^\/api\/v1\/secretbackends\/[^/?]+\/rotate/.test(url)) {
|
|
return { kind: 'operation', operation: 'rotate-secretbackend' };
|
|
}
|
|
|
|
// /api/v1/llms/:name/infer → `run:llms:<name>` (not the default create:llms).
|
|
const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/);
|
|
if (inferMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] };
|
|
}
|
|
|
|
// /api/v1/agents/:name/chat or /threads* → `run:agents:<name>`.
|
|
// Driving a turn or managing its history is a "run" on the agent — listing
|
|
// and CRUD continue to fall through to the default mapping below.
|
|
const agentRunMatch = url.match(/^\/api\/v1\/agents\/([^/?]+)\/(chat|threads)/);
|
|
if (agentRunMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] };
|
|
}
|
|
|
|
// ── Agentic teams: non-CRUD verbs ──
|
|
//
|
|
// Without these the default method mapping applies and every one of these
|
|
// POSTs reads as `create:<resource>`, which is wrong in both directions:
|
|
// it lets anyone who may create a task also accept one, and it refuses a
|
|
// reviewer who may drive tasks but not create them.
|
|
//
|
|
// `run` is the existing verb for "use this resource to do work" (llms/infer,
|
|
// agents/chat, projects/chat), so driving a task's lifecycle and exercising a
|
|
// forge's credential both belong to it. Mutations of the vision record itself
|
|
// stay `edit`.
|
|
|
|
// Driving a work item: transition, the human accept gate, reject, and
|
|
// recording a stage verdict. Separable from `edit:tasks`, which is field edits.
|
|
const taskRunMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/(transition|accept|reject|verdicts)/);
|
|
if (taskRunMatch?.[1] !== undefined) {
|
|
return { kind: 'resource', resource: 'tasks', action: 'run', resourceName: taskRunMatch[1] };
|
|
}
|
|
|
|
// Discovery and import call OUT to the forge using its stored credential, so
|
|
// they are `run`, not `view`/`create` — holding view:gitproviders should not
|
|
// let you exercise the token.
|
|
const forgeRunMatch = url.match(/^\/api\/v1\/gitproviders\/([^/?]+)\/(repos|import|probe)/);
|
|
if (forgeRunMatch?.[1] !== undefined) {
|
|
return { kind: 'resource', resource: 'gitproviders', action: 'run', resourceName: forgeRunMatch[1] };
|
|
}
|
|
|
|
// Marking an Epic delivered, recording an accepted tradeoff, or ticking a
|
|
// criterion all rewrite the vision record → `edit`, same as editing content.
|
|
const epicEditMatch = url.match(/^\/api\/v1\/epics\/([^/?]+)\/(status|tradeoffs|criteria)/);
|
|
if (epicEditMatch?.[1] !== undefined) {
|
|
return { kind: 'resource', resource: 'epics', action: 'edit', resourceName: epicEditMatch[1] };
|
|
}
|
|
|
|
// The roadmap is a read of the epic's graph.
|
|
const roadmapMatch = url.match(/^\/api\/v1\/epics\/([^/?]+)\/roadmap/);
|
|
if (roadmapMatch?.[1] !== undefined) {
|
|
return { kind: 'resource', resource: 'epics', action: 'view', resourceName: roadmapMatch[1] };
|
|
}
|
|
|
|
// Chaining roadmap nodes edits the graph.
|
|
const edgeMatch = url.match(/^\/api\/v1\/epictasks\/([^/?]+)\/edges/);
|
|
if (edgeMatch?.[1] !== undefined) {
|
|
return { kind: 'resource', resource: 'epictasks', action: 'edit', resourceName: edgeMatch[1] };
|
|
}
|
|
|
|
// /api/v1/agents/:name/projects[/:project] — team membership.
|
|
// Reading the roster is `view:agents:<name>`; joining/leaving is
|
|
// `edit:agents:<name>`, mirroring project↔server attach/detach (which is
|
|
// `edit:projects`, not `create`). Without this the default mapping would
|
|
// grade a join as `create:agents` — the permission for minting a whole new
|
|
// agent, which is far too broad for "add this person to a team".
|
|
const membershipAgentName = url.match(/^\/api\/v1\/agents\/([^/?]+)\/projects/)?.[1];
|
|
if (membershipAgentName !== undefined) {
|
|
return {
|
|
kind: 'resource',
|
|
resource: 'agents',
|
|
action: method === 'GET' || method === 'HEAD' ? 'view' : 'edit',
|
|
resourceName: membershipAgentName,
|
|
};
|
|
}
|
|
|
|
// /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent
|
|
// name in the URL; the service-level owner check enforces fine-grained
|
|
// access on top).
|
|
if (url.startsWith('/api/v1/threads/')) {
|
|
return { kind: 'resource', resource: 'agents', action: 'view' };
|
|
}
|
|
|
|
const resourceMap: Record<string, string | undefined> = {
|
|
'servers': 'servers',
|
|
'instances': 'instances',
|
|
'secrets': 'secrets',
|
|
'secretbackends': 'secretbackends',
|
|
'projects': 'projects',
|
|
'templates': 'templates',
|
|
'users': 'users',
|
|
'groups': 'groups',
|
|
'rbac': 'rbac',
|
|
'audit-logs': 'rbac',
|
|
'mcp': 'servers',
|
|
'prompts': 'prompts',
|
|
'promptrequests': 'promptrequests',
|
|
// PR-2: revisions/proposals piggyback on the prompts permission for now.
|
|
// Anyone with view:prompts can read history; anyone with edit:prompts can
|
|
// approve/reject proposals. PR-7 may split these out if RBAC granularity
|
|
// becomes useful (e.g., a "reviewer" role).
|
|
'revisions': 'prompts',
|
|
'proposals': 'prompts',
|
|
// PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be
|
|
// split out later if the operator wants to scope skill writes more
|
|
// tightly than prompt writes — for now, a senior reviewer who can
|
|
// edit prompts can edit skills.
|
|
'skills': 'prompts',
|
|
'mcptokens': 'mcptokens',
|
|
'llms': 'llms',
|
|
// v5: durable inference task queue. Same default action mapping as
|
|
// any other resource (GET=view, POST=create, DELETE=delete). The
|
|
// route layer enforces owner-scoping on top — a caller without
|
|
// resource-wide grant only sees their own tasks.
|
|
//
|
|
// Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams
|
|
// work items own `tasks` now. Pre-split grants are rewritten by
|
|
// backfillInferenceTaskBindings at startup.
|
|
'inference-tasks': 'inference-tasks',
|
|
'agents': 'agents',
|
|
// Personalities inherit the agent's RBAC: managing a personality
|
|
// requires view/edit/create/delete on the `agents` resource.
|
|
'personalities': 'agents',
|
|
// ── Agentic teams ──
|
|
'epics': 'epics',
|
|
'epictasks': 'epictasks',
|
|
// Work items. NOT inference-tasks — see the rbac-tasks-backfill bootstrap.
|
|
'tasks': 'tasks',
|
|
'repos': 'repos',
|
|
'gitproviders': 'gitproviders',
|
|
};
|
|
|
|
const resource = resourceMap[segment];
|
|
if (resource === undefined) return { kind: 'skip' };
|
|
|
|
// Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts
|
|
// We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer
|
|
const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/);
|
|
if (approveMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
|
|
}
|
|
|
|
// Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token.
|
|
const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/);
|
|
if (revokeMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:name/(chat|threads) → run:projects:<name>.
|
|
// Driving a project chat turn or managing its threads is a "run" on the
|
|
// project; per-thread ownership + admin-delete are enforced in the service.
|
|
const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/);
|
|
if (projectRunMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
|
|
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
|
|
if (visiblePromptsMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'prompts', action: 'view' };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:name/promptrequests → create promptrequests
|
|
const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/);
|
|
if (projectPromptrequestsMatch?.[1] && method === 'POST') {
|
|
return { kind: 'resource', resource: 'promptrequests', action: 'create' };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:id/instructions → view projects
|
|
const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/);
|
|
if (instructionsMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
|
|
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
|
|
if (mcpConfigMatch?.[1]) {
|
|
return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
|
|
}
|
|
|
|
// Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
|
|
const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
|
|
if (projectServersMatch?.[1] && method !== 'GET') {
|
|
return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
|
|
}
|
|
|
|
// Map HTTP method to action
|
|
let action: RbacAction;
|
|
switch (method) {
|
|
case 'GET':
|
|
case 'HEAD':
|
|
action = 'view';
|
|
break;
|
|
case 'POST':
|
|
action = 'create';
|
|
break;
|
|
case 'DELETE':
|
|
action = 'delete';
|
|
break;
|
|
default: // PUT, PATCH
|
|
action = 'edit';
|
|
break;
|
|
}
|
|
|
|
// Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
|
|
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
|
|
const resourceName = nameMatch?.[1];
|
|
|
|
const check: PermissionCheck = { kind: 'resource', resource, action };
|
|
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
|
|
return check;
|
|
}
|