agentic-teams: epics, tasks, git forges, agent authority #139
@@ -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
|
||||
|
||||
90
src/mcpd/src/bootstrap/rbac-tasks-backfill.ts
Normal file
90
src/mcpd/src/bootstrap/rbac-tasks-backfill.ts
Normal 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}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
233
src/mcpd/tests/rbac-tasks-backfill.test.ts
Normal file
233
src/mcpd/tests/rbac-tasks-backfill.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
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