diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index 1e3a79600..f91de36cf 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -1293,8 +1293,9 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. - The supported rollback is a pinned code and config rollback: install the earlier stack copy with the same unit flags, including `--sso`, through - `webui-service.sh install --copy `, as the unit script - already describes. Leave accounts.json, its receipts and `.deployed` in + `stack-upgrade.sh rollback --instance NAME --to ` for the + dedicated system units. The accounts instance must not use the + `webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in place. BASE code ignores them, and the next roll-forward needs them. - Before the first accounts org exists, that rollback restores today's behavior completely. `console.sso` owners sign in as before, and FED R6 diff --git a/packages/cli/README.md b/packages/cli/README.md index 114c3412b..ba5ef1de4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1304,8 +1304,10 @@ inactive. Project settings changes are append-only and require human proof. `mosaic accounts` administers the instance-level accounts store locally, as the effective uid owning the config directory. It requires a human shell, rejects `--tester`, and takes no business selector or bus connection. It has no organization rights -verbs. Console sign-in, sessions and membership evaluation are later Phase M -slices; these commands alone do not turn accounts mode on. +verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled +account or changed authentication generation loses account authority immediately; +a legacy access.json root grant is checked independently. Organization membership +evaluation follows in later Phase M slices. ```sh mosaic accounts init --name Operator @@ -1327,3 +1329,11 @@ organization (including archived organizations) or any registry entry lacks its owner metadata during creation, and removes its memberships in the same write. List reports rollback rows needing repair; its JSON omits invite bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets. + +Managed accounts instances use the system units from `scripts/case2-unit.sh`. +The selected uid must use a non-login shell, be the only member of its primary +group, and own its config directory and data root. The shared Case 1/Case 2 +installer writes `/.deployed` as mode 0444 naming the instance. +`webui-service.sh` refuses accounts and deployed instances because its user +manager runs under a human or development seat uid. The receipt uid identifies +the invoking process; it does not attribute a human or resist tampering by that uid. diff --git a/packages/cli/src/deployment.mjs b/packages/cli/src/deployment.mjs index 079320470..4b52a2837 100644 --- a/packages/cli/src/deployment.mjs +++ b/packages/cli/src/deployment.mjs @@ -1,6 +1,6 @@ // M3: executable and instance boundaries. Presence is checked with lstat, so // broken links and unreadable metadata never turn a deployed path into dev. -import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync } from 'node:fs'; +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync, mkdirSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json'))); @@ -45,6 +45,10 @@ export function assertDeployed(system, dir = configDir()) { // Both managed host cases install through case2-install.sh. Existing metadata // must validate; an install never replaces it or follows a destination link. export function writeDeployment(system, dir = configDir()) { + // Default-instance bootstrap creates the config before its data directory. + mkdirSync(system.dataRoot, { recursive: true, mode: 0o700 }); + const root = lstatSync(system.dataRoot); + if (!root.isDirectory() || root.uid !== process.geteuid() || (root.mode & 0o022)) throw new Error('.deployed data root must be an owned directory without public write'); const file = join(system.dataRoot, '.deployed'); if (present(file)) { const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); diff --git a/packages/cli/tests/accounts.test.mjs b/packages/cli/tests/accounts.test.mjs index 377479129..5bb5cb876 100644 --- a/packages/cli/tests/accounts.test.mjs +++ b/packages/cli/tests/accounts.test.mjs @@ -69,3 +69,19 @@ test('host boundary and receipt use effective uid, not real uid (current design try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); } finally { process.getuid = realUid; } }); + +// The uid identifies this process, not the human who invoked it. The instance +// uid can rewrite its receipt; this is neither human attribution nor tamper evidence. +test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => { + const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs'); + const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root); + const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}}; + writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config)); + const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname; + const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000}); + writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/config directory must belong to uid/); + for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false); + // Different real uid, same effective uid: the effective identity is recorded. + writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr); + const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid()); +}); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 49c09d770..2dc47f800 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) { const bin = join(root, "bin"); const log = join(root, "systemctl.log"); script(join(bin, "getent"), `case "$1" in - passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;; + passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;; group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;; *) exit 2 ;; esac`); @@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => { const h = hub(t); const node = outsideNode(); + // This metadata-only test uses a root-owned executable as a path stand-in. + writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); assert.equal(unit(h, { node }).code, 0); for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]); assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `)); @@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under refuses", (t) => test("a re-install with a different --node or different serve flags refuses", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); + writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); // A new node moves PATH too, so the installed unit fails its own Environment= check first. refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/); const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); @@ -1320,3 +1323,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => { mkdirSync(proxyFile(h)); refuses(proxyUnit(h), /is not a regular file/); }); + +// M3: the managed uid is a dedicated non-login service identity. These are +// database fixtures only: no real passwd/group or instance directories change. +test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => { + for (const [kind, shell, extraPasswd, members] of [ + ['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''], + ['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''], + ['supplementary primary-group member','/usr/sbin/nologin','','human'], + ]) { + const h = hub(t); + script(join(h.bin,'getent'), `case "$1" in + passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';; + group) echo 'mosaic:x:${GID}:${members}';; + esac`); + const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false); + } + const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); } +}); +test('M3 host config and data directories belong to the selected uid and both units use it', t => { + const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err); + for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); } + const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/); +}); diff --git a/packages/cli/tests/deployment-boundary.test.mjs b/packages/cli/tests/deployment-boundary.test.mjs index 4e44af756..72a70730e 100644 --- a/packages/cli/tests/deployment-boundary.test.mjs +++ b/packages/cli/tests/deployment-boundary.test.mjs @@ -58,13 +58,16 @@ test('checkout dev owner refuses accounts, SSO or deployment presence, including rmSync(file); } const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; - writeFileSync(s.config, JSON.stringify(config)); const r = s.run(serve(['--dev-local-owner']), REPO); assert.equal(r.status, 2); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); + writeFileSync(s.config, JSON.stringify(config)); + for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); } }); import { readFileSync as awaitRead } from 'node:fs'; test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => { const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed'); + rmSync(system.dataRoot, { recursive: true, force: true }); writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' }); + assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700); assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode); rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file); writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); diff --git a/packages/cli/tests/request-lifecycle-fixture.mjs b/packages/cli/tests/request-lifecycle-fixture.mjs index 89cb79727..8175c2a5a 100644 --- a/packages/cli/tests/request-lifecycle-fixture.mjs +++ b/packages/cli/tests/request-lifecycle-fixture.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; @@ -119,7 +120,7 @@ export async function lifecycleFixture(t) { const issuer = op.issuer('fixture'); sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] }, - { secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); + { accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); const transport = async (verb, args, ctx) => { try { return await rpc('console', { verb, args, subject: ctx.subject }); } catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); } diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index c2e8019d7..63bef58d8 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -159,7 +159,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}} const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); - try{await until(()=>web.exitCode!==null);assert.equal(web.exitCode,2);assert.match(webErr,/stamped build serve requires --sso/);assert.doesNotMatch(webOut,/Mosaic Console:/);} + try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} // The production runtime derives cliPath. Only agent ancestry is mocked; // readProcess passes through for tester.mjs (row 110). diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index 34a6f171f..941f1b66a 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -718,12 +718,25 @@ const baseCase2=()=>{ // read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir. function case2Host(e,dir){ const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700}); + copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json')); const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600); const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true}); const uid=process.getuid(),gid=process.getgid(); - writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755}); + writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755}); writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755}); - const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')}; + // M3: the current renderer queries the config under the selected uid for every + // profile. These copies deliberately stub the CLI; give that query its own + // fixture loader and sudo rather than invoking the machine's sudo. + for(const id of [B,C1,C]){ + const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src'); + if(!existsSync(join(src,'config.mjs'))){ + chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true}); + writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`); + chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555); + } + } + writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755}); + const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')}; const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof]; const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve]; const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}}; diff --git a/packages/webui/README.md b/packages/webui/README.md index 5055d2e80..397d044c2 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -226,6 +226,28 @@ design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send` requires `--sso`. Header-preview mode cannot enable the human writer. +Phase M3 sign-in requires an active account for account authority. Run +`mosaic accounts init` before the first accounts sign-in; configured SSO subjects +without accounts are refused. Legacy access.json users can still reach the root +business under their live grants. A session records account, legacy user and root +ownership separately. Only an active operator whose subject is configured as a +root owner gets the root human transport; an account alone gets 404 on root data +routes. Restarting the Console requires the instance operator role. + +Authentication generations are checked on every request: disable/re-enable, +role or subject changes and `user signout` invalidate earlier account sessions. +Legacy root grants remain independent. There are at most 512 sessions and 128 +pending logins, with eight sessions per account; a ninth evicts that account's +oldest. Sign-out removes the current session entirely. + +Every stack copy carries a read-only `.stack-build.json` naming its commit. +A stamped Console requires `--sso`, a valid mode-0444 `.deployed` naming its +instance, and a loadable accounts file. A malformed stamp refuses every start. +Stamped Console and bus starts refuse dev flags. Checkout development still +supports the named local dev owner, but refuses it if accounts.json, console.sso +or `.deployed` is present. Accounts instances use `case2-unit.sh` system units; +`webui-service.sh` user units remain a checkout development path. + - The client secret is read once at start from `/secrets/mosaic-stack/`. The start refuses a slot that is a symbolic link, not a regular file, owned by another user, has any group or diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index c3228ecf6..be55a66da 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -790,6 +790,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // names (without a port) a remote browser may use; only the dev owner takes them, and only with // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port'); + // The CLI also refuses SSO/dev-owner combinations and LAN flags without the + // dev owner. Keep this guard for callers that construct a session source here. if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path'); if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner'); const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0; @@ -942,7 +944,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const instanceAction = originalPath === '/api/console/restart'; if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); if (session && !session.rootOwner && session.user === undefined) { - bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = upstream = null; + bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null; } if (session?.user !== undefined && !session.rootOwner && !instanceAction) { if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); @@ -1041,7 +1043,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (systemTypesPath) { if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null, - session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin }); + session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); } if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' }); // Row 194 (#1692, D4.1): the tester also launches and terminates. diff --git a/packages/webui/tests/accounts-sso.test.mjs b/packages/webui/tests/accounts-sso.test.mjs index 4f168b409..6611d8b36 100644 --- a/packages/webui/tests/accounts-sso.test.mjs +++ b/packages/webui/tests/accounts-sso.test.mjs @@ -76,10 +76,19 @@ test('root routes refuse account-only sessions and restart checks operator indep test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => { const s = await setup(t, { legacy: true }), r = await s.login(); let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002'); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200); await s.change(d => d.users[1].state = 'disabled'); session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200); await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined); s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null); + assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 401); const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined); assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404); }); + +test('accounts mode refuses any start that could reach the dev LAN same-origin fake', async () => { + const { createDevOwner } = await import('../src/dev-owner.mjs'); + await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' }, devAllowHosts: ['accounts.test'] }), /accounts mode refuses the dev LAN/); + await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' } }), /accounts mode refuses the dev LAN/); +}); diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs index 64bc958e8..1a9db0107 100644 --- a/packages/webui/tests/chat.test.mjs +++ b/packages/webui/tests/chat.test.mjs @@ -309,6 +309,7 @@ test('configured Console process injects chat through its existing human transpo clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }], }] } }; writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config)); + writeFileSync(join(dirname(env.MOSAIC_CONFIG), 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: issuer.name, sub: 'owner-sub' }], name: 'jason', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 }); const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack'); mkdirSync(secrets, { recursive: true }); const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600); diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index 6ed2d7a36..6bf7da990 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -75,7 +75,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm')); assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind'); - assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']); + assert.deepEqual(readdirSync(target).sort(), ['.stack-build.json', 'RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']); assert.deepEqual(readdirSync(join(target, 'scripts')), ['case2-persona-import.mjs', 'mosaic', 'mosaic-config.mjs']); assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation="); assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only'); @@ -240,6 +240,18 @@ async function serve(t, cli, args, env) { return { out, get }; } +test('a stamped copy refuses the former unauthenticated Console start', async t => { + const { dir, script, head } = fixtureRepo(t); + const copies = join(dir, 'copies'); + assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0); + const cli = join(copies, head, 'packages/webui/src/cli.mjs'); + const { env } = fixture(join(dir, 'home')); + const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--business', 'acme'], { env }); + let err = ''; child.stderr.on('data', c => { err += c; }); + assert.equal((await once(child, 'close'))[0], 2); + assert.match(err, /stamped build serve requires --sso/); +}); + test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => { const { dir, script, head } = fixtureRepo(t); const copies = join(dir, 'copies'); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index ee65630f0..1b25839b8 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -867,3 +867,21 @@ test('row196 socket flag renders only for supporting targets and refuses mixed t const both=await run(dir,['render','--tester-socket','--tester-port','7333']); assert.equal(both.status,4); }); + +// M3 follow-up 1: this user-manager path cannot represent the dedicated +// instance uid required by accounts deployments. No systemctl start is used. +test('M3 user-unit path refuses accounts and deployment metadata while checkout development render works', async t => { + for (const kind of ['accounts', 'deployed', 'sso', 'stamp']) { + const dir = tmp(t), configDir = join(dir,'.config/mosaic-dev'), dataRoot = join(dir,'data'); mkdirSync(configDir,{recursive:true}); mkdirSync(dataRoot); + const config = {configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'test'}}; + if(kind==='sso') config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}}; + writeFileSync(join(configDir,'config.json'),JSON.stringify(config)); + if(kind==='accounts') writeFileSync(join(configDir,'accounts.json'),'{}'); + if(kind==='deployed') writeFileSync(join(dataRoot,'.deployed'),'{"instance":"dev"}',{mode:0o444}); + const args = ['render','--port','17890']; + if(kind==='stamp') { const copy=fakeCopy(dir); writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:COPY_ID}),{mode:0o444}); args.push('--copy',COPY_ID); } + const r=await run(dir,args); assert.notEqual(r.status,0,kind); assert.match(r.stderr,/system units.*case2-unit|accounts.*user.unit/,kind); assert.equal(r.stdout,''); + } + const dir=tmp(t); const dev=await run(dir,['render','--port','17890']); assert.equal(dev.status,0,dev.stderr); + for(const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) {const r=await run(dir,['render',flag,'127.0.0.1']);assert.notEqual(r.status,0,flag);assert.equal(r.stdout,'');} +}); diff --git a/packages/webui/tests/system-types-http.test.mjs b/packages/webui/tests/system-types-http.test.mjs index 90ced42b9..9a8ba3fc2 100644 --- a/packages/webui/tests/system-types-http.test.mjs +++ b/packages/webui/tests/system-types-http.test.mjs @@ -46,7 +46,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef // afterRead and beforeWrite let a test act between the route's read and its write. const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; }; const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); }; - const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' }; + const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }; let active = true; const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const organizations = { @@ -61,7 +61,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id; const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type']; const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args }); - return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } }; + return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; } function call(f, path, method = 'GET', body, headers = {}) { @@ -147,6 +147,21 @@ test('a sign-out while the read runs dispatches no write on either route', async assert.deepEqual(f.onDisk(), ['coding']); }); +test('root authority revoked during a read cannot write through a surviving user session', async t => { + for (const projectRoute of [false, true]) { + let revokeOnRead = false; + const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } }); + const [, { revision }] = await call(f, ORG); + revokeOnRead = true; + const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG; + const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] }; + const [status, result] = await call(f, path, 'POST', body); + assert.deepEqual([status, result.error], [401, 'sign-in-required'], path); + assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0); + assert.deepEqual(f.onDisk(), ['coding']); + } +}); + test('the enabled set and its revision come from one read, so a change in between is not undone', async t => { let raced = false; const f = await fixture(t, { beforeWrite: async verb => { diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 1151a774d..acfd9ce70 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -136,12 +136,19 @@ fi # The user, its uid, primary group and home, from the passwd database. [[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME" PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database" -IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW" +IFS=: read -r PW_NAME _ USER_UID USER_GID _ PW_HOME PW_SHELL <<<"$PW" [[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME" +[ "$PW_NAME" = "$USER_NAME" ] || die "passwd name does not match --user" +case "$PW_SHELL" in /usr/sbin/nologin|/sbin/nologin|/bin/false|/usr/bin/false) ;; *) die "--user requires a non-login shell" ;; esac abs_plain "$HOME_DIR" '--home' [ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)" GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database" -GROUP="${GR%%:*}" +IFS=: read -r GROUP _ GROUP_GID GROUP_MEMBERS <<<"$GR" +[ "$GROUP_GID" = "$USER_GID" ] || die "primary group id mismatch" +IFS=, read -r -a PRIMARY_MEMBERS <<<"$GROUP_MEMBERS" +for member in "${PRIMARY_MEMBERS[@]}"; do [ -z "$member" ] || [ "$member" = "$USER_NAME" ] || die "primary group must be sole-member"; done +PASSWD_ALL="$("$GETENT" passwd)" || die "cannot enumerate primary group members" +printf '%s\n' "$PASSWD_ALL" | awk -F: -v gid="$USER_GID" -v user="$USER_NAME" '$4 == gid && $1 != user { bad=1 } $4 == gid && $1 == user { seen++ } END { exit bad || seen != 1 }' || die "primary group must be sole-member" [[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP" case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac @@ -479,6 +486,23 @@ UNIT_PATH="$NODE_DIR" for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done FLAGS="$(serve_flags)" || exit 1 +# The config query runs after dropping uid, through the bounded supervisor. +# Root reads only checked facts and stats the two owned directories. +HOST_FACTS="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "host config query $(tester_capture_reason "$?")" +tester_facts "$HOST_FACTS" || die "host config query has invalid facts" +owned_directory() { + local dir="$1" what="$2" real st + abs_plain "$dir" "$what" + [ -d "$dir" ] && [ ! -L "$dir" ] || die "$what directory must exist without a symlink" + real="$(realpath -e -- "$dir")" || die "cannot resolve $what directory" + [ "$real" = "$dir" ] || die "$what directory contains a symlink" + st="$(stat -c '%u %a' -- "$dir")" || die "cannot stat $what directory" + [ "${st% *}" = "$USER_UID" ] || die "$what directory must be owned by $USER_NAME uid $USER_UID" + (( (8#${st#* } & 8#022) == 0 )) || die "$what directory must not be group or other writable" +} +owned_directory "$HOME_DIR/.config/mosaic-dev" config +owned_directory "$TS_DATA" data-root + HARDENING="ProtectSystem=strict ReadWritePaths=$HOME_DIR ProtectHome=yes diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 7a8585a11..eb95e274d 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -318,6 +318,20 @@ render() { case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac local root root="$(unit_root)" || exit 1 + # Accounts instances use dedicated system services, never a human/seat's + # systemd user manager. The check runs before rendering or installing. + node --input-type=module -e ' + import { lstatSync, readFileSync } from "node:fs"; + import { join } from "node:path"; + const [root, dir, file] = process.argv.slice(1); + const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } }; + let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json")); + if (present(file)) { + const s = JSON.parse(readFileSync(file, "utf8")); + accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed")); + } + if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); } + ' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || exit 1 if [ "$TESTER_SOCKET" = 1 ]; then grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support" fi