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:
Michal
2026-08-08 10:10:53 +01:00
parent 797df2299d
commit a186d95a85
7 changed files with 618 additions and 14 deletions

View File

@@ -154,7 +154,7 @@ mcpctl get task $ID
## RBAC
A new `tasks` resource. Default action mapping:
The `inference-tasks` resource. Default action mapping:
| Method | Action |
|--------|--------|
@@ -162,13 +162,22 @@ A new `tasks` resource. Default action mapping:
| `POST` | `create` |
| `DELETE` | `delete` |
Aliases: `task`, `inference-task`, `inference-tasks` all normalize to
`tasks` so `mcpctl create rbac-binding --resource task --role view ...`
works regardless of which form the operator types.
Aliases: `inference-task` and `inference-tasks` both normalize to
`inference-tasks`, so `mcpctl create rbac-binding --resource inference-task
--role view ...` works either way.
> **Renamed.** v5 shipped this queue under the RBAC resource `tasks`, on the
> reasoning that inference was the only kind of task we had. Agentic teams
> introduces work-item tasks, which take `tasks` (and the `task` alias) over.
> Bindings written before the split literally stored `"tasks"`, so mcpd rewrites
> them to `inference-tasks` once at startup — see
> `src/mcpd/src/bootstrap/rbac-tasks-backfill.ts`. Nothing to do by hand; the
> rewrite logs what it changed. `view:tasks` now grants **work items**, not
> inference.
Owner scoping is enforced at the route layer ON TOP of the RBAC hook:
- A caller with only the implicit role sees and can cancel their own tasks.
- `view:tasks` resource-wide is required to see / list / cancel
- `view:inference-tasks` resource-wide is required to see / list / cancel
another user's tasks.
## GC

View File

@@ -0,0 +1,90 @@
/**
* One-shot rewrite of pre-split `tasks` RBAC bindings to `inference-tasks`.
*
* v5 gave the durable inference queue the RBAC resource `tasks`. Agentic teams
* introduces work-item tasks, which take that name over, and the queue moves to
* `inference-tasks` (see validation/rbac-definition.schema.ts).
*
* 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. Without this rewrite those grants would silently stop
* authorizing `/api/v1/inference-tasks` and start authorizing work items —
* a privilege change in both directions.
*
* Guarded to run exactly once. A blanket rewrite on every boot would clobber
* legitimate work-item grants created after the split, so the marker matters as
* much as the walk. If the marker cannot be persisted the rewrite is skipped
* entirely rather than risk running twice — see the warning path below.
*
* Wildcard bindings (`resource: '*'`) already cover both resources and are left
* alone. Operation bindings carry `action`, not `resource`, and are untouched.
*/
import type { PrismaClient } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import type { UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
import {
SETTING_INFERENCE_TASK_BINDINGS_MIGRATED,
getBooleanSetting,
setSetting,
} from './system-settings.js';
/** The pre-split resource string stored on legacy inference grants. */
const LEGACY_RESOURCE = 'tasks';
/** Where those grants move to. */
const NEW_RESOURCE = 'inference-tasks';
export async function backfillInferenceTaskBindings(
prisma: PrismaClient,
rbacRepo: IRbacDefinitionRepository,
): Promise<void> {
const alreadyRun = await getBooleanSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, false);
if (alreadyRun) return;
// Claim the marker BEFORE rewriting. If we rewrote first and the marker write
// then failed, the next startup would rewrite again — turning work-item grants
// created in between into inference grants. Losing the rewrite is recoverable
// (an admin re-grants); running it twice silently is not.
const claimed = await setSetting(prisma, SETTING_INFERENCE_TASK_BINDINGS_MIGRATED, true);
if (!claimed) {
console.warn(
`mcpd: skipped the ${LEGACY_RESOURCE} → ${NEW_RESOURCE} RBAC rewrite — could not persist the ` +
`run-once marker. Existing '${LEGACY_RESOURCE}' grants now authorize work items, not the ` +
`inference queue. Re-grant them with 'mcpctl apply' or fix the system-settings secret and restart.`,
);
return;
}
const definitions = await rbacRepo.findAll();
let rowsChanged = 0;
let bindingsChanged = 0;
for (const def of definitions) {
const bindings = def.roleBindings as Array<Record<string, unknown>>;
let touched = 0;
const rewritten = bindings.map((b) => {
if (b['resource'] !== LEGACY_RESOURCE) return b;
touched++;
return { ...b, resource: NEW_RESOURCE };
});
if (touched === 0) continue;
await rbacRepo.update(def.id, {
roleBindings: rewritten as UpdateRbacDefinitionInput['roleBindings'],
});
rowsChanged++;
bindingsChanged += touched;
}
if (rowsChanged > 0) {
// eslint-disable-next-line no-console
console.log(
`mcpd: migrated ${String(bindingsChanged)} RBAC binding(s) across ${String(rowsChanged)} ` +
`definition(s) from '${LEGACY_RESOURCE}' to '${NEW_RESOURCE}'`,
);
}
}

