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:
105
src/mcpd/tests/visibility-filter.test.ts
Normal file
105
src/mcpd/tests/visibility-filter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user