commit aa7a27b773f0823569cb384dad4527f5d9faafe2
parent e44064ed125147d0268e4d98d5f78966b607dcc7
Author: finwo <finwo@pm.me>
Date: Sat, 19 Sep 2026 02:06:53 +0200
Authentication, admin API and project variables
Diffstat:
17 files changed, 1653 insertions(+), 15 deletions(-)
diff --git a/conductor.example.yaml b/conductor.example.yaml
@@ -54,6 +54,10 @@ auth:
issuer: null
audience: null
admin_role: conductor-admin
+ # With OIDC configured, built-in accounts stop being accepted. Set this to
+ # keep one as a break-glass login, for example while the provider is being
+ # set up. Ignored when OIDC is not in use.
+ allow_local_login: false
# Created on first boot, only while the users table is empty. With no
# password set, one is generated and written to the log once.
bootstrap_admin:
diff --git a/src/admin-cli.js b/src/admin-cli.js
@@ -10,11 +10,19 @@
// node src/admin-cli.js token:add <name>
// node src/admin-cli.js token:list
// node src/admin-cli.js token:remove <id>
+// node src/admin-cli.js var:set <project> <NAME> <value> [--visible]
+// node src/admin-cli.js var:list <project>
+// node src/admin-cli.js var:remove <project> <NAME>
+// node src/admin-cli.js user:add <username> <password> [--role admin|viewer]
+// node src/admin-cli.js user:list
+// node src/admin-cli.js user:passwd <username> <password>
+// node src/admin-cli.js user:remove <username>
// node src/admin-cli.js run:trigger <project> <sha> [--ref r] [--base b]
// node src/admin-cli.js run:cancel <run-id>
//
-// The HTTP admin API arrives with the auth work. Until then this is the
-// supported way to register projects and mint worker tokens.
+// Everything here is also available over HTTP under /api/admin. This runs
+// against the database directly, so it works before the first account
+// exists and when the server is down.
import crypto from 'node:crypto';
import { loadConfig } from './lib/config.js';
@@ -57,6 +65,13 @@ function usage(message) {
' token:add <name>',
' token:list',
' token:remove <id>',
+ ' var:set <project> <NAME> <value> [--visible]',
+ ' var:list <project>',
+ ' var:remove <project> <NAME>',
+ ' user:add <username> <password> [--role admin|viewer]',
+ ' user:list',
+ ' user:passwd <username> <password>',
+ ' user:remove <username>',
' run:trigger <project> <sha> [--ref r] [--base b]',
' run:cancel <run-id>',
].join('\n')
@@ -67,8 +82,11 @@ function usage(message) {
if (!command || command === 'help' || command === '--help') usage();
const cfg = loadConfig();
-const services = await createServices(cfg, { migrationLogger: () => {} });
-const { projects, workerTokens, scheduler, db } = services;
+// No bootstrap account is created here: the CLI already has full access,
+// and creating one as a side effect of running a command would be a
+// surprise.
+const services = await createServices(cfg, { migrationLogger: () => {}, bootstrap: false });
+const { projects, workerTokens, variables, users, scheduler, db } = services;
function table(rows, columns) {
if (rows.length === 0) {
@@ -177,6 +195,83 @@ try {
break;
}
+ case 'var:set': {
+ const [projectId, name, ...valueParts] = positional;
+ const value = valueParts.join(' ');
+ if (!projectId || !name || valueParts.length === 0) usage('var:set needs a project, a name and a value');
+ if (!(await projects.get(projectId))) usage(`unknown project ${projectId}`);
+ if (!cfg.secrets.encryption_key) {
+ console.warn('warning: secrets.encryption_key is not set, so this is stored in the clear');
+ }
+ await variables.set(projectId, name, value, { masked: flags.visible !== true });
+ console.log(`set ${name} for ${projectId}${flags.visible === true ? '' : ' (masked in logs)'}`);
+ break;
+ }
+
+ case 'var:list': {
+ const [projectId] = positional;
+ if (!projectId) usage('var:list needs a project');
+ const rows = (await variables.list(projectId)).map((v) => ({
+ name: v.name,
+ masked: v.masked ? 'yes' : 'no',
+ at_rest: v.plaintext_at_rest ? 'PLAINTEXT' : 'encrypted',
+ created: when(v.created_at),
+ }));
+ table(rows, ['name', 'masked', 'at_rest', 'created']);
+ break;
+ }
+
+ case 'var:remove': {
+ const [projectId, name] = positional;
+ if (!projectId || !name) usage('var:remove needs a project and a name');
+ console.log(await variables.remove(projectId, name) ? `removed ${name}` : `no such variable ${name}`);
+ break;
+ }
+
+ case 'user:add': {
+ const [username, password] = positional;
+ if (!username || !password) usage('user:add needs a username and a password');
+ const user = await users.create({
+ username,
+ password,
+ role: flags.role ? String(flags.role) : 'viewer',
+ });
+ console.log(`created user ${user.username} with role ${user.role}`);
+ break;
+ }
+
+ case 'user:list': {
+ const rows = (await users.list()).map((u) => ({
+ username: u.username,
+ role: u.role,
+ enabled: u.disabled ? 'no' : 'yes',
+ created: when(u.created_at),
+ last_login: when(u.last_login_at),
+ }));
+ table(rows, ['username', 'role', 'enabled', 'created', 'last_login']);
+ break;
+ }
+
+ case 'user:passwd': {
+ const [username, password] = positional;
+ if (!username || !password) usage('user:passwd needs a username and a password');
+ const existing = await users.byUsername(username);
+ if (!existing) usage(`unknown user ${username}`);
+ await users.setPassword(existing.id, password);
+ console.log(`password updated for ${username}`);
+ break;
+ }
+
+ case 'user:remove': {
+ const [username] = positional;
+ if (!username) usage('user:remove needs a username');
+ const existing = await users.byUsername(username);
+ if (!existing) usage(`unknown user ${username}`);
+ await users.remove(existing.id);
+ console.log(`removed user ${username}`);
+ break;
+ }
+
case 'run:trigger': {
const [projectId, sha] = positional;
if (!projectId || !sha) usage('run:trigger needs a project and a commit');
diff --git a/src/conductor/app.js b/src/conductor/app.js
@@ -14,11 +14,16 @@ import { createLogStore } from '../lib/log.js';
import { createGit } from '../lib/git.js';
import { createProjects } from '../lib/projects.js';
import { createWorkerTokens } from '../lib/workers.js';
+import { createUsers } from '../lib/users.js';
+import { createVariables } from '../lib/variables.js';
+import { createAuth } from '../lib/auth/index.js';
import { createScheduler } from './scheduler.js';
import workerRoutes from './routes/workers.js';
import triggerRoutes from './routes/trigger.js';
import runRoutes from './routes/runs.js';
+import authRoutes from './routes/auth.js';
+import adminRoutes from './routes/admin.js';
export async function createServices(cfg, options = {}) {
ensureStateDirs(cfg);
@@ -32,13 +37,26 @@ export async function createServices(cfg, options = {}) {
const storage = createStorage(cfg);
const logs = createLogStore(cfg);
const git = createGit(cfg);
+ const logger = options.logger ?? console;
+
const projects = createProjects({ db, secrets });
const workerTokens = createWorkerTokens({ db });
+ const variables = createVariables({ db, secrets });
+ const users = createUsers({ db, logger });
+ const auth = createAuth({ cfg, users, logger });
- const logger = options.logger ?? console;
- const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, logger });
+ const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, variables, logger });
+
+ // Without a first administrator there is no way into the admin surface.
+ // Only done for local accounts; with OIDC the provider owns identity.
+ if (options.bootstrap !== false && auth.localLogin) {
+ await users.bootstrap(cfg);
+ }
- return { cfg, db, secrets, storage, logs, git, projects, workerTokens, scheduler, logger };
+ return {
+ cfg, db, secrets, storage, logs, git,
+ projects, workerTokens, variables, users, auth, scheduler, logger,
+ };
}
export async function buildServer(services, options = {}) {
@@ -63,6 +81,8 @@ export async function buildServer(services, options = {}) {
await fastify.register(triggerRoutes, { ...services, prefix: '/api/trigger' });
await fastify.register(workerRoutes, { ...services, prefix: '/api/workers' });
+ await fastify.register(authRoutes, { ...services, prefix: '/api/auth' });
+ await fastify.register(adminRoutes, { ...services, prefix: '/api/admin' });
await fastify.register(runRoutes, { ...services, prefix: '/api' });
return fastify;
diff --git a/src/conductor/routes/admin.js b/src/conductor/routes/admin.js
@@ -0,0 +1,275 @@
+// src/conductor/routes/admin.js - administration
+//
+// Every route here requires the admin role. Secrets are write only: a
+// trigger secret or a variable can be set and replaced, but never read
+// back, and a worker token is returned exactly once when it is created.
+
+import crypto from 'node:crypto';
+import { requireAdmin } from '../../lib/auth/index.js';
+import { SOURCE_MODES } from '../../lib/projects.js';
+import { ROLES } from '../../lib/users.js';
+import { PipelineError } from '../../lib/pipeline/index.js';
+
+export default async function adminRoutes(fastify, services) {
+ const { cfg, db, auth, users, projects, workerTokens, variables, scheduler } = services;
+
+ fastify.addHook('preHandler', requireAdmin(auth));
+
+ const triggerUrl = (id) => `${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${id}`;
+
+ // --- projects ---
+
+ fastify.get('/projects', async (req, reply) => {
+ const rows = await projects.list();
+ return reply.send({
+ projects: rows.map((p) => ({
+ id: p.id,
+ name: p.name,
+ repo_url: p.repo_url,
+ default_branch: p.default_branch,
+ config_path: p.config_path,
+ source_mode: p.source_mode,
+ enabled: p.enabled === 1,
+ // Whether a secret is set, never the secret itself.
+ has_trigger_secret: Boolean(p.trigger_secret),
+ run_count: p.run_counter,
+ created_at: p.created_at,
+ })),
+ });
+ });
+
+ fastify.post('/projects', async (req, reply) => {
+ const body = req.body ?? {};
+ if (typeof body.repo_url !== 'string' || body.repo_url.length === 0) {
+ return reply.code(400).send({ error: 'repo_url is required' });
+ }
+ if (body.source_mode && !SOURCE_MODES.includes(body.source_mode)) {
+ return reply.code(400).send({ error: `source_mode must be one of ${SOURCE_MODES.join(', ')}` });
+ }
+
+ // Generated when not supplied, because an unauthenticated trigger
+ // endpoint is rarely what anyone actually wants.
+ const secret = typeof body.trigger_secret === 'string' && body.trigger_secret.length > 0
+ ? body.trigger_secret
+ : crypto.randomBytes(24).toString('hex');
+
+ try {
+ const project = await projects.create({ ...body, trigger_secret: secret });
+ return reply.code(201).send({
+ project: { id: project.id, name: project.name, repo_url: project.repo_url },
+ trigger_url: triggerUrl(project.id),
+ // Shown once.
+ trigger_secret: secret,
+ });
+ } catch (e) {
+ return reply.code(400).send({ error: e.message });
+ }
+ });
+
+ fastify.patch('/projects/:id', async (req, reply) => {
+ const project = await projects.get(req.params.id);
+ if (!project) return reply.code(404).send({ error: 'unknown project' });
+
+ const body = req.body ?? {};
+ if (typeof body.enabled === 'boolean') await projects.setEnabled(project.id, body.enabled);
+ return reply.send({ project: { ...(await projects.get(project.id)), trigger_secret: undefined } });
+ });
+
+ fastify.post('/projects/:id/trigger-secret', async (req, reply) => {
+ const project = await projects.get(req.params.id);
+ if (!project) return reply.code(404).send({ error: 'unknown project' });
+
+ const secret = typeof req.body?.secret === 'string' && req.body.secret.length > 0
+ ? req.body.secret
+ : crypto.randomBytes(24).toString('hex');
+
+ await projects.setTriggerSecret(project.id, secret);
+ return reply.send({ trigger_url: triggerUrl(project.id), trigger_secret: secret });
+ });
+
+ fastify.delete('/projects/:id', async (req, reply) => {
+ const project = await projects.get(req.params.id);
+ if (!project) return reply.code(404).send({ error: 'unknown project' });
+ // Runs, jobs and artifacts cascade with the project.
+ await projects.remove(project.id);
+ return reply.send({ deleted: project.id });
+ });
+
+ // --- project variables ---
+
+ fastify.get('/projects/:id/variables', async (req, reply) => {
+ if (!(await projects.get(req.params.id))) return reply.code(404).send({ error: 'unknown project' });
+ return reply.send({ variables: await variables.list(req.params.id) });
+ });
+
+ fastify.put('/projects/:id/variables/:name', async (req, reply) => {
+ if (!(await projects.get(req.params.id))) return reply.code(404).send({ error: 'unknown project' });
+ if (!cfg.secrets.encryption_key) {
+ req.log.warn('storing a project variable without secrets.encryption_key; it is kept in the clear');
+ }
+
+ const value = req.body?.value;
+ if (typeof value !== 'string') return reply.code(400).send({ error: 'value must be a string' });
+
+ try {
+ const result = await variables.set(req.params.id, req.params.name, value, {
+ masked: req.body?.masked !== false,
+ });
+ return reply.send({ variable: result });
+ } catch (e) {
+ return reply.code(400).send({ error: e.message });
+ }
+ });
+
+ fastify.delete('/projects/:id/variables/:name', async (req, reply) => {
+ const removed = await variables.remove(req.params.id, req.params.name);
+ if (!removed) return reply.code(404).send({ error: 'unknown variable' });
+ return reply.send({ deleted: req.params.name });
+ });
+
+ // --- worker tokens ---
+
+ fastify.get('/worker-tokens', async (req, reply) => {
+ const rows = await workerTokens.list();
+ return reply.send({
+ worker_tokens: rows.map((t) => ({
+ id: t.id,
+ name: t.name,
+ enabled: t.enabled === 1,
+ created_at: t.created_at,
+ last_seen_at: t.last_seen_at,
+ last_ip: t.last_ip,
+ })),
+ });
+ });
+
+ fastify.post('/worker-tokens', async (req, reply) => {
+ const name = req.body?.name;
+ if (typeof name !== 'string' || name.length === 0) {
+ return reply.code(400).send({ error: 'name is required' });
+ }
+ const created = await workerTokens.create(name);
+ return reply.code(201).send({
+ worker_token: { id: created.id, name: created.name },
+ // Only time the plaintext exists outside the worker.
+ token: created.token,
+ note: 'store this now; it cannot be shown again',
+ });
+ });
+
+ fastify.patch('/worker-tokens/:id', async (req, reply) => {
+ if (typeof req.body?.enabled !== 'boolean') {
+ return reply.code(400).send({ error: 'enabled must be a boolean' });
+ }
+ const ok = await workerTokens.setEnabled(req.params.id, req.body.enabled);
+ if (!ok) return reply.code(404).send({ error: 'unknown worker token' });
+ return reply.send({ id: req.params.id, enabled: req.body.enabled });
+ });
+
+ fastify.delete('/worker-tokens/:id', async (req, reply) => {
+ const ok = await workerTokens.remove(req.params.id);
+ if (!ok) return reply.code(404).send({ error: 'unknown worker token' });
+ return reply.send({ deleted: req.params.id });
+ });
+
+ // --- users ---
+
+ fastify.get('/users', async (req, reply) => {
+ const rows = await users.list();
+ return reply.send({
+ users: rows.map((u) => ({ ...u, disabled: u.disabled === 1 })),
+ // Local accounts are inert when the provider issues the tokens.
+ active: auth.localLogin,
+ });
+ });
+
+ fastify.post('/users', async (req, reply) => {
+ try {
+ const user = await users.create(req.body ?? {});
+ return reply.code(201).send({ user });
+ } catch (e) {
+ return reply.code(400).send({ error: e.message });
+ }
+ });
+
+ fastify.patch('/users/:id', async (req, reply) => {
+ const user = await users.get(req.params.id);
+ if (!user) return reply.code(404).send({ error: 'unknown user' });
+ const body = req.body ?? {};
+
+ try {
+ if (typeof body.password === 'string') await users.setPassword(user.id, body.password);
+ if (typeof body.role === 'string') {
+ if (!ROLES.includes(body.role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
+ // Refuse to remove the last administrator, which would lock
+ // everyone out of this surface.
+ if (user.role === 'admin' && body.role !== 'admin' && await lastAdmin(user.id)) {
+ throw new Error('this is the only administrator; promote another account first');
+ }
+ await users.setRole(user.id, body.role);
+ }
+ if (typeof body.disabled === 'boolean') {
+ if (body.disabled && user.role === 'admin' && await lastAdmin(user.id)) {
+ throw new Error('this is the only administrator; promote another account first');
+ }
+ await users.setDisabled(user.id, body.disabled);
+ }
+ } catch (e) {
+ return reply.code(400).send({ error: e.message });
+ }
+
+ return reply.send({ user: await users.get(user.id) });
+ });
+
+ fastify.delete('/users/:id', async (req, reply) => {
+ const user = await users.get(req.params.id);
+ if (!user) return reply.code(404).send({ error: 'unknown user' });
+ if (user.role === 'admin' && await lastAdmin(user.id)) {
+ return reply.code(400).send({ error: 'this is the only administrator; promote another account first' });
+ }
+ await users.remove(user.id);
+ return reply.send({ deleted: user.id });
+ });
+
+ // --- runs ---
+
+ fastify.post('/runs/:id/cancel', async (req, reply) => {
+ const result = await scheduler.cancelRun(req.params.id, `cancelled by ${req.user.username}`);
+ if (!result.ok) return reply.code(409).send({ error: result.reason });
+ return reply.send({ cancelled: req.params.id });
+ });
+
+ // Re-runs the same commit as a new run, rather than mutating history.
+ fastify.post('/runs/:id/retry', async (req, reply) => {
+ const run = await db.get('SELECT id, project_id, ref, base_sha, head_sha FROM runs WHERE id = {id}',
+ { id: req.params.id });
+ if (!run) return reply.code(404).send({ error: 'unknown run' });
+
+ const project = await projects.get(run.project_id);
+ if (!project) return reply.code(404).send({ error: 'unknown project' });
+
+ try {
+ const created = await scheduler.createRun(project, {
+ ref: run.ref,
+ baseSha: run.base_sha,
+ headSha: run.head_sha,
+ trigger: 'manual',
+ actor: req.user.username,
+ });
+ return reply.code(201).send({ run_id: created.runId, jobs: created.jobCount });
+ } catch (e) {
+ if (e instanceof PipelineError) {
+ return reply.code(422).send({ error: 'invalid pipeline', detail: e.message, problems: e.errors });
+ }
+ return reply.code(500).send({ error: String(e.message ?? e) });
+ }
+ });
+
+ async function lastAdmin(exceptId) {
+ const row = await db.get(
+ "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND disabled = 0 AND id <> {id}",
+ { id: exceptId }
+ );
+ return row.c === 0;
+ }
+}
diff --git a/src/conductor/routes/auth.js b/src/conductor/routes/auth.js
@@ -0,0 +1,62 @@
+// src/conductor/routes/auth.js - logging in and out
+//
+// Only meaningful for built-in accounts. When OIDC is in use the provider
+// issues the token and these endpoints report that, rather than pretending
+// to accept a password.
+
+import { issueToken, sessionCookie, clearedCookie } from '../../lib/auth/index.js';
+
+export default async function authRoutes(fastify, { cfg, auth, users }) {
+ const secure = cfg.server.public_url.startsWith('https://');
+
+ fastify.get('/mode', async (req, reply) => reply.send({
+ mode: auth.mode,
+ local_login: auth.localLogin,
+ issuer: cfg.auth.oidc.issuer ?? null,
+ }));
+
+ fastify.post('/login', async (req, reply) => {
+ if (!auth.localLogin) {
+ return reply.code(400).send({
+ error: 'this conductor authenticates through OIDC; obtain a token from the provider',
+ issuer: cfg.auth.oidc.issuer,
+ });
+ }
+
+ const { username, password } = req.body ?? {};
+ if (typeof username !== 'string' || typeof password !== 'string') {
+ return reply.code(400).send({ error: 'username and password are required' });
+ }
+
+ const user = await users.authenticate(username, password);
+ // Deliberately the same answer for an unknown user and a wrong password.
+ if (!user) return reply.code(401).send({ error: 'invalid username or password' });
+
+ const ttl = cfg.auth.session_ttl;
+ const token = issueToken(cfg.auth.session_secret, {
+ sub: user.id,
+ name: user.username,
+ role: user.role,
+ }, { ttl });
+
+ reply.header('set-cookie', sessionCookie(token, { ttl, secure }));
+ return reply.send({
+ token,
+ expires_in: ttl,
+ user: { id: user.id, username: user.username, role: user.role },
+ });
+ });
+
+ fastify.post('/logout', async (req, reply) => {
+ // Tokens are stateless, so this clears the cookie and nothing more. A
+ // token already in hand stays valid until it expires.
+ reply.header('set-cookie', clearedCookie());
+ return reply.send({ ok: true });
+ });
+
+ fastify.get('/me', async (req, reply) => {
+ const user = await auth.identify(req);
+ if (!user) return reply.code(401).send({ error: 'not authenticated' });
+ return reply.send({ user });
+ });
+}
diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js
@@ -8,13 +8,14 @@ import { pipeline as streamPipeline } from 'node:stream/promises';
import { LogOffsetError } from '../../lib/log.js';
import { hashingTransform } from '../../lib/stream.js';
import { sanitizeRelativePath, keys as storageKeys } from '../../lib/storage/index.js';
+import { maskBuffer } from '../../lib/variables.js';
import { newArtifactId } from '../../lib/ids.js';
// Log chunks are small and frequent; artifacts are streamed, so this only
// bounds a single log append.
const LOG_CHUNK_LIMIT = 1024 * 1024;
-export default async function workerRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens }) {
+export default async function workerRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens, variables }) {
// Raw bodies: log chunks and artifacts arrive as octet streams and must
// not be parsed.
fastify.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, payload));
@@ -33,7 +34,8 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag
// Confirms the job exists and is held by the calling worker.
async function heldJob(req, reply) {
const job = await db.get(
- `SELECT j.id, j.run_id, j.name, j.state, j.worker_token_id, j.log_size, r.project_id, r.head_sha
+ `SELECT j.id, j.run_id, j.name, j.state, j.worker_token_id, j.log_size,
+ r.project_id, r.head_sha
FROM jobs j JOIN runs r ON r.id = j.run_id
WHERE j.id = {id}`,
{ id: req.params.id }
@@ -144,8 +146,20 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag
return reply.code(400).send({ error: 'x-log-offset must be an integer' });
}
+ // The worker masks before sending; this repeats it on ingest so a
+ // worker that does not still cannot write a secret to disk. It works
+ // within a chunk only, which is why the worker holds back a boundary.
+ let payload = Buffer.concat(chunks);
+ if (variables) {
+ try {
+ payload = maskBuffer(payload, await variables.maskedValues(job.project_id));
+ } catch (e) {
+ req.log.warn({ err: e }, `could not mask log output for ${job.id}`);
+ }
+ }
+
try {
- const result = await logs.append(job.run_id, job.id, Buffer.concat(chunks), offset);
+ const result = await logs.append(job.run_id, job.id, payload, offset);
await db.run('UPDATE jobs SET log_size = {size}, heartbeat_at = {now} WHERE id = {id}',
{ id: job.id, size: result.size, now: Date.now() });
return reply.send(result);
diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js
@@ -27,7 +27,7 @@ export const JOB_STATES = ['queued', 'running', 'success', 'failed', 'skipped',
// States that let a dependent proceed.
const TERMINAL = ['success', 'failed', 'skipped', 'cancelled'];
-export function createScheduler({ cfg, db, git, logs, storage, projects, logger = console }) {
+export function createScheduler({ cfg, db, git, logs, storage, projects, variables = null, logger = console }) {
// A job is eligible when it is queued, its run is live, and no dependency
// is outstanding. A failed dependency marked allow_failure still counts as
// satisfied, which is the whole point of the flag.
@@ -235,6 +235,19 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, logger
const spec = JSON.parse(candidate.spec);
const attempt = candidate.attempt + 1;
+
+ // Project variables are resolved at dispatch and handed to the
+ // worker, never written into the jobs table, so the only place a
+ // secret rests is project_variables.
+ let injected = { env: {}, masked: [] };
+ if (variables) {
+ try {
+ injected = await variables.resolve(candidate.project_id);
+ } catch (e) {
+ logger.warn?.(`could not resolve variables for ${candidate.project_id}: ${e.message}`);
+ }
+ }
+
return {
id: candidate.id,
run_id: candidate.run_id,
@@ -249,10 +262,13 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, logger
attempt,
max_attempts: candidate.max_attempts,
script: spec.script,
- // Pipeline env, plus the facts about this run that a script
- // should not have to be told twice. These are set last so a
- // pipeline cannot quietly redefine them.
+ // Values the worker must redact from the log stream.
+ masked: injected.masked,
+ // Project variables, then pipeline env, then the facts about this
+ // run. Ordered so a pipeline cannot quietly redefine the last
+ // group, and a variable cannot shadow an explicit job setting.
env: {
+ ...injected.env,
...spec.env,
CONDUCTOR_PROJECT: candidate.project_id,
CONDUCTOR_RUN_ID: candidate.run_id,
diff --git a/src/lib/auth/index.js b/src/lib/auth/index.js
@@ -0,0 +1,90 @@
+// src/lib/auth/index.js - request authentication
+//
+// OIDC is used when auth.oidc.issuer is configured, built-in accounts
+// otherwise. Local logins remain available alongside OIDC only when
+// auth.allow_local_login is explicitly enabled, so that turning on OIDC does
+// not silently leave a second way in.
+//
+// A credential arrives either as a bearer token or as the session cookie
+// set at login, so the dashboard and scripts can use the same endpoints.
+
+import { verifyToken, parseCookies, SESSION_COOKIE } from './token.js';
+import { createOidc } from './oidc.js';
+
+export { issueToken, verifyToken, sessionCookie, clearedCookie, SESSION_COOKIE } from './token.js';
+export { hashPassword, verifyPassword, generatePassword, validatePassword } from './password.js';
+
+export function createAuth({ cfg, users, logger = console }) {
+ const oidc = cfg.auth.mode === 'oidc' ? createOidc(cfg) : null;
+ const localLogin = cfg.auth.mode !== 'oidc' || cfg.auth.allow_local_login === true;
+
+ function credentialFrom(req) {
+ const header = req.headers.authorization;
+ if (typeof header === 'string' && header.startsWith('Bearer ')) {
+ return header.slice(7).trim();
+ }
+ const cookies = parseCookies(req.headers.cookie);
+ return cookies[SESSION_COOKIE] ?? null;
+ }
+
+ return {
+ mode: cfg.auth.mode,
+ localLogin,
+
+ // Resolves a request to a user, or null. Never throws: an unparseable
+ // credential is simply not authenticated.
+ async identify(req) {
+ const credential = credentialFrom(req);
+ if (!credential) return null;
+
+ if (oidc) {
+ try {
+ return await oidc.verify(credential);
+ } catch (e) {
+ logger.debug?.(`oidc verification failed: ${e.message}`);
+ // Fall through only when local logins are also permitted.
+ if (!localLogin) return null;
+ }
+ }
+
+ if (!localLogin) return null;
+
+ const claims = verifyToken(cfg.auth.session_secret, credential);
+ if (!claims) return null;
+
+ // Re-read the account, so disabling a user takes effect immediately
+ // rather than when their token happens to expire.
+ const user = await users.get(claims.sub);
+ if (!user || user.disabled === 1) return null;
+
+ return { id: user.id, username: user.username, role: user.role, source: 'local' };
+ },
+ };
+}
+
+// Fastify preHandler factories. Kept here so every route guards the same way.
+export function requireUser(auth) {
+ return async function guard(req, reply) {
+ const user = await auth.identify(req);
+ if (!user) {
+ reply.code(401).send({ error: 'authentication required' });
+ return;
+ }
+ req.user = user;
+ };
+}
+
+export function requireAdmin(auth) {
+ return async function guard(req, reply) {
+ const user = await auth.identify(req);
+ if (!user) {
+ reply.code(401).send({ error: 'authentication required' });
+ return;
+ }
+ if (user.role !== 'admin') {
+ reply.code(403).send({ error: 'administrator role required' });
+ return;
+ }
+ req.user = user;
+ };
+}
diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js
@@ -0,0 +1,75 @@
+// src/lib/auth/oidc.js - OIDC bearer verification
+//
+// Engaged when auth.oidc.issuer is configured. Tokens are issued by the
+// provider, so jose does the work here: fetching and caching the JWKS,
+// checking the signature, issuer, audience and expiry.
+//
+// Role mapping deliberately looks in several places. Keycloak puts realm
+// roles under realm_access.roles, other providers use a flat roles claim or
+// groups, and there is no standard.
+
+const ADMIN = 'admin';
+const VIEWER = 'viewer';
+
+export function collectRoles(claims) {
+ const roles = new Set();
+ const add = (value) => {
+ if (typeof value === 'string') roles.add(value);
+ else if (Array.isArray(value)) for (const item of value) if (typeof item === 'string') roles.add(item);
+ };
+
+ add(claims.roles);
+ add(claims.groups);
+ add(claims.realm_access?.roles);
+ for (const resource of Object.values(claims.resource_access ?? {})) add(resource?.roles);
+
+ return [...roles];
+}
+
+export function createOidc(cfg) {
+ const { issuer, audience, admin_role: adminRole } = cfg.auth.oidc;
+ let jwks = null;
+ let jwtVerify = null;
+
+ async function load() {
+ if (jwks) return;
+ let jose;
+ try {
+ jose = await import('jose');
+ } catch (e) {
+ throw new Error('auth.oidc.issuer is set but the jose package is not installed', { cause: e });
+ }
+ jwtVerify = jose.jwtVerify;
+ // jose caches the key set and refetches on rotation.
+ jwks = jose.createRemoteJWKSet(new URL(`${issuer.replace(/\/+$/, '')}/protocol/openid-connect/certs`));
+ }
+
+ return {
+ mode: 'oidc',
+ issuer,
+
+ // Allows a deployment to point at a provider whose JWKS is not at the
+ // Keycloak path.
+ setJwksUri(uri) {
+ jwks = null;
+ this.jwksUri = uri;
+ },
+
+ async verify(token) {
+ await load();
+ const options = { issuer };
+ if (audience) options.audience = audience;
+
+ const { payload } = await jwtVerify(token, jwks, options);
+ const roles = collectRoles(payload);
+
+ return {
+ id: payload.sub,
+ username: payload.preferred_username ?? payload.email ?? payload.sub,
+ role: roles.includes(adminRole) ? ADMIN : VIEWER,
+ source: 'oidc',
+ roles,
+ };
+ },
+ };
+}
diff --git a/src/lib/auth/password.js b/src/lib/auth/password.js
@@ -0,0 +1,73 @@
+// src/lib/auth/password.js - password hashing
+//
+// scrypt from node:crypto, so there is no native module to build and no
+// dependency to audit. Parameters are stored alongside the hash, which lets
+// them be raised later without invalidating existing passwords.
+//
+// Format: scrypt$N$r$p$salt$hash, salt and hash base64.
+
+import crypto from 'node:crypto';
+import { promisify } from 'node:util';
+
+const scrypt = promisify(crypto.scrypt);
+
+// 16384 * 8 * 128 bytes is 16 MB of memory per hash, which is the usual
+// interactive-login setting and comfortably inside node's default maxmem.
+export const DEFAULT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 64 };
+
+const MIN_LENGTH = 8;
+const MAX_LENGTH = 1024;
+
+export function validatePassword(password) {
+ if (typeof password !== 'string') return 'password must be a string';
+ if (password.length < MIN_LENGTH) return `password must be at least ${MIN_LENGTH} characters`;
+ if (password.length > MAX_LENGTH) return `password must be at most ${MAX_LENGTH} characters`;
+ return null;
+}
+
+export async function hashPassword(password, params = DEFAULT_PARAMS) {
+ const problem = validatePassword(password);
+ if (problem) throw new Error(problem);
+
+ const { N, r, p, keylen } = params;
+ const salt = crypto.randomBytes(16);
+ const derived = await scrypt(password, salt, keylen, { N, r, p, maxmem: 256 * N * r });
+ return ['scrypt', N, r, p, salt.toString('base64'), derived.toString('base64')].join('$');
+}
+
+export async function verifyPassword(password, stored) {
+ if (typeof password !== 'string' || typeof stored !== 'string') return false;
+
+ const parts = stored.split('$');
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
+
+ const N = parseInt(parts[1], 10);
+ const r = parseInt(parts[2], 10);
+ const p = parseInt(parts[3], 10);
+ if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
+
+ let salt;
+ let expected;
+ try {
+ salt = Buffer.from(parts[4], 'base64');
+ expected = Buffer.from(parts[5], 'base64');
+ } catch {
+ return false;
+ }
+ if (salt.length === 0 || expected.length === 0) return false;
+
+ let derived;
+ try {
+ derived = await scrypt(password, salt, expected.length, { N, r, p, maxmem: 256 * N * r });
+ } catch {
+ return false;
+ }
+
+ return crypto.timingSafeEqual(derived, expected);
+}
+
+// Used when generating a bootstrap password, which is shown once and then
+// only ever stored as a hash.
+export function generatePassword(bytes = 18) {
+ return crypto.randomBytes(bytes).toString('base64url');
+}
diff --git a/src/lib/auth/token.js b/src/lib/auth/token.js
@@ -0,0 +1,95 @@
+// src/lib/auth/token.js - session tokens
+//
+// Compact HS256 tokens in the JWT shape, so they can be inspected with any
+// ordinary tool, signed with node:crypto rather than a library. The jose
+// dependency exists for OIDC, where the token is issued elsewhere and the
+// verification rules genuinely are complicated. Signing our own does not
+// need it.
+//
+// A token carries only the subject, name and role. Anything more would go
+// stale, since it is not re-read from the database on every request.
+
+import crypto from 'node:crypto';
+
+const HEADER = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
+
+function sign(secret, data) {
+ return crypto.createHmac('sha256', secret).update(data).digest('base64url');
+}
+
+export function issueToken(secret, { sub, name, role }, { ttl = 43200, now = Date.now() } = {}) {
+ const issued = Math.floor(now / 1000);
+ const payload = Buffer.from(JSON.stringify({
+ sub,
+ name,
+ role,
+ iat: issued,
+ exp: issued + ttl,
+ })).toString('base64url');
+
+ const body = `${HEADER}.${payload}`;
+ return `${body}.${sign(secret, body)}`;
+}
+
+// Returns the claims, or null. Never throws, so a malformed token from a
+// browser is an ordinary 401 rather than a 500.
+export function verifyToken(secret, token, { now = Date.now() } = {}) {
+ if (typeof token !== 'string') return null;
+
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ const [header, payload, signature] = parts;
+
+ const expected = sign(secret, `${header}.${payload}`);
+ const given = Buffer.from(signature);
+ const want = Buffer.from(expected);
+ if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) return null;
+
+ let claims;
+ try {
+ claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
+ } catch {
+ return null;
+ }
+
+ if (typeof claims !== 'object' || claims === null) return null;
+ if (typeof claims.exp !== 'number' || claims.exp * 1000 <= now) return null;
+
+ return claims;
+}
+
+export const SESSION_COOKIE = 'conductor_session';
+
+// Minimal cookie parsing, to avoid a dependency for one header.
+export function parseCookies(header) {
+ const out = {};
+ if (typeof header !== 'string') return out;
+ for (const part of header.split(';')) {
+ const index = part.indexOf('=');
+ if (index === -1) continue;
+ const key = part.slice(0, index).trim();
+ if (!key) continue;
+ try {
+ out[key] = decodeURIComponent(part.slice(index + 1).trim());
+ } catch {
+ out[key] = part.slice(index + 1).trim();
+ }
+ }
+ return out;
+}
+
+export function sessionCookie(token, { ttl = 43200, secure = false } = {}) {
+ const attributes = [
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
+ 'Path=/',
+ 'HttpOnly',
+ 'SameSite=Lax',
+ `Max-Age=${ttl}`,
+ ];
+ if (secure) attributes.push('Secure');
+ return attributes.join('; ');
+}
+
+export function clearedCookie() {
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
+}
diff --git a/src/lib/config.js b/src/lib/config.js
@@ -53,6 +53,10 @@ const DEFAULTS = {
// invalidates existing sessions on restart.
session_secret: null,
session_ttl: 43200,
+ // When OIDC is configured, built-in accounts stop being accepted unless
+ // this is set. Turning on OIDC should not quietly leave a second way in,
+ // but a deployment may want one deliberately as a break-glass account.
+ allow_local_login: false,
oidc: {
issuer: null,
audience: null,
diff --git a/src/lib/users.js b/src/lib/users.js
@@ -0,0 +1,121 @@
+// src/lib/users.js - built-in user accounts
+//
+// Used when no OIDC issuer is configured. Passwords are stored as scrypt
+// hashes and never recoverable; a forgotten password is reset, not read.
+
+import { newUserId } from './ids.js';
+import { hashPassword, verifyPassword, generatePassword, validatePassword } from './auth/password.js';
+
+export const ROLES = ['admin', 'viewer'];
+
+const PUBLIC_COLUMNS = 'id, username, role, disabled, created_at, last_login_at';
+
+export function createUsers({ db, logger = console }) {
+ return {
+ async get(id) {
+ return db.get(`SELECT ${PUBLIC_COLUMNS} FROM users WHERE id = {id}`, { id });
+ },
+
+ async byUsername(username) {
+ return db.get(
+ `SELECT id, username, password_hash, role, disabled FROM users WHERE username = {username}`,
+ { username }
+ );
+ },
+
+ async list() {
+ return db.all(`SELECT ${PUBLIC_COLUMNS} FROM users ORDER BY username`);
+ },
+
+ async count() {
+ return (await db.get('SELECT COUNT(*) AS c FROM users')).c;
+ },
+
+ async create({ username, password, role = 'viewer' }) {
+ if (typeof username !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.@-]{1,63}$/.test(username)) {
+ throw new Error('username must be 2 to 64 characters of letters, digits, underscore, dot, at or hyphen');
+ }
+ if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
+
+ const problem = validatePassword(password);
+ if (problem) throw new Error(problem);
+
+ if (await this.byUsername(username)) throw new Error(`user ${username} already exists`);
+
+ const id = newUserId();
+ await db.run(
+ `INSERT INTO users (id, username, password_hash, role, disabled, created_at)
+ VALUES ({id}, {username}, {hash}, {role}, 0, {now})`,
+ { id, username, hash: await hashPassword(password), role, now: Date.now() }
+ );
+ return this.get(id);
+ },
+
+ // Returns the user on success, or null. Deliberately gives the caller
+ // no way to tell an unknown user from a wrong password.
+ async authenticate(username, password) {
+ const row = await this.byUsername(username);
+ if (!row || row.disabled === 1) {
+ // Spend the time anyway, so a missing user is not measurably faster.
+ await verifyPassword(password, 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAA');
+ return null;
+ }
+ if (!(await verifyPassword(password, row.password_hash))) return null;
+
+ await db.run('UPDATE users SET last_login_at = {now} WHERE id = {id}', { id: row.id, now: Date.now() });
+ return { id: row.id, username: row.username, role: row.role, source: 'local' };
+ },
+
+ async setPassword(id, password) {
+ const problem = validatePassword(password);
+ if (problem) throw new Error(problem);
+ const res = await db.run(
+ 'UPDATE users SET password_hash = {hash} WHERE id = {id}',
+ { id, hash: await hashPassword(password) }
+ );
+ return res.changes > 0;
+ },
+
+ async setRole(id, role) {
+ if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
+ const res = await db.run('UPDATE users SET role = {role} WHERE id = {id}', { id, role });
+ return res.changes > 0;
+ },
+
+ async setDisabled(id, disabled) {
+ const res = await db.run(
+ 'UPDATE users SET disabled = {disabled} WHERE id = {id}',
+ { id, disabled: disabled ? 1 : 0 }
+ );
+ return res.changes > 0;
+ },
+
+ async remove(id) {
+ const res = await db.run('DELETE FROM users WHERE id = {id}', { id });
+ return res.changes > 0;
+ },
+
+ // Creates the first administrator, once, while the table is empty. With
+ // no password configured one is generated and printed, because an
+ // install that silently has no way in is worse than a noisy log line.
+ async bootstrap(cfg) {
+ if (await this.count() > 0) return null;
+
+ const username = cfg.auth.bootstrap_admin.username || 'admin';
+ const configured = cfg.auth.bootstrap_admin.password;
+ const password = configured || generatePassword();
+
+ const user = await this.create({ username, password, role: 'admin' });
+
+ if (configured) {
+ logger.info?.(`created the initial administrator ${username} from configuration`);
+ } else {
+ logger.warn?.(
+ `created the initial administrator ${username} with a generated password: ${password}\n` +
+ 'This is shown once. Change it, or set auth.bootstrap_admin.password.'
+ );
+ }
+ return { ...user, password: configured ? null : password };
+ },
+ };
+}
diff --git a/src/lib/variables.js b/src/lib/variables.js
@@ -0,0 +1,144 @@
+// src/lib/variables.js - per project variables
+//
+// Values are sealed with the secret box, bound to their project and name so
+// a row copied elsewhere will not open. They are injected into a job's
+// environment when it is claimed, and never written into the jobs table, so
+// the only place a secret rests is this one.
+//
+// Masked variables are also redacted from job logs. The worker does that
+// before anything leaves the host; the conductor repeats it on ingest as a
+// backstop against a worker that does not.
+
+const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+export function createVariables({ db, secrets, ttl = 30000 }) {
+ // Decrypting on every log append would be wasteful, so the masked values
+ // for a project are cached briefly.
+ const maskCache = new Map();
+
+ function aad(projectId, name) {
+ return `project:${projectId}/variable:${name}`;
+ }
+
+ function open(row) {
+ return secrets.open(row.value, aad(row.project_id, row.name));
+ }
+
+ return {
+ async list(projectId) {
+ const rows = await db.all(
+ `SELECT project_id, name, value, masked, created_at
+ FROM project_variables WHERE project_id = {project} ORDER BY name`,
+ { project: projectId }
+ );
+ // Values are never listed, only whether they are masked.
+ return rows.map((row) => ({
+ name: row.name,
+ masked: row.masked === 1,
+ created_at: row.created_at,
+ plaintext_at_rest: secrets.isPlaintext(row.value),
+ }));
+ },
+
+ async set(projectId, name, value, { masked = true } = {}) {
+ if (!NAME_PATTERN.test(String(name))) {
+ throw new Error(`variable name ${JSON.stringify(name)} is not a valid environment variable name`);
+ }
+ if (typeof value !== 'string') throw new Error('variable value must be a string');
+ if (value.length > 8192) throw new Error('variable value must be at most 8192 characters');
+
+ const sealed = secrets.seal(value, aad(projectId, name));
+
+ // No portable upsert, so the read and the write happen together.
+ await db.transaction(async (tx) => {
+ const existing = await tx.get(
+ 'SELECT name FROM project_variables WHERE project_id = {project} AND name = {name}',
+ { project: projectId, name }
+ );
+ if (existing) {
+ await tx.run(
+ `UPDATE project_variables SET value = {value}, masked = {masked}
+ WHERE project_id = {project} AND name = {name}`,
+ { project: projectId, name, value: sealed, masked: masked ? 1 : 0 }
+ );
+ } else {
+ await tx.run(
+ `INSERT INTO project_variables (project_id, name, value, masked, created_at)
+ VALUES ({project}, {name}, {value}, {masked}, {now})`,
+ { project: projectId, name, value: sealed, masked: masked ? 1 : 0, now: Date.now() }
+ );
+ }
+ });
+
+ maskCache.delete(projectId);
+ return { name, masked };
+ },
+
+ async remove(projectId, name) {
+ const res = await db.run(
+ 'DELETE FROM project_variables WHERE project_id = {project} AND name = {name}',
+ { project: projectId, name }
+ );
+ maskCache.delete(projectId);
+ return res.changes > 0;
+ },
+
+ // Decrypted, for injection into a job environment.
+ async resolve(projectId) {
+ const rows = await db.all(
+ 'SELECT project_id, name, value, masked FROM project_variables WHERE project_id = {project}',
+ { project: projectId }
+ );
+
+ const env = {};
+ const masked = [];
+ for (const row of rows) {
+ let value;
+ try {
+ value = open(row);
+ } catch {
+ // A value that cannot be opened is skipped rather than failing
+ // the job, since the rest of the pipeline may not need it.
+ continue;
+ }
+ env[row.name] = value;
+ if (row.masked === 1) masked.push(value);
+ }
+ return { env, masked };
+ },
+
+ // Just the values that must not appear in a log.
+ async maskedValues(projectId) {
+ const cached = maskCache.get(projectId);
+ if (cached && cached.expires > Date.now()) return cached.values;
+
+ const { masked } = await this.resolve(projectId);
+ maskCache.set(projectId, { values: masked, expires: Date.now() + ttl });
+ return masked;
+ },
+
+ invalidate(projectId) {
+ if (projectId) maskCache.delete(projectId);
+ else maskCache.clear();
+ },
+ };
+}
+
+// Replaces known secrets in a buffer. Used by the conductor on ingest as a
+// backstop; it works within a chunk only, since the worker is responsible
+// for handling values split across chunk boundaries.
+export function maskBuffer(buffer, values) {
+ if (!values || values.length === 0) return buffer;
+
+ let text = buffer.toString('utf8');
+ let changed = false;
+ // Longest first, so an overlapping shorter value cannot partially reveal
+ // a longer one.
+ for (const value of [...values].sort((a, b) => b.length - a.length)) {
+ if (value.length < 4 || !text.includes(value)) continue;
+ text = text.split(value).join('[masked]');
+ changed = true;
+ }
+
+ return changed ? Buffer.from(text, 'utf8') : buffer;
+}
diff --git a/test/admin.test.js b/test/admin.test.js
@@ -0,0 +1,393 @@
+// test/admin.test.js - the authenticated administration surface
+//
+// The recurring concern here is that secrets are write only: a trigger
+// secret, a worker token and a variable can each be set, but only ever read
+// back once at the moment they are created.
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { startHarness } from './helpers/harness.js';
+
+async function withAdmin(options, fn) {
+ const h = await startHarness({ ...options, bootstrap: true });
+ try {
+ return await fn(h, await h.login());
+ } finally {
+ await h.stop();
+ }
+}
+
+const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
+
+test('login issues a token and a session cookie', async () => {
+ await withAdmin({}, async (h) => {
+ const res = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
+ });
+
+ assert.equal(res.statusCode, 200);
+ const body = res.json();
+ assert.equal(body.user.role, 'admin');
+ assert.ok(body.token);
+ assert.match(res.headers['set-cookie'], /conductor_session=/);
+ assert.match(res.headers['set-cookie'], /HttpOnly/);
+ });
+});
+
+test('a wrong password and an unknown user are indistinguishable', async () => {
+ await withAdmin({}, async (h) => {
+ const wrong = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'admin', password: 'nope' }),
+ });
+ const absent = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'nobody', password: 'nope' }),
+ });
+
+ assert.equal(wrong.statusCode, 401);
+ assert.equal(absent.statusCode, 401);
+ assert.deepEqual(wrong.json(), absent.json());
+ });
+});
+
+test('the session cookie authenticates as well as the bearer token', async () => {
+ await withAdmin({}, async (h) => {
+ const login = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
+ });
+ const cookie = login.headers['set-cookie'].split(';')[0];
+
+ const res = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie } });
+ assert.equal(res.statusCode, 200);
+ assert.equal(res.json().user.username, 'admin');
+ });
+});
+
+test('admin routes reject anonymous and non-admin callers', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const anonymous = await h.app.inject({ method: 'GET', url: '/api/admin/projects' });
+ assert.equal(anonymous.statusCode, 401);
+
+ await h.app.inject({
+ method: 'POST',
+ url: '/api/admin/users',
+ headers: json(admin),
+ payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password', role: 'viewer' }),
+ });
+
+ const login = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'viewer1', password: 'viewer-password' }),
+ });
+ const viewer = { authorization: `Bearer ${login.json().token}` };
+
+ const forbidden = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers: viewer });
+ assert.equal(forbidden.statusCode, 403);
+ });
+});
+
+test('a disabled account stops being accepted immediately', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const created = await h.app.inject({
+ method: 'POST',
+ url: '/api/admin/users',
+ headers: json(admin),
+ payload: JSON.stringify({ username: 'temp', password: 'temp-password', role: 'viewer' }),
+ });
+ const id = created.json().user.id;
+
+ const login = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'temp', password: 'temp-password' }),
+ });
+ const headers = { authorization: `Bearer ${login.json().token}` };
+ assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 200);
+
+ await h.app.inject({
+ method: 'PATCH',
+ url: `/api/admin/users/${id}`,
+ headers: json(admin),
+ payload: JSON.stringify({ disabled: true }),
+ });
+
+ // The token has not expired, but the account is checked on every call.
+ assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 401);
+ });
+});
+
+test('the last administrator cannot be removed or demoted', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const me = (await h.app.inject({ method: 'GET', url: '/api/auth/me', headers: admin })).json().user;
+
+ const demote = await h.app.inject({
+ method: 'PATCH',
+ url: `/api/admin/users/${me.id}`,
+ headers: json(admin),
+ payload: JSON.stringify({ role: 'viewer' }),
+ });
+ assert.equal(demote.statusCode, 400);
+ assert.match(demote.json().error, /only administrator/);
+
+ const removed = await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${me.id}`, headers: admin });
+ assert.equal(removed.statusCode, 400);
+ });
+});
+
+test('projects can be created, listed and removed, and the secret is shown once', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const created = await h.app.inject({
+ method: 'POST',
+ url: '/api/admin/projects',
+ headers: json(admin),
+ payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }),
+ });
+ assert.equal(created.statusCode, 201);
+ const secret = created.json().trigger_secret;
+ assert.ok(secret && secret.length >= 32);
+
+ const listed = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers: admin });
+ const project = listed.json().projects.find((p) => p.id === 'newproj');
+ assert.equal(project.has_trigger_secret, true);
+ // Listing must never return the value itself.
+ assert.equal(project.trigger_secret, undefined);
+ assert.ok(!JSON.stringify(listed.json()).includes(secret));
+
+ const deleted = await h.app.inject({ method: 'DELETE', url: '/api/admin/projects/newproj', headers: admin });
+ assert.equal(deleted.statusCode, 200);
+ });
+});
+
+test('a rotated trigger secret actually signs triggers', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const rotated = await h.app.inject({
+ method: 'POST',
+ url: '/api/admin/projects/demo/trigger-secret',
+ headers: json(admin),
+ payload: JSON.stringify({ secret: 'rotated-secret' }),
+ });
+ assert.equal(rotated.statusCode, 200);
+
+ // The old secret stops working, the new one starts.
+ const old = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'test-secret' });
+ assert.equal(old.statusCode, 401);
+
+ const fresh = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }, { secret: 'rotated-secret' });
+ assert.equal(fresh.statusCode, 200);
+ });
+});
+
+test('worker tokens are issued once and can be revoked', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const created = await h.app.inject({
+ method: 'POST',
+ url: '/api/admin/worker-tokens',
+ headers: json(admin),
+ payload: JSON.stringify({ name: 'builder-2' }),
+ });
+ assert.equal(created.statusCode, 201);
+ const { token, worker_token: record } = created.json();
+
+ // It works.
+ const poll = await h.app.inject({
+ method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` },
+ });
+ assert.notEqual(poll.statusCode, 401);
+
+ // It is never listed again.
+ const listed = await h.app.inject({ method: 'GET', url: '/api/admin/worker-tokens', headers: admin });
+ assert.ok(!JSON.stringify(listed.json()).includes(token));
+
+ // Disabling it takes effect at once.
+ await h.app.inject({
+ method: 'PATCH',
+ url: `/api/admin/worker-tokens/${record.id}`,
+ headers: json(admin),
+ payload: JSON.stringify({ enabled: false }),
+ });
+ const after = await h.app.inject({
+ method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token}` },
+ });
+ assert.equal(after.statusCode, 401);
+ });
+});
+
+test('project variables reach a job environment and are never listed', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const set = await h.app.inject({
+ method: 'PUT',
+ url: '/api/admin/projects/demo/variables/DEPLOY_TOKEN',
+ headers: json(admin),
+ payload: JSON.stringify({ value: 'super-secret-value', masked: true }),
+ });
+ assert.equal(set.statusCode, 200);
+
+ const listed = await h.app.inject({
+ method: 'GET', url: '/api/admin/projects/demo/variables', headers: admin,
+ });
+ const listing = listed.json().variables;
+ assert.equal(listing[0].name, 'DEPLOY_TOKEN');
+ assert.equal(listing[0].masked, true);
+ // The value must not come back out.
+ assert.ok(!JSON.stringify(listing).includes('super-secret-value'));
+
+ await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
+ const claimed = await h.poll({});
+ const job = claimed.json().job;
+
+ assert.equal(job.env.DEPLOY_TOKEN, 'super-secret-value');
+ assert.deepEqual(job.masked, ['super-secret-value']);
+ });
+});
+
+test('a variable is encrypted at rest and bound to its project and name', async () => {
+ await withAdmin({}, async (h, admin) => {
+ await h.app.inject({
+ method: 'PUT',
+ url: '/api/admin/projects/demo/variables/TOKEN',
+ headers: json(admin),
+ payload: JSON.stringify({ value: 'rest-secret-value' }),
+ });
+
+ const row = await h.services.db.get(
+ 'SELECT value FROM project_variables WHERE project_id = {p} AND name = {n}',
+ { p: 'demo', n: 'TOKEN' }
+ );
+ assert.ok(row.value.startsWith('v1.'), 'expected an encrypted value');
+ assert.ok(!row.value.includes('rest-secret-value'));
+
+ // The same ciphertext under a different name must not open.
+ await h.services.db.run(
+ `INSERT INTO project_variables (project_id, name, value, masked, created_at)
+ VALUES ({p}, {n}, {v}, 1, {t})`,
+ { p: 'demo', n: 'MOVED', v: row.value, t: Date.now() }
+ );
+ const resolved = await h.services.variables.resolve('demo');
+ assert.equal(resolved.env.TOKEN, 'rest-secret-value');
+ assert.equal(resolved.env.MOVED, undefined, 'a relocated ciphertext must not open');
+ });
+});
+
+test('a pipeline setting wins over a project variable of the same name', async () => {
+ const pipeline = `
+version: 1
+jobs:
+ a:
+ image: alpine
+ script: ['true']
+ env:
+ SHARED: from-pipeline
+`;
+ await withAdmin({ pipeline }, async (h, admin) => {
+ await h.app.inject({
+ method: 'PUT',
+ url: '/api/admin/projects/demo/variables/SHARED',
+ headers: json(admin),
+ payload: JSON.stringify({ value: 'from-variable' }),
+ });
+
+ await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
+ const job = (await h.poll({})).json().job;
+ assert.equal(job.env.SHARED, 'from-pipeline');
+ });
+});
+
+test('a masked variable is redacted from ingested logs', async () => {
+ await withAdmin({}, async (h, admin) => {
+ await h.app.inject({
+ method: 'PUT',
+ url: '/api/admin/projects/demo/variables/LEAKY',
+ headers: json(admin),
+ payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }),
+ });
+
+ await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
+ const job = (await h.poll({})).json().job;
+
+ // A worker that does not mask still must not get the secret onto disk.
+ await h.app.inject({
+ method: 'POST',
+ url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`,
+ headers: { ...h.auth, 'content-type': 'application/octet-stream' },
+ payload: Buffer.from('echo leaked-secret-value here\n'),
+ });
+
+ const log = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` });
+ assert.ok(!log.body.includes('leaked-secret-value'), `log leaked: ${log.body}`);
+ assert.match(log.body, /\[masked\]/);
+ });
+});
+
+test('a run can be cancelled and retried through the api', async () => {
+ await withAdmin({}, async (h, admin) => {
+ const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id;
+
+ const cancelled = await h.app.inject({
+ method: 'POST', url: `/api/admin/runs/${run}/cancel`, headers: json(admin), payload: '{}',
+ });
+ assert.equal(cancelled.statusCode, 200);
+
+ const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` });
+ assert.equal(detail.json().run.state, 'cancelled');
+
+ const retried = await h.app.inject({
+ method: 'POST', url: `/api/admin/runs/${run}/retry`, headers: json(admin), payload: '{}',
+ });
+ assert.equal(retried.statusCode, 201);
+ assert.notEqual(retried.json().run_id, run);
+
+ const fresh = await h.app.inject({ method: 'GET', url: `/api/runs/${retried.json().run_id}` });
+ assert.equal(fresh.json().run.state, 'running');
+ assert.equal(fresh.json().run.head_sha, h.sha);
+ });
+});
+
+test('with oidc configured, local login is refused unless allowed', async () => {
+ const h = await startHarness({ oidcIssuer: 'https://idp.example.com/realms/ci', bootstrap: true });
+ try {
+ assert.equal(h.cfg.auth.mode, 'oidc');
+ const res = await h.app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
+ });
+ assert.equal(res.statusCode, 400);
+ assert.match(res.json().error, /OIDC/);
+
+ const mode = await h.app.inject({ method: 'GET', url: '/api/auth/mode' });
+ assert.equal(mode.json().mode, 'oidc');
+ assert.equal(mode.json().local_login, false);
+ } finally {
+ await h.stop();
+ }
+});
+
+test('local login can be kept as a break-glass account alongside oidc', async () => {
+ const h = await startHarness({
+ oidcIssuer: 'https://idp.example.com/realms/ci',
+ allowLocalLogin: true,
+ bootstrap: true,
+ });
+ try {
+ const headers = await h.login();
+ const res = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers });
+ assert.equal(res.statusCode, 200);
+ } finally {
+ await h.stop();
+ }
+});
diff --git a/test/auth.test.js b/test/auth.test.js
@@ -0,0 +1,126 @@
+// test/auth.test.js - password hashing, session tokens and role mapping
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { hashPassword, verifyPassword, validatePassword, generatePassword } from '../src/lib/auth/password.js';
+import { issueToken, verifyToken, parseCookies, sessionCookie, SESSION_COOKIE } from '../src/lib/auth/token.js';
+import { collectRoles } from '../src/lib/auth/oidc.js';
+import { maskBuffer } from '../src/lib/variables.js';
+
+// scrypt is intentionally slow, so these use the smallest sane parameters.
+const FAST = { N: 1024, r: 8, p: 1, keylen: 32 };
+
+test('a password round trips and a wrong one is refused', async () => {
+ const stored = await hashPassword('correct horse battery', FAST);
+ assert.ok(stored.startsWith('scrypt$1024$8$1$'));
+ assert.ok(!stored.includes('correct horse battery'));
+
+ assert.equal(await verifyPassword('correct horse battery', stored), true);
+ assert.equal(await verifyPassword('wrong', stored), false);
+});
+
+test('the same password hashes differently each time', async () => {
+ const a = await hashPassword('same password', FAST);
+ const b = await hashPassword('same password', FAST);
+ assert.notEqual(a, b, 'a per password salt is required');
+ assert.equal(await verifyPassword('same password', a), true);
+ assert.equal(await verifyPassword('same password', b), true);
+});
+
+test('parameters are read back from the stored hash', async () => {
+ // A hash written with one cost must still verify after the default changes.
+ const stored = await hashPassword('portable', { N: 2048, r: 8, p: 1, keylen: 32 });
+ assert.ok(stored.startsWith('scrypt$2048$'));
+ assert.equal(await verifyPassword('portable', stored), true);
+});
+
+test('a malformed stored hash is refused rather than throwing', async () => {
+ for (const bad of ['', 'nonsense', 'scrypt$x$8$1$aa$bb', 'bcrypt$1$2$3$4$5', 'scrypt$1024$8$1$$']) {
+ assert.equal(await verifyPassword('anything', bad), false, `${JSON.stringify(bad)} should be refused`);
+ }
+});
+
+test('password length is bounded', () => {
+ assert.match(validatePassword('short'), /at least 8/);
+ assert.match(validatePassword('x'.repeat(2000)), /at most/);
+ assert.equal(validatePassword('long enough'), null);
+ assert.ok(generatePassword().length >= 20);
+});
+
+test('a session token round trips its claims', () => {
+ const token = issueToken('secret', { sub: 'u1', name: 'alice', role: 'admin' });
+ const claims = verifyToken('secret', token);
+ assert.equal(claims.sub, 'u1');
+ assert.equal(claims.name, 'alice');
+ assert.equal(claims.role, 'admin');
+});
+
+test('a token signed with another secret is refused', () => {
+ const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' });
+ assert.equal(verifyToken('other-secret', token), null);
+});
+
+test('a tampered payload is refused', () => {
+ const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'viewer' });
+ const [header, payload, signature] = token.split('.');
+ const forged = Buffer.from(JSON.stringify({
+ ...JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')),
+ role: 'admin',
+ })).toString('base64url');
+
+ assert.equal(verifyToken('secret', `${header}.${forged}.${signature}`), null);
+});
+
+test('an expired token is refused', () => {
+ const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' }, { ttl: 60 });
+ assert.ok(verifyToken('secret', token));
+ assert.equal(verifyToken('secret', token, { now: Date.now() + 61_000 }), null);
+});
+
+test('a malformed token is refused rather than throwing', () => {
+ for (const bad of ['', 'a.b', 'a.b.c.d', 'not-a-token', null, undefined, 12]) {
+ assert.equal(verifyToken('secret', bad), null);
+ }
+});
+
+test('cookies are parsed and issued', () => {
+ const cookies = parseCookies(`a=1; ${SESSION_COOKIE}=abc%20def; b=2`);
+ assert.equal(cookies[SESSION_COOKIE], 'abc def');
+ assert.deepEqual(parseCookies(undefined), {});
+
+ const header = sessionCookie('tok', { ttl: 60, secure: true });
+ assert.ok(header.includes('HttpOnly'));
+ assert.ok(header.includes('SameSite=Lax'));
+ assert.ok(header.includes('Secure'));
+ assert.ok(header.includes('Max-Age=60'));
+ assert.ok(!sessionCookie('tok', { secure: false }).includes('Secure'));
+});
+
+test('oidc roles are collected from the usual claim shapes', () => {
+ assert.deepEqual(collectRoles({ roles: ['a'] }), ['a']);
+ assert.deepEqual(collectRoles({ realm_access: { roles: ['keycloak-admin'] } }), ['keycloak-admin']);
+ assert.deepEqual(collectRoles({ groups: 'single' }), ['single']);
+
+ const combined = collectRoles({
+ roles: ['a'],
+ groups: ['b'],
+ realm_access: { roles: ['c'] },
+ resource_access: { app: { roles: ['d'] } },
+ });
+ assert.deepEqual(combined.sort(), ['a', 'b', 'c', 'd']);
+ assert.deepEqual(collectRoles({}), []);
+});
+
+test('mask replaces secrets, longest first', () => {
+ const out = maskBuffer(Buffer.from('token=abcdef and abc12345 here'), ['abcdef', 'abc12345']).toString();
+ assert.ok(!out.includes('abcdef'));
+ assert.ok(!out.includes('abc12345'));
+ assert.equal(out, 'token=[masked] and [masked] here');
+});
+
+test('mask ignores very short values and leaves clean output alone', () => {
+ assert.equal(maskBuffer(Buffer.from('a b c'), ['a']).toString(), 'a b c');
+ const clean = Buffer.from('nothing to hide');
+ assert.equal(maskBuffer(clean, ['absent-secret']), clean);
+ assert.equal(maskBuffer(clean, []), clean);
+});
diff --git a/test/helpers/harness.js b/test/helpers/harness.js
@@ -84,6 +84,13 @@ export async function startHarness(options = {}) {
'scheduler:',
' heartbeat_timeout: 120',
' reap_interval: 30',
+ 'auth:',
+ ' session_secret: test-session-secret-not-for-real-use',
+ ...(options.oidcIssuer ? [' oidc:', ` issuer: ${options.oidcIssuer}`] : []),
+ ...(options.allowLocalLogin ? [' allow_local_login: true'] : []),
+ ' bootstrap_admin:',
+ ` username: ${options.adminUsername ?? 'admin'}`,
+ ` password: ${options.adminPassword ?? 'bootstrap-password'}`,
'',
].join('\n'));
@@ -93,7 +100,13 @@ export async function startHarness(options = {}) {
if (savedConfig === undefined) delete process.env.CONDUCTOR_CONFIG;
else process.env.CONDUCTOR_CONFIG = savedConfig;
- const services = await createServices(cfg, { migrationLogger: () => {}, logger: silentLogger() });
+ const services = await createServices(cfg, {
+ migrationLogger: () => {},
+ logger: silentLogger(),
+ // scrypt is deliberately slow, so the admin account is only created for
+ // the tests that actually sign in.
+ bootstrap: options.bootstrap === true,
+ });
const app = await buildServer(services, { logger: false });
const project = await services.projects.create({
@@ -154,6 +167,24 @@ export async function startHarness(options = {}) {
});
},
+ // Signs in as the bootstrap administrator and returns headers that
+ // authenticate subsequent admin calls.
+ async login(username = options.adminUsername ?? 'admin', password = options.adminPassword ?? 'bootstrap-password') {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/auth/login',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({ username, password }),
+ });
+ if (res.statusCode !== 200) {
+ throw new Error(`login failed: ${res.statusCode} ${res.body}`);
+ }
+ // Only the credential. A content-type here would make every bodyless
+ // request fail JSON parsing.
+ const { token } = res.json();
+ return { authorization: `Bearer ${token}` };
+ },
+
async stop() {
await app.close();
await services.db.close();