View File

@@ -7,7 +7,9 @@
* See the `backup-ssh` secret for the original precedent.
*/
import type { PrismaClient } from '@prisma/client';
import type { Prisma, PrismaClient } from '@prisma/client';
import { DEFAULT_PLAINTEXT_BACKEND_NAME } from './secret-backends.js';
/** Well-known secret holding global mcpd settings as `data` JSON. */
export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
@@ -15,6 +17,12 @@ export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
/** Settings key: whether newly-created users get self password-change permission. */
export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange';
/**
* Settings key: whether the one-shot `tasks` → `inference-tasks` RBAC binding
* rewrite has run. See bootstrap/rbac-tasks-backfill.ts.
*/
export const SETTING_INFERENCE_TASK_BINDINGS_MIGRATED = 'inferenceTaskBindingsMigrated';
/** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */
function toBool(v: unknown, dflt: boolean): boolean {
if (typeof v === 'boolean') return v;
@@ -45,3 +53,87 @@ export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise<
return true;
}
}
/**
* Read one boolean setting. Missing secret / missing key / read error all
* return `dflt` — callers treat "can't tell" as "not set".
*/
export async function getBooleanSetting(
prisma: PrismaClient,
key: string,
dflt: boolean,
): Promise<boolean> {
try {
const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
if (!secret) return dflt;
const data = (secret.data ?? {}) as Record<string, unknown>;
if (!(key in data)) return dflt;
return toBool(data[key], dflt);
} catch {
return dflt;
}
}
/**
* Persist one setting into the system-settings secret, creating the secret on
* the default plaintext backend if it doesn't exist yet.
*
* Returns whether the value was durably stored. Callers that use a setting as
* a run-once marker MUST treat `false` as "the marker did not stick" — the
* guarded work will be attempted again on the next startup.
*
* Writes `data` directly, which is only correct for the plaintext backend
* (non-plaintext backends keep their values behind `externalRef`). If the
* secret has been migrated onto another backend we refuse rather than write a
* value that reads back empty.
*/
export async function setSetting(
prisma: PrismaClient,
key: string,
value: unknown,
): Promise<boolean> {
try {
const secret = await prisma.secret.findUnique({
where: { name: SYSTEM_SETTINGS_SECRET },
include: { backend: true },
});
if (secret) {
if (secret.backend.type !== 'plaintext') {
console.warn(
`mcpd: cannot persist system setting '${key}' — secret '${SYSTEM_SETTINGS_SECRET}' lives on ` +
`the '${secret.backend.type}' backend; set it there manually.`,
);
return false;
}
const data = { ...((secret.data ?? {}) as Record<string, unknown>), [key]: value };
await prisma.secret.update({
where: { id: secret.id },
data: {
data: data as Prisma.InputJsonValue,
keyNames: Object.keys(data).sort(),
version: { increment: 1 },
},
});
return true;
}
const backend =
(await prisma.secretBackend.findUnique({ where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME } })) ??
(await prisma.secretBackend.findFirst({ where: { isDefault: true } }));
if (!backend) return false;
const data = { [key]: value };
await prisma.secret.create({
data: {
name: SYSTEM_SETTINGS_SECRET,
backendId: backend.id,
data: data as Prisma.InputJsonValue,
keyNames: Object.keys(data).sort(),
},
});
return true;
} catch {
return false;
}
}

View File

