feat(rbac): split the tasks namespace into tasks + inference-tasks
v5 gave the durable inference queue the RBAC resource `tasks`, on the reasoning that inference was the only kind of task we had. Agentic teams introduces work-item tasks (Epic -> epictask -> task), which need that name. The queue moves to its own `inference-tasks` resource, matching its existing /api/v1/inference-tasks route prefix, and `task`/`tasks` now mean work items. Also lands the agentic-teams aliases: epic, epictask/et, repo/repository, session. The rename is not purely a code change. ResourceBindingSchema runs normalizeResource at parse time and stores the normalized string, so every RbacDefinition row written before the split literally contains "tasks" meaning inference. Shipping the alias change alone would silently stop those grants authorizing the queue and start them authorizing work items -- a privilege change in both directions. bootstrap/rbac-tasks-backfill.ts rewrites them once at startup, alongside migrateAdminRole. The run-once marker is claimed BEFORE the rewrite, not after. A failed marker write after a successful rewrite would let the next startup rewrite again, converting genuine post-split work-item grants into inference grants. Losing the rewrite is recoverable by re-granting; running it twice silently is not -- so if the marker cannot be persisted we skip the rewrite and warn loudly. mapUrlToPermission moves in lockstep: /api/v1/inference-tasks maps to `inference-tasks`, otherwise the split would break queue authorization on the first request. Adds generic getBooleanSetting/setSetting helpers to bootstrap/system-settings.ts, following the existing "global settings live in a Secret" pattern. setSetting refuses to write when the settings secret has been migrated onto a non-plaintext backend, where a direct `data` write would read back empty. Out of scope, tracked separately: the CLI still routes `mcpctl get tasks` to the inference queue (src/cli/src/commands/shared.ts). Repointing it needs the work-item resource to exist first, or the command breaks with no replacement. Tests: 14 unit tests cover alias resolution, the rewrite, wildcard and operation bindings being left alone, idempotency, and both marker-failure paths. Full workspace suite green (2291 tests). The new smoke test is RED until mcpd is deployed with this change -- it asserts post-split storage against a live daemon, and the currently deployed one predates the split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
156
src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts
Normal file
156
src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Smoke: the `tasks` → `inference-tasks` RBAC namespace split, against live mcpd.
|
||||
*
|
||||
* `ResourceBindingSchema` normalizes a binding's `resource` at parse time and
|
||||
* stores the normalized string, so what a definition reads back as is the whole
|
||||
* behaviour under test:
|
||||
*
|
||||
* 1. `resource: inference-task` → stored as `inference-tasks` (the queue).
|
||||
* 2. `resource: task` → stored as `tasks` (agentic-teams work items).
|
||||
* 3. The agentic-teams aliases (`epic`, `et`, `repository`, `session`) resolve.
|
||||
*
|
||||
* Pre-split, cases 1 and 2 both stored `tasks`. A failure here against a
|
||||
* reachable daemon means mcpd is running code older than the split — deploy it,
|
||||
* don't relax the assertion.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
|
||||
const SUFFIX = Date.now().toString(36);
|
||||
const RBAC_NAME = `smoke-tasksplit-${SUFFIX}`;
|
||||
|
||||
interface CliResult { code: number; stdout: string; stderr: string }
|
||||
|
||||
function run(args: string): CliResult {
|
||||
try {
|
||||
const stdout = execSync(`mcpctl --direct ${args}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err) {
|
||||
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
code: e.status ?? 1,
|
||||
stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '',
|
||||
stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const req = driver.get(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); },
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
interface RbacDoc { name?: string; roleBindings?: Array<{ resource?: string }> }
|
||||
|
||||
/**
|
||||
* The resource strings mcpd stored, in binding order.
|
||||
*
|
||||
* `get -o json` emits an array of apply-shaped documents even for a single
|
||||
* named resource, so unwrap before reading bindings.
|
||||
*/
|
||||
function storedResources(): string[] {
|
||||
const res = run(`get rbac ${RBAC_NAME} -o json`);
|
||||
expect(res.code, `get rbac failed: ${res.stderr || res.stdout}`).toBe(0);
|
||||
const parsed = JSON.parse(res.stdout) as RbacDoc | RbacDoc[];
|
||||
const doc = Array.isArray(parsed)
|
||||
? parsed.find((d) => d.name === RBAC_NAME) ?? parsed[0]
|
||||
: parsed;
|
||||
const bindings = doc?.roleBindings ?? [];
|
||||
expect(bindings.length, `no roleBindings on '${RBAC_NAME}'`).toBeGreaterThan(0);
|
||||
return bindings.map((b) => b.resource ?? '');
|
||||
}
|
||||
|
||||
let mcpdUp = false;
|
||||
let workDir: string | null = null;
|
||||
|
||||
describe('rbac tasks namespace split smoke', () => {
|
||||
beforeAll(async () => {
|
||||
mcpdUp = await healthz(MCPD_URL);
|
||||
if (!mcpdUp) {
|
||||
console.warn(`\n ○ rbac tasks split smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// RBAC definitions have no `create` subcommand — they come in via apply.
|
||||
// Note `apply` maps a few singular aliases client-side, but none of the ones
|
||||
// under test, so these strings reach mcpd verbatim and it is mcpd's
|
||||
// normalizeResource that we are exercising.
|
||||
workDir = mkdtempSync(join(tmpdir(), 'mcpctl-smoke-rbac-'));
|
||||
const manifestPath = join(workDir, 'rbac.yaml');
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
[
|
||||
'rbacBindings:',
|
||||
` - name: ${RBAC_NAME}`,
|
||||
' subjects:',
|
||||
' - kind: Group',
|
||||
` name: smoke-nonexistent-group-${SUFFIX}`,
|
||||
' roleBindings:',
|
||||
' - role: view',
|
||||
' resource: inference-task',
|
||||
' - role: view',
|
||||
' resource: task',
|
||||
' - role: view',
|
||||
' resource: epic',
|
||||
' - role: view',
|
||||
' resource: et',
|
||||
' - role: view',
|
||||
' resource: repository',
|
||||
' - role: view',
|
||||
' resource: session',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const res = run(`apply -f ${manifestPath}`);
|
||||
expect(res.code, `apply failed: ${res.stderr || res.stdout}`).toBe(0);
|
||||
}, 40_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (mcpdUp) run(`delete rbac ${RBAC_NAME}`);
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('stores inference-task as inference-tasks, not tasks', () => {
|
||||
if (!mcpdUp) return;
|
||||
const resources = storedResources();
|
||||
expect(resources[0]).toBe('inference-tasks');
|
||||
expect(resources[0]).not.toBe('tasks');
|
||||
});
|
||||
|
||||
it('stores task as tasks — work items own the short name now', () => {
|
||||
if (!mcpdUp) return;
|
||||
const resources = storedResources();
|
||||
expect(resources[1]).toBe('tasks');
|
||||
});
|
||||
|
||||
it('resolves the agentic-teams aliases', () => {
|
||||
if (!mcpdUp) return;
|
||||
const resources = storedResources();
|
||||
expect(resources.slice(2)).toEqual(['epics', 'epictasks', 'repos', 'sessions']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user