feat: v7 RBAC visibility scope for Llms and Agents #72

Open
michal wants to merge 3 commits from feat/v7-rbac-visibility into main
Owner

Summary

Adds a per-row visibility scope (public | private) to Llm and
Agent so users on a shared mcpd cluster can publish private virtual
Llms without exposing them to everyone with view:llms.

  • Stage 1 (21f8bed) — schema column, ownerId, Viewer predicate,
    unit tests (13/13 passing).
  • Stage 2 (2c98a21) — route layer + RBAC isAdmin split + mcplocal
    defaults to private + CLI --visibility flag + VISIBILITY column
    on get llm/get agent + completions regen.
  • Stage 3 (2b2444a)docs/virtual-llms.md visibility section +
    live smoke (virtual-llm-visibility.smoke.test.ts) covering the
    register → list → get-by-name round-trip.

Visibility model:

  • public — anyone with view:llms sees it. Default for hand-created.
  • private — only owner + name-scoped grant + * admin. Default for
    newly-published virtual rows from mcplocal.

A plain view:llms resource grant is not admin: the v7 split ensures
that grant only widens RBAC name-scoping but the visibility filter
still applies on top. Cross-resource * grant is the explicit admin
escape hatch.

Legacy rows (pre-v7) backfill visibility=public, ownerId=NULL — fully
backwards compatible.

Test plan

  • mcpd unit tests pass (908/908)
  • mcplocal unit tests pass (731/731)
  • cli unit tests pass (437/437)
  • completions regenerated and the match generator output test passes
  • Run bash fulldeploy.sh to roll out mcpd + mcplocal RPM
  • After deploy, smoke passes (pnpm test:smoke includes the new
    virtual-llm-visibility.smoke.test.ts)
  • Manual two-user check: alice publishes alice-vllm-local private,
    bob with only view:llms doesn't see it; mcpctl create rbac bob view:llms --name alice-vllm-local makes it visible

🤖 Generated with Claude Code

## Summary Adds a per-row **visibility** scope (`public` | `private`) to `Llm` and `Agent` so users on a shared mcpd cluster can publish private virtual Llms without exposing them to everyone with `view:llms`. - **Stage 1 (`21f8bed`)** — schema column, `ownerId`, `Viewer` predicate, unit tests (13/13 passing). - **Stage 2 (`2c98a21`)** — route layer + RBAC `isAdmin` split + mcplocal defaults to `private` + CLI `--visibility` flag + `VISIBILITY` column on `get llm`/`get agent` + completions regen. - **Stage 3 (`2b2444a`)** — `docs/virtual-llms.md` visibility section + live smoke (`virtual-llm-visibility.smoke.test.ts`) covering the register → list → get-by-name round-trip. Visibility model: - `public` — anyone with `view:llms` sees it. Default for hand-created. - `private` — only owner + name-scoped grant + `*` admin. Default for newly-published virtual rows from mcplocal. A plain `view:llms` resource grant is *not* admin: the v7 split ensures that grant only widens RBAC name-scoping but the visibility filter still applies on top. Cross-resource `*` grant is the explicit admin escape hatch. Legacy rows (pre-v7) backfill `visibility=public, ownerId=NULL` — fully backwards compatible. ## Test plan - [x] mcpd unit tests pass (`908/908`) - [x] mcplocal unit tests pass (`731/731`) - [x] cli unit tests pass (`437/437`) - [x] completions regenerated and the `match generator output` test passes - [ ] Run `bash fulldeploy.sh` to roll out mcpd + mcplocal RPM - [ ] After deploy, smoke passes (`pnpm test:smoke` includes the new `virtual-llm-visibility.smoke.test.ts`) - [ ] Manual two-user check: alice publishes `alice-vllm-local` private, bob with only `view:llms` doesn't see it; `mcpctl create rbac bob view:llms --name alice-vllm-local` makes it visible 🤖 Generated with [Claude Code](https://claude.com/claude-code)
michal added 3 commits 2026-04-29 00:08:35 +00:00
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.
Stage 1 added the schema + service predicate. This stage threads the
filter through every surface that lists or fetches Llms/Agents:

- mcpd routes: viewerFromRequest helper builds a Viewer from the
  request's RBAC scope. List endpoints rely on the existing
  preSerialization hook (now two-phase: name-scope first, visibility
  second). get-by-id/get-by-name routes pass the viewer to the service
  which 404s on hidden rows.
- RBAC: AllowedScope gains `isAdmin` to distinguish a `*` cross-resource
  grant (admins skip visibility) from a plain `view:llms` grant
  (wildcard for RBAC, but visibility still applies). FastifyRequest
  augmentation updated.
- VirtualLlmService.register accepts ownerId and stamps it on freshly
  created virtual rows; defaults visibility to 'private' on first
  create, leaves existing rows untouched on sticky reconnect.
- AgentService.registerVirtualAgents mirrors the same defaults.
- mcplocal: LlmProviderFileEntry / AgentFileEntry / RegistrarPublishedX
  carry visibility through to the register payload (default 'private').
- CLI: VISIBILITY column on `mcpctl get llm` and `mcpctl get agent`,
  `--visibility` flag on `mcpctl create llm` / `create agent`. YAML
  round-trip works because visibility passes through stripInternalFields
  unchanged (ownerId is already stripped). Completions regenerated.

Tests: mcpd 908/908, mcplocal 731/731, cli 437/437.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs+smoke(v7): visibility section in virtual-llms.md + register/list smoke
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m42s
CI/CD / build (pull_request) Successful in 5m37s
CI/CD / publish (pull_request) Has been skipped
2b2444a2c5
Wraps up v7 Stage 3:

- docs/virtual-llms.md gains a "Visibility scope (v7)" section that
  explains public-vs-private semantics, who skips the filter (owner +
  `*` admin), how to grant single-row exceptions via name-scoped RBAC,
  per-row override syntax in mcplocal config, the `--visibility` flag
  on `mcpctl create llm`/`create agent`, and YAML round-trip behavior.
- New smoke (virtual-llm-visibility.smoke.test.ts) publishes one
  public + one private virtual Llm via the registrar against the
  live mcpd and asserts the GET /llms response carries visibility
  + a non-empty ownerId for both, and that GET /llms/<name> returns
  the private row to its owner without 404. Cross-user filtering is
  covered by mcpd's visibility-filter unit tests; smoke proves the
  fields make the round-trip end-to-end.

Will pass once mcpd is rebuilt + deployed via fulldeploy.sh on this
branch (current main is v6, doesn't yet serialize visibility).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m11s
CI/CD / lint (pull_request) Successful in 2m49s
CI/CD / smoke (pull_request) Failing after 1m42s
CI/CD / build (pull_request) Successful in 5m37s
CI/CD / publish (pull_request) Has been skipped
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/v7-rbac-visibility:feat/v7-rbac-visibility
git checkout feat/v7-rbac-visibility
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#72