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.
This commit is contained in:
Michal
2026-04-29 00:46:06 +01:00
parent 3071bcee8e
commit 21f8bede2e
7 changed files with 287 additions and 10 deletions

View File

@@ -0,0 +1,25 @@
-- v7: per-user RBAC scoping for virtual Llms and Agents.
--
-- `Llm.ownerId` is new — we don't have a record of who created legacy
-- rows, so existing data is left NULL (treated as "no owner, public").
-- The list/get filter in the service layer handles NULL ownerId
-- correctly: a NULL-owner public row stays visible to everyone.
--
-- `Llm.visibility` and `Agent.visibility` default to 'public' so the
-- backfill is automatic — pre-v7 setups continue to behave identically.
-- New rows created post-deploy carry the value the service writes
-- (mcplocal virtuals → 'private'; CLI `mcpctl create llm` → 'public'
-- by default unless `--visibility private` is passed).
ALTER TABLE "Llm"
ADD COLUMN "ownerId" TEXT,
ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public';
ALTER TABLE "Agent"
ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public';
-- Composite index supports the list-filter hot path:
-- `WHERE visibility='public' OR ownerId=$1`
-- on tables that may grow as more publishers / users come online.
CREATE INDEX "Llm_visibility_ownerId_idx" ON "Llm"("visibility", "ownerId");
CREATE INDEX "Agent_visibility_ownerId_idx" ON "Agent"("visibility", "ownerId");

View File

@@ -225,6 +225,30 @@ model Llm {
lastHeartbeatAt DateTime? // bumped on every publisher heartbeat
status LlmStatus @default(active)
inactiveSince DateTime? // when status flipped from active; used for 4-h GC
// ── Per-user RBAC scoping (v7) ──
// `ownerId` records who created/published the row. NULL on legacy rows
// (those created before the v7 migration) — those continue to behave
// as `visibility=public` for back-compat. New rows always carry an
// ownerId set by the service layer (`User.id` of the authenticated
// caller, or the publishing mcplocal user for virtuals).
//
// `visibility` controls who can see / use the row:
// - 'public' : anyone with the resource grant (`view:llms`,
// `run:llms:<name>`, etc.) sees it. Legacy default
// mirrors today's behavior — explicit
// `mcpctl create llm` calls keep this default.
// - 'private' : only the owner sees it by default; other users need
// an explicit name-scoped RBAC binding
// (`view:llms:<name>`, `run:llms:<name>`). The list
// endpoint hides foreign-private rows from
// unauthorized callers; get/describe returns 404 to
// prevent name enumeration.
//
// mcplocal-published virtual Llms default to 'private' on register —
// a workstation-published model isn't typically meant for the whole
// org until the publisher explicitly shares it. See docs/virtual-llms.md.
ownerId String?
visibility String @default("public")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -238,6 +262,10 @@ model Llm {
@@index([kind, status])
@@index([providerSessionId])
@@index([poolName])
// List filter on the hot path: "rows visible to caller X" decomposes
// into `visibility='public' OR ownerId=X` + an RBAC join. Composite
// index keeps the predicate fast even on a large table.
@@index([visibility, ownerId])
}
// ── Groups ──
@@ -495,6 +523,14 @@ model Agent {
status LlmStatus @default(active)
inactiveSince DateTime?
ownerId String
// v7: per-user RBAC scoping. Mirrors `Llm.visibility` semantics —
// 'public' (default, today's behavior) lets anyone with the resource
// grant see the agent; 'private' restricts to owner + explicit
// name-scoped RBAC bindings. mcplocal-published virtual agents
// default to 'private' on register so a workstation-published persona
// isn't broadcast to the whole org until shared explicitly. Existing
// rows backfill to 'public' so pre-v7 setups keep working unchanged.
visibility String @default("public")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -514,6 +550,7 @@ model Agent {
@@index([defaultPersonalityId])
@@index([kind, status])
@@index([providerSessionId])
@@index([visibility, ownerId])
}
// ── Personalities (named overlay bundles of prompts on top of an Agent) ──