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,105 @@
/**
* v7 Stage 1 — pure-function tests for the visibility predicate.
* Lives separately from the service-level tests because the predicate
* is the single source of truth for "can user X see this row" and is
* called from both LlmService.list and AgentService.list. We exercise
* every branch of the decision tree to lock the semantics in.
*/
import { describe, it, expect } from 'vitest';
import { isLlmVisibleTo, type Viewer } from '../src/services/llm.service.js';
import { isAgentVisibleTo, type AgentViewer } from '../src/services/agent.service.js';
const llmRow = (overrides: { name?: string; ownerId?: string | null; visibility?: string } = {}): { name: string; ownerId: string | null; visibility: string } => ({
name: 'vllm-alice',
ownerId: 'alice',
visibility: 'private',
...overrides,
});
const agentRow = (overrides: { name?: string; ownerId?: string; visibility?: string } = {}): { name: string; ownerId: string; visibility: string } => ({
name: 'reviewer',
ownerId: 'alice',
visibility: 'private',
...overrides,
});
describe('isLlmVisibleTo (v7)', () => {
it('null viewer skips the filter — internal callers see everything', () => {
// Cron sweeps, audit collectors, and tests without a request
// context get a null viewer. The visibility filter is then a
// no-op, which matches the pre-v7 behavior of those code paths.
expect(isLlmVisibleTo(llmRow(), null)).toBe(true);
});
it('public rows are visible to anyone with the resource grant', () => {
const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() };
expect(isLlmVisibleTo(llmRow({ visibility: 'public' }), v)).toBe(true);
});
it('wildcard viewer (admin) sees private rows owned by others', () => {
const v: Viewer = { userId: 'admin', wildcard: true, allowedNames: new Set() };
expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
it('owner sees their own private row', () => {
const v: Viewer = { userId: 'alice', wildcard: false, allowedNames: new Set() };
expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
it('non-owner without name-scoped grant cannot see a private row', () => {
const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() };
expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false);
});
it('non-owner WITH name-scoped grant can see a private row', () => {
// alice published vllm-alice as private; alice ran
// `mcpctl create rbac binding view:llms:vllm-alice --user bob`,
// so bob now sees the row in his list output.
const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['vllm-alice']) };
expect(isLlmVisibleTo(llmRow({ name: 'vllm-alice', visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
it('treats null ownerId as no-owner (legacy rows pre-v7 backfill stay visible if public)', () => {
// The migration sets visibility='public' for legacy rows, so they
// pass the public-visibility check before the ownerId branch is
// ever reached. A row with NULL ownerId AND visibility='private'
// is unreachable via normal flows, but we still want the predicate
// to behave: no owner + bob viewing = not visible.
const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() };
expect(isLlmVisibleTo(llmRow({ ownerId: null, visibility: 'private' }), v)).toBe(false);
});
});
describe('isAgentVisibleTo (v7)', () => {
// Same shape as Llm; agents always have a non-null ownerId because
// `Agent.ownerId` is required, so we don't need the legacy-null
// branch test.
it('null viewer = visible (internal calls bypass filter)', () => {
expect(isAgentVisibleTo(agentRow(), null)).toBe(true);
});
it('public agents visible to anyone with resource grant', () => {
const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() };
expect(isAgentVisibleTo(agentRow({ visibility: 'public' }), v)).toBe(true);
});
it('owner sees own private agent', () => {
const v: AgentViewer = { userId: 'alice', wildcard: false, allowedNames: new Set() };
expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
it('non-owner without grant blocked from private agent', () => {
const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() };
expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false);
});
it('non-owner WITH name-scoped grant can see private agent', () => {
const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['reviewer']) };
expect(isAgentVisibleTo(agentRow({ name: 'reviewer', visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
it('wildcard viewer sees private agent owned by another user', () => {
const v: AgentViewer = { userId: 'admin', wildcard: true, allowedNames: new Set() };
expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true);
});
});