@@ -23,6 +23,7 @@ import {
import { SecretBackendRepository } from './repositories/secret-backend.repository.js';
import { SecretBackendService } from './services/secret-backend.service.js';
import { SecretMigrateService } from './services/secret-migrate.service.js';
import { backfillInferenceTaskBindings } from './bootstrap/rbac-tasks-backfill.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
@@ -211,7 +212,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
// any other resource (GET=view, POST=create, DELETE=delete). The
// route layer enforces owner-scoping on top — a caller without
// resource-wide grant only sees their own tasks.
'inference-tasks': 'tasks',
//
// Maps to its own `inference-tasks` resource, NOT `tasks` — agentic-teams
// work items own `tasks` now. Pre-split grants are rewritten by
// backfillInferenceTaskBindings at startup.
'inference-tasks': 'inference-tasks',
'agents': 'agents',
// Personalities inherit the agent's RBAC: managing a personality
// requires view/edit/create/delete on the `agents` resource.
@@ -449,6 +454,11 @@ async function main(): Promise<void> {
// Migrate legacy 'admin' role → granular roles
await migrateAdminRole(rbacDefinitionRepo);
// Repoint pre-split inference-queue grants at the `inference-tasks` resource
// now that `tasks` means agentic-teams work items. Runs once; needs the
// default secret backend, so it must follow bootstrapSecretBackends.
await backfillInferenceTaskBindings(prisma, rbacDefinitionRepo);
// Orchestrator — select backend via MCPD_ORCHESTRATOR env var
const orchestrator = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
? new KubernetesOrchestrator()

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'tasks'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens', 'inference-tasks', 'epics', 'epictasks', 'tasks', 'repos', 'sessions'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
@@ -17,13 +17,27 @@ const RESOURCE_ALIASES: Record<string, string> = {
mcptoken: 'mcptokens',
secretbackend: 'secretbackends',
llm: 'llms',
// v5: durable inference task queue. The route prefix is
// `/api/v1/inference-tasks` (multi-word for clarity in the URL); the
// RBAC resource is just `tasks` (shorter; the only "task" we have
// is inference for now). Singular alias `task` maps to the same.
// v5 shipped the durable inference task queue under the RBAC resource
// `tasks`, on the reasoning that inference was the only "task" we had.
// Agentic teams introduces work-item tasks, so the queue moves to its own
// `inference-tasks` resource — matching its `/api/v1/inference-tasks` route
// prefix — and `task`/`tasks` now mean work items.
//
// Pre-split RbacDefinition rows literally store `"tasks"` (ResourceBindingSchema
// normalizes at parse time), so they are rewritten to `inference-tasks` by the
// one-shot backfill in bootstrap/rbac-tasks-backfill.ts. Do not repoint these
// aliases without that migration — existing inference grants would silently
// become work-item grants.
'inference-task': 'inference-tasks',
// ── Agentic teams (Epic → epictask → task) ──
epic: 'epics',
epictask: 'epictasks',
et: 'epictasks',
task: 'tasks',
'inference-task': 'tasks',
'inference-tasks': 'tasks',
repo: 'repos',
repository: 'repos',
repositories: 'repos',
session: 'sessions',
};
/** Normalize a resource name to its canonical plural form. */

View File

@@ -0,0 +1,233 @@
import type { PrismaClient, RbacDefinition } from '@prisma/client';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { backfillInferenceTaskBindings } from '../src/bootstrap/rbac-tasks-backfill.js';
import { SYSTEM_SETTINGS_SECRET } from '../src/bootstrap/system-settings.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import { RBAC_RESOURCES, normalizeResource } from '../src/validation/rbac-definition.schema.js';
function makeDef(overrides: Partial<RbacDefinition> = {}): RbacDefinition {
return {
id: 'def-1',
name: 'test-rbac',
subjects: [{ kind: 'User', name: 'alice@example.com' }],
roleBindings: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function mockRepo(definitions: RbacDefinition[]): IRbacDefinitionRepository & {
update: ReturnType<typeof vi.fn>;
} {
const update = vi.fn(async () => makeDef());
return {
findAll: vi.fn(async () => definitions),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => makeDef()),
update,
delete: vi.fn(async () => {}),
} as unknown as IRbacDefinitionRepository & { update: ReturnType<typeof vi.fn> };
}
/**
* In-memory stand-in for the system-settings secret plus its backend, enough to
* exercise the read/claim path in setSetting.
*/
function mockPrisma(opts: {
/** Existing settings data, or null when the secret does not exist yet. */
settings?: Record<string, unknown> | null;
/** Backend type the settings secret sits on. */
backendType?: string;
/** When false, no default SecretBackend exists, so the marker cannot be created. */
hasDefaultBackend?: boolean;
} = {}): { prisma: PrismaClient; written: () => Record<string, unknown> | null } {
const backendType = opts.backendType ?? 'plaintext';
const hasDefaultBackend = opts.hasDefaultBackend ?? true;
let stored: Record<string, unknown> | null = opts.settings ?? null;
const secretRow = (): unknown =>
stored === null
? null
: {
id: 'sec-1',
name: SYSTEM_SETTINGS_SECRET,
data: stored,
backend: { id: 'be-1', name: 'default', type: backendType },
};
const prisma = {
secret: {
findUnique: vi.fn(async () => secretRow()),
update: vi.fn(async ({ data }: { data: { data: Record<string, unknown> } }) => {
stored = data.data;
return secretRow();
}),
create: vi.fn(async ({ data }: { data: { data: Record<string, unknown> } }) => {
stored = data.data;
return secretRow();
}),
},
secretBackend: {
findUnique: vi.fn(async () =>
hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null,
),
findFirst: vi.fn(async () =>
hasDefaultBackend ? { id: 'be-1', name: 'default', type: backendType } : null,
),
},
} as unknown as PrismaClient;
return { prisma, written: () => stored };
}
const MARKER = 'inferenceTaskBindingsMigrated';
describe('RBAC tasks namespace split', () => {
it('maps task aliases to the work-item resource', () => {
expect(normalizeResource('task')).toBe('tasks');
expect(normalizeResource('tasks')).toBe('tasks');
});
it('maps inference aliases to their own resource', () => {
expect(normalizeResource('inference-task')).toBe('inference-tasks');
expect(normalizeResource('inference-tasks')).toBe('inference-tasks');
});
it('no longer collapses inference-tasks onto tasks', () => {
expect(normalizeResource('inference-tasks')).not.toBe('tasks');
expect(normalizeResource('inference-task')).not.toBe('tasks');
});
it('maps the agentic-teams resource aliases', () => {
expect(normalizeResource('epic')).toBe('epics');
expect(normalizeResource('epictask')).toBe('epictasks');
expect(normalizeResource('et')).toBe('epictasks');
expect(normalizeResource('repo')).toBe('repos');
expect(normalizeResource('repository')).toBe('repos');
expect(normalizeResource('repositories')).toBe('repos');
expect(normalizeResource('session')).toBe('sessions');
});
it('leaves unknown resources untouched', () => {
expect(normalizeResource('widgets')).toBe('widgets');
expect(normalizeResource('*')).toBe('*');
});
it('declares the new resources', () => {
for (const r of ['epics', 'epictasks', 'tasks', 'repos', 'sessions', 'inference-tasks']) {
expect(RBAC_RESOURCES).toContain(r);
}
});
});
describe('backfillInferenceTaskBindings', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('rewrites legacy tasks bindings to inference-tasks', async () => {
const def = makeDef({
roleBindings: [
{ role: 'view', resource: 'tasks' },
{ role: 'create', resource: 'tasks' },
{ role: 'view', resource: 'llms' },
],
});
const repo = mockRepo([def]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).toHaveBeenCalledTimes(1);
expect(repo.update.mock.calls[0]?.[1]).toEqual({
roleBindings: [
{ role: 'view', resource: 'inference-tasks' },
{ role: 'create', resource: 'inference-tasks' },
{ role: 'view', resource: 'llms' },
],
});
});
it('leaves wildcard bindings alone — they already cover both resources', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'edit', resource: '*' }] })]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
});
it('leaves operation bindings alone', async () => {
const repo = mockRepo([
makeDef({ roleBindings: [{ role: 'run', action: 'backup' }] }),
]);
const { prisma } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
});
it('sets the run-once marker', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma, written } = mockPrisma();
await backfillInferenceTaskBindings(prisma, repo);
expect(written()).toMatchObject({ [MARKER]: true });
});
it('is idempotent — a second run does not rewrite new work-item grants', async () => {
const { prisma, written } = mockPrisma();
const first = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
await backfillInferenceTaskBindings(prisma, first);
expect(first.update).toHaveBeenCalledTimes(1);
expect(written()).toMatchObject({ [MARKER]: true });
// A genuine post-split work-item grant must survive the next startup.
const second = mockRepo([
makeDef({ id: 'def-2', roleBindings: [{ role: 'view', resource: 'tasks' }] }),
]);
await backfillInferenceTaskBindings(prisma, second);
expect(second.update).not.toHaveBeenCalled();
expect(second.findAll).not.toHaveBeenCalled();
});
it('preserves other settings when claiming the marker', async () => {
const repo = mockRepo([]);
const { prisma, written } = mockPrisma({ settings: { allowSelfPasswordChange: false } });
await backfillInferenceTaskBindings(prisma, repo);
expect(written()).toEqual({ allowSelfPasswordChange: false, [MARKER]: true });
});
it('skips the rewrite entirely when the marker cannot be persisted', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma } = mockPrisma({ hasDefaultBackend: false });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await backfillInferenceTaskBindings(prisma, repo);
// Running without a durable marker risks a second pass clobbering real
// work-item grants, so we would rather do nothing.
expect(repo.update).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
it('refuses to write settings onto a non-plaintext backend', async () => {
const repo = mockRepo([makeDef({ roleBindings: [{ role: 'view', resource: 'tasks' }] })]);
const { prisma } = mockPrisma({ settings: {}, backendType: 'openbao' });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await backfillInferenceTaskBindings(prisma, repo);
expect(repo.update).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
});

View 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']);
});
});