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

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

3 Commits

Author SHA1 Message Date
Michal
2b2444a2c5 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
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>
2026-04-29 01:08:03 +01:00
Michal
2c98a21323 feat(mcpd+cli+mcplocal): wire visibility filter through routes, CLI, registrar (v7 Stage 2)
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>
2026-04-29 01:03:58 +01:00
Michal
21f8bede2e feat(mcpd+db): visibility scope + ownership for Llms and Agents (v7 Stage 1)
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.
2026-04-29 00:46:06 +01:00