conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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:
Mconductor.example.yaml | 4++++
Msrc/admin-cli.js | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/conductor/app.js | 26+++++++++++++++++++++++---
Asrc/conductor/routes/admin.js | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/auth.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/workers.js | 20+++++++++++++++++---
Msrc/conductor/scheduler.js | 24++++++++++++++++++++----
Asrc/lib/auth/index.js | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/auth/oidc.js | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/auth/password.js | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/auth/token.js | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/config.js | 4++++
Asrc/lib/users.js | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/variables.js | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/admin.test.js | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/auth.test.js | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/helpers/harness.js | 33++++++++++++++++++++++++++++++++-
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();