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:
@@ -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");
|
||||
@@ -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) ──
|
||||
|
||||
Reference in New Issue
Block a user