feat: v7 RBAC visibility scope for Llms and Agents #72
Reference in New Issue
Block a user
Delete Branch "feat/v7-rbac-visibility"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds a per-row visibility scope (
public|private) toLlmandAgentso users on a shared mcpd cluster can publish private virtualLlms without exposing them to everyone with
view:llms.21f8bed) — schema column,ownerId,Viewerpredicate,unit tests (13/13 passing).
2c98a21) — route layer + RBACisAdminsplit + mcplocaldefaults to
private+ CLI--visibilityflag +VISIBILITYcolumnon
get llm/get agent+ completions regen.2b2444a) —docs/virtual-llms.mdvisibility section +live smoke (
virtual-llm-visibility.smoke.test.ts) covering theregister → list → get-by-name round-trip.
Visibility model:
public— anyone withview:llmssees it. Default for hand-created.private— only owner + name-scoped grant +*admin. Default fornewly-published virtual rows from mcplocal.
A plain
view:llmsresource grant is not admin: the v7 split ensuresthat grant only widens RBAC name-scoping but the visibility filter
still applies on top. Cross-resource
*grant is the explicit adminescape hatch.
Legacy rows (pre-v7) backfill
visibility=public, ownerId=NULL— fullybackwards compatible.
Test plan
908/908)731/731)437/437)match generator outputtest passesbash fulldeploy.shto roll out mcpd + mcplocal RPMpnpm test:smokeincludes the newvirtual-llm-visibility.smoke.test.ts)alice-vllm-localprivate,bob with only
view:llmsdoesn't see it;mcpctl create rbac bob view:llms --name alice-vllm-localmakes it visible🤖 Generated with Claude Code
Adds the schema + service-layer machinery for per-user RBAC scoping of virtual Llms and Agents. Without this, anyone with `view:llms` sees every other user's published model — fine for a single-user homelab, wrong for org use where workstation-published models or paid keys aren't meant to be broadcast. Schema: - Llm: new `ownerId String?` + `visibility String @default("public")`. NULL ownerId on legacy rows is treated as public for back-compat. - Agent: `visibility String @default("public")` (Agent already has `ownerId`, required). - Composite index `(visibility, ownerId)` on both tables for the list-filter hot path. - Migration backfills both columns to 'public' so pre-v7 setups behave identically post-deploy. Service layer: - New `Viewer` / `AgentViewer` shape: `{ userId, wildcard, allowedNames }`. The route layer computes this from `request.userId` + `RbacService.getAllowedScope` and passes it down. NULL viewer = skip the filter (internal callers — cron sweeps, audit, tests). - `isLlmVisibleTo` / `isAgentVisibleTo` pure predicates encode the decision tree: visibility=public → visible (RBAC layer above already passed) viewer=null OR wildcard → visible ownerId === viewer.userId → visible row.name in viewer.allowedNames → visible else → hidden - LlmService.list/getById/getByName + AgentService equivalents accept an optional Viewer arg and apply the predicate. Get-style methods 404 (not 403) on hidden rows so name enumeration via differential status is impossible. Repositories: CreateInput/UpdateInput types gained `ownerId`/ `visibility` (Llm) and `visibility` (Agent). Update is in place; ownerId is set-once at create time. Tests: - 13 unit tests on the predicate covering every branch (null viewer, public, wildcard, owner, name-scoped grant, foreign private, legacy null-ownerId). - mcpd 908/908 (was 893; +15 across the merge windows + this PR). Stage 2 (next): route plumbing — every list/get endpoint needs to build the Viewer from the request and pass it through. mcplocal virtuals default to visibility=private on register. CLI adds a VISIBILITY column and a --visibility flag. yaml round-trip preserves the field.Pull request closed