mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Threads per-user ownership through sessions, cases, cron, and the permission policy. All scoping is a no-op in single-user mode (isMultiUserMode() guards). Sessions - Session.owner stamped at every create path from req.authUser / job.owner: POST /api/sessions, /api/run, /api/quick-start, ralph start, cron launch, plan generation. Round-trips through recovery (MuxSession.owner mirror, read muxSession.owner ?? savedState?.owner) and the mux layer. - findSessionOrFail(ctx, id, req) now does a NOT_FOUND owner check (never 403, so other users' session existence is not leaked); wired at ~50 call sites. - List endpoints filtered by owner: GET /api/sessions, /api/sessions/unified (live+persisted+lifecycle scoped, host-wide transcripts admin-only), cron jobs. Permission policy (section 6.3) - resolveClaudeModeForUsername wraps getClaudeModeConfig at every spawn site so a non-granted user is forced to --permission-mode auto (bypass -> auto), including recovery (or a reboot would un-downgrade). buildPromptArgs now respects the session's claudeMode, closing the one-shot (runPrompt) bypass hole. - Shell mode and cron launchCommand require canBypassPermissions: 403 at POST /api/sessions, /api/quick-start create, cron job create, AND cron fire time (re-checked against the owner's current grant). Cases - resolveCasesDir(user): per-user ~/codeman-users/<name>/cases in multi-user, the shared ~/codeman-cases otherwise. All case CRUD + ralph + plan + quick-start resolve through it. resolveCasePath is owner-aware. - GET /api/cases scoped per user (own folders; legacy linked cases admin-only; remote/docker cases owner-filtered). RemoteCase/DockerCase gain owner, stamped at link/quickcreate/import. - Remote + Docker host CRUD is admin-only. - Non-admin workingDir confinement (the linchpin): realpath must resolve inside the user's space, enforced at POST /api/sessions and /api/run BEFORE any disk write. Limits - sessionCapacityState / sessionCapacityMessage centralize the global + per-user cap (CODEMAN_MAX_SESSIONS_PER_USER, default global/2), replacing the 6 copy-pasted MAX_CONCURRENT_SESSIONS checks. Tests: test/ownership-scoping.test.ts (case isolation, host-CRUD gate, workingDir + shell gates, and the scoping helpers). Deferred to phase 4: WS owner gate, SSE fan-out filtering, file-route preview/thumbnail helper scoping, push routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
/**
|
|
* @fileoverview Phase 3 ownership-scoping tests (live server, port 3172).
|
|
*
|
|
* Verifies multi-user isolation at the API level: case lists are disjoint per user,
|
|
* a non-admin cannot read/kill another user's session, workingDir confinement +
|
|
* shell gate + host-CRUD admin gate are enforced, and admins see everything.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
|
import { canAccessOwned, findSessionOrFail, sessionCapacityState } from '../src/web/route-helpers.js';
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
const PORT = 3172;
|
|
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
|
|
|
let server: WebServer;
|
|
let dataDir: string;
|
|
let spacesDir: string;
|
|
const saved: Record<string, string | undefined> = {};
|
|
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
|
|
|
// Route returns are wrapped in the {success,data} envelope; unwrap to the payload.
|
|
async function getJson(p: string, headers: Record<string, string>): Promise<unknown> {
|
|
const body = await (await fetch(url(p), { headers })).json();
|
|
return body && typeof body === 'object' && 'data' in body ? (body as { data: unknown }).data : body;
|
|
}
|
|
const alice = { Authorization: basic('alice', 'alicepass1') };
|
|
const bob = { Authorization: basic('bob', 'bobpass1234') };
|
|
const admin = { Authorization: basic('root', 'rootpass123') };
|
|
|
|
beforeAll(async () => {
|
|
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-data-'));
|
|
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'own-spaces-'));
|
|
for (const k of [
|
|
'CODEMAN_DATA_DIR',
|
|
'CODEMAN_USER_SPACES_DIR',
|
|
'CODEMAN_MULTIUSER',
|
|
'CODEMAN_PASSWORD',
|
|
'CODEMAN_USERNAME',
|
|
]) {
|
|
saved[k] = process.env[k];
|
|
}
|
|
process.env.CODEMAN_DATA_DIR = dataDir;
|
|
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
invalidateUsersCache();
|
|
|
|
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
|
await createUser({ username: 'alice', role: 'user', password: 'alicepass1' });
|
|
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
|
|
|
|
server = new WebServer(PORT, false, true);
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.stop();
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
invalidateUsersCache();
|
|
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
|
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
describe('case scoping', () => {
|
|
it('creates cases in per-user spaces and lists them disjointly', async () => {
|
|
const mk = await fetch(url('/api/cases'), {
|
|
method: 'POST',
|
|
headers: { ...alice, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'aliceproj' }),
|
|
});
|
|
expect(mk.status).toBe(200);
|
|
|
|
// Case folder is under alice's space.
|
|
expect(await exists(path.join(spacesDir, 'alice', 'cases', 'aliceproj'))).toBe(true);
|
|
|
|
const aliceList = (await getJson('/api/cases', alice)) as Array<{ name: string }>;
|
|
expect(aliceList.map((c) => c.name)).toContain('aliceproj');
|
|
|
|
const bobList = (await getJson('/api/cases', bob)) as Array<{ name: string }>;
|
|
expect(bobList.map((c) => c.name)).not.toContain('aliceproj');
|
|
});
|
|
});
|
|
|
|
describe('host CRUD is admin-only', () => {
|
|
it('rejects a non-admin defining a docker host', async () => {
|
|
const res = await fetch(url('/api/docker-hosts'), {
|
|
method: 'POST',
|
|
headers: { ...bob, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: 'h1', label: 'x', image: 'codeman/agent:base' }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('allows an admin to list docker hosts', async () => {
|
|
const res = await fetch(url('/api/docker-hosts'), { headers: admin });
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('session creation gates', () => {
|
|
it('confines a non-admin workingDir to their space', async () => {
|
|
const foreign = path.join(spacesDir, 'alice', 'cases', 'aliceproj');
|
|
const res = await fetch(url('/api/sessions'), {
|
|
method: 'POST',
|
|
headers: { ...bob, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: foreign }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('refuses shell mode for a non-granted user', async () => {
|
|
const mine = path.join(spacesDir, 'bob', 'cases');
|
|
await fs.mkdir(mine, { recursive: true });
|
|
const res = await fetch(url('/api/sessions'), {
|
|
method: 'POST',
|
|
headers: { ...bob, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: mine, mode: 'shell' }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// The session-scoping logic (findSessionOrFail owner check, list filter, per-user
|
|
// cap) is tested directly against the helpers under the same multi-user env, since
|
|
// real session spawning is no-op'd in test mode and does not durably populate the
|
|
// live map. These are the exact functions every session route uses.
|
|
describe('session-scoping helpers (multi-user)', () => {
|
|
const fakeSession = (owner?: string) => ({ owner }) as unknown as import('../src/session.js').Session;
|
|
const ctxWith = (map: Map<string, unknown>) => ({ sessions: map }) as never;
|
|
const reqAs = (username: string, role: 'admin' | 'user') => ({ authUser: { username, role } }) as never;
|
|
|
|
it('canAccessOwned isolates non-admins to their own', () => {
|
|
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'alice')).toBe(true);
|
|
expect(canAccessOwned({ username: 'alice', role: 'user' }, 'bob')).toBe(false);
|
|
expect(canAccessOwned({ username: 'alice', role: 'user' }, undefined)).toBe(false);
|
|
expect(canAccessOwned({ username: 'root', role: 'admin' }, 'bob')).toBe(true);
|
|
});
|
|
|
|
it('findSessionOrFail 404s a foreign session for a non-admin, returns it for owner/admin', () => {
|
|
const map = new Map<string, unknown>([['s1', fakeSession('alice')]]);
|
|
expect(() => findSessionOrFail(ctxWith(map), 's1', reqAs('bob', 'user'))).toThrow();
|
|
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('alice', 'user'))).toBeDefined();
|
|
expect(findSessionOrFail(ctxWith(map), 's1', reqAs('root', 'admin'))).toBeDefined();
|
|
});
|
|
|
|
it('per-user session cap counts only the owner sessions', () => {
|
|
const map = new Map<string, unknown>([
|
|
['a', fakeSession('alice')],
|
|
['b', fakeSession('alice')],
|
|
['c', fakeSession('bob')],
|
|
]);
|
|
process.env.CODEMAN_MAX_SESSIONS_PER_USER = '2';
|
|
expect(sessionCapacityState(map as never, 'alice').atUserCap).toBe(true);
|
|
expect(sessionCapacityState(map as never, 'bob').atUserCap).toBe(false);
|
|
delete process.env.CODEMAN_MAX_SESSIONS_PER_USER;
|
|
});
|
|
});
|
|
|
|
async function exists(p: string): Promise<boolean> {
|
|
try {
|
|
await fs.stat(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|