diff --git a/docs/inference-tasks.md b/docs/inference-tasks.md index d7845fc..6304d91 100644 --- a/docs/inference-tasks.md +++ b/docs/inference-tasks.md @@ -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 diff --git a/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts b/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts new file mode 100644 index 0000000..08932b0 --- /dev/null +++ b/src/mcpd/src/bootstrap/rbac-tasks-backfill.ts @@ -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 { + 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>; + 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}'`, + ); + } +} diff --git a/src/mcpd/src/bootstrap/system-settings.ts b/src/mcpd/src/bootstrap/system-settings.ts index 9fb3f3e..561de76 100644 --- a/src/mcpd/src/bootstrap/system-settings.ts +++ b/src/mcpd/src/bootstrap/system-settings.ts @@ -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 { + try { + const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } }); + if (!secret) return dflt; + const data = (secret.data ?? {}) as Record; + 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 { + 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), [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; + } +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 02a92a5..fa6fc33 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { // 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() diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 61d6749..c5dca98 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -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 = { @@ -17,13 +17,27 @@ const RESOURCE_ALIASES: Record = { 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. */ diff --git a/src/mcpd/tests/rbac-tasks-backfill.test.ts b/src/mcpd/tests/rbac-tasks-backfill.test.ts new file mode 100644 index 0000000..f00e196 --- /dev/null +++ b/src/mcpd/tests/rbac-tasks-backfill.test.ts @@ -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 { + 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; +} { + 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 }; +} + +/** + * 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 | 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 | null } { + const backendType = opts.backendType ?? 'plaintext'; + const hasDefaultBackend = opts.hasDefaultBackend ?? true; + let stored: Record | 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 } }) => { + stored = data.data; + return secretRow(); + }), + create: vi.fn(async ({ data }: { data: { data: Record } }) => { + 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(); + }); +}); diff --git a/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts b/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts new file mode 100644 index 0000000..cac704f --- /dev/null +++ b/src/mcplocal/tests/smoke/rbac-tasks-split.smoke.test.ts @@ -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 { + 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']); + }); +});