diff --git a/README.md b/README.md index c94fe55..96985f9 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,44 @@ Writes are **write-through to one primary** (it serializes them, so there are no multi-master conflicts); concurrent edits to different entries merge cleanly (ULID ids), and same-entry edits are last-writer-wins with the prior value kept in history. Online writes require connectivity. **Tokens** are never stored in `config.json` — they live in -`~/.braincontext/credentials.json` (mode `0600`) or `BCTX_TOKEN_` env. Auth and -per-member permissions are a planned managed-service layer; today, group members share a -project token. +`~/.braincontext/credentials.json` (mode `0600`) or `BCTX_TOKEN_` env. > A plain SQLite file on S3/R2 is **not** a sync backend (single-writer only); use it for > backup, not multi-user sync. libSQL replicas are the supported path. +## Users & permissions + +A shared project can name its members and give each one a role. Roles are enforced by +every bctx surface — the CLI, `bctx studio`, and the MCP server — and every mutation is +attributed and logged. + +```bash +bctx access init # become owner; switch enforcement on +bctx access user add ana --role writer # prints a one-paste join code +bctx project join # on ana's machine — stores her key, done + +bctx whoami # who am I here, and what may I do +bctx access user ls # roles at a glance +bctx access user update ana --cap "-delete" # per-user exception to the role +bctx access key revoke # takes effect at each client's next sync +bctx access log --deny-only # what was refused, and to whom +``` + +Roles: `owner` and `admin` (everything), `writer` (read/write/delete + files), `reader` +(read only). `--cap "+x,-y"` layers exceptions over a role. Keys are shown **once**, at +creation, and stored only as scrypt hashes. + +> **This is advisory, not a security boundary.** Clients sync against the libSQL primary +> directly, so anyone holding the raw database token can bypass these rules with any +> SQLite client — and the join code contains that token. It gives you roles, attribution, +> an audit trail, revocation, and protection against mistakes; it does not contain someone +> who sets out to defeat it. Hand join codes only to people you would trust with full +> access. Locked out? `bctx access recover --db ` works on any store file you can +> open on disk. + +Access control is **off** until you run `bctx access init`, and a project that never opts +in behaves exactly as it always has. + ## Skills (for agents) The CLI ships its own **bundled, version-matched skill docs** with progressive diff --git a/progress.md b/progress.md index 9c2d69f..c7ded4d 100644 --- a/progress.md +++ b/progress.md @@ -151,11 +151,51 @@ and remote connections, so "go online" is a config change, not a rewrite. No aut - [x] Vitest: `test/registry.test.ts` (precedence, token isolation + 0600, default seed) and `test/online.test.ts` (FTS5 lock + faithful remote seed); **59 tests** total, all gates green. - [x] Verified end-to-end on `dist/`: project create/use/isolation/`--project` override/status/path/rm (local). The replica/sync round-trip needs a real `libsql://` remote (manually verifiable); the seed (its core) is unit-tested. -> **Auth/permissions = North Star, not built.** Future managed control-plane: accounts, -> project membership, RBAC (owner/editor/viewer), and server-minted scoped tokens -> (`bctx login`, `bctx project share … --role editor`). Reuses `agent_source` + -> `context_history` for attribution/audit. **Phase 3:** offline-write reconciliation -> (Turso CDC / `updated_at` merge). **S3/R2** = backup only, never a sync backend. +> **Phase 3:** offline-write reconciliation (Turso CDC / `updated_at` merge). +> **S3/R2** = backup only, never a sync backend. + +## Access control — users, keys, roles (shipped) + +Per-member permissions on a shared project. Migration `0004_access` adds `principals`, +`principal_keys` (scrypt hashes + a public lookup prefix; the secret is never stored) and +`access_log`, plus a nullable `principal_id` on `contexts`/`context_history`/`files`. +Inert until `store_config['access.enabled']` is set, so every existing project is +unaffected — `resolveSession` short-circuits on one indexed config read. + +- [x] **Core** — `src/core/access/`: `capabilities` (9 capabilities × 4 roles + per-user + overrides), `keys` (scrypt, `bctxk..`, all failure modes distinguished), + `principals` (CRUD + last-owner / admin-vs-owner policy), `session` (+ `AsyncLocalStorage` + for attribution), `gate` (`authorize` + `restrictForSession`), `readonly` (Kysely + write-rejecting plugin, allow-list so new node kinds fail closed), `audit`, `joincode` + (checksummed, so a mangled paste says so), `cache` (60s revocation window for long-lived + surfaces). +- [x] **CLI** — gated inside `withDb` via a command-path → capability map + (`src/core/access/commands.ts`), filled in by `dbOptsFrom`, so **no command handler + changed**. `buildProgram()` extracted to `src/program.ts` so a test can walk the tree; + unmapped paths fail closed. New: `bctx access {init,status,disable,recover,user,key,log}`, + `bctx whoami`, `bctx project join `. +- [x] **MCP** — `installAccessGate` wraps `registerTool`/`registerResource` once, so all 33 + tools (and any added later) are gated; denials come back as a readable `isError` message + an agent can act on. +- [x] **Studio** — `accessGuard` beside `localOnlyGuard`, rule-based route→capability mapping, + cookie sessions with local-key adoption (the admin who launched Studio is already signed + in), `/api/auth/*`, `/api/access/*`, a login gate, and a Users & access settings panel + that reveals each secret exactly once. +- [x] **Latent bug fixed** — `SEED_TABLES` in `src/core/dump.ts` listed only the `0001` tables, + so `migrate-online` silently dropped `page_properties`, `store_config` (the storage + credentials!) and `files`. Now complete, with a drift test against `sqlite_master`. +- [x] Vitest: `access`, `command-caps`, `mcp-access`, `studio-auth` (+ migration/seed + coverage) — **319 tests**, all gates green. Verified end-to-end on `dist/` across two + isolated `BCTX_HOME`s (join code → reader → denials → promotion → revocation → recovery) + and in the browser. + +> **Advisory, and documented as such.** Clients sync against the libSQL primary directly, so +> the raw database token bypasses all of this and the join code carries that token. What this +> buys: roles, attribution, audit, revocation, mistake-prevention across every bctx surface. +> `access.mode` is reserved (`advisory | token | relay`) so the two hard-enforcement upgrades +> — per-user Turso tokens (real read-only), then a write-relay — are a value change, not a +> redesign. **Still out of scope:** binding Studio beyond `127.0.0.1` (no TLS, no rate limit), +> and resource-scoped permissions (per-tag / per-namespace). ## Concurrency hardening — multi-agent stress pass (shipped) diff --git a/src/cli.ts b/src/cli.ts index 1a3a1fb..559d877 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,105 +1,16 @@ #!/usr/bin/env node -import { Command } from 'commander' import { ZodError } from 'zod' -import { addCommand } from './commands/add' -import { configCommand } from './commands/config' -import { exportCommand } from './commands/export' -import { fileCommand } from './commands/file' -import { getCommand } from './commands/get' -import { importCommand } from './commands/import' -import { initCommand } from './commands/init' -import { listCommand } from './commands/list' -import { mcpCommand } from './commands/mcp' -import { projectCommand } from './commands/project' -import { rmCommand } from './commands/rm' -import { searchCommand } from './commands/search' -import { skillCommand } from './commands/skill' -import { skillsCommand } from './commands/skills' -import { statusCommand } from './commands/status' -import { studioCommand } from './commands/studio' -import { updateCommand } from './commands/update' -import { wikiCommand } from './commands/wiki' -import { getVersion } from './lib/pkg' - -const program = new Command() - -program - .name('bctx') - .description( - 'braincontext — a local-first context store for AI agents.\n' + - 'Preferred workflow: build a linked knowledge wiki (bctx wiki). The direct\n' + - 'context commands (add/get/list/search/update/rm) are for individual entries.', - ) - .version(getVersion(), '-v, --version') - .option('--db ', 'explicit path to the SQLite store') - .option('--project ', 'use a named project from the registry') - .option('--global', 'use the global store (~/.braincontext/store.db)') - .option('--local', 'use the project store (./.braincontext/store.db)') - .option('--no-sync', 'skip the online sync for this command (replica projects)') - -program.addCommand(initCommand()) -// Orient: where is the store, what's in it, are exports stale. -program.addCommand(statusCommand()) -// Project & sync management. -program.addCommand(projectCommand()) -// Per-store config (in the DB, travels with the project) + S3/R2 file storage. -program.addCommand(configCommand()) -program.addCommand(fileCommand()) -// Preferred workflow first. -program.addCommand(wikiCommand()) -// Direct context operations (individual entries). -program.addCommand(addCommand()) -program.addCommand(getCommand()) -program.addCommand(listCommand()) -program.addCommand(updateCommand()) -program.addCommand(rmCommand()) -program.addCommand(searchCommand()) -// Agent-facing surfaces. -program.addCommand(skillsCommand()) -program.addCommand(skillCommand()) -program.addCommand(exportCommand()) -program.addCommand(importCommand()) -program.addCommand(mcpCommand()) -// Human-facing surface: local web UI + JSON API. -program.addCommand(studioCommand()) - -program.addHelpText( - 'after', - ` -Preferred — knowledge wiki (durable, linked, compounding): - $ bctx wiki ingest ./article.md --title "TLS notes" # store a source + synthesis checklist - $ echo "See [[Gateway]]." | bctx wiki new "OAuth2" --type concept --file - - $ bctx wiki link "OAuth2" "Gateway" --type relates - $ bctx wiki search "tls" · bctx wiki lint · bctx wiki index - $ bctx skills get braincontext-wiki --full # the wiki-maintainer playbook - -Individual context operations (single entries — CRUD): - $ echo "Use pnpm, never npm" | bctx add --kind rule --tags tooling --agent claude - $ bctx list --kind rule --json · bctx search "pnpm" · bctx get - $ bctx update --add-tag important · bctx rm - -Files in S3/R2 (blobs in your bucket, metadata in the store): - $ bctx config set storage.endpoint https://.r2.cloudflarestorage.com - $ bctx config set storage.bucket notes && bctx file test - $ bctx file add ./diagram.png # prints wiki embed snippets - $ bctx file ls · bctx file url · bctx file rm - -Projects & online sync (same context across sessions, devices, members): - $ bctx project create work · bctx project use work - $ bctx project migrate-online work --url libsql://… --auth-token … # go online - $ bctx project link work --url libsql://… --auth-token … # on another device - -Wiki pages are hidden from plain list/search (use --include-wiki to include them). -Store precedence: --db/BCTX_DB > --global/--local > --project/BCTX_PROJECT > -current project > ./.braincontext (if present) > default project (~/.braincontext) -`, -) +import { AccessDeniedError } from './core/access/errors' +import { buildProgram } from './program' try { - await program.parseAsync(process.argv) + await buildProgram().parseAsync(process.argv) } catch (err) { if (err instanceof ZodError) { console.error(err.issues.map((i) => i.message).join('; ')) + } else if (err instanceof AccessDeniedError) { + // Already a complete, actionable sentence (see access/session.ts describeFailure). + console.error(err.message) } else { console.error(err instanceof Error ? err.message : String(err)) } diff --git a/src/commands/_shared.ts b/src/commands/_shared.ts index d2538d5..851cb1d 100644 --- a/src/commands/_shared.ts +++ b/src/commands/_shared.ts @@ -1,12 +1,31 @@ import type { Command } from 'commander' import type { Kysely } from 'kysely' +import { resolveCommandCapability } from '../core/access/commands' import { type Context, getContext } from '../core/contexts' import type { DbOpts } from '../core/paths' import type { Database } from '../core/types' -/** Pull the inherited global store flags (--db/--global/--local/--project/--no-sync). */ +/** + * The command's full path, root name excluded: `bctx wiki table set` → `wiki table set`. + * The root program is the only Command without a parent, which is what stops the walk. + */ +export function commandPath(command: Command): string { + const parts: string[] = [] + for (let c: Command | null = command; c?.parent; c = c.parent) parts.unshift(c.name()) + return parts.join(' ') +} + +/** + * Pull the inherited global store flags (--db/--global/--local/--project/--no-sync) + * plus the access capability this command requires. + * + * Deriving the capability here — rather than at each of the ~70 call sites — is why + * gating the CLI needed no changes inside the command handlers: every one of them + * already funnels its store access through `withDb(dbOptsFrom(command), …)`. + */ export function dbOptsFrom(command: Command): DbOpts { const o = command.optsWithGlobals() + const action = commandPath(command) // commander exposes `--no-sync` as `o.sync === false`. return { db: o.db, @@ -14,6 +33,8 @@ export function dbOptsFrom(command: Command): DbOpts { local: o.local, project: o.project, noSync: o.sync === false, + requires: resolveCommandCapability(action), + action, } } diff --git a/src/commands/access.ts b/src/commands/access.ts new file mode 100644 index 0000000..17534ff --- /dev/null +++ b/src/commands/access.ts @@ -0,0 +1,584 @@ +import { userInfo } from 'node:os' +import { Command } from 'commander' +import type { Kysely } from 'kysely' +import { listAccessLog } from '../core/access/audit' +import { + type CapabilityOverrides, + formatCapabilities, + isRole, + parseCapabilitySpec, +} from '../core/access/capabilities' +import { AccessError } from '../core/access/errors' +import { encodeJoinCode, type JoinPayload, joinCodeWarning } from '../core/access/joincode' +import { + createPrincipal, + deletePrincipal, + getPrincipalByHandle, + issueKey, + listKeys, + listPrincipals, + type Principal, + revokeKey, + updatePrincipal, +} from '../core/access/principals' +import { describeFailure, type SessionResult } from '../core/access/session' +import { isAccessEnabled, setAccessEnabled, setAccessMode } from '../core/access/settings' +import { accessStatus } from '../core/access/status' +import { withDb } from '../core/db' +import { resolveTarget } from '../core/paths' +import { getProject, resolveToken, setAccessKey } from '../core/registry' +import type { Database, Role } from '../core/types' +import { dbOptsFrom, parsePositiveInt } from './_shared' + +/** The registry project this command is operating on, or null for a raw `--db` file. */ +function projectNameFor(command: Command): string | null { + return resolveTarget(dbOptsFrom(command)).project ?? null +} + +/** A sensible default handle for the bootstrap owner: the OS username. */ +function defaultHandle(): string { + try { + const name = userInfo().username?.replace(/[^a-zA-Z0-9._-]/g, '-') + return name && /^[a-z0-9]/i.test(name) ? name : 'owner' + } catch { + return 'owner' + } +} + +/** + * Persist a freshly issued key for the local machine and say where it went. Keys + * for a raw `--db` target have no registry entry to live in, so the caller is told + * to export `BCTX_KEY` instead. + */ +function storeOwnKey(project: string | null, key: string): void { + if (project) { + setAccessKey(project, key) + console.log(`Saved to ~/.braincontext/credentials.json for project "${project}".`) + } else { + console.log('No registry project for this store — export it to use it:') + console.log(` export BCTX_KEY=${key}`) + } +} + +/** Build the one-paste join code for a member of `project`, with its disclosure. */ +function joinCodeFor( + project: string | null, + handle: string, + key: string, +): { code: string; warning: string } | null { + if (!project) return null + const entry = getProject(project) + if (!entry) return null + const payload: JoinPayload = { + v: 1, + n: project, + u: entry.syncUrl, + t: entry.syncUrl ? resolveToken(project) : undefined, + k: key, + h: handle, + } + return { code: encodeJoinCode(payload), warning: joinCodeWarning(payload) } +} + +function printIssuedKey(opts: { + handle: string + key: string + project: string | null + joinCode: boolean +}): void { + console.log('') + if (opts.joinCode) { + const join = joinCodeFor(opts.project, opts.handle, opts.key) + if (join) { + console.log(`Join code for "${opts.handle}" — send this one line:`) + console.log('') + console.log(` ${join.code}`) + console.log('') + console.log('They run: bctx project join ') + console.log('') + console.log(join.warning) + console.log('') + console.log(`Raw key (if they prefer BCTX_KEY): ${opts.key}`) + return + } + console.log('(This store has no registry project, so there is no join code to hand out.)') + } + console.log(`Access key for "${opts.handle}" — shown once, store it now:`) + console.log('') + console.log(` ${opts.key}`) +} + +function parseOverrideOpt(spec: string | undefined): CapabilityOverrides | undefined { + return spec ? parseCapabilitySpec(spec) : undefined +} + +function requireRole(value: string): Role { + if (!isRole(value)) { + throw new AccessError( + `Invalid role: "${value}" (owner, admin, writer, reader).`, + 'invalid_role', + ) + } + return value +} + +function formatPrincipalLine(p: Principal): string { + const flags = p.status === 'active' ? '' : ' [disabled]' + const overrides = Object.entries(p.overrides) + .map(([c, v]) => `${v ? '+' : '-'}${c}`) + .join(',') + return `${p.handle.padEnd(20)} ${p.role.padEnd(7)}${flags}${overrides ? ` (${overrides})` : ''}` +} + +/** The session behind the current command, or null when unauthenticated/disabled. */ +function actorOf(access: SessionResult): Principal | null { + return access.enabled && access.ok ? access.session.principal : null +} + +export function accessCommand(): Command { + const access = new Command('access').description( + 'Users, keys, and the access log for a shared project.\n' + + 'Enforcement is advisory: it binds every bctx surface (CLI, Studio, MCP), but\n' + + 'anyone holding the raw libSQL token can still reach the database directly.', + ) + + // ── init ────────────────────────────────────────────────────────────────── + access + .command('init') + .description('Become the owner of this project and switch access control on.') + .option('--handle ', 'your handle (default: your OS username)') + .option('--display-name ', 'human-readable name') + .action(async (opts: { handle?: string; displayName?: string }, command: Command) => { + const project = projectNameFor(command) + const result = await withDb(dbOptsFrom(command), async (db) => { + if (await isAccessEnabled(db)) { + throw new AccessError( + 'Access control is already enabled here. Use `bctx access user add ` to invite someone.', + 'already_enabled', + ) + } + const owner = await createPrincipal(db, { + handle: opts.handle ?? defaultHandle(), + role: 'owner', + displayName: opts.displayName ?? null, + }) + const issued = await issueKey(db, owner.handle, { label: 'owner', createdBy: owner.id }) + // Enable LAST: until this flips, the store is unguarded, so a failure above + // leaves a project that still works rather than one nobody can open. + await setAccessMode(db, 'advisory') + await setAccessEnabled(db, true) + return { owner, key: issued.key } + }) + + console.log(`Access control enabled. You are "${result.owner.handle}" (owner).`) + printIssuedKey({ handle: result.owner.handle, key: result.key, project, joinCode: false }) + console.log('') + storeOwnKey(project, result.key) + console.log('') + console.log('Next: bctx access user add --role writer') + }) + + // ── status ──────────────────────────────────────────────────────────────── + access + .command('status') + .description('Show whether access control is on, and who you are on this project.') + .option('--json', 'output JSON') + .action(async (opts: { json?: boolean }, command: Command) => { + const status = await withDb(dbOptsFrom(command), async (db, session) => + accessStatus(db, session.enabled && session.ok ? session.session : null), + ) + if (opts.json) { + console.log(JSON.stringify(status, null, 2)) + return + } + if (!status.enabled) { + console.log('Access control: off (every client with the store can do anything).') + console.log('Enable it with `bctx access init`.') + return + } + console.log(`Access control: on (${status.mode})`) + console.log(`Users: ${status.userCount} Active keys: ${status.activeKeyCount}`) + if (status.me) { + console.log(`You: ${status.me.handle} (${status.me.role})`) + console.log(`Capabilities: ${status.me.capabilities.join(', ')}`) + } else { + console.log('You: not authenticated on this project.') + } + }) + + // ── disable ─────────────────────────────────────────────────────────────── + access + .command('disable') + .description('Turn access control off. Users and keys are kept, just not enforced.') + .action(async (_opts, command: Command) => { + await withDb(dbOptsFrom(command), async (db, session) => { + const actor = actorOf(session) + if (actor && actor.role !== 'owner') { + throw new AccessError('Only an owner can disable access control.', 'owner_required') + } + if (!(await isAccessEnabled(db))) { + console.log('Access control is already off.') + return + } + await setAccessEnabled(db, false) + console.log('Access control disabled. Anyone who can open this store can do anything.') + console.log('Users and keys are preserved — `bctx access init` is not needed to re-enable.') + }) + }) + + // ── recover ─────────────────────────────────────────────────────────────── + access + .command('recover') + .description('Regain owner access to a store file you can open on disk (requires --db ).') + .option('--handle ', 'handle to restore or create as owner (default: your OS username)') + .action(async (opts: { handle?: string }, command: Command) => { + const dbOpts = dbOptsFrom(command) + // Filesystem ownership is the real boundary in the advisory model: if you can + // open the file, you can already rewrite these tables with sqlite3. Restricting + // recovery to a local path is what stops it from being a remote backdoor. + if (!dbOpts.db) { + throw new AccessError( + 'Recovery needs a local store file: `bctx access recover --db ~/.braincontext/projects/.db`.', + 'db_path_required', + ) + } + const target = resolveTarget(dbOpts) + if (target.mode !== 'local') { + throw new AccessError( + 'Recovery only works against a local file, not a remote.', + 'local_only', + ) + } + + const handle = opts.handle ?? defaultHandle() + const result = await withDb(dbOpts, async (db) => { + const existing = await getPrincipalByHandle(db, handle) + const owner = existing + ? await updatePrincipal(db, handle, { role: 'owner', status: 'active' }) + : await createPrincipal(db, { handle, role: 'owner' }) + const issued = await issueKey(db, owner.handle, { label: 'recovery', createdBy: owner.id }) + return { owner, key: issued.key, restored: Boolean(existing) } + }) + + console.log( + `${result.restored ? 'Restored' : 'Created'} owner "${result.owner.handle}" on ${target.file}.`, + ) + printIssuedKey({ + handle: result.owner.handle, + key: result.key, + project: null, + joinCode: false, + }) + }) + + access.addCommand(userCommand()) + access.addCommand(keyCommand()) + + // ── log ─────────────────────────────────────────────────────────────────── + access + .command('log') + .description('Show recent access decisions (newest first).') + .option('--limit ', 'how many entries (default 50)') + .option('--user ', 'only this user') + .option('--deny-only', 'only refused attempts') + .option('--json', 'output JSON') + .action( + async ( + opts: { limit?: string; user?: string; denyOnly?: boolean; json?: boolean }, + command: Command, + ) => { + const rows = await withDb(dbOptsFrom(command), (db) => + listAccessLog(db, { + limit: parsePositiveInt(opts.limit, 'limit') ?? 50, + handle: opts.user, + denyOnly: opts.denyOnly, + }), + ) + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)) + return + } + if (rows.length === 0) { + console.log('No access log entries.') + return + } + for (const r of rows) { + const who = r.handle ?? '(unauthenticated)' + const mark = r.decision === 'deny' ? 'DENY ' : 'allow' + console.log(`${r.at} ${mark} ${who.padEnd(18)} ${r.surface.padEnd(6)} ${r.action}`) + } + }, + ) + + return access +} + +function userCommand(): Command { + const user = new Command('user').description('Create and manage the people on this project.') + + user + .command('add ') + .description('Create a user, issue their first key, and print a join code.') + .requiredOption('--role ', 'owner | admin | writer | reader') + .option('--display-name ', 'human-readable name') + .option('--cap ', 'capability overrides, e.g. "+delete,-files.write"') + .option('--expires ', 'expiry for the issued key (ISO 8601)') + .option('--no-join-code', 'print only the raw key') + .action( + async ( + handle: string, + opts: { + role: string + displayName?: string + cap?: string + expires?: string + joinCode: boolean + }, + command: Command, + ) => { + const project = projectNameFor(command) + const result = await withDb(dbOptsFrom(command), async (db, session) => { + const actor = actorOf(session) + const role = requireRole(opts.role) + if (actor && actor.role !== 'owner' && (role === 'owner' || role === 'admin')) { + throw new AccessError(`Only an owner can create an ${role}.`, 'owner_required') + } + const principal = await createPrincipal(db, { + handle, + role, + displayName: opts.displayName ?? null, + overrides: parseOverrideOpt(opts.cap), + createdBy: actor?.id ?? null, + }) + const issued = await issueKey(db, principal.handle, { + expiresAt: opts.expires ?? null, + createdBy: actor?.id ?? null, + }) + return { principal, key: issued.key } + }) + + console.log(`Created "${result.principal.handle}" (${result.principal.role}).`) + console.log(`Capabilities: ${result.principal.capabilities.join(', ')}`) + printIssuedKey({ + handle: result.principal.handle, + key: result.key, + project, + joinCode: opts.joinCode, + }) + }, + ) + + user + .command('ls') + .description('List the users on this project.') + .option('--json', 'output JSON') + .action(async (opts: { json?: boolean }, command: Command) => { + const people = await withDb(dbOptsFrom(command), (db) => listPrincipals(db)) + if (opts.json) { + console.log(JSON.stringify(people, null, 2)) + return + } + if (people.length === 0) { + console.log('No users yet. Run `bctx access init`.') + return + } + for (const p of people) console.log(formatPrincipalLine(p)) + }) + + user + .command('show ') + .description('Show one user, their capabilities, and their keys.') + .option('--json', 'output JSON') + .action(async (handle: string, opts: { json?: boolean }, command: Command) => { + const data = await withDb(dbOptsFrom(command), async (db) => { + const principal = await requireUser(db, handle) + return { principal, keys: await listKeys(db, principal.id) } + }) + if (opts.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + const p = data.principal + console.log(`${p.handle}${p.displayName ? ` (${p.displayName})` : ''}`) + console.log(`Role: ${p.role}`) + console.log(`Status: ${p.status}`) + console.log(`Capabilities: ${formatCapabilities(new Set(p.capabilities))}`) + console.log(`Created: ${p.createdAt}`) + console.log(`Keys: ${data.keys.length}`) + for (const k of data.keys) { + const state = k.revokedAt ? 'revoked' : k.active ? 'active' : 'expired' + const used = k.lastUsedAt ? `last used ${k.lastUsedAt}` : 'never used' + console.log( + ` ${k.id} ${k.prefix}… ${state.padEnd(7)} ${used}${k.label ? ` [${k.label}]` : ''}`, + ) + } + }) + + user + .command('update ') + .description("Change a user's role, capabilities, or status.") + .option('--role ', 'owner | admin | writer | reader') + .option('--display-name ', 'human-readable name') + .option('--cap ', 'replace capability overrides, e.g. "+delete,-files.write"') + .option('--enable', 'reactivate a disabled user') + .option('--disable', 'block the user without deleting them or their history') + .action( + async ( + handle: string, + opts: { + role?: string + displayName?: string + cap?: string + enable?: boolean + disable?: boolean + }, + command: Command, + ) => { + if (opts.enable && opts.disable) throw new Error('Pass --enable or --disable, not both.') + const updated = await withDb(dbOptsFrom(command), async (db, session) => + updatePrincipal( + db, + handle, + { + role: opts.role ? requireRole(opts.role) : undefined, + displayName: opts.displayName, + overrides: parseOverrideOpt(opts.cap), + status: opts.disable ? 'disabled' : opts.enable ? 'active' : undefined, + }, + actorOf(session), + ), + ) + console.log(`Updated ${formatPrincipalLine(updated)}`) + }, + ) + + user + .command('rm ') + .description('Delete a user and all their keys. Their entries in the access log are kept.') + .action(async (handle: string, _opts, command: Command) => { + const removed = await withDb(dbOptsFrom(command), async (db, session) => + deletePrincipal(db, handle, actorOf(session)), + ) + console.log(`Removed "${removed.handle}" (${removed.role}) and their keys.`) + }) + + return user +} + +function keyCommand(): Command { + const key = new Command('key').description('Issue, list, and revoke access keys.') + + key + .command('issue ') + .description('Issue an additional key for a user (rotation, or a second device).') + .option('--label ', 'what this key is for, e.g. "laptop"') + .option('--expires ', 'expiry (ISO 8601)') + .option('--join-code', 'print a full join code instead of the raw key') + .action( + async ( + handle: string, + opts: { label?: string; expires?: string; joinCode?: boolean }, + command: Command, + ) => { + const project = projectNameFor(command) + const issued = await withDb(dbOptsFrom(command), async (db, session) => { + const principal = await requireUser(db, handle) + return issueKey(db, principal.handle, { + label: opts.label ?? null, + expiresAt: opts.expires ?? null, + createdBy: actorOf(session)?.id ?? null, + }) + }) + printIssuedKey({ handle, key: issued.key, project, joinCode: Boolean(opts.joinCode) }) + }, + ) + + key + .command('ls [handle]') + .description('List keys (all users, or just one).') + .option('--json', 'output JSON') + .action(async (handle: string | undefined, opts: { json?: boolean }, command: Command) => { + const rows = await withDb(dbOptsFrom(command), async (db) => { + const principal = handle ? await requireUser(db, handle) : null + const keys = await listKeys(db, principal?.id) + const people = await listPrincipals(db) + const byId = new Map(people.map((p) => [p.id, p.handle])) + return keys.map((k) => ({ ...k, handle: byId.get(k.principalId) ?? '(deleted)' })) + }) + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)) + return + } + if (rows.length === 0) { + console.log('No keys.') + return + } + for (const k of rows) { + const state = k.revokedAt ? 'revoked' : k.active ? 'active' : 'expired' + console.log( + `${k.id} ${k.handle.padEnd(18)} ${k.prefix}… ${state.padEnd(7)} ${k.label ?? ''}`, + ) + } + }) + + key + .command('revoke ') + .description('Revoke a key immediately (takes effect on each client at its next sync).') + .action(async (keyId: string, _opts, command: Command) => { + const revoked = await withDb(dbOptsFrom(command), (db) => revokeKey(db, keyId)) + console.log(`Revoked key ${revoked.id} (${revoked.prefix}…).`) + }) + + return key +} + +async function requireUser(db: Kysely, handle: string): Promise { + const p = await getPrincipalByHandle(db, handle) + if (!p) throw new AccessError(`No such user: "${handle}".`, 'no_such_principal') + return p +} + +/** + * `bctx whoami` — deliberately ungated, because the people who most need it are the + * ones whose key stopped working. It reports WHY rather than refusing. + */ +export function whoamiCommand(): Command { + return new Command('whoami') + .description('Show which identity you are using on this project, and what it can do.') + .option('--json', 'output JSON') + .action(async (opts: { json?: boolean }, command: Command) => { + const info = await withDb(dbOptsFrom(command), async (_db, session) => { + if (!session.enabled) return { enabled: false as const } + if (!session.ok) + return { enabled: true as const, ok: false as const, reason: session.reason } + const s = session.session + return { + enabled: true as const, + ok: true as const, + handle: s.principal.handle, + displayName: s.principal.displayName, + role: s.principal.role, + capabilities: s.principal.capabilities, + readOnly: s.readOnly, + } + }) + if (opts.json) { + console.log(JSON.stringify(info, null, 2)) + return + } + if (!info.enabled) { + console.log('Access control is off on this project — no identity is in use.') + return + } + if (!info.ok) { + console.log('Not authenticated.') + console.log(describeFailure(info.reason)) + return + } + console.log( + `${info.handle}${info.displayName ? ` (${info.displayName})` : ''} — ${info.role}`, + ) + console.log(`Capabilities: ${info.capabilities.join(', ')}`) + if (info.readOnly) console.log('This identity is read-only.') + }) +} diff --git a/src/commands/project.ts b/src/commands/project.ts index d526c07..074781d 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,6 +1,10 @@ import { mkdirSync, rmSync } from 'node:fs' import { dirname } from 'node:path' import { Command } from 'commander' +import type { Capability } from '../core/access/capabilities' +import { enterGate } from '../core/access/gate' +import { decodeJoinCode } from '../core/access/joincode' +import { describeFailure, resolveSession } from '../core/access/session' import { type DbTarget, openStore } from '../core/db' import { contextRowCount, seedDatabase } from '../core/dump' import { withFileLock } from '../core/lock' @@ -15,7 +19,9 @@ import { projectFilePath, projectToTarget, removeProject, + resolveAccessKey, resolveToken, + setAccessKey, setCurrent, setToken, updateProject, @@ -47,6 +53,20 @@ function requireProject(name: string): ProjectEntry { return entry } +/** + * Enforce a capability for a project command. These commands open stores through + * `openStore`/`withTarget` (they manage topology, sometimes two stores at once), so + * they are outside `withDb`'s gate and have to ask for the check themselves. + */ +async function requireProjectCapability( + db: Awaited>['db'], + name: string, + capability: Capability, + action: string, +): Promise { + await enterGate(db, { key: resolveAccessKey(name), requires: capability, action, surface: 'cli' }) +} + /** The token to use for an online operation: --auth-token > --auth-token-env > stored. */ function operationToken(name: string, opts: { authToken?: string; authTokenEnv?: string }): string { const token = opts.authToken ?? (opts.authTokenEnv ? process.env[opts.authTokenEnv] : undefined) @@ -83,6 +103,49 @@ async function reachRemote(url: string, fn: () => Promise): Promise { } } +/** + * Attach this machine to an existing remote primary: bootstrap the local replica + * FIRST, so a failed connection leaves nothing half-registered, and only persist + * the registry entry after a successful sync. Shared by `link` and `join`. + */ +async function bootstrapReplica(opts: { + name: string + url: string + token: string + syncInterval?: number +}): Promise { + const { name, url, token, syncInterval } = opts + if (getProject(name)) throw new Error(`Project already exists: "${name}".`) + validateProjectName(name) + const file = projectFilePath({ mode: 'replica', file: defaultProjectFile(name), createdAt: '' }) + + mkdirSync(dirname(file), { recursive: true }) + await withFileLock(`${file}.bootstrap.lock`, () => + reachRemote(url, async () => { + const store = openStore({ + mode: 'replica', + file, + syncUrl: url, + authToken: token, + syncInterval, + }) + try { + await store.sync() + } finally { + await store.close() + } + }), + ) + + addProject(name, { + mode: 'replica', + file: defaultProjectFile(name), + syncUrl: url, + syncInterval, + createdAt: new Date().toISOString(), + }) +} + export function projectCommand(): Command { const project = new Command('project').description( 'Manage projects: multiple stores you can switch between, and take online so the\n' + @@ -194,6 +257,13 @@ export function projectCommand(): Command { const syncInterval = parsePositiveInt(opts.syncInterval, 'sync-interval') const localFile = projectFilePath(entry) + // This command drives the store through `openStore` rather than `withDb`, so + // it does not inherit the gate there — publish the project only if the caller + // is allowed to manage it. + await withTarget({ mode: 'local', file: localFile, project: name }, (db) => + requireProjectCapability(db, name, 'project.manage', 'project migrate-online'), + ) + // Serialize the whole bootstrap so two concurrent `migrate-online` runs can't // double-seed the remote or race the local-file swap. await withFileLock(`${localFile}.bootstrap.lock`, async () => { @@ -248,48 +318,69 @@ export function projectCommand(): Command { name: string, opts: { url: string; authToken?: string; authTokenEnv?: string; syncInterval?: string }, ) => { - if (getProject(name)) throw new Error(`Project already exists: "${name}".`) - validateProjectName(name) const token = operationToken(name, opts) - const syncInterval = parsePositiveInt(opts.syncInterval, 'sync-interval') - const file = projectFilePath({ - mode: 'replica', - file: defaultProjectFile(name), - createdAt: '', + await bootstrapReplica({ + name, + url: opts.url, + token, + syncInterval: parsePositiveInt(opts.syncInterval, 'sync-interval'), }) - - // Bootstrap the local replica from the primary FIRST, so a failed connection - // leaves nothing half-registered. Only persist after a successful sync. - mkdirSync(dirname(file), { recursive: true }) - await withFileLock(`${file}.bootstrap.lock`, () => - reachRemote(opts.url, async () => { - const store = openStore({ - mode: 'replica', - file, - syncUrl: opts.url, - authToken: token, - syncInterval, - }) - try { - await store.sync() - } finally { - await store.close() - } - }), - ) - if (opts.authToken) setToken(name, opts.authToken) - addProject(name, { - mode: 'replica', - file: defaultProjectFile(name), - syncUrl: opts.url, - syncInterval, - createdAt: new Date().toISOString(), - }) console.log(`Linked project "${name}" ⇄ ${opts.url}. Run \`bctx project use ${name}\`.`) }, ) + // ── join (attach using a join code from the project admin) ───────────────── + project + .command('join ') + .description('Join a shared project from the join code your project admin gave you.') + .option('--name ', 'register under a different local name') + .option('--sync-interval ', 'background replica sync interval') + .option('--no-use', 'do not switch to the project after joining') + .action(async (code: string, opts: { name?: string; syncInterval?: string; use: boolean }) => { + const payload = decodeJoinCode(code) + const name = opts.name ?? payload.n + + if (payload.u) { + if (!payload.t) { + throw new Error('Join code has a remote URL but no token — ask for a new code.') + } + await bootstrapReplica({ + name, + url: payload.u, + token: payload.t, + syncInterval: parsePositiveInt(opts.syncInterval, 'sync-interval'), + }) + setToken(name, payload.t) + } else if (!getProject(name)) { + // A code with no URL only carries the key: it is for a store the member + // already has (a local project shared by other means), not a remote one. + throw new Error( + `Join code carries no remote URL, and there is no local project "${name}" to attach the key to.`, + ) + } + setAccessKey(name, payload.k) + + // Identify the caller against the store they just synced — this is where a + // stale or already-revoked key surfaces, rather than on their next command. + const target = projectToTarget(name, requireProject(name)) + const who = await withTarget(target, (db) => resolveSession(db, payload.k)) + if (who.enabled && !who.ok) { + console.error(`Joined "${name}", but the key did not authenticate.`) + console.error(describeFailure(who.reason)) + process.exitCode = 1 + return + } + + if (opts.use) setCurrent(name) + const identity = + who.enabled && who.ok + ? `${who.session.principal.handle} (${who.session.principal.role})` + : 'no access control on this project' + console.log(`Joined "${name}" as ${identity}.`) + if (!opts.use) console.log(`Run \`bctx project use ${name}\` to switch to it.`) + }) + // ── sync ────────────────────────────────────────────────────────────────── project .command('sync [name]') diff --git a/src/core/access/audit.ts b/src/core/access/audit.ts new file mode 100644 index 0000000..1744a4e --- /dev/null +++ b/src/core/access/audit.ts @@ -0,0 +1,108 @@ +import type { Kysely } from 'kysely' +import { withWriteRetry } from '../tx' +import type { AccessLogTable, Database } from '../types' + +export type Surface = 'cli' | 'studio' | 'mcp' + +export interface AccessLogEntry { + principalId?: string | null + handle?: string | null + agentSource?: string | null + surface: Surface + /** The command path or route that was attempted, e.g. `wiki new`. */ + action: string + targetType?: string | null + targetId?: string | null + decision: 'allow' | 'deny' + /** Serialized to JSON. Keep it small — this row travels to the remote primary. */ + detail?: unknown +} + +export interface AccessLogRow { + id: number + at: string + principalId: string | null + handle: string | null + agentSource: string | null + surface: string + action: string + targetType: string | null + targetId: string | null + decision: 'allow' | 'deny' + detail: string | null +} + +/** + * Append an access decision. + * + * Best effort by design: a store that can't take the log row (a read-only + * connection, a full disk, a racing writer) must not turn a permitted operation + * into a failed one. A missing audit row is a smaller problem than a CLI that + * refuses to work. + */ +export async function logAccess(db: Kysely, entry: AccessLogEntry): Promise { + try { + await withWriteRetry(db, async (trx) => { + await trx + .insertInto('access_log') + .values({ + at: new Date().toISOString(), + principal_id: entry.principalId ?? null, + handle: entry.handle ?? null, + agent_source: entry.agentSource ?? null, + surface: entry.surface, + action: entry.action, + target_type: entry.targetType ?? null, + target_id: entry.targetId ?? null, + decision: entry.decision, + detail: entry.detail === undefined ? null : JSON.stringify(entry.detail), + }) + .execute() + }) + } catch { + // See the doc comment: never let audit bookkeeping fail the operation. + } +} + +export interface AccessLogFilter { + limit?: number + /** Case-insensitive handle match. */ + handle?: string + denyOnly?: boolean + /** ISO instant; only entries at or after it. */ + since?: string +} + +function toRow(r: AccessLogTable & { id: number }): AccessLogRow { + return { + id: r.id, + at: r.at, + principalId: r.principal_id, + handle: r.handle, + agentSource: r.agent_source, + surface: r.surface, + action: r.action, + targetType: r.target_type, + targetId: r.target_id, + decision: r.decision, + detail: r.detail, + } +} + +export async function listAccessLog( + db: Kysely, + filter: AccessLogFilter = {}, +): Promise { + let q = db.selectFrom('access_log').selectAll() + if (filter.denyOnly) q = q.where('decision', '=', 'deny') + if (filter.since) q = q.where('at', '>=', filter.since) + if (filter.handle) { + const want = filter.handle.toLowerCase() + q = q.where((eb) => eb(eb.fn('lower', ['handle']), '=', want)) + } + const rows = await q + .orderBy('id', 'desc') + .limit(filter.limit ?? 50) + .execute() + return rows.map((r) => toRow(r as AccessLogTable & { id: number })) +} diff --git a/src/core/access/cache.ts b/src/core/access/cache.ts new file mode 100644 index 0000000..5c37706 --- /dev/null +++ b/src/core/access/cache.ts @@ -0,0 +1,53 @@ +import type { Kysely } from 'kysely' +import type { Database } from '../types' +import { resolveSession, type SessionResult } from './session' + +/** + * How long a resolved session is trusted before the key is re-verified. + * + * This is the revocation window for long-lived surfaces (the MCP server, a Studio + * login). Verifying costs an scrypt hash — right once per CLI invocation, wrong on + * every MCP tool call — so the choice is between per-call latency and how quickly + * a revoked key stops working. A minute is well inside the time it takes a replica + * to even learn about the revocation from its primary. + */ +export const SESSION_TTL_MS = 60_000 + +export type SessionResolver = (() => Promise) & { invalidate: () => void } + +/** + * A memoizing session resolver for a fixed key. Concurrent callers during a + * refresh share the one in-flight verification rather than each starting their own. + */ +export function createSessionResolver( + db: Kysely, + key: string | null | undefined, + ttlMs = SESSION_TTL_MS, + now: () => number = Date.now, +): SessionResolver { + let cached: SessionResult | null = null + let cachedAt = 0 + let inFlight: Promise | null = null + + const resolver = (async (): Promise => { + if (cached && now() - cachedAt < ttlMs) return cached + if (inFlight) return inFlight + inFlight = resolveSession(db, key) + .then((result) => { + cached = result + cachedAt = now() + return result + }) + .finally(() => { + inFlight = null + }) + return inFlight + }) as SessionResolver + + /** Force the next call to re-verify (used after a key or role change). */ + resolver.invalidate = () => { + cached = null + cachedAt = 0 + } + return resolver +} diff --git a/src/core/access/capabilities.ts b/src/core/access/capabilities.ts new file mode 100644 index 0000000..9ae30cf --- /dev/null +++ b/src/core/access/capabilities.ts @@ -0,0 +1,119 @@ +import { ROLES, type Role } from '../types' + +/** + * The permission vocabulary. Every gated surface (CLI command, Studio route, MCP + * tool) maps to exactly one of these, so "what can this user do" is answerable + * without reading any handler. + */ +export const CAPABILITIES = [ + /** Read contexts, wiki pages, links, tags, the graph, exports. */ + 'read', + /** Create and update contexts, wiki pages, links, tables, properties. */ + 'write', + /** Remove rows (soft or hard). Separate from `write` so a writer can be denied it. */ + 'delete', + /** Download file content / mint presigned URLs. */ + 'files.read', + /** Upload and remove files. */ + 'files.write', + /** View the per-store config (secrets stay masked regardless). */ + 'config.read', + /** Change the per-store config, including storage credentials. */ + 'config.write', + /** Manage principals and keys, and read the access log. */ + 'users.manage', + /** Go online, link, disconnect, enable/disable access control. */ + 'project.manage', +] as const + +export type Capability = (typeof CAPABILITIES)[number] + +const ALL = [...CAPABILITIES] + +/** + * Default capability set per role. + * + * `owner` and `admin` are identical here on purpose: they differ by POLICY, not by + * capability — the last owner cannot be demoted or removed, and only an owner may + * disable access control or delete an admin (see principals.ts). + */ +export const ROLE_CAPABILITIES: Record = { + owner: ALL, + admin: ALL, + writer: ['read', 'write', 'delete', 'files.read', 'files.write', 'config.read'], + reader: ['read', 'files.read', 'config.read'], +} + +/** Per-capability overrides layered over the role defaults. */ +export type CapabilityOverrides = Partial> + +export function isCapability(v: string): v is Capability { + return (CAPABILITIES as readonly string[]).includes(v) +} + +export function isRole(v: string): v is Role { + return (ROLES as readonly string[]).includes(v) +} + +/** The effective capabilities of a role once its overrides are applied. */ +export function resolveCapabilities( + role: Role, + overrides: CapabilityOverrides = {}, +): Set { + const set = new Set(ROLE_CAPABILITIES[role]) + for (const [cap, allowed] of Object.entries(overrides)) { + if (!isCapability(cap)) continue + if (allowed) set.add(cap) + else set.delete(cap) + } + return set +} + +/** Read the stored `capabilities` JSON column, tolerating anything malformed. */ +export function parseOverrides(raw: string | null): CapabilityOverrides { + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const out: CapabilityOverrides = {} + for (const [k, v] of Object.entries(parsed as Record)) { + if (isCapability(k) && typeof v === 'boolean') out[k] = v + } + return out + } catch { + return {} + } +} + +/** Serialize overrides for storage; an empty set becomes NULL, not `{}`. */ +export function serializeOverrides(overrides: CapabilityOverrides): string | null { + const entries = Object.entries(overrides).filter(([k]) => isCapability(k)) + return entries.length ? JSON.stringify(Object.fromEntries(entries)) : null +} + +/** + * Parse the `--cap` CLI spec: a comma-separated list of capabilities, each + * optionally signed. `+delete,-files.write` grants delete and denies files.write; + * an unsigned name is a grant. Throws on an unknown capability rather than + * silently ignoring it — a typo'd `--cap` must never look like it applied. + */ +export function parseCapabilitySpec(spec: string): CapabilityOverrides { + const out: CapabilityOverrides = {} + for (const raw of spec.split(',')) { + const token = raw.trim() + if (!token) continue + const sign = token[0] === '+' || token[0] === '-' ? token[0] : null + const name = sign ? token.slice(1).trim() : token + if (!isCapability(name)) { + throw new Error(`Unknown capability: "${name}". Known: ${CAPABILITIES.join(', ')}.`) + } + out[name] = sign !== '-' + } + return out +} + +/** Render an effective capability set for display, in declaration order. */ +export function formatCapabilities(caps: Set): string { + const list = CAPABILITIES.filter((c) => caps.has(c)) + return list.length ? list.join(', ') : '(none)' +} diff --git a/src/core/access/commands.ts b/src/core/access/commands.ts new file mode 100644 index 0000000..219cff7 --- /dev/null +++ b/src/core/access/commands.ts @@ -0,0 +1,153 @@ +import type { Capability } from './capabilities' + +/** + * `null` = not gated. Either the command never opens a store (local registry + * bookkeeping, disk scaffolding, bundled docs) or it is deliberately reachable + * without a capability (`whoami`, `access status`, `project join`, and the + * `access recover` escape hatch, which are how a locked-out member finds out why). + */ +export type CommandCapability = Capability | null + +/** + * Every CLI command's required capability, keyed by its full commander path. + * + * This is the single place the CLI's permission surface is described — reading it + * top to bottom answers "what can a reader actually run?" without opening a + * handler. `test/command-caps.test.ts` walks the real command tree and fails if an + * entry is missing or extra, so a new command cannot ship ungated by accident. + */ +export const COMMAND_CAPABILITIES: Record = { + // Orientation — open the store, change nothing. + init: 'read', + status: 'read', + whoami: null, + + // Project topology lives in the local registry, not the store. + 'project create': null, + 'project list': null, + 'project use': null, + 'project current': null, + 'project path': null, + 'project rm': null, + 'project status': null, + 'project disconnect': null, + // `link` and `join` bootstrap a replica: the member cannot hold a capability on + // a store they have not synced yet, so the key is verified after the sync + // instead (see commands/project.ts). + 'project link': null, + 'project join': null, + 'project sync': 'read', + 'project migrate-online': 'project.manage', + + // Per-store config (storage credentials). + 'config get': 'config.read', + 'config set': 'config.write', + 'config unset': 'config.write', + + // Files: blobs in the bucket, metadata in the store. + 'file ls': 'files.read', + 'file url': 'files.read', + 'file add': 'files.write', + 'file rm': 'files.write', + // `test` writes and deletes a probe object in the bucket. + 'file test': 'files.write', + + // Access control itself. `status` is ungated on purpose: a member whose key was + // revoked must still be able to see that access control is on and who to ask. + 'access status': null, + 'access recover': null, + 'access init': 'project.manage', + 'access disable': 'project.manage', + 'access log': 'users.manage', + 'access user add': 'users.manage', + 'access user ls': 'users.manage', + 'access user show': 'users.manage', + 'access user update': 'users.manage', + 'access user rm': 'users.manage', + 'access key issue': 'users.manage', + 'access key ls': 'users.manage', + 'access key revoke': 'users.manage', + + // Wiki — reads. + 'wiki get': 'read', + 'wiki show': 'read', + 'wiki backlinks': 'read', + 'wiki related': 'read', + 'wiki path': 'read', + 'wiki graph': 'read', + 'wiki search': 'read', + 'wiki log': 'read', + 'wiki lint': 'read', + 'wiki export': 'read', + 'wiki query': 'read', + 'wiki list-properties': 'read', + 'wiki index': 'read', + 'wiki table get': 'read', + + // Wiki — writes. + 'wiki new': 'write', + 'wiki update': 'write', + 'wiki patch-section': 'write', + 'wiki replace': 'write', + 'wiki link': 'write', + 'wiki unlink': 'write', + 'wiki ingest': 'write', + 'wiki import': 'write', + 'wiki set-prop': 'write', + // Stamps `verifiedAt` and baselines source hashes — a mutation, not a report. + 'wiki verify': 'write', + // Rebuilds derived state (page_properties, and with --links the graph). + 'wiki reindex': 'write', + 'wiki table set': 'write', + 'wiki table add-row': 'write', + 'wiki table rm-row': 'write', + 'wiki table add-col': 'write', + 'wiki table rm-col': 'write', + 'wiki table rename-col': 'write', + 'wiki datatable new': 'write', + 'wiki datatable extract': 'write', + 'wiki view new': 'write', + 'wiki rm': 'delete', + + // Individual context entries. + get: 'read', + list: 'read', + search: 'read', + export: 'read', + add: 'write', + update: 'write', + import: 'write', + rm: 'delete', + + // Skills. The bundled docs ship with the CLI; only `skill` touches the store. + 'skills list': null, + 'skills get': null, + 'skills path': null, + 'skill init': null, + 'skill list': 'read', + 'skill export': 'read', + 'skill add': 'write', + + // Long-running servers. They authenticate once at startup and gate each + // tool/route themselves — see mcp/server.ts and studio/access.ts. + mcp: null, + studio: null, +} + +/** Look up a command path. Unknown paths fail closed — see resolveCommandCapability. */ +export function commandCapability(path: string): CommandCapability | undefined { + return Object.hasOwn(COMMAND_CAPABILITIES, path) ? COMMAND_CAPABILITIES[path] : undefined +} + +/** + * The capability for a command path, defaulting to the most privileged one when + * the path is unmapped. + * + * Fail-closed is the only safe default: a command added without a map entry must + * not silently become world-writable. The exhaustiveness test is what keeps this + * fallback from ever being hit in practice. + */ +export function resolveCommandCapability(path: string): CommandCapability { + const known = commandCapability(path) + return known === undefined ? 'project.manage' : known +} diff --git a/src/core/access/errors.ts b/src/core/access/errors.ts new file mode 100644 index 0000000..90ade89 --- /dev/null +++ b/src/core/access/errors.ts @@ -0,0 +1,30 @@ +import type { Capability } from './capabilities' + +/** A rule of the access layer was violated (bad input, broken invariant). */ +export class AccessError extends Error { + readonly code: string + constructor(message: string, code = 'access_error') { + super(message) + this.name = 'AccessError' + this.code = code + } +} + +/** + * The caller is not permitted to do this. Carries the capability that was missing + * so the audit log and the HTTP layer can report it without re-deriving it. + */ +export class AccessDeniedError extends AccessError { + readonly capability: Capability | undefined + /** True when no identity could be established at all (vs. an identity that lacks the capability). */ + readonly unauthenticated: boolean + constructor( + message: string, + opts: { capability?: Capability; unauthenticated?: boolean; code?: string } = {}, + ) { + super(message, opts.code ?? (opts.unauthenticated ? 'unauthenticated' : 'forbidden')) + this.name = 'AccessDeniedError' + this.capability = opts.capability + this.unauthenticated = opts.unauthenticated ?? false + } +} diff --git a/src/core/access/gate.ts b/src/core/access/gate.ts new file mode 100644 index 0000000..55f79cc --- /dev/null +++ b/src/core/access/gate.ts @@ -0,0 +1,127 @@ +import type { Kysely } from 'kysely' +import { resolveAgent } from '../../lib/agent' +import type { Database } from '../types' +import { logAccess, type Surface } from './audit' +import type { Capability } from './capabilities' +import type { CommandCapability } from './commands' +import { readOnlyPlugin } from './readonly' +import { + type AccessSession, + requireCapability, + resolveSession, + type SessionResult, +} from './session' +import { logReadsEnabled } from './settings' + +/** Capabilities whose ALLOW decisions are always logged (see logReadsEnabled). */ +const ALWAYS_LOGGED: ReadonlySet = new Set([ + 'write', + 'delete', + 'files.write', + 'config.write', + 'users.manage', + 'project.manage', +]) + +export interface GateOptions { + /** The presented access key, if any. */ + key?: string | null + /** Capability to enforce; `null` runs the operation ungated. */ + requires?: CommandCapability + /** What was attempted, for the audit log (a command path or a route). */ + action: string + surface: Surface + targetType?: string | null + targetId?: string | null +} + +export interface Gate { + /** + * The handle to hand downstream. Identical to the input unless the caller + * authenticated as a read-only principal, in which case it carries the + * write-rejecting plugin. + */ + db: Kysely + /** The authenticated session, or null (access off, or no valid key). */ + session: AccessSession | null + result: SessionResult +} + +/** + * Enforce one capability against an already-resolved session, and record the + * decision. The single place all three surfaces (CLI, Studio, MCP) agree on what a + * permission check means. + * + * Throws {@link AccessDeniedError} when the capability is missing — after writing + * the deny to the access log, so a refused attempt is never invisible. + * + * Takes a resolved `SessionResult` rather than a key because the long-lived + * surfaces authenticate once and check many times: re-verifying a key costs an + * scrypt hash, which is right per CLI invocation and wrong per MCP tool call. + */ +export async function authorize( + db: Kysely, + result: SessionResult, + opts: Omit, +): Promise { + // Access control is off: no checks, no log rows. This is the + // backwards-compatibility contract for every project that never opts in. + if (!result.enabled) return + const capability = opts.requires + if (!capability) return + + const session = result.ok ? result.session : null + const base = { + principalId: session?.principal.id ?? null, + handle: session?.principal.handle ?? null, + agentSource: resolveAgent(), + surface: opts.surface, + action: opts.action, + targetType: opts.targetType ?? null, + targetId: opts.targetId ?? null, + } + try { + requireCapability(result, capability) + } catch (err) { + await logAccess(db, { + ...base, + decision: 'deny', + detail: { capability, reason: result.ok ? 'missing_capability' : result.reason }, + }) + throw err + } + if (ALWAYS_LOGGED.has(capability) || (await logReadsEnabled(db))) { + await logAccess(db, { ...base, decision: 'allow', detail: { capability } }) + } +} + +/** One read-only wrapper per underlying handle — `db()` is called per request. */ +const readOnlyHandles = new WeakMap, Kysely>() + +/** + * Wrap a handle so an authenticated read-only principal physically cannot write. + * + * Only an AUTHENTICATED read-only principal gets it. An unauthenticated caller is + * already blocked by the capability check; wrapping it too would break + * `bctx access recover`, whose whole purpose is to write to a store it cannot + * authenticate against. + */ +export function restrictForSession( + db: Kysely, + session: AccessSession | null | undefined, +): Kysely { + if (!session?.readOnly) return db + const cached = readOnlyHandles.get(db) + if (cached) return cached + const wrapped = db.withPlugin(readOnlyPlugin) + readOnlyHandles.set(db, wrapped) + return wrapped +} + +/** Authenticate with a key, enforce a capability, and return a suitable handle. */ +export async function enterGate(db: Kysely, opts: GateOptions): Promise { + const result = await resolveSession(db, opts.key) + await authorize(db, result, opts) + const session = result.enabled && result.ok ? result.session : null + return { db: restrictForSession(db, session), session, result } +} diff --git a/src/core/access/joincode.ts b/src/core/access/joincode.ts new file mode 100644 index 0000000..2aa9703 --- /dev/null +++ b/src/core/access/joincode.ts @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto' +import { AccessError } from './errors' +import { parseKey } from './keys' + +/** Marker segment, so a pasted code is self-identifying. */ +export const JOIN_SCHEME = 'bctxj' + +/** + * Everything a new member needs, in one paste. + * + * SECURITY: in `advisory` mode `t` is the project's SHARED libSQL token — whoever + * holds this code can reach the database directly, with any SQLite client, and is + * bound by these permissions only for as long as they choose to use bctx. Every + * command that prints a join code must say so. The field is reserved for the + * per-user token that `token` mode will mint, which is what makes that upgrade a + * value change rather than a format change. + */ +export interface JoinPayload { + v: 1 + /** Project name to register locally. */ + n: string + /** Remote primary URL. Absent for a local-only project. */ + u?: string + /** libSQL auth token for `u`. */ + t?: string + /** The member's bctx access key. */ + k: string + /** The member's handle. Display only — the key is what establishes identity. */ + h?: string +} + +function checksum(body: string): string { + return createHash('sha256').update(body).digest('base64url').slice(0, 8) +} + +export function encodeJoinCode(payload: JoinPayload): string { + const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return `${JOIN_SCHEME}.${body}.${checksum(body)}` +} + +/** + * Parse and validate a join code. The trailing checksum exists to turn the most + * common failure — a code truncated by a chat client or a line wrap — into a + * precise message instead of a JSON parse error. + */ +export function decodeJoinCode(code: string): JoinPayload { + const bad = (msg: string): never => { + throw new AccessError(msg, 'invalid_join_code') + } + const parts = code.trim().split('.') + if (parts.length !== 3 || parts[0] !== JOIN_SCHEME) { + return bad('Not a join code (expected `bctxj..`).') + } + const [, body, sum] = parts as [string, string, string] + if (checksum(body) !== sum) { + return bad('Join code is truncated or corrupted — copy the whole line and try again.') + } + + let parsed: unknown + try { + parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) + } catch { + return bad('Join code payload is unreadable.') + } + if (!parsed || typeof parsed !== 'object') return bad('Join code payload is unreadable.') + + const p = parsed as Partial + if (p.v !== 1) return bad(`Unsupported join code version: ${String(p.v)}. Upgrade bctx.`) + if (typeof p.n !== 'string' || !p.n) return bad('Join code has no project name.') + if (typeof p.k !== 'string' || !parseKey(p.k)) return bad('Join code has no valid access key.') + if (p.u !== undefined && typeof p.u !== 'string') return bad('Join code has an invalid sync URL.') + if (p.t !== undefined && typeof p.t !== 'string') return bad('Join code has an invalid token.') + + return { + v: 1, + n: p.n, + u: p.u, + t: p.t, + k: p.k, + h: typeof p.h === 'string' ? p.h : undefined, + } +} + +/** Shown when the code carries the shared database token — the honest disclosure. */ +export const JOIN_CODE_WARNING = + "This code contains the project's database token. Anyone who has it can read and\n" + + 'write the remote database directly, bypassing bctx permissions. Send it over a\n' + + 'private channel and treat it like a password.' + +/** Shown for a local project, whose code carries a key but no way to reach a database. */ +export const LOCAL_JOIN_CODE_NOTE = + 'This project has no remote, so the code carries only the access key — it works\n' + + 'for someone who already has a copy of this store. Treat it like a password.' + +/** + * The disclosure that belongs with a given code. Which one applies depends on + * whether a database token is actually embedded, so no surface has to guess (and + * none can warn about a token that is not there). + */ +export function joinCodeWarning(payload: JoinPayload): string { + return payload.t ? JOIN_CODE_WARNING : LOCAL_JOIN_CODE_NOTE +} diff --git a/src/core/access/keys.ts b/src/core/access/keys.ts new file mode 100644 index 0000000..717c133 --- /dev/null +++ b/src/core/access/keys.ts @@ -0,0 +1,160 @@ +import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' +import type { Kysely } from 'kysely' +import type { Database, PrincipalKeysTable, PrincipalsTable } from '../types' + +const scrypt = promisify(scryptCb) as ( + secret: string, + salt: Buffer, + keylen: number, + opts: { N: number; r: number; p: number }, +) => Promise + +/** Marker so a leaked string is recognizable as a bctx key (and greppable in logs). */ +export const KEY_SCHEME = 'bctxk' + +/** + * scrypt work factors. The secret is a 192-bit value WE generate, never a + * human-chosen passphrase, so brute force is already infeasible and the KDF is + * defense in depth (against a store whose keys were somehow seeded by hand). + * These are the standard interactive parameters, ~60ms — a cost paid once per + * CLI invocation, and once per session for Studio/MCP. They live inside the + * stored hash string, so raising them later needs no migration. + */ +const N = 16384 +const R = 8 +const P = 1 +const KEYLEN = 32 +const SALT_BYTES = 16 + +/** 9 bytes → 12 base64url chars. Public half; only needs to be collision-free. */ +const PREFIX_BYTES = 9 +/** 24 bytes → 32 base64url chars (192 bits). */ +const SECRET_BYTES = 24 + +export interface GeneratedKey { + /** The full `bctxk..` string. Shown once, never stored. */ + key: string + prefix: string + secretHash: string +} + +/** Mint a new key: the caller must persist `prefix`/`secretHash` and hand `key` over exactly once. */ +export async function generateKey(): Promise { + const prefix = randomBytes(PREFIX_BYTES).toString('base64url') + const secret = randomBytes(SECRET_BYTES).toString('base64url') + return { + key: `${KEY_SCHEME}.${prefix}.${secret}`, + prefix, + secretHash: await hashSecret(secret), + } +} + +/** PHC-style `scrypt$N$r$p$salt$hash` so the parameters travel with the digest. */ +export async function hashSecret(secret: string): Promise { + const salt = randomBytes(SALT_BYTES) + const hash = await scrypt(secret, salt, KEYLEN, { N, r: R, p: P }) + return `scrypt$${N}$${R}$${P}$${salt.toString('base64url')}$${hash.toString('base64url')}` +} + +export async function verifySecret(secret: string, stored: string): Promise { + const parts = stored.split('$') + if (parts.length !== 6 || parts[0] !== 'scrypt') return false + const n = Number(parts[1]) + const r = Number(parts[2]) + const p = Number(parts[3]) + if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false + let expected: Buffer + let actual: Buffer + try { + expected = Buffer.from(parts[5] as string, 'base64url') + actual = await scrypt(secret, Buffer.from(parts[4] as string, 'base64url'), expected.length, { + N: n, + r, + p, + }) + } catch { + return false // unusable parameters (e.g. maxmem) — treat as a non-match, never throw + } + return expected.length === actual.length && timingSafeEqual(expected, actual) +} + +export interface ParsedKey { + prefix: string + secret: string +} + +/** Split a `bctxk..` string, or null if it isn't one. */ +export function parseKey(key: string): ParsedKey | null { + const parts = key.trim().split('.') + if (parts.length !== 3 || parts[0] !== KEY_SCHEME) return null + const [, prefix, secret] = parts + if (!prefix || !secret) return null + return { prefix, secret } +} + +/** Why a key did not authenticate. Surfaces decide how much of this to reveal. */ +export type KeyFailure = 'malformed' | 'unknown' | 'revoked' | 'expired' | 'disabled' + +export type VerifiedKey = + | { ok: true; principal: PrincipalsTable; key: PrincipalKeysTable } + | { ok: false; reason: KeyFailure } + +/** Refresh `last_used_at` at most this often, so a read command doesn't become a + * write to the remote primary on every invocation. Hour granularity is all any + * "last seen" display needs. */ +const LAST_USED_REFRESH_MS = 60 * 60 * 1000 + +/** + * Authenticate a key against the store. One indexed lookup by the public prefix, + * then a constant-time comparison of the scrypt digest. + * + * A wrong secret is reported as `unknown`, identical to a prefix that does not + * exist: whether a given prefix is registered is not something an unauthenticated + * caller should learn. + */ +export async function verifyKey(db: Kysely, key: string): Promise { + const parsed = parseKey(key) + if (!parsed) return { ok: false, reason: 'malformed' } + + const row = await db + .selectFrom('principal_keys') + .selectAll() + .where('prefix', '=', parsed.prefix) + .executeTakeFirst() + if (!row) return { ok: false, reason: 'unknown' } + if (!(await verifySecret(parsed.secret, row.secret_hash))) return { ok: false, reason: 'unknown' } + + if (row.revoked_at) return { ok: false, reason: 'revoked' } + const now = Date.now() + if (row.expires_at && Date.parse(row.expires_at) <= now) return { ok: false, reason: 'expired' } + + const principal = await db + .selectFrom('principals') + .selectAll() + .where('id', '=', row.principal_id) + .executeTakeFirst() + // The FK is ON DELETE CASCADE, so a missing principal means the row was written + // out-of-band with foreign_keys off — refuse rather than authenticate a ghost. + if (!principal) return { ok: false, reason: 'unknown' } + if (principal.status !== 'active') return { ok: false, reason: 'disabled' } + + await touchKey(db, row, now) + return { ok: true, principal, key: row } +} + +async function touchKey(db: Kysely, row: PrincipalKeysTable, now: number): Promise { + const last = row.last_used_at ? Date.parse(row.last_used_at) : 0 + if (Number.isFinite(last) && now - last < LAST_USED_REFRESH_MS) return + try { + await db + .updateTable('principal_keys') + .set({ last_used_at: new Date(now).toISOString() }) + .where('id', '=', row.id) + .execute() + } catch { + // Best effort. A read-only connection (a phase-2 read-only Turso token) must + // still be able to authenticate — losing a "last seen" timestamp is not a + // reason to lock a legitimate reader out. + } +} diff --git a/src/core/access/principals.ts b/src/core/access/principals.ts new file mode 100644 index 0000000..89555d4 --- /dev/null +++ b/src/core/access/principals.ts @@ -0,0 +1,338 @@ +import type { Kysely } from 'kysely' +import { ulid } from 'ulidx' +import { withWriteRetry } from '../tx' +import type { Database, PrincipalKeysTable, PrincipalStatus, PrincipalsTable, Role } from '../types' +import { + CAPABILITIES, + type Capability, + type CapabilityOverrides, + isRole, + parseOverrides, + resolveCapabilities, + serializeOverrides, +} from './capabilities' +import { AccessError } from './errors' +import { generateKey } from './keys' + +/** + * A principal as the surfaces see it: role plus the RESOLVED capability list, so a + * caller never has to merge overrides itself. The `capabilities` column's raw JSON + * is kept as `overrides` for editing round-trips. + */ +export interface Principal { + id: string + handle: string + displayName: string | null + role: Role + overrides: CapabilityOverrides + capabilities: Capability[] + status: PrincipalStatus + createdAt: string + createdBy: string | null + updatedAt: string +} + +/** A key as the surfaces see it. The hash and the secret are never in this shape. */ +export interface KeyRecord { + id: string + principalId: string + label: string | null + prefix: string + createdAt: string + expiresAt: string | null + lastUsedAt: string | null + revokedAt: string | null + createdBy: string | null + /** Neither revoked nor past its expiry. */ + active: boolean +} + +const HANDLE_RE = /^[a-z0-9][a-z0-9._-]*$/i +const HANDLE_MAX = 64 + +export function validateHandle(handle: string): string { + const h = handle.trim() + if (!HANDLE_RE.test(h) || h.length > HANDLE_MAX) { + throw new AccessError( + `Invalid handle: "${handle}" (letters, digits, '.', '-', '_'; max ${HANDLE_MAX}).`, + 'invalid_handle', + ) + } + return h +} + +export function toPrincipal(row: PrincipalsTable): Principal { + const overrides = parseOverrides(row.capabilities) + const resolved = resolveCapabilities(row.role, overrides) + return { + id: row.id, + handle: row.handle, + displayName: row.display_name, + role: row.role, + overrides, + capabilities: CAPABILITIES.filter((c) => resolved.has(c)), + status: row.status, + createdAt: row.created_at, + createdBy: row.created_by, + updatedAt: row.updated_at, + } +} + +export function toKeyRecord(row: PrincipalKeysTable, now = Date.now()): KeyRecord { + const expired = row.expires_at ? Date.parse(row.expires_at) <= now : false + return { + id: row.id, + principalId: row.principal_id, + label: row.label, + prefix: row.prefix, + createdAt: row.created_at, + expiresAt: row.expires_at, + lastUsedAt: row.last_used_at, + revokedAt: row.revoked_at, + createdBy: row.created_by, + active: !row.revoked_at && !expired, + } +} + +// ── Reads ────────────────────────────────────────────────────────────────────── + +export async function listPrincipals(db: Kysely): Promise { + const rows = await db.selectFrom('principals').selectAll().orderBy('handle').execute() + return rows.map(toPrincipal) +} + +export async function getPrincipalById( + db: Kysely, + id: string, +): Promise { + const row = await db.selectFrom('principals').selectAll().where('id', '=', id).executeTakeFirst() + return row ? toPrincipal(row) : null +} + +/** Handles are matched case-insensitively (the unique index is on `lower(handle)`). */ +export async function getPrincipalByHandle( + db: Kysely, + handle: string, +): Promise { + const rows = await db.selectFrom('principals').selectAll().execute() + const want = handle.trim().toLowerCase() + const row = rows.find((r) => r.handle.toLowerCase() === want) + return row ? toPrincipal(row) : null +} + +async function requirePrincipal(db: Kysely, handle: string): Promise { + const p = await getPrincipalByHandle(db, handle) + if (!p) throw new AccessError(`No such user: "${handle}".`, 'no_such_principal') + return p +} + +export async function countActiveOwners(db: Kysely): Promise { + const rows = await db + .selectFrom('principals') + .select('id') + .where('role', '=', 'owner') + .where('status', '=', 'active') + .execute() + return rows.length +} + +// ── Policy ───────────────────────────────────────────────────────────────────── + +/** + * Owner/admin protection. Two rules, kept here so every surface gets them: + * 1. the last ACTIVE owner can't be demoted, disabled, or deleted — otherwise the + * project locks itself out of its own user management; + * 2. only an owner may modify or delete an owner or an admin, so an admin can't + * quietly remove their peers or their boss. + * `actor` is null for the bootstrap path (`bctx access init`) and for recovery. + */ +async function assertMayModify( + db: Kysely, + actor: Principal | null, + target: Principal, + change: { role?: Role; status?: PrincipalStatus; deleting?: boolean }, +): Promise { + if (actor && actor.role !== 'owner' && (target.role === 'owner' || target.role === 'admin')) { + throw new AccessError( + `Only an owner can modify the ${target.role} "${target.handle}".`, + 'owner_required', + ) + } + const losesOwner = + target.role === 'owner' && + target.status === 'active' && + (change.deleting === true || + change.status === 'disabled' || + (change.role && change.role !== 'owner')) + if (losesOwner && (await countActiveOwners(db)) <= 1) { + throw new AccessError( + `"${target.handle}" is the last active owner — promote another owner first.`, + 'last_owner', + ) + } +} + +// ── Writes ───────────────────────────────────────────────────────────────────── + +export interface CreatePrincipalInput { + handle: string + role: Role + displayName?: string | null + overrides?: CapabilityOverrides + createdBy?: string | null +} + +export async function createPrincipal( + db: Kysely, + input: CreatePrincipalInput, +): Promise { + const handle = validateHandle(input.handle) + if (!isRole(input.role)) throw new AccessError(`Invalid role: "${input.role}".`, 'invalid_role') + if (await getPrincipalByHandle(db, handle)) { + throw new AccessError(`User already exists: "${handle}".`, 'duplicate_handle') + } + const now = new Date().toISOString() + const row: PrincipalsTable = { + id: ulid(), + handle, + display_name: input.displayName ?? null, + role: input.role, + capabilities: serializeOverrides(input.overrides ?? {}), + status: 'active', + created_at: now, + created_by: input.createdBy ?? null, + updated_at: now, + } + await withWriteRetry(db, async (trx) => { + await trx.insertInto('principals').values(row).execute() + }) + return toPrincipal(row) +} + +export interface UpdatePrincipalInput { + role?: Role + displayName?: string | null + overrides?: CapabilityOverrides + status?: PrincipalStatus +} + +export async function updatePrincipal( + db: Kysely, + handle: string, + patch: UpdatePrincipalInput, + actor: Principal | null = null, +): Promise { + const target = await requirePrincipal(db, handle) + if (patch.role !== undefined && !isRole(patch.role)) { + throw new AccessError(`Invalid role: "${patch.role}".`, 'invalid_role') + } + await assertMayModify(db, actor, target, { role: patch.role, status: patch.status }) + + const next = { + role: patch.role ?? target.role, + display_name: patch.displayName === undefined ? target.displayName : patch.displayName, + capabilities: serializeOverrides(patch.overrides ?? target.overrides), + status: patch.status ?? target.status, + updated_at: new Date().toISOString(), + } + await withWriteRetry(db, async (trx) => { + await trx.updateTable('principals').set(next).where('id', '=', target.id).execute() + }) + const updated = await getPrincipalById(db, target.id) + if (!updated) throw new AccessError(`User "${handle}" vanished mid-update.`, 'no_such_principal') + return updated +} + +export async function deletePrincipal( + db: Kysely, + handle: string, + actor: Principal | null = null, +): Promise { + const target = await requirePrincipal(db, handle) + await assertMayModify(db, actor, target, { deleting: true }) + // principal_keys cascades; access_log deliberately does not (the record of what + // this identity did must outlive the identity). + await withWriteRetry(db, async (trx) => { + await trx.deleteFrom('principals').where('id', '=', target.id).execute() + }) + return target +} + +// ── Keys ─────────────────────────────────────────────────────────────────────── + +export interface IssueKeyInput { + label?: string | null + /** ISO 8601 instant. Must be in the future. */ + expiresAt?: string | null + createdBy?: string | null +} + +export interface IssuedKey { + /** The full secret. Displayed once at issue time and never recoverable after. */ + key: string + record: KeyRecord +} + +export async function issueKey( + db: Kysely, + handle: string, + input: IssueKeyInput = {}, +): Promise { + const principal = await requirePrincipal(db, handle) + const expiresAt = normalizeExpiry(input.expiresAt) + const generated = await generateKey() + const now = new Date().toISOString() + const row: PrincipalKeysTable = { + id: ulid(), + principal_id: principal.id, + label: input.label ?? null, + prefix: generated.prefix, + secret_hash: generated.secretHash, + created_at: now, + expires_at: expiresAt, + last_used_at: null, + revoked_at: null, + created_by: input.createdBy ?? null, + } + await withWriteRetry(db, async (trx) => { + await trx.insertInto('principal_keys').values(row).execute() + }) + return { key: generated.key, record: toKeyRecord(row) } +} + +function normalizeExpiry(value: string | null | undefined): string | null { + if (!value) return null + const ms = Date.parse(value) + if (Number.isNaN(ms)) { + throw new Error(`Invalid --expires: "${value}" (expected an ISO 8601 date).`) + } + if (ms <= Date.now()) throw new Error(`--expires must be in the future: "${value}".`) + return new Date(ms).toISOString() +} + +export async function listKeys(db: Kysely, principalId?: string): Promise { + let q = db.selectFrom('principal_keys').selectAll() + if (principalId) q = q.where('principal_id', '=', principalId) + const rows = await q.orderBy('created_at', 'desc').execute() + const now = Date.now() + return rows.map((r) => toKeyRecord(r, now)) +} + +export async function revokeKey(db: Kysely, keyId: string): Promise { + const row = await db + .selectFrom('principal_keys') + .selectAll() + .where('id', '=', keyId) + .executeTakeFirst() + if (!row) throw new AccessError(`No such key: "${keyId}".`, 'no_such_key') + if (row.revoked_at) return toKeyRecord(row) + const revokedAt = new Date().toISOString() + await withWriteRetry(db, async (trx) => { + await trx + .updateTable('principal_keys') + .set({ revoked_at: revokedAt }) + .where('id', '=', keyId) + .execute() + }) + return toKeyRecord({ ...row, revoked_at: revokedAt }) +} diff --git a/src/core/access/readonly.ts b/src/core/access/readonly.ts new file mode 100644 index 0000000..4e81b11 --- /dev/null +++ b/src/core/access/readonly.ts @@ -0,0 +1,53 @@ +import type { + KyselyPlugin, + PluginTransformQueryArgs, + PluginTransformResultArgs, + QueryResult, + RootOperationNode, + UnknownRow, +} from 'kysely' +import { AccessDeniedError } from './errors' + +/** + * Leading keyword of a statement, ignoring leading comments and whitespace. Only + * the FIRST keyword is inspected: matching write verbs anywhere in the text would + * reject legitimate selects (`context_history` filters on the literal `'delete'`). + */ +const WRITE_HEAD = + /^(insert|update|delete|replace|drop|alter|create|vacuum|reindex|attach|detach)\b/i + +function isWriteSql(text: string): boolean { + const head = text.replace(/^(?:\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)+/, '') + return WRITE_HEAD.test(head) +} + +/** + * Defense in depth for a read-only session. + * + * The real gate is the capability check at the surface layer; this is the backstop + * that catches a code path someone forgot to annotate. It is an allow-list: only + * `SelectQueryNode` and non-mutating raw SQL (FTS queries, `PRAGMA`) get through, + * so a query node type added by a future Kysely version fails closed. + * + * Install it with `db.withPlugin(readOnlyPlugin)` AFTER migrations have run — the + * migrator's DDL would otherwise be rejected. + */ +export const readOnlyPlugin: KyselyPlugin = { + transformQuery(args: PluginTransformQueryArgs): RootOperationNode { + const node = args.node + if (node.kind === 'SelectQueryNode') return node + if (node.kind === 'RawNode') { + const raw = node as RootOperationNode & { sqlFragments?: readonly string[] } + const text = (raw.sqlFragments ?? []).join(' ') + if (!isWriteSql(text)) return node + } + throw new AccessDeniedError( + 'This session is read-only: your role grants no write capability.', + { code: 'read_only' }, + ) + }, + + async transformResult(args: PluginTransformResultArgs): Promise> { + return args.result + }, +} diff --git a/src/core/access/session.ts b/src/core/access/session.ts new file mode 100644 index 0000000..ecc4a12 --- /dev/null +++ b/src/core/access/session.ts @@ -0,0 +1,139 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { Kysely } from 'kysely' +import type { Database } from '../types' +import type { Capability } from './capabilities' +import { resolveCapabilities } from './capabilities' +import { AccessDeniedError } from './errors' +import type { KeyFailure } from './keys' +import { verifyKey } from './keys' +import { type Principal, toPrincipal } from './principals' +import { isAccessEnabled } from './settings' + +/** Capabilities that mutate something. A session holding none of these is read-only. */ +const WRITE_CAPABILITIES: Capability[] = [ + 'write', + 'delete', + 'files.write', + 'config.write', + 'users.manage', + 'project.manage', +] + +export interface AccessSession { + principal: Principal + capabilities: Set + /** Id of the key that authenticated this session, for the audit log. */ + keyId: string + /** True when the session holds no mutating capability at all. */ + readOnly: boolean + can(cap: Capability): boolean +} + +/** Why no session could be established. `missing` = the client presented no key. */ +export type SessionFailure = KeyFailure | 'missing' + +export type SessionResult = + /** The project has access control switched off — every surface behaves as before. */ + | { enabled: false } + | { enabled: true; ok: true; session: AccessSession } + | { enabled: true; ok: false; reason: SessionFailure } + +function buildSession(principal: Principal, keyId: string): AccessSession { + const capabilities = resolveCapabilities(principal.role, principal.overrides) + return { + principal, + capabilities, + keyId, + readOnly: !WRITE_CAPABILITIES.some((c) => capabilities.has(c)), + can: (cap) => capabilities.has(cap), + } +} + +/** + * Resolve who the caller is on this store. + * + * The `isAccessEnabled` short-circuit is what keeps this feature invisible to + * every existing project: one indexed `store_config` read, then straight back out. + * Only a store that ran `bctx access init` pays for key verification. + */ +export async function resolveSession( + db: Kysely, + key: string | null | undefined, +): Promise { + if (!(await isAccessEnabled(db))) return { enabled: false } + if (!key?.trim()) return { enabled: true, ok: false, reason: 'missing' } + + const verified = await verifyKey(db, key.trim()) + if (!verified.ok) return { enabled: true, ok: false, reason: verified.reason } + return { + enabled: true, + ok: true, + session: buildSession(toPrincipal(verified.principal), verified.key.id), + } +} + +/** A user-facing explanation of a failed authentication, with the way out. */ +export function describeFailure(reason: SessionFailure): string { + switch (reason) { + case 'missing': + return 'This project requires an access key. Run `bctx project join ` with the code your project admin gave you.' + case 'malformed': + return 'Access key is malformed (expected `bctxk..`). Re-run `bctx project join `.' + case 'unknown': + return 'Access key was not recognized. Ask your project admin for a new join code.' + case 'revoked': + return 'Your access key has been revoked. Ask your project admin for a new one.' + case 'expired': + return 'Your access key has expired. Ask your project admin for a new one.' + case 'disabled': + return 'Your account is disabled on this project. Ask your project admin to re-enable it.' + } +} + +/** + * Enforce a capability, throwing {@link AccessDeniedError} if it is missing. A + * disabled project (`enabled: false`) permits everything — that is the whole + * backwards-compatibility contract. + */ +export function requireCapability(result: SessionResult, capability: Capability): void { + if (!result.enabled) return + if (!result.ok) { + throw new AccessDeniedError(describeFailure(result.reason), { + capability, + unauthenticated: true, + }) + } + if (!result.session.can(capability)) { + const { handle, role } = result.session.principal + throw new AccessDeniedError( + `Permission denied: "${handle}" (${role}) lacks the \`${capability}\` capability.`, + { capability }, + ) + } +} + +// ── Ambient session ──────────────────────────────────────────────────────────── + +/** + * The session in scope for the current operation. + * + * This is `AsyncLocalStorage` rather than an explicit parameter because the only + * consumer is attribution: `contexts.ts`, `wiki.ts`, and `files.ts` stamp + * `principal_id` on the rows they write. Threading an actor argument through every + * core function and its ~20 call sites would be a large, invasive change for one + * nullable column, and every one of those call sites would be free to forget it. + */ +const storage = new AsyncLocalStorage() + +export function runWithSession(session: AccessSession | null, fn: () => Promise): Promise { + return session ? storage.run(session, fn) : fn() +} + +export function currentSession(): AccessSession | undefined { + return storage.getStore() +} + +/** The authenticated author for a row being written, or null when unauthenticated. */ +export function currentPrincipalId(): string | null { + return storage.getStore()?.principal.id ?? null +} diff --git a/src/core/access/settings.ts b/src/core/access/settings.ts new file mode 100644 index 0000000..1aeb44c --- /dev/null +++ b/src/core/access/settings.ts @@ -0,0 +1,56 @@ +import type { Kysely } from 'kysely' +import { getConfigValue, setConfigValue } from '../storeConfig' +import type { Database } from '../types' + +/** + * Access-control settings live in `store_config` (the `access.*` namespace) so they + * travel with the project to every replica, exactly like `storage.*`. + * + * They are deliberately NOT in `STORAGE_CONFIG_KEYS`, so `bctx config set` cannot + * reach them: turning enforcement off is a privileged operation that goes through + * `bctx access disable` and its owner check. + */ +export const ACCESS_ENABLED_KEY = 'access.enabled' +export const ACCESS_MODE_KEY = 'access.mode' +export const ACCESS_LOG_READS_KEY = 'access.logReads' + +/** + * How the project expects its permissions to be enforced. Only `advisory` is + * implemented; the key exists so a store written today is readable by the + * hard-enforcement modes without another migration. + * - `advisory` — enforced by the bctx CLI/Studio/MCP surfaces only. + * - `token` — plus per-user libSQL tokens (read-only tokens for readers). + * - `relay` — writes must go through a server that holds the real credentials. + */ +export const ACCESS_MODES = ['advisory', 'token', 'relay'] as const +export type AccessMode = (typeof ACCESS_MODES)[number] + +export async function isAccessEnabled(db: Kysely): Promise { + return (await getConfigValue(db, ACCESS_ENABLED_KEY)) === '1' +} + +export async function setAccessEnabled(db: Kysely, enabled: boolean): Promise { + await setConfigValue(db, ACCESS_ENABLED_KEY, enabled ? '1' : null) +} + +export async function getAccessMode(db: Kysely): Promise { + const raw = await getConfigValue(db, ACCESS_MODE_KEY) + return (ACCESS_MODES as readonly string[]).includes(raw ?? '') ? (raw as AccessMode) : 'advisory' +} + +export async function setAccessMode(db: Kysely, mode: AccessMode): Promise { + await setConfigValue(db, ACCESS_MODE_KEY, mode) +} + +/** + * Whether allowed READS are written to the access log. Off by default: on a busy + * store the log would be dominated by routine reads, and every entry is a write to + * the remote primary. Denials and allowed writes are always logged. + */ +export async function logReadsEnabled(db: Kysely): Promise { + return (await getConfigValue(db, ACCESS_LOG_READS_KEY)) === '1' +} + +export async function setLogReads(db: Kysely, on: boolean): Promise { + await setConfigValue(db, ACCESS_LOG_READS_KEY, on ? '1' : null) +} diff --git a/src/core/access/status.ts b/src/core/access/status.ts new file mode 100644 index 0000000..7b14dea --- /dev/null +++ b/src/core/access/status.ts @@ -0,0 +1,62 @@ +import type { Kysely } from 'kysely' +import type { Database } from '../types' +import type { Capability } from './capabilities' +import type { AccessSession } from './session' +import { type AccessMode, getAccessMode, isAccessEnabled, logReadsEnabled } from './settings' + +/** The caller's own identity, safe to serve anywhere. */ +export interface Identity { + handle: string + displayName: string | null + role: string + capabilities: Capability[] + readOnly: boolean +} + +/** + * Safe-to-serve view of the access layer — the analogue of `storageStatus()` for + * `storage.*`. Contains no key material of any kind: secrets are write-only, and + * even a key's public prefix is only exposed through the explicit key listings + * that `users.manage` gates. + */ +export interface AccessStatus { + enabled: boolean + mode: AccessMode + logReads: boolean + userCount: number + activeKeyCount: number + /** Null when access control is off, or when the caller is unauthenticated. */ + me: Identity | null +} + +export function toIdentity(session: AccessSession): Identity { + return { + handle: session.principal.handle, + displayName: session.principal.displayName, + role: session.principal.role, + capabilities: session.principal.capabilities, + readOnly: session.readOnly, + } +} + +export async function accessStatus( + db: Kysely, + session?: AccessSession | null, +): Promise { + const enabled = await isAccessEnabled(db) + const [users, keys] = await Promise.all([ + db.selectFrom('principals').select('id').execute(), + db.selectFrom('principal_keys').select(['revoked_at', 'expires_at']).execute(), + ]) + const now = Date.now() + return { + enabled, + mode: await getAccessMode(db), + logReads: await logReadsEnabled(db), + userCount: users.length, + activeKeyCount: keys.filter( + (k) => !k.revoked_at && !(k.expires_at && Date.parse(k.expires_at) <= now), + ).length, + me: session ? toIdentity(session) : null, + } +} diff --git a/src/core/contexts.ts b/src/core/contexts.ts index 4d80e1f..8cd6d54 100644 Binary files a/src/core/contexts.ts and b/src/core/contexts.ts differ diff --git a/src/core/db.ts b/src/core/db.ts index f846c64..78a5edd 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -3,8 +3,11 @@ import { dirname } from 'node:path' import { type Client, createClient } from '@libsql/client' import { LibsqlDialect } from '@libsql/kysely-libsql' import { Kysely, sql } from 'kysely' +import { enterGate } from './access/gate' +import { runWithSession, type SessionResult } from './access/session' import { migrateToLatest } from './migrate' import { type DbOpts, resolveTarget } from './paths' +import { resolveAccessKey } from './registry' import type { Database } from './types' /** @@ -17,9 +20,16 @@ import type { Database } from './types' * seed during `migrate-online` and for fully-online usage. */ export type DbTarget = - | { mode: 'local'; file: string } - | { mode: 'replica'; file: string; syncUrl: string; authToken?: string; syncInterval?: number } - | { mode: 'remote'; url: string; authToken?: string } + | { mode: 'local'; file: string; project?: string } + | { + mode: 'replica' + file: string + syncUrl: string + authToken?: string + syncInterval?: number + project?: string + } + | { mode: 'remote'; url: string; authToken?: string; project?: string } /** * Busy timeout (ms) for local `file:` databases. Passed as the libSQL client @@ -113,12 +123,18 @@ export async function dataVersion(db: Kysely): Promise { /** * Resolve the connection target, ensure its directory exists, open it, freshen a - * replica, run any pending migrations (idempotent), run `fn`, settle the replica, - * then always close the connection. + * replica, run any pending migrations (idempotent), enforce access control, run + * `fn`, settle the replica, then always close the connection. + * + * The access gate sits here — after migrations (its tables must exist) and before + * `fn` — because this is the one path every CLI command's store access goes + * through. `fn` receives the possibly read-only-wrapped handle plus the resolved + * session, and runs inside `runWithSession` so writes downstream can attribute + * themselves without threading an actor argument through core/. */ export async function withDb( opts: DbOpts, - fn: (db: Kysely) => Promise, + fn: (db: Kysely, access: SessionResult) => Promise, ): Promise { const target = resolveTarget(opts) if (target.mode !== 'remote') mkdirSync(dirname(target.file), { recursive: true }) @@ -129,7 +145,13 @@ export async function withDb( await migrateToLatest(store.db, { lockFile: target.mode !== 'remote' ? target.file : undefined, }) - const result = await fn(store.db) + const gate = await enterGate(store.db, { + key: resolveAccessKey(target.project), + requires: opts.requires, + action: opts.action ?? 'cli', + surface: 'cli', + }) + const result = await runWithSession(gate.session, () => fn(gate.db, gate.result)) if (!opts.noSync) await store.sync() return result } finally { diff --git a/src/core/dump.ts b/src/core/dump.ts index dabae0d..303b252 100644 --- a/src/core/dump.ts +++ b/src/core/dump.ts @@ -7,8 +7,14 @@ import type { Database } from './types' * `contexts_fts` and its FTS5 shadow tables are intentionally excluded: the INSERT * trigger on `contexts` rebuilds the full-text index on the destination as rows * land, so copying them would be redundant (and corrupt the shadow layout). + * + * EVERY other table must be listed, including derived ones (`page_properties` is + * rebuilt on write, not on insert, so an unseeded copy would start empty) and + * `store_config` (whose whole point is that per-store settings travel with the + * project). `test/dump.test.ts` asserts this list against the live schema so a new + * migration can't silently drop a table from `bctx project migrate-online`. */ -const SEED_TABLES = [ +export const SEED_TABLES = [ 'contexts', 'tags', 'context_tags', @@ -16,6 +22,12 @@ const SEED_TABLES = [ 'skill_files', 'links', 'wiki_log', + 'page_properties', + 'store_config', + 'files', + 'principals', + 'principal_keys', + 'access_log', ] as const function ident(name: string): string { diff --git a/src/core/files.ts b/src/core/files.ts index b689ca8..f38cc4e 100644 --- a/src/core/files.ts +++ b/src/core/files.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import type { Kysely } from 'kysely' import { ulid } from 'ulidx' +import { currentPrincipalId } from './access/session' import { createS3Store, type StoreFactory } from './storage/s3' import { getStorageConfig, type StorageConfig } from './storeConfig' import type { Database } from './types' @@ -180,6 +181,7 @@ export async function uploadFile( metadata: '{}', created_at: new Date().toISOString(), deleted_at: null, + principal_id: currentPrincipalId(), } await db.insertInto('files').values(row).execute() return toMeta(row) diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 5ce88e3..051e0cd 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -3,6 +3,7 @@ import { Migrator } from 'kysely/migration' import { migration as init0001 } from '../migrations/0001_init' import { migration as pageProps0002 } from '../migrations/0002_page_properties' import { migration as files0003 } from '../migrations/0003_file_storage' +import { migration as access0004 } from '../migrations/0004_access' import { withFileLock } from './lock' import type { Database } from './types' @@ -12,8 +13,9 @@ const MIGRATIONS = { '0001_init': init0001, '0002_page_properties': pageProps0002, '0003_file_storage': files0003, + '0004_access': access0004, } as const -const LATEST = '0003_file_storage' +const LATEST = '0004_access' /** True if the latest migration is already recorded (fast path: no lock, no migrator). */ async function isCurrent(db: Kysely): Promise { diff --git a/src/core/paths.ts b/src/core/paths.ts index 942570a..42d09e7 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import type { CommandCapability } from './access/commands' import type { DbTarget } from './db' import { DEFAULT_PROJECT, getProject, projectToTarget, readConfig } from './registry' @@ -18,6 +19,14 @@ export interface DbOpts { project?: string /** Skip replica sync for this operation (faster; may read/write stale). */ noSync?: boolean + /** + * Capability to enforce when the store has access control on; `null` runs + * ungated. Filled in by `dbOptsFrom` from the command path, so no call site has + * to remember it. Omitted (undefined) means the same as `null`. + */ + requires?: CommandCapability + /** What was attempted, for the access log. Defaults to the command path. */ + action?: string } /** ~/.braincontext/store.db */ diff --git a/src/core/registry.ts b/src/core/registry.ts index 6f3e292..229217d 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -124,47 +124,113 @@ export function writeConfig(cfg: Config): void { // ── Credentials (tokens) live apart from config.json, with 0600 perms ────────── -type Credentials = Record +/** + * Per-project secrets. Two distinct things, both bearer credentials: + * - `authToken` — the libSQL token that reaches the remote primary; + * - `accessKey` — this member's bctx key, which decides what they may do + * (see core/access). Absent for projects without access control. + */ +export interface ProjectCredentials { + authToken?: string + accessKey?: string +} -function readCredentials(): Credentials { +/** On disk an entry is either the object form or a bare token string (pre-0.9). */ +type StoredCredentials = Record + +function readCredentials(): Record { const p = credentialsPath() if (!existsSync(p)) return {} try { const raw = JSON.parse(readFileSync(p, 'utf8')) - return raw && typeof raw === 'object' ? (raw as Credentials) : {} + if (!raw || typeof raw !== 'object') return {} + const out: Record = {} + for (const [name, value] of Object.entries(raw as StoredCredentials)) { + // Legacy entries are the token itself; normalize on read so callers see one shape. + if (typeof value === 'string') out[name] = { authToken: value } + else if (value && typeof value === 'object') { + out[name] = { + authToken: typeof value.authToken === 'string' ? value.authToken : undefined, + accessKey: typeof value.accessKey === 'string' ? value.accessKey : undefined, + } + } + } + return out } catch { return {} } } -function writeCredentials(creds: Credentials): void { +function writeCredentials(creds: Record): void { ensureHome() - atomicWrite(credentialsPath(), `${JSON.stringify(creds, null, 2)}\n`, 0o600) + const out: StoredCredentials = {} + for (const [name, value] of Object.entries(creds)) { + if (!value.authToken && !value.accessKey) continue + // Keep the legacy string shape when there is nothing else to store, so a store + // that never uses access control stays readable by an older bctx. + out[name] = value.accessKey ? value : (value.authToken as string) + } + atomicWrite(credentialsPath(), `${JSON.stringify(out, null, 2)}\n`, 0o600) +} + +function mutateCredential(name: string, patch: ProjectCredentials): void { + const creds = readCredentials() + creds[name] = { ...creds[name], ...patch } + writeCredentials(creds) } /** Persist (or clear, with `null`) a project's auth token in credentials.json. */ export function setToken(name: string, token: string | null): void { + mutateCredential(name, { authToken: token ?? undefined }) +} + +/** Persist (or clear, with `null`) this member's bctx access key for a project. */ +export function setAccessKey(name: string, key: string | null): void { + mutateCredential(name, { accessKey: key ?? undefined }) +} + +/** Forget every secret held for a project. */ +export function clearCredentials(name: string): void { const creds = readCredentials() - if (token === null) delete creds[name] - else creds[name] = token + delete creds[name] writeCredentials(creds) } /** - * Env-var key for a project token, or null when the name can't map unambiguously. Env var - * names allow only `[A-Z0-9_]`, so a name with `.`/`-` would collapse to `_` and could - * collide with a *different* project (sending the wrong token to the wrong remote). For - * those, we expose no env override — credentials.json is keyed by the exact name. + * Env-var key for a per-project secret, or null when the name can't map unambiguously. + * Env var names allow only `[A-Z0-9_]`, so a name with `.`/`-` would collapse to `_` and + * could collide with a *different* project (sending the wrong token to the wrong remote). + * For those, we expose no env override — credentials.json is keyed by the exact name. */ -function tokenEnvKey(name: string): string | null { - return /^[A-Za-z0-9_]+$/.test(name) ? `BCTX_TOKEN_${name.toUpperCase()}` : null +function envKey(prefix: string, name: string): string | null { + return /^[A-Za-z0-9_]+$/.test(name) ? `${prefix}${name.toUpperCase()}` : null } /** Resolve a project's token: `BCTX_TOKEN_` env wins, else credentials.json. */ export function resolveToken(name: string): string | undefined { - const key = tokenEnvKey(name) + const key = envKey('BCTX_TOKEN_', name) const fromEnv = key ? process.env[key] : undefined - return fromEnv ?? readCredentials()[name] + return fromEnv ?? readCredentials()[name]?.authToken +} + +/** + * Resolve this member's access key: `BCTX_KEY_` env, then credentials.json, + * then the unscoped `BCTX_KEY`. + * + * The unscoped fallback exists because a `--db ` target has no project name + * to key on. It is safe to try against the wrong project: a key only verifies + * against the store that issued it, so a mismatch fails authentication rather than + * granting anything. + */ +export function resolveAccessKey(name?: string): string | undefined { + if (name) { + const key = envKey('BCTX_KEY_', name) + const fromEnv = key ? process.env[key] : undefined + if (fromEnv) return fromEnv + const stored = readCredentials()[name]?.accessKey + if (stored) return stored + } + return process.env.BCTX_KEY || undefined } // ── Project CRUD ─────────────────────────────────────────────────────────────── @@ -243,7 +309,7 @@ export function removeProject(name: string): ProjectEntry { delete cfg.projects[name] if (cfg.currentProject === name) cfg.currentProject = DEFAULT_PROJECT }) - setToken(name, null) + clearCredentials(name) // mutateConfig throws above if the project was missing, so `removed` is always set here. return removed as ProjectEntry } @@ -258,7 +324,7 @@ export function projectFilePath(entry: ProjectEntry): string { export function projectToTarget(name: string, entry: ProjectEntry): DbTarget { if (entry.mode === 'remote') { if (!entry.syncUrl) throw new Error(`Project "${name}" is remote but has no syncUrl.`) - return { mode: 'remote', url: entry.syncUrl, authToken: resolveToken(name) } + return { mode: 'remote', url: entry.syncUrl, authToken: resolveToken(name), project: name } } const file = projectFilePath(entry) if (entry.mode === 'replica') { @@ -269,7 +335,8 @@ export function projectToTarget(name: string, entry: ProjectEntry): DbTarget { syncUrl: entry.syncUrl, authToken: resolveToken(name), syncInterval: entry.syncInterval, + project: name, } } - return { mode: 'local', file } + return { mode: 'local', file, project: name } } diff --git a/src/core/types.ts b/src/core/types.ts index 031bd46..f0157f7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -81,6 +81,8 @@ export interface ContextsTable { created_at: string updated_at: string deleted_at: string | null + /** Authenticated author, when the project has access control on. See 0004. */ + principal_id: string | null } export interface TagsTable { @@ -101,6 +103,8 @@ export interface ContextHistoryTable { new_body: string | null agent_source: string | null changed_at: string + /** Authenticated author, when the project has access control on. See 0004. */ + principal_id: string | null } /** Sidecar files of a SKILL.md bundle (scripts/references/assets). See 0002. */ @@ -181,6 +185,69 @@ export interface FilesTable { created_at: string /** Soft delete keeps the id stable for dangling markdown references. */ deleted_at: string | null + /** Authenticated uploader, when the project has access control on. See 0004. */ + principal_id: string | null +} + +// --------------------------------------------------------------------------- +// Access control (see 0004 and core/access/). Inert until `access.enabled`. +// --------------------------------------------------------------------------- + +/** Project roles, ordered most- to least-privileged. */ +export const ROLES = ['owner', 'admin', 'writer', 'reader'] as const +export type Role = (typeof ROLES)[number] + +export const PRINCIPAL_STATUSES = ['active', 'disabled'] as const +export type PrincipalStatus = (typeof PRINCIPAL_STATUSES)[number] + +/** A named identity on this project. */ +export interface PrincipalsTable { + id: string + handle: string + display_name: string | null + role: Role + /** JSON object of per-capability overrides merged over the role defaults. */ + capabilities: string | null + status: PrincipalStatus + created_at: string + /** Principal id of the creator (null for the bootstrap owner). */ + created_by: string | null + updated_at: string +} + +/** A key belonging to a principal. The secret itself is never stored. */ +export interface PrincipalKeysTable { + id: string + principal_id: string + label: string | null + /** Public lookup half of `bctxk..`. */ + prefix: string + /** PHC-style `scrypt$N$r$p$salt$hash`. */ + secret_hash: string + created_at: string + expires_at: string | null + last_used_at: string | null + revoked_at: string | null + created_by: string | null +} + +/** Append-only allow/deny decisions. `handle` is denormalized so the log + * outlives the principal it describes. */ +export interface AccessLogTable { + id: Generated + at: string + principal_id: string | null + handle: string | null + agent_source: string | null + /** 'cli' | 'studio' | 'mcp'. */ + surface: string + /** The command path or route that was attempted, e.g. `wiki new`. */ + action: string + target_type: string | null + target_id: string | null + decision: 'allow' | 'deny' + /** JSON-encoded extra detail (e.g. the missing capability). */ + detail: string | null } export interface Database { @@ -195,4 +262,7 @@ export interface Database { page_properties: PagePropertiesTable store_config: StoreConfigTable files: FilesTable + principals: PrincipalsTable + principal_keys: PrincipalKeysTable + access_log: AccessLogTable } diff --git a/src/mcp/access.ts b/src/mcp/access.ts new file mode 100644 index 0000000..e18019a --- /dev/null +++ b/src/mcp/access.ts @@ -0,0 +1,114 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { Kysely } from 'kysely' +import type { SessionResolver } from '../core/access/cache' +import type { Capability } from '../core/access/capabilities' +import { AccessDeniedError } from '../core/access/errors' +import { authorize } from '../core/access/gate' +import { runWithSession } from '../core/access/session' +import type { Database } from '../core/types' + +/** + * Capability required by each MCP tool. Mirrors COMMAND_CAPABILITIES for the CLI — + * `test/mcp-access.test.ts` asserts every registered tool appears here. + */ +export const MCP_TOOL_CAPABILITIES: Record = { + // Contexts. + search_contexts: 'read', + get_context: 'read', + list_contexts: 'read', + create_context: 'write', + update_context: 'write', + // Soft-delete over MCP, but still the `delete` capability: a writer denied + // `-delete` must not be able to route around it through an agent. + delete_context: 'delete', + + // Wiki — reads. + wiki_search: 'read', + wiki_get: 'read', + wiki_query: 'read', + wiki_list_properties: 'read', + wiki_lint: 'read', + wiki_graph: 'read', + wiki_related: 'read', + wiki_path: 'read', + wiki_table_get: 'read', + wiki_ingest_status: 'read', + + // Wiki — writes. + wiki_new: 'write', + wiki_update: 'write', + wiki_patch_section: 'write', + wiki_replace: 'write', + wiki_link: 'write', + wiki_unlink: 'write', + wiki_ingest: 'write', + wiki_verify: 'write', + wiki_set_prop: 'write', + wiki_datatable_new: 'write', + wiki_view_new: 'write', + wiki_table_set_cell: 'write', + wiki_table_add_row: 'write', + wiki_table_delete_row: 'write', + wiki_table_add_column: 'write', + wiki_table_delete_column: 'write', + wiki_table_rename_column: 'write', +} + +export interface McpAccessContext { + /** The UNWRAPPED store handle — audit rows must be writable even for a reader. */ + db: Kysely + session: SessionResolver +} + +/** + * Gate every tool and resource this server exposes. + * + * Implemented by wrapping `registerTool`/`registerResource` before anything is + * registered, rather than by touching 33 handlers: one interception point means a + * tool added later is gated automatically, and an unmapped name fails closed on + * `project.manage` instead of silently becoming public. + * + * Call this BEFORE registering tools. + */ +export function installAccessGate(server: McpServer, ctx: McpAccessContext): void { + const registerTool = server.registerTool.bind(server) + const registerResource = server.registerResource.bind(server) + + const check = async (action: string, capability: Capability) => { + const result = await ctx.session() + await authorize(ctx.db, result, { requires: capability, action, surface: 'mcp' }) + return result.enabled && result.ok ? result.session : null + } + + server.registerTool = ((name: string, config: unknown, handler: (...a: never[]) => unknown) => + registerTool( + name as never, + config as never, + (async (...args: never[]) => { + const capability = MCP_TOOL_CAPABILITIES[name] ?? 'project.manage' + let session: Awaited> + try { + session = await check(name, capability) + } catch (err) { + // An agent gets a readable refusal it can act on, not a protocol error. + if (err instanceof AccessDeniedError) { + return { content: [{ type: 'text' as const, text: err.message }], isError: true } + } + throw err + } + return runWithSession(session, async () => handler(...args)) + }) as never, + )) as typeof server.registerTool + + server.registerResource = ((name: string, ...rest: unknown[]) => { + const handler = rest.pop() as (...a: never[]) => unknown + return registerResource(name as never, ...(rest as [never, never]), (async ( + ...args: never[] + ) => { + // Resource reads have no error shape of their own; throwing surfaces as a + // protocol error, which is the honest outcome for a refused read. + const session = await check(`resource:${name}`, 'read') + return runWithSession(session, async () => handler(...args)) + }) as never) + }) as typeof server.registerResource +} diff --git a/src/mcp/run.ts b/src/mcp/run.ts index 27294ad..82f2337 100644 --- a/src/mcp/run.ts +++ b/src/mcp/run.ts @@ -1,9 +1,13 @@ import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { createSessionResolver } from '../core/access/cache' +import { restrictForSession } from '../core/access/gate' +import { describeFailure } from '../core/access/session' import { openStore } from '../core/db' import { migrateToLatest } from '../core/migrate' import { type DbOpts, resolveTarget } from '../core/paths' +import { resolveAccessKey } from '../core/registry' import { buildServer } from './server' /** @@ -19,7 +23,30 @@ export async function runMcpStdio(opts: DbOpts): Promise { await store.prepare() await store.sync() await migrateToLatest(store.db, { lockFile: target.mode !== 'remote' ? target.file : undefined }) - const server = buildServer(store.db) + + // Authenticate once, here, so tool handlers can close over a handle that already + // reflects the identity (a reader gets one that physically refuses writes). The + // resolver re-verifies on a short TTL, so a revoked key stops working without + // paying for a key hash on every tool call. + const key = resolveAccessKey(target.project) + const session = createSessionResolver(store.db, key) + const initial = await session() + const gatedDb = restrictForSession( + store.db, + initial.enabled && initial.ok ? initial.session : null, + ) + const server = buildServer(gatedDb, { db: store.db, session }) + + if (initial.enabled) { + // stderr, so an agent operator can see why its tools are refusing. The server + // still starts: every tool call then answers with the reason, which an agent + // can relay, whereas refusing to boot usually shows up as a silent failure. + console.error( + initial.ok + ? `bctx mcp: authenticated as ${initial.session.principal.handle} (${initial.session.principal.role}).` + : `bctx mcp: NOT authenticated — ${describeFailure(initial.reason)}`, + ) + } let closing = false const shutdown = async () => { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c9bd96e..efe34c2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -12,6 +12,7 @@ import { } from '../core/contexts' import { type Database, KINDS, SCOPES } from '../core/types' import { getVersion } from '../lib/pkg' +import { installAccessGate, type McpAccessContext } from './access' import { registerWikiTools } from './wiki-tools' function ok(value: unknown) { @@ -48,13 +49,20 @@ or use a separate device/replica.` /** * Build an MCP server exposing the store. Full CRUD by default; delete is * soft-only (never a hard delete over MCP — history keeps it recoverable). + * + * `access` gates every tool and resource against the identity the server started + * with. Omitting it leaves the server ungated, which is what the tests and any + * caller on a store without access control want. */ -export function buildServer(db: Kysely): McpServer { +export function buildServer(db: Kysely, access?: McpAccessContext): McpServer { const server = new McpServer( { name: 'bctx', version: getVersion() }, { instructions: INSTRUCTIONS }, ) + // Before any registration: the gate works by wrapping the register* methods. + if (access) installAccessGate(server, access) + server.registerTool( 'search_contexts', { diff --git a/src/migrations/0004_access.ts b/src/migrations/0004_access.ts new file mode 100644 index 0000000..77a0225 --- /dev/null +++ b/src/migrations/0004_access.ts @@ -0,0 +1,130 @@ +import { type Kysely, sql } from 'kysely' +import type { Migration } from 'kysely/migration' + +/** + * Project access control: named users (`principals`), the hashed keys they + * authenticate with (`principal_keys`), and an append-only decision log + * (`access_log`). + * + * The feature is OFF until `store_config['access.enabled']` is set, so applying + * this migration alone changes no behavior — see core/access/session.ts. + * + * Enforcement is advisory: the remote is a libSQL primary that clients sync + * against directly, so anyone holding the raw libSQL token can bypass these + * tables with any SQLite client. They give roles, attribution, audit, and + * revocation across the bctx surfaces — not a boundary against a member who + * sets out to defeat them. + * + * Ships as its own incremental migration (see 0002's note): editing 0001 would + * never reach an already-migrated store. All DDL is idempotent to tolerate + * remote/replica re-runs. + */ +export const migration: Migration = { + async up(db: Kysely): Promise { + // --- principals: one row per human/agent identity on this project -------- + await sql` + CREATE TABLE IF NOT EXISTS principals ( + id TEXT PRIMARY KEY, + handle TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'reader' + CHECK (role IN ('owner','admin','writer','reader')), + capabilities TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','disabled')), + created_at TEXT NOT NULL, + created_by TEXT, + updated_at TEXT NOT NULL + ) + `.execute(db) + // Handles are compared case-insensitively, so uniqueness must be too. + await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_principals_handle + ON principals(lower(handle))`.execute(db) + await sql`CREATE INDEX IF NOT EXISTS idx_principals_role ON principals(role)`.execute(db) + + // --- principal_keys: the secrets, stored only as scrypt hashes ----------- + // `prefix` is the PUBLIC half of a key (`bctxk..`): it makes + // verification a single indexed lookup instead of a scan-and-hash over every + // row, and it is what listings show. `secret_hash` is a PHC-style string, so + // the KDF parameters can change without a migration. + await sql` + CREATE TABLE IF NOT EXISTS principal_keys ( + id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE, + label TEXT, + prefix TEXT NOT NULL UNIQUE, + secret_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + last_used_at TEXT, + revoked_at TEXT, + created_by TEXT + ) + `.execute(db) + await sql`CREATE INDEX IF NOT EXISTS idx_principal_keys_principal + ON principal_keys(principal_id)`.execute(db) + + // --- access_log: append-only allow/deny decisions ------------------------ + // `principal_id` is deliberately NOT a foreign key and `handle` is + // denormalized: the log must outlive the user it describes (deleting a + // compromised account must not erase what that account did). + await sql` + CREATE TABLE IF NOT EXISTS access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + principal_id TEXT, + handle TEXT, + agent_source TEXT, + surface TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + decision TEXT NOT NULL CHECK (decision IN ('allow','deny')), + detail TEXT + ) + `.execute(db) + await sql`CREATE INDEX IF NOT EXISTS idx_access_log_at ON access_log(at)`.execute(db) + await sql`CREATE INDEX IF NOT EXISTS idx_access_log_principal + ON access_log(principal_id)`.execute(db) + await sql`CREATE INDEX IF NOT EXISTS idx_access_log_decision + ON access_log(decision)`.execute(db) + + // --- attribution on the rows themselves --------------------------------- + // Nullable: rows written before access control (or with it disabled) keep + // NULL, and `agent_source` stays exactly what it was — this adds identity + // alongside the tool label, it does not redefine it. + await addColumn(db, 'contexts', 'principal_id') + await addColumn(db, 'context_history', 'principal_id') + await addColumn(db, 'files', 'principal_id') + }, + + async down(db: Kysely): Promise { + // The added columns are left in place: SQLite's DROP COLUMN is unavailable on + // older engines and rebuilding `contexts` would take its FTS triggers with it. + // A nullable, unread column is harmless; the tables below are the real state. + for (const stmt of [ + 'DROP TABLE IF EXISTS access_log', + 'DROP TABLE IF EXISTS principal_keys', + 'DROP TABLE IF EXISTS principals', + ]) { + await sql.raw(stmt).execute(db) + } + }, +} + +/** + * `ALTER TABLE … ADD COLUMN`, made idempotent. SQLite has no `IF NOT EXISTS` for + * columns, and this migration can run twice against one remote primary (two + * replicas bootstrapping at once — see core/migrate.ts), so we both pre-check + * and tolerate the racing duplicate. + */ +async function addColumn(db: Kysely, table: string, column: string): Promise { + const info = await sql<{ name: string }>`SELECT name FROM pragma_table_info(${table})`.execute(db) + if (info.rows.some((r) => r.name === column)) return + try { + await sql.raw(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`).execute(db) + } catch (e) { + const msg = String((e as { message?: string })?.message ?? e) + if (!/duplicate column name/i.test(msg)) throw e + } +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..7319978 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,113 @@ +import { Command } from 'commander' +import { accessCommand, whoamiCommand } from './commands/access' +import { addCommand } from './commands/add' +import { configCommand } from './commands/config' +import { exportCommand } from './commands/export' +import { fileCommand } from './commands/file' +import { getCommand } from './commands/get' +import { importCommand } from './commands/import' +import { initCommand } from './commands/init' +import { listCommand } from './commands/list' +import { mcpCommand } from './commands/mcp' +import { projectCommand } from './commands/project' +import { rmCommand } from './commands/rm' +import { searchCommand } from './commands/search' +import { skillCommand } from './commands/skill' +import { skillsCommand } from './commands/skills' +import { statusCommand } from './commands/status' +import { studioCommand } from './commands/studio' +import { updateCommand } from './commands/update' +import { wikiCommand } from './commands/wiki' +import { getVersion } from './lib/pkg' + +/** + * Build the full CLI. Kept apart from the entry point (cli.ts) so the command tree + * can be inspected without running it — `test/command-caps.test.ts` walks it to + * prove every command declares an access capability. + */ +export function buildProgram(): Command { + const program = new Command() + + program + .name('bctx') + .description( + 'braincontext — a local-first context store for AI agents.\n' + + 'Preferred workflow: build a linked knowledge wiki (bctx wiki). The direct\n' + + 'context commands (add/get/list/search/update/rm) are for individual entries.', + ) + .version(getVersion(), '-v, --version') + .option('--db ', 'explicit path to the SQLite store') + .option('--project ', 'use a named project from the registry') + .option('--global', 'use the global store (~/.braincontext/store.db)') + .option('--local', 'use the project store (./.braincontext/store.db)') + .option('--no-sync', 'skip the online sync for this command (replica projects)') + + program.addCommand(initCommand()) + // Orient: where is the store, what's in it, are exports stale. + program.addCommand(statusCommand()) + // Project & sync management. + program.addCommand(projectCommand()) + // Per-store config (in the DB, travels with the project) + S3/R2 file storage. + program.addCommand(configCommand()) + program.addCommand(fileCommand()) + // Users, keys, and the access log for a shared project. + program.addCommand(accessCommand()) + program.addCommand(whoamiCommand()) + // Preferred workflow first. + program.addCommand(wikiCommand()) + // Direct context operations (individual entries). + program.addCommand(addCommand()) + program.addCommand(getCommand()) + program.addCommand(listCommand()) + program.addCommand(updateCommand()) + program.addCommand(rmCommand()) + program.addCommand(searchCommand()) + // Agent-facing surfaces. + program.addCommand(skillsCommand()) + program.addCommand(skillCommand()) + program.addCommand(exportCommand()) + program.addCommand(importCommand()) + program.addCommand(mcpCommand()) + // Human-facing surface: local web UI + JSON API. + program.addCommand(studioCommand()) + + program.addHelpText( + 'after', + ` +Preferred — knowledge wiki (durable, linked, compounding): + $ bctx wiki ingest ./article.md --title "TLS notes" # store a source + synthesis checklist + $ echo "See [[Gateway]]." | bctx wiki new "OAuth2" --type concept --file - + $ bctx wiki link "OAuth2" "Gateway" --type relates + $ bctx wiki search "tls" · bctx wiki lint · bctx wiki index + $ bctx skills get braincontext-wiki --full # the wiki-maintainer playbook + +Individual context operations (single entries — CRUD): + $ echo "Use pnpm, never npm" | bctx add --kind rule --tags tooling --agent claude + $ bctx list --kind rule --json · bctx search "pnpm" · bctx get + $ bctx update --add-tag important · bctx rm + +Files in S3/R2 (blobs in your bucket, metadata in the store): + $ bctx config set storage.endpoint https://.r2.cloudflarestorage.com + $ bctx config set storage.bucket notes && bctx file test + $ bctx file add ./diagram.png # prints wiki embed snippets + $ bctx file ls · bctx file url · bctx file rm + +Projects & online sync (same context across sessions, devices, members): + $ bctx project create work · bctx project use work + $ bctx project migrate-online work --url libsql://… --auth-token … # go online + $ bctx project link work --url libsql://… --auth-token … # on another device + +Shared projects with per-member permissions (advisory — see \`bctx access status\`): + $ bctx access init # become owner, switch enforcement on + $ bctx access user add ana --role writer # prints a one-paste join code + $ bctx project join # on the member's machine + $ bctx whoami · bctx access user ls · bctx access log --deny-only + +Wiki pages are hidden from plain list/search (use --include-wiki to include them). +Store precedence: --db/BCTX_DB > --global/--local > --project/BCTX_PROJECT > +current project > ./.braincontext (if present) > default project (~/.braincontext) +`, + ) + + return program +} diff --git a/src/studio/access.ts b/src/studio/access.ts new file mode 100644 index 0000000..9831cbe --- /dev/null +++ b/src/studio/access.ts @@ -0,0 +1,154 @@ +import { randomBytes } from 'node:crypto' +import type { MiddlewareHandler } from 'hono' +import { getCookie } from 'hono/cookie' +import type { Kysely } from 'kysely' +import { createSessionResolver, type SessionResolver } from '../core/access/cache' +import type { Capability } from '../core/access/capabilities' +import { AccessDeniedError } from '../core/access/errors' +import { authorize } from '../core/access/gate' +import { runWithSession, type SessionResult } from '../core/access/session' +import type { Database } from '../core/types' +import type { StoreProvider } from './stores' + +export const SESSION_COOKIE = 'bctx_sid' + +/** + * Which capability an API request needs. + * + * Rule-based rather than a route table: Studio's routes are conventional (GET + * reads, other verbs write), so a table would be 60 lines that drift the moment a + * route is added. The tail default is the safe one — an unrecognized path is + * treated as ordinary store data. + */ +export function capabilityFor(method: string, path: string): Capability | null { + const p = path.replace(/^\/api/, '') || '/' + const mutating = method !== 'GET' && method !== 'HEAD' + + // Ungated: liveness, the login endpoints themselves, and local registry + // navigation (the CLI's `project use`/`project list` are ungated for the same + // reason — they select WHICH store to open, and that store enforces its own rules). + if (p === '/health') return null + if (p === '/auth' || p.startsWith('/auth/')) return null + if (p === '/projects' || p === '/project' || p === '/project/switch') return null + + if (p === '/project/sync' || p === '/version') return 'read' + if (p === '/access' || p.startsWith('/access/')) return 'users.manage' + + if (p === '/files/status') return 'config.read' + if (p === '/files/config') return mutating ? 'config.write' : 'config.read' + if (p === '/files/config/test') return 'files.write' + if (p === '/files' || p.startsWith('/files/')) return mutating ? 'files.write' : 'files.read' + + if (method === 'DELETE') return 'delete' + return mutating ? 'write' : 'read' +} + +/** + * Browser sessions for Studio. In-process and non-persistent: the server is a + * single short-lived local process, so a restart logging everyone out is correct + * behavior, not a limitation. + * + * Session resolution is cached per (store handle, key). Keying on the handle means + * switching projects at runtime re-verifies against the new store automatically — + * a key issued by project A must not silently authenticate against project B. + */ +export interface StudioSessions { + login(key: string): string + logout(sid: string): void + keyFor(sid: string | undefined): string | undefined + resolve(db: Kysely, key: string | null | undefined): Promise +} + +export function createStudioSessions(): StudioSessions { + const keysBySid = new Map() + const resolvers = new WeakMap, Map>() + + return { + login(key) { + const sid = randomBytes(24).toString('base64url') + keysBySid.set(sid, key) + return sid + }, + logout(sid) { + keysBySid.delete(sid) + }, + keyFor(sid) { + return sid ? keysBySid.get(sid) : undefined + }, + resolve(db, key) { + let perDb = resolvers.get(db) + if (!perDb) { + perDb = new Map() + resolvers.set(db, perDb) + } + const cacheKey = key ?? '' + let resolver = perDb.get(cacheKey) + if (!resolver) { + resolver = createSessionResolver(db, key) + perDb.set(cacheKey, resolver) + } + return resolver() + }, + } +} + +export interface AccessGuardOptions { + provider: StoreProvider + sessions: StudioSessions + /** + * The key this machine already holds for the served project, adopted when a + * request carries no session cookie. It is why the admin who ran `bctx studio` + * never sees a login screen: they are already authenticated at the CLI level, + * and the browser is just another view of that same identity. + */ + localKey?: string | null +} + +/** The key a request presents: its session cookie, else the local machine's key. */ +export function keyForRequest( + sessions: StudioSessions, + cookie: string | undefined, + localKey: string | null | undefined, +): string | null { + return sessions.keyFor(cookie) ?? localKey ?? null +} + +/** + * Enforce access control on `/api/*`. Mounted directly after `localOnlyGuard`, so + * a request has already passed the DNS-rebinding and CSRF checks by the time + * identity is considered. + * + * Handlers run inside `runWithSession`, which is what lets `provider.db()` hand + * back a write-rejecting handle to a reader and lets core/ stamp `principal_id`. + */ +export function accessGuard(opts: AccessGuardOptions): MiddlewareHandler { + return async (c, next) => { + if (!c.req.path.startsWith('/api')) return next() + + const key = keyForRequest(opts.sessions, getCookie(c, SESSION_COOKIE), opts.localKey) + // The RAW handle: audit rows must be writable even for a read-only principal. + const db = opts.provider.db() + const result = await opts.sessions.resolve(db, key) + if (!result.enabled) return next() + + const capability = capabilityFor(c.req.method, c.req.path) + if (capability) { + try { + await authorize(db, result, { + requires: capability, + action: `${c.req.method} ${c.req.path}`, + surface: 'studio', + }) + } catch (err) { + if (err instanceof AccessDeniedError) { + return c.json( + { error: err.message, code: err.code, capability }, + err.unauthenticated ? 401 : 403, + ) + } + throw err + } + } + return runWithSession(result.ok ? result.session : null, () => next()) + } +} diff --git a/src/studio/routes/access.ts b/src/studio/routes/access.ts new file mode 100644 index 0000000..ff8f15d --- /dev/null +++ b/src/studio/routes/access.ts @@ -0,0 +1,199 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { listAccessLog } from '../../core/access/audit' +import { CAPABILITIES, type CapabilityOverrides } from '../../core/access/capabilities' +import { AccessDeniedError, AccessError } from '../../core/access/errors' +import { + encodeJoinCode, + type JoinPayload, + joinCodeWarning, + LOCAL_JOIN_CODE_NOTE, +} from '../../core/access/joincode' +import { + createPrincipal, + deletePrincipal, + getPrincipalByHandle, + issueKey, + listKeys, + listPrincipals, + revokeKey, + updatePrincipal, +} from '../../core/access/principals' +import { currentSession } from '../../core/access/session' +import { accessStatus } from '../../core/access/status' +import { getProject, resolveToken } from '../../core/registry' +import { ROLES } from '../../core/types' +import { intQuery, readJson, strQuery } from '../http' +import type { StoreProvider } from '../stores' + +const overrides = z.record(z.enum(CAPABILITIES), z.boolean()).optional() + +const createBody = z.object({ + handle: z.string().min(1), + role: z.enum(ROLES), + displayName: z.string().nullable().optional(), + capabilities: overrides, + expiresAt: z.string().nullable().optional(), +}) + +const updateBody = z.object({ + role: z.enum(ROLES).optional(), + displayName: z.string().nullable().optional(), + capabilities: overrides, + status: z.enum(['active', 'disabled']).optional(), +}) + +const issueBody = z.object({ + label: z.string().nullable().optional(), + expiresAt: z.string().nullable().optional(), +}) + +/** + * Users, keys, and the access log for the Studio settings panel. Every route here + * needs `users.manage` (see capabilityFor), so the handlers assume the caller is + * already authorized and only apply the actor-relative rules (an admin may not + * touch an owner) that core/access enforces. + * + * Issued secrets are returned EXACTLY ONCE, in the response that creates them. + * Nothing else in this module can read a key back out. + */ +export function accessRoutes(provider: StoreProvider): Hono { + const app = new Hono() + + // Domain error → status. `owner_required` is a permission refusal, not bad input; + // `last_owner` is a well-formed request that would break an invariant. + const STATUS: Record = { + owner_required: 403, + last_owner: 409, + duplicate_handle: 409, + no_such_principal: 404, + no_such_key: 404, + } + app.onError((err, c) => { + if (err instanceof AccessDeniedError) return c.json({ error: err.message, code: err.code }, 403) + if (err instanceof AccessError) { + return c.json({ error: err.message, code: err.code }, STATUS[err.code] ?? 400) + } + throw err + }) + + app.get('/status', async (c) => c.json(await accessStatus(provider.db(), currentSession()))) + + app.get('/users', async (c) => c.json({ users: await listPrincipals(provider.db()) })) + + app.post('/users', async (c) => { + const parsed = await readJson(c, createBody) + if (!parsed.ok) return parsed.res + const db = provider.db() + const actor = currentSession()?.principal ?? null + const { role } = parsed.data + if (actor && actor.role !== 'owner' && (role === 'owner' || role === 'admin')) { + return c.json({ error: `Only an owner can create an ${role}.`, code: 'owner_required' }, 403) + } + const principal = await createPrincipal(db, { + handle: parsed.data.handle, + role, + displayName: parsed.data.displayName ?? null, + overrides: (parsed.data.capabilities ?? {}) as CapabilityOverrides, + createdBy: actor?.id ?? null, + }) + const issued = await issueKey(db, principal.handle, { + expiresAt: parsed.data.expiresAt ?? null, + createdBy: actor?.id ?? null, + }) + return c.json( + { user: principal, ...secretPayload(provider, principal.handle, issued.key) }, + 201, + ) + }) + + app.patch('/users/:handle', async (c) => { + const parsed = await readJson(c, updateBody) + if (!parsed.ok) return parsed.res + const user = await updatePrincipal( + provider.db(), + c.req.param('handle'), + { + role: parsed.data.role, + displayName: parsed.data.displayName, + overrides: parsed.data.capabilities as CapabilityOverrides | undefined, + status: parsed.data.status, + }, + currentSession()?.principal ?? null, + ) + return c.json({ user }) + }) + + app.delete('/users/:handle', async (c) => { + const removed = await deletePrincipal( + provider.db(), + c.req.param('handle'), + currentSession()?.principal ?? null, + ) + return c.json({ removed }) + }) + + app.get('/users/:handle/keys', async (c) => { + const user = await requireUser(provider, c.req.param('handle')) + return c.json({ keys: await listKeys(provider.db(), user.id) }) + }) + + app.post('/users/:handle/keys', async (c) => { + const parsed = await readJson(c, issueBody) + if (!parsed.ok) return parsed.res + const handle = c.req.param('handle') + await requireUser(provider, handle) + const issued = await issueKey(provider.db(), handle, { + label: parsed.data.label ?? null, + expiresAt: parsed.data.expiresAt ?? null, + createdBy: currentSession()?.principal.id ?? null, + }) + return c.json({ record: issued.record, ...secretPayload(provider, handle, issued.key) }, 201) + }) + + app.delete('/keys/:id', async (c) => { + const record = await revokeKey(provider.db(), c.req.param('id')) + return c.json({ record }) + }) + + app.get('/log', async (c) => + c.json({ + entries: await listAccessLog(provider.db(), { + limit: intQuery(c, 'limit') ?? 50, + handle: strQuery(c, 'user'), + denyOnly: c.req.query('denyOnly') === 'true', + }), + }), + ) + + return app +} + +async function requireUser(provider: StoreProvider, handle: string) { + const user = await getPrincipalByHandle(provider.db(), handle) + if (!user) throw new AccessError(`No such user: "${handle}".`, 'no_such_principal') + return user +} + +/** + * The one-time secret payload: the raw key plus, when the project has a remote, a + * ready-to-send join code and the warning that must accompany it. + */ +function secretPayload( + provider: StoreProvider, + handle: string, + key: string, +): { key: string; joinCode: string | null; warning: string } { + const project = provider.status().project + const entry = project ? getProject(project) : undefined + if (!project || !entry) return { key, joinCode: null, warning: LOCAL_JOIN_CODE_NOTE } + const payload: JoinPayload = { + v: 1, + n: project, + u: entry.syncUrl, + t: entry.syncUrl ? resolveToken(project) : undefined, + k: key, + h: handle, + } + return { key, joinCode: encodeJoinCode(payload), warning: joinCodeWarning(payload) } +} diff --git a/src/studio/routes/auth.ts b/src/studio/routes/auth.ts new file mode 100644 index 0000000..de59bf0 --- /dev/null +++ b/src/studio/routes/auth.ts @@ -0,0 +1,96 @@ +import { Hono } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { z } from 'zod' +import { describeFailure } from '../../core/access/session' +import { isAccessEnabled } from '../../core/access/settings' +import { toIdentity } from '../../core/access/status' +import { keyForRequest, SESSION_COOKIE, type StudioSessions } from '../access' +import { readJson } from '../http' +import type { StoreProvider } from '../stores' + +const loginBody = z.object({ key: z.string().min(1) }) + +/** + * Deliberate delay on a failed login. The key space is 192 bits, so this is not + * what makes guessing infeasible — it just stops a scripted client from turning + * the endpoint into a fast oracle. + */ +const FAILED_LOGIN_DELAY_MS = 400 + +/** + * Login/logout/identity. Ungated (see capabilityFor) — these are how a caller + * ACQUIRES an identity, so requiring one would be circular. + */ +export function authRoutes( + provider: StoreProvider, + sessions: StudioSessions, + opts: { localKey?: string | null } = {}, +): Hono { + const app = new Hono() + + app.get('/auth/me', async (c) => { + const db = provider.db() + if (!(await isAccessEnabled(db))) { + return c.json({ enabled: false, authenticated: false, identity: null }) + } + const cookie = getCookie(c, SESSION_COOKIE) + const key = keyForRequest(sessions, cookie, opts.localKey) + const result = await sessions.resolve(db, key) + if (!result.enabled || !result.ok) { + return c.json({ + enabled: true, + authenticated: false, + identity: null, + reason: result.enabled ? result.reason : null, + message: result.enabled ? describeFailure(result.reason) : null, + }) + } + return c.json({ + enabled: true, + authenticated: true, + identity: toIdentity(result.session), + // True when the identity came from this machine's stored key rather than a + // browser login, so the UI can offer "sign in as someone else". + adopted: !cookie, + }) + }) + + app.post('/auth/login', async (c) => { + const parsed = await readJson(c, loginBody) + if (!parsed.ok) return parsed.res + + const db = provider.db() + if (!(await isAccessEnabled(db))) { + return c.json({ error: 'access control is not enabled on this project' }, 400) + } + const result = await sessions.resolve(db, parsed.data.key) + if (!result.enabled || !result.ok) { + await new Promise((r) => setTimeout(r, FAILED_LOGIN_DELAY_MS)) + return c.json( + { + error: result.enabled ? describeFailure(result.reason) : 'access control is not enabled', + reason: result.enabled ? result.reason : null, + }, + 401, + ) + } + + const sid = sessions.login(parsed.data.key) + setCookie(c, SESSION_COOKIE, sid, { + httpOnly: true, + sameSite: 'Strict', + path: '/', + // No `secure`: Studio is http on 127.0.0.1, where a Secure cookie is dropped. + }) + return c.json({ enabled: true, authenticated: true, identity: toIdentity(result.session) }) + }) + + app.post('/auth/logout', (c) => { + const sid = getCookie(c, SESSION_COOKIE) + if (sid) sessions.logout(sid) + deleteCookie(c, SESSION_COOKIE, { path: '/' }) + return c.json({ ok: true }) + }) + + return app +} diff --git a/src/studio/run.ts b/src/studio/run.ts index cb1bac1..bcd9ca1 100644 --- a/src/studio/run.ts +++ b/src/studio/run.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { serve } from '@hono/node-server' import type { Hono } from 'hono' import type { DbOpts } from '../core/paths' +import { resolveAccessKey } from '../core/registry' import { isInteractive } from '../lib/ansi' import { openInBrowser } from '../lib/open' import { resolveStudioDir } from './assets' @@ -36,7 +37,11 @@ export async function runStudio(opts: StudioOpts): Promise { ) console.error(' Run `npm run build` first, or use `npm run studio:dev` for HMR development.') } - const app = buildStudioApp(stores, { staticDir }) + // Adopt the key this machine already holds for the served project, so the person + // who launched Studio is signed in as the identity their CLI uses rather than + // being asked to paste a key into their own browser. + const localKey = resolveAccessKey(stores.status().project ?? undefined) + const app = buildStudioApp(stores, { staticDir, localKey }) let closing = false const shutdown = async () => { diff --git a/src/studio/server.ts b/src/studio/server.ts index 0a0793d..e9e1409 100644 --- a/src/studio/server.ts +++ b/src/studio/server.ts @@ -3,6 +3,9 @@ import { readFile } from 'node:fs/promises' import { extname, join, normalize, sep } from 'node:path' import { Hono, type MiddlewareHandler } from 'hono' import type { StoreFactory } from '../core/storage/s3' +import { accessGuard, createStudioSessions } from './access' +import { accessRoutes } from './routes/access' +import { authRoutes } from './routes/auth' import { contextsRoutes } from './routes/contexts' import { exportRoutes } from './routes/export' import { filesRoutes } from './routes/files' @@ -31,6 +34,12 @@ export interface StudioAppOpts { staticDir: string /** Object-store factory override so tests can fake S3/R2 (defaults to the real client). */ filesStoreFactory?: StoreFactory + /** + * This machine's access key for the served project. Requests without a session + * cookie adopt it, so the person who ran `bctx studio` is already signed in as + * the identity their CLI uses. Omit it to require an explicit browser login. + */ + localKey?: string | null } // Loopback host names. The server binds 127.0.0.1, but that alone does not stop a @@ -110,9 +119,17 @@ export function buildStudioApp(provider: StoreProvider, opts: StudioAppOpts): Ho } }) + // Identity + capability check. After localOnlyGuard (so the request has already + // passed the rebinding/CSRF checks) and before every route. A project without + // access control falls straight through, unchanged. + const sessions = createStudioSessions() + app.use('/api/*', accessGuard({ provider, sessions, localKey: opts.localKey })) + // --- read/write JSON API (same-origin) --- app.route('/api', healthRoutes(provider)) + app.route('/api', authRoutes(provider, sessions, { localKey: opts.localKey })) app.route('/api', projectsRoutes(provider)) + app.route('/api/access', accessRoutes(provider)) app.route('/api/contexts', contextsRoutes(provider)) app.route('/api/wiki', wikiRoutes(provider)) app.route('/api/tags', tagsRoutes(provider)) diff --git a/src/studio/stores.ts b/src/studio/stores.ts index cfb1fc5..d743ee4 100644 --- a/src/studio/stores.ts +++ b/src/studio/stores.ts @@ -1,6 +1,8 @@ import { mkdirSync } from 'node:fs' import { dirname } from 'node:path' import type { Kysely } from 'kysely' +import { restrictForSession } from '../core/access/gate' +import { currentSession } from '../core/access/session' import { type DbTarget, openStore, type Store } from '../core/db' import { migrateToLatest } from '../core/migrate' import { type DbOpts, resolveTarget } from '../core/paths' @@ -37,7 +39,11 @@ export interface ProjectInfo { * the real {@link StoreManager}. */ export interface StoreProvider { - /** The current store's Kysely handle. Re-read on every request — it changes on switch. */ + /** + * The current store's Kysely handle. Re-read on every request — it changes on + * switch, and it reflects the caller: inside a request handled for a read-only + * principal it returns a handle that refuses writes (see restrictForSession). + */ db(): Kysely status(): ProjectStatus projects(): ProjectInfo[] @@ -125,7 +131,7 @@ export async function createStoreManager(opts: DbOpts): Promise { } return { - db: () => store.db, + db: () => restrictForSession(store.db, currentSession()), status, projects: () => listProjects().map((p) => ({ name: p.name, mode: p.entry.mode, current: p.name === name })), @@ -148,7 +154,7 @@ export async function createStoreManager(opts: DbOpts): Promise { */ export function staticProvider(db: Kysely): StoreProvider { return { - db: () => db, + db: () => restrictForSession(db, currentSession()), status: () => ({ project: 'test', mode: 'local', location: ':test:', noSync: true }), projects: () => [{ name: 'test', mode: 'local', current: true }], switch: async () => ({ project: 'test', mode: 'local', location: ':test:', noSync: true }), diff --git a/studio/src/App.tsx b/studio/src/App.tsx index fc41d1a..79809c6 100644 --- a/studio/src/App.tsx +++ b/studio/src/App.tsx @@ -3,6 +3,7 @@ import { DARK, LIGHT } from './lib/theme' import { useRoute } from './state/router' import { StoreProvider, useApp } from './state/StoreContext' import { ContextsView } from './views/ContextsView' +import { LoginView } from './views/LoginView' import { SettingsView } from './views/SettingsView' import { WikiView } from './views/WikiView' import './styles/studio.css' @@ -32,10 +33,18 @@ function Shell() { fontFamily: "'IBM Plex Sans',sans-serif", } as React.CSSProperties + // Access control is on and this browser has no identity: every /api call would + // 401, so show the one screen that can fix that instead of an empty workspace. + // `auth === null` is "not asked yet" — render nothing rather than flash a login. + // `promptLogin` is the deliberate "sign in as someone else" path. + const locked = (app.auth?.enabled === true && !app.auth.authenticated) || app.promptLogin + return (
- {view === 'contexts' ? ( + {locked ? ( + + ) : view === 'contexts' ? ( ) : view === 'settings' ? ( diff --git a/studio/src/api/access.ts b/studio/src/api/access.ts new file mode 100644 index 0000000..b1a0574 --- /dev/null +++ b/studio/src/api/access.ts @@ -0,0 +1,131 @@ +import { api, qs } from './client' + +/** + * Access-control client. Mirrors src/core/access + src/studio/routes/access.ts. + * + * A raw key is only ever present in the response that CREATES it (`IssuedSecret`); + * nothing here can read one back afterwards. + */ + +export type Capability = + | 'read' + | 'write' + | 'delete' + | 'files.read' + | 'files.write' + | 'config.read' + | 'config.write' + | 'users.manage' + | 'project.manage' + +export type Role = 'owner' | 'admin' | 'writer' | 'reader' + +export const ROLE_OPTIONS: Role[] = ['owner', 'admin', 'writer', 'reader'] + +export interface Identity { + handle: string + displayName: string | null + role: string + capabilities: Capability[] + readOnly: boolean +} + +export interface AuthState { + enabled: boolean + authenticated: boolean + identity: Identity | null + /** Set when unauthenticated: why, in words the user can act on. */ + message?: string | null + /** True when the identity came from this machine's stored key, not a browser login. */ + adopted?: boolean +} + +export interface User { + id: string + handle: string + displayName: string | null + role: Role + overrides: Partial> + capabilities: Capability[] + status: 'active' | 'disabled' + createdAt: string + updatedAt: string +} + +export interface KeyRecord { + id: string + principalId: string + label: string | null + prefix: string + createdAt: string + expiresAt: string | null + lastUsedAt: string | null + revokedAt: string | null + active: boolean +} + +/** Returned exactly once, when a key is minted. */ +export interface IssuedSecret { + key: string + joinCode: string | null + warning: string +} + +export interface AccessLogEntry { + id: number + at: string + handle: string | null + agentSource: string | null + surface: string + action: string + decision: 'allow' | 'deny' + detail: string | null +} + +export const authApi = { + me: () => api.get('/auth/me'), + login: (key: string) => api.post('/auth/login', { key }), + logout: () => api.post<{ ok: true }>('/auth/logout'), +} + +export const accessApi = { + users: () => api.get<{ users: User[] }>('/access/users').then((r) => r.users), + createUser: (input: { + handle: string + role: Role + displayName?: string | null + capabilities?: Partial> + }) => api.post<{ user: User } & IssuedSecret>('/access/users', input), + updateUser: ( + handle: string, + patch: { + role?: Role + displayName?: string | null + capabilities?: Partial> + status?: 'active' | 'disabled' + }, + ) => api.patch<{ user: User }>(`/access/users/${encodeURIComponent(handle)}`, patch), + deleteUser: (handle: string) => + api.del<{ removed: User }>(`/access/users/${encodeURIComponent(handle)}`), + keys: (handle: string) => + api + .get<{ keys: KeyRecord[] }>(`/access/users/${encodeURIComponent(handle)}/keys`) + .then((r) => r.keys), + issueKey: (handle: string, label?: string) => + api.post<{ record: KeyRecord } & IssuedSecret>( + `/access/users/${encodeURIComponent(handle)}/keys`, + { label: label ?? null }, + ), + revokeKey: (id: string) => + api.del<{ record: KeyRecord }>(`/access/keys/${encodeURIComponent(id)}`), + log: (opts: { limit?: number; user?: string; denyOnly?: boolean } = {}) => + api + .get<{ entries: AccessLogEntry[] }>( + `/access/log${qs({ + limit: opts.limit, + user: opts.user, + denyOnly: opts.denyOnly ? 'true' : undefined, + })}`, + ) + .then((r) => r.entries), +} diff --git a/studio/src/state/StoreContext.tsx b/studio/src/state/StoreContext.tsx index 4ac8c46..9b062c9 100644 --- a/studio/src/state/StoreContext.tsx +++ b/studio/src/state/StoreContext.tsx @@ -1,5 +1,6 @@ import type React from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { type AuthState, authApi, type Capability } from '../api/access' import { ApiError, api } from '../api/client' import { projectsApi } from '../api/projects' import type { ProjectInfo, ProjectStatus } from '../api/types' @@ -27,6 +28,27 @@ interface AppState { rev: number bump: () => void toast: (message: string) => void + /** Who we are on this store. Null until the first /auth/me resolves. */ + auth: AuthState | null + refreshAuth: () => Promise + login: (key: string) => Promise + logout: () => Promise + /** + * Force the sign-in screen while already authenticated. + * + * Needed because signing out cannot end an ADOPTED session: that identity comes + * from the key this machine holds on disk, so clearing the cookie just re-adopts + * it. Signing in as someone else is the only way to change identity there. + */ + promptLogin: boolean + beginSwitchIdentity: () => void + cancelSwitchIdentity: () => void + /** + * Whether the current identity holds a capability. True when access control is + * off, so every existing project keeps its full UI. This drives affordances only + * — the server refuses the request either way. + */ + can: (capability: Capability) => boolean } const Ctx = createContext(null) @@ -44,6 +66,8 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { const [projects, setProjects] = useState([]) const [rev, setRev] = useState(0) const [toastMsg, setToastMsg] = useState(null) + const [auth, setAuth] = useState(null) + const [promptLogin, setPromptLogin] = useState(false) const toggleTheme = useCallback(() => { setTheme((t) => { @@ -112,10 +136,68 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { } }, [bump, toast]) + const refreshAuth = useCallback(async () => { + try { + setAuth(await authApi.me()) + } catch { + // The server is briefly unavailable (a project switch, a restart). Leaving the + // previous answer in place avoids flashing the login screen at someone who is + // signed in; the next call corrects it. + } + }, []) + + const login = useCallback( + async (key: string) => { + const next = await authApi.login(key) + setAuth(next) + setPromptLogin(false) + bump() + toast(`Signed in as ${next.identity?.handle ?? 'unknown'}`) + }, + [bump, toast], + ) + + const logout = useCallback(async () => { + try { + await authApi.logout() + } finally { + // The server may hand back an adopted identity immediately (this machine's + // key), so report what actually happened rather than assuming "signed out". + const next = await authApi.me().catch(() => null) + if (next) setAuth(next) + bump() + toast( + next?.authenticated + ? `Signed out — now using this machine's key (${next.identity?.handle ?? 'unknown'})` + : 'Signed out', + ) + } + }, [bump, toast]) + + const beginSwitchIdentity = useCallback(() => setPromptLogin(true), []) + const cancelSwitchIdentity = useCallback(() => setPromptLogin(false), []) + + const can = useCallback( + (capability: Capability) => { + // Unknown or disabled → permissive: a project without access control must look + // exactly as it did before this feature existed. + if (!auth?.enabled) return true + return auth.identity?.capabilities.includes(capability) ?? false + }, + [auth], + ) + useEffect(() => { refreshProjects() }, [refreshProjects]) + // Identity is re-read on every project switch: a key issued by one project does + // not authenticate against another, so `rev` (which bumps on switch) is the trigger. + // biome-ignore lint/correctness/useExhaustiveDependencies: refetch on project switch + useEffect(() => { + void refreshAuth() + }, [refreshAuth, rev]) + // Live refresh: poll the store's data_version so writes from OTHER connections // (agents via MCP, the CLI) show up without a manual reload. Any change means // "maybe modified" (the counter is per-connection; magnitude is meaningless). @@ -155,6 +237,14 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { rev, bump, toast, + auth, + refreshAuth, + login, + logout, + can, + promptLogin, + beginSwitchIdentity, + cancelSwitchIdentity, }), [ theme, @@ -169,6 +259,14 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { rev, bump, toast, + auth, + refreshAuth, + login, + logout, + can, + promptLogin, + beginSwitchIdentity, + cancelSwitchIdentity, ], ) diff --git a/studio/src/views/AccessSection.tsx b/studio/src/views/AccessSection.tsx new file mode 100644 index 0000000..945cc17 --- /dev/null +++ b/studio/src/views/AccessSection.tsx @@ -0,0 +1,445 @@ +import { useCallback, useEffect, useState } from 'react' +import { + type AccessLogEntry, + accessApi, + type IssuedSecret, + type KeyRecord, + ROLE_OPTIONS, + type Role, + type User, +} from '../api/access' +import { ApiError } from '../api/client' +import { Hov, sx } from '../lib/dc' +import { useApp } from '../state/StoreContext' + +const label = + "display:block;font:600 11px 'IBM Plex Mono';letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 0 5px;" +const input = + "width:100%;box-sizing:border-box;padding:8px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font:400 13.5px 'IBM Plex Mono';outline:none;" +const btn = (primary: boolean) => + `padding:8px 16px;border-radius:8px;cursor:pointer;border:1px solid ${primary ? 'var(--accent)' : 'var(--border)'};background:${primary ? 'var(--accent)' : 'var(--surface)'};color:${primary ? '#fff' : 'var(--ink-soft)'};font:600 13px 'IBM Plex Sans';` +const chip = + "display:inline-block;padding:2px 8px;border-radius:999px;font:600 11px 'IBM Plex Mono';border:1px solid var(--border);color:var(--muted);" + +const ROLE_HINT: Record = { + owner: 'everything, including access control itself', + admin: 'everything except removing the last owner', + writer: 'read and write pages, files and entries', + reader: 'read only', +} + +/** + * Users & access, rendered inside #/settings for anyone holding `users.manage`. + * + * The one-time secret panel is the load-bearing part: a key exists in the response + * that created it and nowhere else, so this is the only moment it can be copied. + */ +export function AccessSection() { + const app = useApp() + const [users, setUsers] = useState(null) + const [keys, setKeys] = useState>({}) + const [expanded, setExpanded] = useState(null) + const [log, setLog] = useState([]) + const [showLog, setShowLog] = useState(false) + const [busy, setBusy] = useState(false) + + // The freshly minted secret, held until dismissed. Never refetched. + const [issued, setIssued] = useState<(IssuedSecret & { handle: string }) | null>(null) + + const [newHandle, setNewHandle] = useState('') + const [newRole, setNewRole] = useState('writer') + const [pendingDelete, setPendingDelete] = useState(null) + + const fail = useCallback( + (e: unknown, fallback: string) => app.toast(e instanceof ApiError ? e.message : fallback), + [app.toast], + ) + + const refresh = useCallback(async () => { + try { + setUsers(await accessApi.users()) + } catch (e) { + fail(e, 'Failed to load users') + } + }, [fail]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: refetch on project switch/write + useEffect(() => { + void refresh() + }, [refresh, app.rev]) + + const loadKeys = async (handle: string) => { + if (expanded === handle) { + setExpanded(null) + return + } + setExpanded(handle) + try { + setKeys((k) => ({ ...k, [handle]: [] })) + const list = await accessApi.keys(handle) + setKeys((k) => ({ ...k, [handle]: list })) + } catch (e) { + fail(e, 'Failed to load keys') + } + } + + const addUser = async () => { + const handle = newHandle.trim() + if (!handle) return + setBusy(true) + try { + const res = await accessApi.createUser({ handle, role: newRole }) + setIssued({ ...res, handle }) + setNewHandle('') + await refresh() + } catch (e) { + fail(e, 'Could not create the user') + } finally { + setBusy(false) + } + } + + const changeRole = async (handle: string, role: Role) => { + try { + await accessApi.updateUser(handle, { role }) + await refresh() + app.toast(`${handle} is now ${role}`) + } catch (e) { + fail(e, 'Could not change the role') + } + } + + const toggleStatus = async (user: User) => { + const status = user.status === 'active' ? 'disabled' : 'active' + try { + await accessApi.updateUser(user.handle, { status }) + await refresh() + } catch (e) { + fail(e, 'Could not change the status') + } + } + + const removeUser = async (handle: string) => { + if (pendingDelete !== handle) { + setPendingDelete(handle) + return + } + setPendingDelete(null) + try { + await accessApi.deleteUser(handle) + await refresh() + app.toast(`Removed ${handle}`) + } catch (e) { + fail(e, 'Could not remove the user') + } + } + + const issueKey = async (handle: string) => { + try { + const res = await accessApi.issueKey(handle) + setIssued({ ...res, handle }) + if (expanded === handle) setKeys((k) => ({ ...k, [handle]: [] })) + await loadKeysSilently(handle) + } catch (e) { + fail(e, 'Could not issue a key') + } + } + + const loadKeysSilently = async (handle: string) => { + try { + const list = await accessApi.keys(handle) + setKeys((k) => ({ ...k, [handle]: list })) + } catch { + /* the listing is refreshed on the next expand */ + } + } + + const revoke = async (handle: string, id: string) => { + try { + await accessApi.revokeKey(id) + await loadKeysSilently(handle) + app.toast('Key revoked') + } catch (e) { + fail(e, 'Could not revoke the key') + } + } + + const openLog = async () => { + setShowLog((s) => !s) + if (showLog) return + try { + setLog(await accessApi.log({ limit: 40 })) + } catch (e) { + fail(e, 'Failed to load the access log') + } + } + + const copy = (text: string, what: string) => { + void navigator.clipboard?.writeText(text).then( + () => app.toast(`${what} copied`), + () => app.toast('Could not copy — select the text instead'), + ) + } + + return ( + <> +

+ Users & access +

+

+ Roles are enforced by every bctx surface — this UI, the CLI, and the MCP server. They are + not a barrier against someone using the raw database token directly, so only hand that token + to people you would trust with full access. +

+ + {issued && setIssued(null)} />} + + {/* ── add ─────────────────────────────────────────────────────────── */} + +
+ setNewHandle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !busy) void addUser() + }} + /> + + { + if (!busy) void addUser() + }} + > + {busy ? 'Adding…' : 'Add'} + +
+

+ {newRole}: {ROLE_HINT[newRole]} +

+ + {/* ── list ────────────────────────────────────────────────────────── */} + + {users === null ? ( +
Loading…
+ ) : users.length === 0 ? ( +
No users yet.
+ ) : ( +
+ {users.map((u, i) => ( +
0 ? 'border-top:1px solid var(--border);' : ''}background:var(--surface);`, + )} + > +
+ + {u.handle} + + {u.handle === app.auth?.identity?.handle && you} + {u.status === 'disabled' && ( + disabled + )} + + + void loadKeys(u.handle)}> + {expanded === u.handle ? 'Hide keys' : 'Keys'} + + void toggleStatus(u)}> + {u.status === 'active' ? 'Disable' : 'Enable'} + + void removeUser(u.handle)}> + {pendingDelete === u.handle ? 'Confirm?' : 'Remove'} + +
+ + {expanded === u.handle && ( +
+ {(keys[u.handle] ?? []).length === 0 ? ( +
+ No keys. +
+ ) : ( + (keys[u.handle] ?? []).map((k) => ( +
+ {k.prefix}… + {k.revokedAt ? 'revoked' : k.active ? 'active' : 'expired'} + {k.lastUsedAt ? `used ${k.lastUsedAt.slice(0, 10)}` : 'unused'} + + {!k.revokedAt && ( + void revoke(u.handle, k.id)}> + Revoke + + )} +
+ )) + )} + void issueKey(u.handle)}>Issue a new key +
+ )} +
+ ))} +
+ )} + + {/* ── audit ───────────────────────────────────────────────────────── */} +
+ + {showLog ? 'Hide access log' : 'Show access log'} + +
+ {showLog && ( +
+ {log.length === 0 ? ( +
+ Nothing logged yet. Denials are always recorded; allowed reads are not. +
+ ) : ( + log.map((e) => ( +
+ {e.at.slice(0, 19).replace('T', ' ')} + {e.decision === 'deny' ? 'DENY' : 'allow'} + {e.handle ?? '—'} + {e.surface} + {e.action} +
+ )) + )} +
+ )} + + ) +} + +function Action({ + children, + onClick, + danger, +}: { + children: React.ReactNode + onClick: () => void + danger?: boolean +}) { + return ( + + {children} + + ) +} + +/** The only place a raw key is ever visible. Dismissing it loses the secret for good. */ +function SecretPanel({ + issued, + onCopy, + onDismiss, +}: { + issued: IssuedSecret & { handle: string } + onCopy: (text: string, what: string) => void + onDismiss: () => void +}) { + return ( +
+
+ Credentials for “{issued.handle}” — shown once +
+ + {issued.joinCode ? ( + <> +
+ Join code — they run bctx project join <code> +
+ onCopy(issued.joinCode as string, 'Join code')} + /> + + ) : ( +
+ This project has no remote, so there is no join code — hand over the key itself. +
+ )} + +
+ Raw key +
+ onCopy(issued.key, 'Key')} /> + +
+ {issued.warning} +
+ + + I have saved it + +
+ ) +} + +function Secret({ value, onCopy }: { value: string; onCopy: () => void }) { + return ( +
+ e.currentTarget.select()} + style={sx(`${input}flex:1;font-size:12px;`)} + /> + + Copy + +
+ ) +} diff --git a/studio/src/views/LoginView.tsx b/studio/src/views/LoginView.tsx new file mode 100644 index 0000000..ab71e51 --- /dev/null +++ b/studio/src/views/LoginView.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { ApiError } from '../api/client' +import { Hov, sx } from '../lib/dc' +import { useApp } from '../state/StoreContext' + +const input = + "width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font:400 13.5px 'IBM Plex Mono';outline:none;" + +/** + * Shown when the project has access control on and this browser has no identity. + * + * The person who launched `bctx studio` normally never sees it — the server adopts + * the key their CLI already holds. It appears when there is no local key, when the + * key was revoked, or after an explicit sign-out. + */ +export function LoginView({ message }: { message?: string | null }) { + const app = useApp() + // Reached from "Switch identity" rather than by being locked out. + const switching = app.auth?.authenticated === true + const [key, setKey] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const submit = async () => { + const trimmed = key.trim() + if (!trimmed) return + setBusy(true) + setError(null) + try { + await app.login(trimmed) + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Sign-in failed') + } finally { + setBusy(false) + } + } + + return ( +
+
+

+ {switching + ? 'Sign in as someone else' + : `${app.project?.project ?? 'This project'} needs a key`} +

+

+ {switching + ? `Currently ${app.auth?.identity?.handle ?? 'unknown'} (from this machine's stored key). Paste another key to use a different identity here.` + : (message ?? + 'This project has access control enabled. Paste the access key your project admin gave you.')} +

+ + setKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void submit() + }} + /> + + {error && ( +
+ {error} +
+ )} + + { + if (!busy) void submit() + }} + > + {busy ? 'Checking…' : 'Sign in'} + + + {switching && ( + + Cancel + + )} + +

+ Have a join code instead? Run bctx project join <code> in a terminal — + it stores the key for this machine, and Studio picks it up on reload. +

+
+
+ ) +} diff --git a/studio/src/views/SettingsView.tsx b/studio/src/views/SettingsView.tsx index f0fb7fb..0f08887 100644 --- a/studio/src/views/SettingsView.tsx +++ b/studio/src/views/SettingsView.tsx @@ -4,6 +4,7 @@ import { type FileMeta, fileContentUrl, filesApi, type StorageStatus } from '../ import { Icon } from '../components/common/Icon' import { Hov, sx } from '../lib/dc' import { useApp } from '../state/StoreContext' +import { AccessSection } from './AccessSection' const label = "display:block;font:600 11px 'IBM Plex Mono';letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:14px 0 5px;" @@ -71,9 +72,45 @@ function groupFiles(files: FileMeta[], groupBy: 'date' | 'type'): FileGroup[] { } /** - * Store settings (#/settings). Currently one section: S3/R2 file storage. The - * secret is write-only — it is submitted only when the field is non-empty and is - * never echoed back by the API. + * Who you are on this store, plus a way out. Rendered only when the project has + * access control on — otherwise there is no identity to show. + */ +function IdentityBadge() { + const app = useApp() + const identity = app.auth?.enabled ? app.auth.identity : null + if (!identity) return null + return ( + <> + + {identity.handle} · {identity.role} + + (app.auth?.adopted ? app.beginSwitchIdentity() : void app.logout())} + title={ + app.auth?.adopted + ? "This identity comes from this machine's stored key — sign in to use another" + : 'Sign out of Studio' + } + > + {app.auth?.adopted ? 'Switch identity' : 'Sign out'} + + + ) +} + +/** + * Store settings (#/settings). Two sections: S3/R2 file storage, and — for anyone + * who can manage them — users and keys. The storage secret is write-only: it is + * submitted only when the field is non-empty and is never echoed back by the API. */ export function SettingsView({ onNav }: { onNav: (v: 'wiki' | 'contexts' | 'settings') => void }) { const app = useApp() @@ -192,6 +229,7 @@ export function SettingsView({ onNav }: { onNav: (v: 'wiki' | 'contexts' | 'sett Settings + )} + + {/* Users & keys — only for an identity that can manage them. The routes + enforce this too; hiding it just avoids showing a panel that 403s. */} + {app.can('users.manage') && }
diff --git a/test/access.test.ts b/test/access.test.ts new file mode 100644 index 0000000..2f34521 --- /dev/null +++ b/test/access.test.ts @@ -0,0 +1,393 @@ +import { createHash } from 'node:crypto' +import type { Kysely } from 'kysely' +import { describe, expect, it } from 'vitest' +import { listAccessLog } from '../src/core/access/audit' +import { parseCapabilitySpec, resolveCapabilities } from '../src/core/access/capabilities' +import { AccessDeniedError, AccessError } from '../src/core/access/errors' +import { enterGate } from '../src/core/access/gate' +import { decodeJoinCode, encodeJoinCode } from '../src/core/access/joincode' +import { generateKey, parseKey, verifyKey, verifySecret } from '../src/core/access/keys' +import { + createPrincipal, + deletePrincipal, + issueKey, + listKeys, + revokeKey, + updatePrincipal, +} from '../src/core/access/principals' +import { resolveSession } from '../src/core/access/session' +import { setAccessEnabled } from '../src/core/access/settings' +import { accessStatus } from '../src/core/access/status' +import { + createContext, + getContext, + listContexts, + listHistory, + searchContexts, +} from '../src/core/contexts' +import { dataVersion } from '../src/core/db' +import { queryPages } from '../src/core/query' +import type { Database } from '../src/core/types' +import { backlinks, createPage, listPages, wikiGraph } from '../src/core/wiki' +import { freshDb } from './_db' + +/** A store with access control on, one owner, and one key for `handle`/`role`. */ +async function storeWith(role: 'owner' | 'admin' | 'writer' | 'reader', handle = 'member') { + const db = await freshDb() + await createPrincipal(db, { handle: 'boss', role: 'owner' }) + const principal = await createPrincipal(db, { handle, role }) + const issued = await issueKey(db, handle) + await setAccessEnabled(db, true) + return { db, principal, key: issued.key, keyId: issued.record.id } +} + +describe('keys', () => { + it('round-trips a generated secret and rejects a wrong one', async () => { + const generated = await generateKey() + const parsed = parseKey(generated.key) + expect(parsed?.prefix).toBe(generated.prefix) + expect(await verifySecret(parsed?.secret as string, generated.secretHash)).toBe(true) + expect(await verifySecret('not-the-secret', generated.secretHash)).toBe(false) + }) + + it('never stores the secret itself', async () => { + const { db, key } = await storeWith('writer') + const rows = await db.selectFrom('principal_keys').selectAll().execute() + const secret = parseKey(key)?.secret as string + expect(JSON.stringify(rows)).not.toContain(secret) + await db.destroy() + }) + + it('authenticates a valid key', async () => { + const { db, key } = await storeWith('writer', 'ana') + const result = await verifyKey(db, key) + expect(result.ok).toBe(true) + expect(result.ok && result.principal.handle).toBe('ana') + await db.destroy() + }) + + it.each([ + ['malformed', 'not-a-key'], + ['unknown', 'bctxk.aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + ])('rejects a %s key', async (reason, candidate) => { + const { db } = await storeWith('writer') + const result = await verifyKey(db, candidate) + expect(result).toEqual({ ok: false, reason }) + await db.destroy() + }) + + it('rejects a revoked key', async () => { + const { db, key, keyId } = await storeWith('writer') + await revokeKey(db, keyId) + expect(await verifyKey(db, key)).toEqual({ ok: false, reason: 'revoked' }) + await db.destroy() + }) + + it('rejects an expired key', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'temp', role: 'reader' }) + const issued = await issueKey(db, 'temp', { + expiresAt: new Date(Date.now() + 50).toISOString(), + }) + await new Promise((r) => setTimeout(r, 80)) + expect(await verifyKey(db, issued.key)).toEqual({ ok: false, reason: 'expired' }) + await db.destroy() + }) + + it('rejects a key belonging to a disabled user', async () => { + const { db, key } = await storeWith('writer', 'ana') + await updatePrincipal(db, 'ana', { status: 'disabled' }) + expect(await verifyKey(db, key)).toEqual({ ok: false, reason: 'disabled' }) + await db.destroy() + }) + + it('refuses an expiry in the past', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'x', role: 'reader' }) + await expect(issueKey(db, 'x', { expiresAt: '2000-01-01T00:00:00Z' })).rejects.toThrow( + /must be in the future/, + ) + await db.destroy() + }) +}) + +describe('capabilities', () => { + it('gives each role its documented set', () => { + expect([...resolveCapabilities('owner')]).toContain('users.manage') + expect([...resolveCapabilities('writer')]).toContain('write') + expect(resolveCapabilities('writer').has('users.manage')).toBe(false) + expect(resolveCapabilities('reader').has('write')).toBe(false) + expect(resolveCapabilities('reader').has('read')).toBe(true) + }) + + it('layers overrides over the role defaults', () => { + const caps = resolveCapabilities('writer', parseCapabilitySpec('-delete,+users.manage')) + expect(caps.has('delete')).toBe(false) + expect(caps.has('users.manage')).toBe(true) + expect(caps.has('write')).toBe(true) + }) + + it('rejects an unknown capability rather than ignoring it', () => { + expect(() => parseCapabilitySpec('+wrte')).toThrow(/Unknown capability/) + }) +}) + +describe('principal policy', () => { + it('refuses to remove the last active owner', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'solo', role: 'owner' }) + await expect(deletePrincipal(db, 'solo')).rejects.toThrow(/last active owner/) + await expect(updatePrincipal(db, 'solo', { role: 'reader' })).rejects.toThrow( + /last active owner/, + ) + await expect(updatePrincipal(db, 'solo', { status: 'disabled' })).rejects.toThrow( + /last active owner/, + ) + await db.destroy() + }) + + it('allows demoting an owner once another owner exists', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'a', role: 'owner' }) + await createPrincipal(db, { handle: 'b', role: 'owner' }) + expect((await updatePrincipal(db, 'b', { role: 'writer' })).role).toBe('writer') + await db.destroy() + }) + + it('stops an admin from modifying an owner or another admin', async () => { + const db = await freshDb() + const owner = await createPrincipal(db, { handle: 'boss', role: 'owner' }) + const admin = await createPrincipal(db, { handle: 'adm', role: 'admin' }) + await createPrincipal(db, { handle: 'adm2', role: 'admin' }) + await expect(updatePrincipal(db, 'boss', { role: 'reader' }, admin)).rejects.toThrow( + /Only an owner/, + ) + await expect(deletePrincipal(db, 'adm2', admin)).rejects.toThrow(/Only an owner/) + // The owner may. + expect((await updatePrincipal(db, 'adm2', { role: 'writer' }, owner)).role).toBe('writer') + await db.destroy() + }) + + it('rejects duplicate handles case-insensitively', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'Ana', role: 'writer' }) + await expect(createPrincipal(db, { handle: 'ana', role: 'reader' })).rejects.toThrow( + AccessError, + ) + await db.destroy() + }) + + it('keeps the access log when a user is deleted', async () => { + const { db, principal } = await storeWith('writer', 'ana') + await enterGate(db, { + key: null, + requires: 'write', + action: 'wiki new', + surface: 'cli', + }).catch(() => undefined) + await deletePrincipal(db, 'ana') + expect(await listKeys(db, principal.id)).toEqual([]) // keys cascade + expect((await listAccessLog(db)).length).toBeGreaterThan(0) // history does not + await db.destroy() + }) +}) + +describe('enterGate', () => { + const gate = (db: Kysely, key: string | null, requires: 'read' | 'write' | 'delete') => + enterGate(db, { key, requires, action: `test ${requires}`, surface: 'cli' }) + + it('permits everything when access control is off', async () => { + const db = await freshDb() + const result = await gate(db, null, 'write') + expect(result.result.enabled).toBe(false) + expect(result.session).toBeNull() + expect(await listAccessLog(db)).toEqual([]) // a disabled project logs nothing + await db.destroy() + }) + + it('lets a writer write and a reader read', async () => { + const w = await storeWith('writer') + await expect(gate(w.db, w.key, 'write')).resolves.toBeTruthy() + await w.db.destroy() + + const r = await storeWith('reader') + await expect(gate(r.db, r.key, 'read')).resolves.toBeTruthy() + await r.db.destroy() + }) + + it('denies a reader a write, and records the denial', async () => { + const { db, key } = await storeWith('reader', 'ana') + await expect(gate(db, key, 'write')).rejects.toThrow(AccessDeniedError) + const denials = await listAccessLog(db, { denyOnly: true }) + expect(denials).toHaveLength(1) + expect(denials[0]?.handle).toBe('ana') + expect(denials[0]?.action).toBe('test write') + await db.destroy() + }) + + it('denies an unauthenticated caller with a way out', async () => { + const { db } = await storeWith('reader') + await expect(gate(db, null, 'read')).rejects.toThrow(/bctx project join/) + await db.destroy() + }) + + it('hands a reader a handle that physically refuses writes', async () => { + const { db, key } = await storeWith('reader') + const readOnly = (await gate(db, key, 'read')).db + await expect(listContexts(readOnly, {})).resolves.toEqual([]) + // The capability check already refused the operation; this is the backstop for + // any path that forgot to ask. + await expect(createContext(readOnly, { body: 'sneaky', kind: 'note' })).rejects.toThrow( + /read-only/, + ) + await db.destroy() + }) + + it('does not restrict a writer handle', async () => { + const { db, key } = await storeWith('writer') + const handle = (await gate(db, key, 'write')).db + await expect(createContext(handle, { body: 'ok', kind: 'note' })).resolves.toBeTruthy() + await db.destroy() + }) + + it('leaves every read path a reader uses working', async () => { + // The read-only plugin inspects raw SQL, so a careless rule would reject + // legitimate reads — FTS5 search and `PRAGMA data_version` (which Studio polls + // every 2.5s) are both raw. A false positive here would silently break search + // for every reader, so exercise the real query paths, not just the ORM ones. + const db = await freshDb() + const ctx = await createContext(db, { body: 'pnpm over npm', kind: 'rule', title: 'Pkg' }) + const page = await createPage(db, { + title: 'Alpha', + pageType: 'concept', + body: 'see [[Beta]]', + metadata: { props: { status: 'active' } }, + }) + await createPrincipal(db, { handle: 'r', role: 'reader' }) + const issued = await issueKey(db, 'r') + await setAccessEnabled(db, true) + const readOnly = (await gate(db, issued.key, 'read')).db + + expect((await searchContexts(readOnly, 'pnpm')).map((c) => c.id)).toEqual([ctx.id]) + expect(await dataVersion(readOnly)).toBeTypeOf('number') + expect((await getContext(readOnly, ctx.id))?.id).toBe(ctx.id) + expect((await listHistory(readOnly, ctx.id)).length).toBeGreaterThan(0) + expect((await listPages(readOnly, {})).length).toBe(1) + expect((await queryPages(readOnly, { where: { status: 'active' } })).length).toBe(1) + await expect(wikiGraph(readOnly, {})).resolves.toBeTruthy() + await expect(backlinks(readOnly, page.id)).resolves.toBeTruthy() + await db.destroy() + }) + + it('logs allowed writes but not allowed reads by default', async () => { + const { db, key } = await storeWith('writer') + await gate(db, key, 'read') + expect(await listAccessLog(db)).toEqual([]) + await gate(db, key, 'write') + const log = await listAccessLog(db) + expect(log).toHaveLength(1) + expect(log[0]?.decision).toBe('allow') + await db.destroy() + }) +}) + +describe('attribution', () => { + it('stamps the authenticated principal on rows it writes', async () => { + const { db, key, principal } = await storeWith('writer', 'ana') + const { runWithSession } = await import('../src/core/access/session') + const session = await resolveSession(db, key) + if (!session.enabled || !session.ok) throw new Error('expected a session') + + const ctx = await runWithSession(session.session, () => + createContext(db, { body: 'authored', kind: 'note' }), + ) + const row = await db + .selectFrom('contexts') + .select('principal_id') + .where('id', '=', ctx.id) + .executeTakeFirst() + expect(row?.principal_id).toBe(principal.id) + + const history = await db + .selectFrom('context_history') + .select('principal_id') + .where('context_id', '=', ctx.id) + .executeTakeFirst() + expect(history?.principal_id).toBe(principal.id) + await db.destroy() + }) + + it('leaves principal_id null when access control is off', async () => { + const db = await freshDb() + const ctx = await createContext(db, { body: 'anon', kind: 'note' }) + const row = await db + .selectFrom('contexts') + .select('principal_id') + .where('id', '=', ctx.id) + .executeTakeFirst() + expect(row?.principal_id).toBeNull() + await db.destroy() + }) +}) + +describe('accessStatus', () => { + it('reports counts and never leaks key material', async () => { + const { db, key } = await storeWith('writer', 'ana') + const session = await resolveSession(db, key) + const status = await accessStatus(db, session.enabled && session.ok ? session.session : null) + expect(status.enabled).toBe(true) + expect(status.mode).toBe('advisory') + expect(status.userCount).toBe(2) + expect(status.activeKeyCount).toBe(1) + expect(status.me?.handle).toBe('ana') + const serialized = JSON.stringify(status) + expect(serialized).not.toContain(parseKey(key)?.secret as string) + expect(serialized).not.toContain('scrypt$') + await db.destroy() + }) +}) + +describe('join codes', () => { + it('round-trips a payload', () => { + const payload = { + v: 1 as const, + n: 'work', + u: 'libsql://example.turso.io', + t: 'tok', + k: 'bctxk.aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + h: 'ana', + } + expect(decodeJoinCode(encodeJoinCode(payload))).toEqual(payload) + }) + + it('detects a body that lost characters, instead of failing obscurely', () => { + const code = encodeJoinCode({ + v: 1, + n: 'work', + k: 'bctxk.aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }) + // A code mangled in transit but still shaped like one: the checksum is what + // turns this into an actionable message rather than a JSON parse error. + const [scheme, body, sum] = code.split('.') as [string, string, string] + const damaged = `${scheme}.${body.slice(0, -5)}.${sum}` + expect(() => decodeJoinCode(damaged)).toThrow(/truncated or corrupted/) + }) + + it('rejects something that is not a join code', () => { + expect(() => decodeJoinCode('hello')).toThrow(/Not a join code/) + // A code cut off before its checksum no longer has three segments. + const code = encodeJoinCode({ + v: 1, + n: 'work', + k: 'bctxk.aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }) + expect(() => decodeJoinCode(code.slice(0, code.length - 12))).toThrow(/Not a join code/) + }) + + it('rejects a payload without a usable key', () => { + const body = Buffer.from(JSON.stringify({ v: 1, n: 'work', k: 'nope' })).toString('base64url') + // Checksum recomputed, so it is the KEY that fails validation, not the framing. + const sum = createHash('sha256').update(body).digest('base64url').slice(0, 8) + expect(() => decodeJoinCode(`bctxj.${body}.${sum}`)).toThrow(/no valid access key/) + }) +}) diff --git a/test/command-caps.test.ts b/test/command-caps.test.ts new file mode 100644 index 0000000..e5900e6 --- /dev/null +++ b/test/command-caps.test.ts @@ -0,0 +1,46 @@ +import type { Command } from 'commander' +import { describe, expect, it } from 'vitest' +import { COMMAND_CAPABILITIES, commandCapability } from '../src/core/access/commands' +import { buildProgram } from '../src/program' + +/** Every runnable command path (a command with no subcommands of its own). */ +function leafPaths(command: Command, prefix: string[]): string[] { + const here = [...prefix, command.name()] + const children = command.commands as Command[] + if (children.length === 0) return [here.join(' ')] + return children.flatMap((child) => leafPaths(child, here)) +} + +describe('COMMAND_CAPABILITIES covers the CLI exactly', () => { + const paths = (buildProgram().commands as Command[]).flatMap((c) => leafPaths(c, [])) + + it('finds a non-trivial command tree', () => { + expect(paths.length).toBeGreaterThan(60) + expect(new Set(paths).size).toBe(paths.length) // no duplicate paths + }) + + it('declares a capability for every command', () => { + // A command with no entry falls back to `project.manage` (fail closed), which + // would lock every non-admin out of it. That default is a backstop, not a + // design — anything missing here is a bug in the new command, not in the map. + const missing = paths.filter((p) => commandCapability(p) === undefined) + expect(missing).toEqual([]) + }) + + it('has no entries for commands that no longer exist', () => { + const live = new Set(paths) + expect(Object.keys(COMMAND_CAPABILITIES).filter((p) => !live.has(p))).toEqual([]) + }) + + it('gates the obvious write paths and leaves reads readable', () => { + expect(commandCapability('wiki new')).toBe('write') + expect(commandCapability('wiki rm')).toBe('delete') + expect(commandCapability('wiki get')).toBe('read') + expect(commandCapability('config set')).toBe('config.write') + expect(commandCapability('access user add')).toBe('users.manage') + // Deliberately ungated so a locked-out member can find out why. + expect(commandCapability('whoami')).toBeNull() + expect(commandCapability('access status')).toBeNull() + expect(commandCapability('project join')).toBeNull() + }) +}) diff --git a/test/mcp-access.test.ts b/test/mcp-access.test.ts new file mode 100644 index 0000000..458f99f --- /dev/null +++ b/test/mcp-access.test.ts @@ -0,0 +1,120 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import type { Kysely } from 'kysely' +import { describe, expect, it } from 'vitest' +import { listAccessLog } from '../src/core/access/audit' +import { createSessionResolver } from '../src/core/access/cache' +import { restrictForSession } from '../src/core/access/gate' +import { createPrincipal, issueKey } from '../src/core/access/principals' +import { resolveSession } from '../src/core/access/session' +import { setAccessEnabled } from '../src/core/access/settings' +import type { Database } from '../src/core/types' +import { MCP_TOOL_CAPABILITIES } from '../src/mcp/access' +import { buildServer } from '../src/mcp/server' +import { freshDb } from './_db' + +/** Mirror of runMcpStdio's startup: authenticate once, gate the server with it. */ +async function connectAs(db: Kysely, key: string | null): Promise { + const session = createSessionResolver(db, key) + const initial = await session() + const gatedDb = restrictForSession(db, initial.enabled && initial.ok ? initial.session : null) + const server = buildServer(gatedDb, { db, session }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return client +} + +async function storeWith(role: 'writer' | 'reader') { + const db = await freshDb() + await createPrincipal(db, { handle: 'boss', role: 'owner' }) + await createPrincipal(db, { handle: 'member', role }) + const issued = await issueKey(db, 'member') + await setAccessEnabled(db, true) + return { db, key: issued.key } +} + +describe('MCP access gate', () => { + it('maps every registered tool to a capability', async () => { + const db = await freshDb() + const client = await connectAs(db, null) + const names = (await client.listTools()).tools.map((t) => t.name) + expect(names.length).toBeGreaterThan(30) + // An unmapped tool falls back to `project.manage`, locking out everyone but an + // admin. That is the fail-closed backstop, not the intended configuration. + expect(names.filter((n) => !MCP_TOOL_CAPABILITIES[n])).toEqual([]) + // And no stale entries for tools that no longer exist. + const live = new Set(names) + expect(Object.keys(MCP_TOOL_CAPABILITIES).filter((n) => !live.has(n))).toEqual([]) + await db.destroy() + }) + + it('lets a writer create and a reader read', async () => { + const w = await storeWith('writer') + const writer = await connectAs(w.db, w.key) + const created = await writer.callTool({ + name: 'create_context', + arguments: { body: 'from an agent', kind: 'note' }, + }) + expect(created.isError).toBeFalsy() + await w.db.destroy() + + const r = await storeWith('reader') + const reader = await connectAs(r.db, r.key) + const listed = await reader.callTool({ name: 'list_contexts', arguments: {} }) + expect(listed.isError).toBeFalsy() + await r.db.destroy() + }) + + it('refuses a reader a write tool, with a message the agent can act on', async () => { + const { db, key } = await storeWith('reader') + const client = await connectAs(db, key) + const result = await client.callTool({ + name: 'create_context', + arguments: { body: 'should not land', kind: 'note' }, + }) + expect(result.isError).toBe(true) + expect(JSON.stringify(result.content)).toMatch(/Permission denied/) + expect(await db.selectFrom('contexts').selectAll().execute()).toEqual([]) + const denials = await listAccessLog(db, { denyOnly: true }) + expect(denials[0]?.action).toBe('create_context') + expect(denials[0]?.surface).toBe('mcp') + await db.destroy() + }) + + it('refuses an unauthenticated caller once access control is on', async () => { + const { db } = await storeWith('writer') + const client = await connectAs(db, null) + const result = await client.callTool({ name: 'list_contexts', arguments: {} }) + expect(result.isError).toBe(true) + expect(JSON.stringify(result.content)).toMatch(/requires an access key/) + await db.destroy() + }) + + it('stays fully open when access control is off', async () => { + const db = await freshDb() + const client = await connectAs(db, null) + const created = await client.callTool({ + name: 'create_context', + arguments: { body: 'no gate here', kind: 'note' }, + }) + expect(created.isError).toBeFalsy() + expect(await listAccessLog(db)).toEqual([]) + await db.destroy() + }) + + it('attributes an agent write to the authenticated principal', async () => { + const { db, key } = await storeWith('writer') + const client = await connectAs(db, key) + await client.callTool({ + name: 'create_context', + arguments: { body: 'attributed', kind: 'note' }, + }) + const session = await resolveSession(db, key) + const row = await db.selectFrom('contexts').select('principal_id').executeTakeFirst() + expect(row?.principal_id).toBe( + session.enabled && session.ok ? session.session.principal.id : null, + ) + await db.destroy() + }) +}) diff --git a/test/migration.test.ts b/test/migration.test.ts index 8884795..dfad7ca 100644 --- a/test/migration.test.ts +++ b/test/migration.test.ts @@ -14,8 +14,8 @@ const tableExists = async (db: Awaited>, name: string } describe('migrations — incrementals upgrade an existing store without data loss', () => { - it('applies 0002 + 0003 to a store already at 0001, preserving data', async () => { - const db = await freshDb() // fully migrated (0001 + 0002 + 0003) + it('applies 0002 + 0003 + 0004 to a store already at 0001, preserving data', async () => { + const db = await freshDb() // fully migrated (0001 … 0004) // Seed real data BEFORE the "downgrade", to prove the migration preserves it. const page = await createPage(db, { @@ -30,17 +30,24 @@ describe('migrations — incrementals upgrade an existing store without data los await sql`DROP TABLE page_properties`.execute(db) await sql`DROP TABLE files`.execute(db) await sql`DROP TABLE store_config`.execute(db) - await sql`DELETE FROM kysely_migration WHERE name IN ('0002_page_properties', '0003_file_storage')`.execute( + await sql`DROP TABLE access_log`.execute(db) + await sql`DROP TABLE principal_keys`.execute(db) + await sql`DROP TABLE principals`.execute(db) + await sql`DELETE FROM kysely_migration WHERE name IN ('0002_page_properties', '0003_file_storage', '0004_access')`.execute( db, ) expect(await tableExists(db, 'page_properties')).toBe(false) expect(await tableExists(db, 'files')).toBe(false) + expect(await tableExists(db, 'principals')).toBe(false) - // The upgrade path: opening the store re-runs migrations → 0002 and 0003 apply. + // The upgrade path: opening the store re-runs migrations → 0002..0004 apply. await migrateToLatest(db) expect(await tableExists(db, 'page_properties')).toBe(true) expect(await tableExists(db, 'files')).toBe(true) expect(await tableExists(db, 'store_config')).toBe(true) + expect(await tableExists(db, 'principals')).toBe(true) + expect(await tableExists(db, 'principal_keys')).toBe(true) + expect(await tableExists(db, 'access_log')).toBe(true) const applied = await sql<{ name: string @@ -49,8 +56,16 @@ describe('migrations — incrementals upgrade an existing store without data los '0001_init', '0002_page_properties', '0003_file_storage', + '0004_access', ]) + // 0004's ALTER TABLE half is idempotent: `contexts.principal_id` survived the + // partial rollback above, so re-running must not fail on a duplicate column. + const cols = await sql<{ + name: string + }>`SELECT name FROM pragma_table_info('contexts')`.execute(db) + expect(cols.rows.map((r) => r.name)).toContain('principal_id') + // Data survived the migration untouched. const still = await getContext(db, page.id) expect(still?.title).toBe('Existing') diff --git a/test/online.test.ts b/test/online.test.ts index 2a7aa53..84fc6ca 100644 --- a/test/online.test.ts +++ b/test/online.test.ts @@ -6,8 +6,9 @@ import { type Kysely, sql } from 'kysely' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createContext, getContext, searchContexts } from '../src/core/contexts' import { kyselyFor } from '../src/core/db' -import { contextRowCount, seedDatabase } from '../src/core/dump' +import { contextRowCount, SEED_TABLES, seedDatabase } from '../src/core/dump' import { migrateToLatest } from '../src/core/migrate' +import { setConfigValue } from '../src/core/storeConfig' import type { Database } from '../src/core/types' import { addLink, createPage } from '../src/core/wiki' @@ -101,4 +102,48 @@ describe('online seed (migrate-online core)', () => { expect(await contextRowCount(remote.db)).toBeGreaterThan(0) await remote.close() }) + + it('carries per-store config and page properties to the remote', async () => { + const local = await fileDb(join(dir, 'cfg-local.db')) + await setConfigValue(local.db, 'storage.bucket', 'notes') + await createPage(local.db, { + title: 'Props', + pageType: 'concept', + body: 'x', + metadata: { props: { status: 'active' } }, + }) + + const remote = await fileDb(join(dir, 'cfg-remote.db')) + const counts = await seedDatabase(local.db, remote.db) + + // Regression: these three tables were absent from SEED_TABLES, so going online + // silently dropped the storage credentials, the file index, and the props mirror. + expect(counts.store_config).toBe(1) + expect(counts.page_properties).toBe(1) + expect(counts.files).toBe(0) + const bucket = await sql<{ + value: string + }>`SELECT value FROM store_config WHERE key='storage.bucket'`.execute(remote.db) + expect(bucket.rows[0]?.value).toBe('notes') + + await local.close() + await remote.close() + }) +}) + +describe('SEED_TABLES', () => { + it('covers every table in the live schema, so a new migration cannot drift', async () => { + const h = await fileDb(join(dir, 'drift.db')) + // FTS5 shadow tables (contexts_fts*) are rebuilt by the insert triggers on the + // destination; kysely_migration* is the migrator's own bookkeeping. + const r = await sql<{ name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE 'kysely_%' + AND name NOT LIKE 'contexts_fts%' + `.execute(h.db) + expect(r.rows.map((x) => x.name).sort()).toEqual([...SEED_TABLES].sort()) + await h.close() + }) }) diff --git a/test/studio-auth.test.ts b/test/studio-auth.test.ts new file mode 100644 index 0000000..d49f3f1 --- /dev/null +++ b/test/studio-auth.test.ts @@ -0,0 +1,308 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Hono } from 'hono' +import type { Kysely } from 'kysely' +import { describe, expect, it } from 'vitest' +import { listAccessLog } from '../src/core/access/audit' +import { createPrincipal, issueKey, revokeKey } from '../src/core/access/principals' +import { setAccessEnabled } from '../src/core/access/settings' +import type { Database, Role } from '../src/core/types' +import { capabilityFor, SESSION_COOKIE } from '../src/studio/access' +import { buildStudioApp } from '../src/studio/server' +import { staticProvider } from '../src/studio/stores' +import { freshDb } from './_db' + +function fakeStudioDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'bctx-studio-auth-')) + writeFileSync(join(dir, 'index.html'), '
poc
') + return dir +} + +interface Fixture { + app: Hono + db: Kysely + keys: Record +} + +/** A studio app over a store with access control on and one key per listed role. */ +async function fixture(roles: Role[], localKey?: string | null): Promise { + const db = await freshDb() + const keys: Record = {} + await createPrincipal(db, { handle: 'boss', role: 'owner' }) + keys.boss = (await issueKey(db, 'boss')).key + for (const role of roles) { + if (role === 'owner') continue + await createPrincipal(db, { handle: role, role }) + keys[role] = (await issueKey(db, role)).key + } + await setAccessEnabled(db, true) + const app = buildStudioApp(staticProvider(db), { + staticDir: fakeStudioDir(), + localKey: localKey === undefined ? null : localKey, + }) + return { app, db, keys } +} + +const post = (body: unknown) => ({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), +}) + +/** Log in and return the session cookie value to send on later requests. */ +async function login(app: Hono, key: string): Promise { + const res = await app.request('/api/auth/login', post({ key })) + expect(res.status).toBe(200) + const cookie = res.headers.get('set-cookie') ?? '' + const value = /bctx_sid=([^;]+)/.exec(cookie)?.[1] + expect(value).toBeTruthy() + return `${SESSION_COOKIE}=${value}` +} + +describe('capabilityFor', () => { + it('maps the API surface to capabilities', () => { + expect(capabilityFor('GET', '/api/health')).toBeNull() + expect(capabilityFor('POST', '/api/auth/login')).toBeNull() + expect(capabilityFor('POST', '/api/project/switch')).toBeNull() + expect(capabilityFor('GET', '/api/contexts')).toBe('read') + expect(capabilityFor('POST', '/api/contexts')).toBe('write') + expect(capabilityFor('PATCH', '/api/wiki/pages/x')).toBe('write') + expect(capabilityFor('DELETE', '/api/contexts/x')).toBe('delete') + expect(capabilityFor('GET', '/api/files')).toBe('files.read') + expect(capabilityFor('POST', '/api/files')).toBe('files.write') + expect(capabilityFor('GET', '/api/files/status')).toBe('config.read') + expect(capabilityFor('PUT', '/api/files/config')).toBe('config.write') + expect(capabilityFor('GET', '/api/access/users')).toBe('users.manage') + // An unmapped path is treated as ordinary store data, never as ungated. + expect(capabilityFor('GET', '/api/something-new')).toBe('read') + expect(capabilityFor('POST', '/api/something-new')).toBe('write') + }) +}) + +describe('studio without access control', () => { + it('behaves exactly as before — no login, no gate', async () => { + const db = await freshDb() + const app = buildStudioApp(staticProvider(db), { staticDir: fakeStudioDir() }) + + const me = await app.request('/api/auth/me') + expect(await me.json()).toEqual({ enabled: false, authenticated: false, identity: null }) + + const created = await app.request('/api/contexts', post({ body: 'open', kind: 'note' })) + expect(created.status).toBe(201) + expect(await listAccessLog(db)).toEqual([]) + await db.destroy() + }) +}) + +describe('studio login', () => { + it('reports an unauthenticated caller and why', async () => { + const { app, db } = await fixture(['writer']) + const body = (await (await app.request('/api/auth/me')).json()) as Record + expect(body.enabled).toBe(true) + expect(body.authenticated).toBe(false) + expect(String(body.message)).toMatch(/requires an access key/) + await db.destroy() + }) + + it('refuses API access without a key, with 401', async () => { + const { app, db } = await fixture(['writer']) + const res = await app.request('/api/contexts') + expect(res.status).toBe(401) + await db.destroy() + }) + + it('accepts a valid key and issues a session cookie', async () => { + const { app, db, keys } = await fixture(['writer']) + const cookie = await login(app, keys.writer as string) + + const me = (await ( + await app.request('/api/auth/me', { headers: { cookie } }) + ).json()) as Record + expect(me.identity?.handle).toBe('writer') + + const created = await app.request('/api/contexts', { + ...post({ body: 'from studio', kind: 'note' }), + headers: { 'content-type': 'application/json', cookie }, + }) + expect(created.status).toBe(201) + await db.destroy() + }) + + it('rejects a bad key without saying whether the prefix exists', async () => { + const { app, db } = await fixture(['writer']) + const res = await app.request( + '/api/auth/login', + post({ key: 'bctxk.aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }), + ) + expect(res.status).toBe(401) + expect(String(((await res.json()) as { error: string }).error)).toMatch(/not recognized/) + await db.destroy() + }) + + it('rejects a revoked key and stops an established session', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'boss', role: 'owner' }) + await createPrincipal(db, { handle: 'ana', role: 'writer' }) + const issued = await issueKey(db, 'ana') + await setAccessEnabled(db, true) + const app = buildStudioApp(staticProvider(db), { staticDir: fakeStudioDir(), localKey: null }) + const cookie = await login(app, issued.key) + expect((await app.request('/api/contexts', { headers: { cookie } })).status).toBe(200) + + await revokeKey(db, issued.record.id) + const { createStudioSessions } = await import('../src/studio/access') + // The live app caches for SESSION_TTL_MS, so assert the underlying behavior + // directly: a fresh resolver sees the revocation immediately. + const fresh = createStudioSessions() + const result = await fresh.resolve(db, issued.key) + expect(result.enabled && !result.ok && result.reason).toBe('revoked') + await db.destroy() + }) + + it('logs out', async () => { + const { app, db, keys } = await fixture(['writer']) + const cookie = await login(app, keys.writer as string) + const out = await app.request('/api/auth/logout', { method: 'POST', headers: { cookie } }) + expect(out.status).toBe(200) + expect((await app.request('/api/contexts', { headers: { cookie } })).status).toBe(401) + await db.destroy() + }) + + it('never returns key material from the auth endpoints', async () => { + const { app, db, keys } = await fixture(['writer']) + const key = keys.writer as string + const res = await app.request('/api/auth/login', post({ key })) + const text = await res.text() + expect(text).not.toContain(key) + expect(text).not.toContain('scrypt$') + await db.destroy() + }) +}) + +describe('studio capability enforcement', () => { + it('lets a reader read but refuses a write with 403', async () => { + const { app, db, keys } = await fixture(['reader']) + const cookie = await login(app, keys.reader as string) + + expect((await app.request('/api/contexts', { headers: { cookie } })).status).toBe(200) + + const res = await app.request('/api/contexts', { + ...post({ body: 'nope', kind: 'note' }), + headers: { 'content-type': 'application/json', cookie }, + }) + expect(res.status).toBe(403) + expect(String(((await res.json()) as { error: string }).error)).toMatch(/Permission denied/) + expect(await db.selectFrom('contexts').selectAll().execute()).toEqual([]) + + const denials = await listAccessLog(db, { denyOnly: true }) + expect(denials[0]?.handle).toBe('reader') + expect(denials[0]?.surface).toBe('studio') + await db.destroy() + }) + + it('refuses a writer the user-management routes', async () => { + const { app, db, keys } = await fixture(['writer']) + const cookie = await login(app, keys.writer as string) + expect((await app.request('/api/access/users', { headers: { cookie } })).status).toBe(403) + await db.destroy() + }) + + it('lets an owner manage users and returns each secret exactly once', async () => { + const { app, db, keys } = await fixture([]) + const cookie = await login(app, keys.boss as string) + + const created = await app.request('/api/access/users', { + ...post({ handle: 'ana', role: 'writer' }), + headers: { 'content-type': 'application/json', cookie }, + }) + expect(created.status).toBe(201) + const payload = (await created.json()) as { key: string; warning: string } + expect(payload.key).toMatch(/^bctxk\./) + expect(payload.warning).toMatch(/like a password/) + + // The key is not retrievable afterwards, from any listing. + const listed = await (await app.request('/api/access/users', { headers: { cookie } })).text() + expect(listed).not.toContain(payload.key) + const keyList = await ( + await app.request('/api/access/users/ana/keys', { headers: { cookie } }) + ).text() + expect(keyList).not.toContain(payload.key) + expect(keyList).not.toContain('scrypt$') + await db.destroy() + }) + + it('stops an admin from touching an owner', async () => { + const { app, db, keys } = await fixture(['admin']) + const cookie = await login(app, keys.admin as string) + const res = await app.request('/api/access/users/boss', { + method: 'PATCH', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ role: 'reader' }), + }) + expect(res.status).toBe(403) + await db.destroy() + }) + + it('maps domain errors to honest statuses', async () => { + const { app, db, keys } = await fixture([]) + const cookie = await login(app, keys.boss as string) + const headers = { 'content-type': 'application/json', cookie } + + // Unknown user → 404, not 400. + expect((await app.request('/api/access/users/ghost/keys', { headers })).status).toBe(404) + // Removing the only owner would lock the project out of its own administration. + const last = await app.request('/api/access/users/boss', { method: 'DELETE', headers }) + expect(last.status).toBe(409) + // Duplicate handle → conflict. + await app.request('/api/access/users', { + ...post({ handle: 'ana', role: 'reader' }), + headers, + }) + const dupe = await app.request('/api/access/users', { + ...post({ handle: 'ANA', role: 'reader' }), + headers, + }) + expect(dupe.status).toBe(409) + await db.destroy() + }) + + it('adopts this machine key when the request has no cookie', async () => { + const db = await freshDb() + await createPrincipal(db, { handle: 'boss', role: 'owner' }) + const issued = await issueKey(db, 'boss') + await setAccessEnabled(db, true) + const app = buildStudioApp(staticProvider(db), { + staticDir: fakeStudioDir(), + localKey: issued.key, + }) + + const me = (await (await app.request('/api/auth/me')).json()) as { + authenticated: boolean + adopted: boolean + identity: { handle: string } + } + expect(me.authenticated).toBe(true) + expect(me.adopted).toBe(true) + expect(me.identity.handle).toBe('boss') + // …and it actually authorizes, not just reports. + expect((await app.request('/api/contexts', post({ body: 'x', kind: 'note' }))).status).toBe(201) + await db.destroy() + }) + + it('attributes a studio write to the logged-in principal', async () => { + const { app, db, keys } = await fixture(['writer']) + const cookie = await login(app, keys.writer as string) + await app.request('/api/contexts', { + ...post({ body: 'authored in studio', kind: 'note' }), + headers: { 'content-type': 'application/json', cookie }, + }) + const row = await db + .selectFrom('contexts') + .innerJoin('principals', 'principals.id', 'contexts.principal_id') + .select('principals.handle') + .executeTakeFirst() + expect(row?.handle).toBe('writer') + await db.destroy() + }) +})