diff --git a/dashcaddy-api/__tests__/network-acl-compiler.test.js b/dashcaddy-api/__tests__/network-acl-compiler.test.js new file mode 100644 index 0000000..36d8e0b --- /dev/null +++ b/dashcaddy-api/__tests__/network-acl-compiler.test.js @@ -0,0 +1,284 @@ +/** + * Tests for src/network/acl-compiler.js — DC-148. + * + * Golden-policy cases, lockout adversarial cases (each hard rule), drift + * detection both ways, baseline-section passthrough. 100% pure — no I/O, + * no jest mocks needed. + */ + +'use strict'; + +/* eslint-disable require-await, no-unused-vars */ +// require-await: helper stubs are async to match real signatures without awaiting. +// no-unused-vars: fixture records exist to document the people shape. + +const { + compilePolicy, + assertNoLockout, + detectDrift, + compileAndValidate, + hashAcls, + LockoutError, +} = require('../src/network/acl-compiler'); + +// ── fixtures ────────────────────────────────────────────────────────────── + +const ADMIN = { id: 'p-admin', providerUserId: '1001', groups: ['Admin'], status: 'active' }; +const WIFE = { id: 'p-wife', providerUserId: '1002', groups: ['Family'], status: 'active' }; +const KID = { id: 'p-kid', providerUserId: '1003', groups: ['Family'], status: 'active' }; +const BLOCKED = { id: 'p-bad', providerUserId: '1004', groups: ['Family'], status: 'blocked' }; +const REMOVED = { id: 'p-gone', providerUserId: '1005', groups: ['Admin'], status: 'removed' }; + +const RESOURCES = [ + { name: 'dashcaddy', host: 'dashcaddy.internal', ports: ['80', '443', '8080'] }, + { name: 'plex', host: 'plex.internal', ports: ['32400'] }, + { name: 'nas', host: 'nas.internal' }, // ports default → ['*'] +]; + +const HOST_DEVICE = 'dev-host-123'; + +function grants(...list) { + return { grants: list.map(([type, id, resource, allowed]) => + ({ principal: { type, id }, resource, allowed })) }; +} + +const BASELINE = { + ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }], + tagOwners: { 'tag:dc-host': ['autogroup:admin'] }, +}; + +// ── golden-policy: compilePolicy ────────────────────────────────────────── + +describe('acl-compiler: golden policies', () => { + test('single admin with all-resource grants → per-user src entries + host egress', () => { + const map = grants( + ['person', 'p-admin', 'dashcaddy', true], + ['person', 'p-admin', 'plex', true], + ['person', 'p-admin', 'nas', true], + ); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(policy.acls).toEqual([ + { action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:80', 'dashcaddy.internal:443', 'dashcaddy.internal:8080'], proto: ['tcp', 'udp'] }, + { action: 'accept', src: ['1001'], dst: ['plex.internal:32400'], proto: ['tcp', 'udp'] }, + { action: 'accept', src: ['1001'], dst: ['nas.internal:*'], proto: ['tcp', 'udp'] }, + { action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] }, + ]); + }); + + test('family group grant expands to each active member (no Tailscale groups)', () => { + const map = grants(['group', 'Family', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN, WIFE, KID, BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + const srcs = policy.acls.map(e => e.src[0]); + expect(srcs).toContain('1002'); + expect(srcs).toContain('1003'); + expect(srcs).not.toContain('1004'); // blocked compiles to nothing + expect(srcs).not.toContain('1005'); // removed compiles to nothing + // No group object is ever emitted in dst (free-plan rule) + for (const e of policy.acls) { + for (const d of e.dst) expect(d.startsWith('group:')).toBe(false); + } + }); + + test('blocked person is absent even with an explicit personal allow grant', () => { + const map = grants( + ['person', 'p-bad', 'plex', true], + ['group', 'Family', 'plex', true], + ); + const policy = compilePolicy({ accessMap: map, people: [BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(policy.acls).toEqual([ + { action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] }, + ]); + }); + + test('person-level override beats group grant (deny and allow)', () => { + const map = grants( + ['group', 'Family', 'plex', true], + ['person', 'p-kid', 'plex', false], // kid explicitly denied despite Family + ['group', 'Family', 'nas', false], + ['person', 'p-wife', 'nas', true], // wife explicitly allowed despite Family deny + ); + const policy = compilePolicy({ accessMap: map, people: [WIFE, KID], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + const wife = policy.acls.filter(e => e.src.includes('1002')); + const kid = policy.acls.filter(e => e.src.includes('1003')); + expect(wife.map(e => e.dst[0]).sort()).toEqual(['nas.internal:*', 'plex.internal:32400']); // nas via override + plex via group + expect(kid).toEqual([]); // plex denied by override, nas denied by group + }); + + test('default-deny: ungranted resources and people compile to nothing', () => { + const map = grants(['group', 'Family', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + // only the host egress rule — no catch-all accept, nothing for ADMIN + expect(policy.acls).toEqual([ + { action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] }, + ]); + const flat = JSON.stringify(policy.acls); + expect(flat.includes('*:*')).toBe(true); // host device egress only + expect(policy.acls.filter(e => e.src.includes('1001'))).toEqual([]); + }); + + test('grant for an unknown resource compiles to nothing', () => { + const map = grants(['person', 'p-admin', 'ghost-app', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(policy.acls).toHaveLength(1); // host egress only + }); + + test('person without providerUserId compiles to nothing', () => { + const nobody = { ...ADMIN, id: 'p-noid', providerUserId: '' }; + const map = grants(['person', 'p-noid', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [nobody], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(policy.acls).toHaveLength(1); // host egress only + }); + + test('baseline ssh + tagOwners pass through untouched; acls replaced', () => { + const map = grants(['person', 'p-admin', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: BASELINE }); + expect(policy.ssh).toEqual(BASELINE.ssh); + expect(policy.tagOwners).toEqual(BASELINE.tagOwners); + expect(Object.keys(policy).sort()).toEqual(['acls', 'ssh', 'tagOwners']); + }); + + test('baseline acls section is dropped (fully DC-managed)', () => { + const withOldAcls = { ...BASELINE, acls: [{ action: 'accept', src: ['stale'], dst: ['x:*'] }] }; + const map = grants(['person', 'p-admin', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: withOldAcls }); + expect(policy.acls.some(e => e.src.includes('stale'))).toBe(false); + }); + + test('input validation throws TypeError', () => { + expect(() => compilePolicy()).toThrow(TypeError); + expect(() => compilePolicy({ accessMap: {} })).toThrow(TypeError); + expect(() => compilePolicy({ accessMap: { grants: [] }, people: [] })).toThrow(TypeError); + expect(() => compilePolicy({ accessMap: { grants: [] }, people: [], resources: [] })).toThrow(TypeError); + }); +}); + +// ── lockout adversarial ─────────────────────────────────────────────────── + +describe('acl-compiler: lockout guard (assertNoLockout)', () => { + const hostResource = RESOURCES[0]; // dashcaddy + + test('last-admin-denied throws LockoutError', () => { + // admin granted plex only — dashcaddy host resource NOT granted + const map = grants(['person', 'p-admin', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).toThrow(LockoutError); + expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })) + .toThrow(/every active admin/); + }); + + test('host-egress-missing throws LockoutError', () => { + const map = grants(['person', 'p-admin', 'dashcaddy', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + // simulate a policy that lost the egress rule + policy.acls = policy.acls.filter(e => !e.dst.includes('*:*')); + expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource, hostDeviceId: HOST_DEVICE })) + .toThrow(/egress/); + }); + + test('two admins, one denied, one allowed → passes (not a lockout)', () => { + const admin2 = { id: 'p-admin2', providerUserId: '1006', groups: ['Admin'], status: 'active' }; + const map = grants( + ['person', 'p-admin', 'plex', true], // admin1: NO dashcaddy + ['person', 'p-admin2', 'dashcaddy', true], // admin2: has dashcaddy + ); + const policy = compilePolicy({ accessMap: map, people: [ADMIN, admin2], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(() => assertNoLockout(policy, { adminUserIds: ['1001', '1006'], hostResource })).not.toThrow(); + }); + + test('empty adminUserIds throws (no admin at all is a lockout)', () => { + const map = grants(['person', 'p-wife', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [WIFE], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(LockoutError); + expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(/no active admin/); + }); + + test('admin allowed via group grant counts (Family granted dashcaddy)', () => { + const adminViaGroup = { ...ADMIN, groups: ['Family'] }; + const map = grants(['group', 'Family', 'dashcaddy', true]); + const policy = compilePolicy({ accessMap: map, people: [adminViaGroup], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).not.toThrow(); + }); + + test('requires a policy object (TypeError)', () => { + expect(() => assertNoLockout(null, {})).toThrow(TypeError); + expect(() => assertNoLockout({}, {})).toThrow(TypeError); + }); + + test('LockoutError shape: code, statusCode, name', () => { + const map = grants(['person', 'p-admin', 'plex', true]); + const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE }); + try { + assertNoLockout(policy, { adminUserIds: ['1001'], hostResource }); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(LockoutError); + expect(err.name).toBe('LockoutError'); + expect(err.code).toBe('DC_ACL_LOCKOUT'); + expect(err.statusCode).toBe(409); + expect(err.isOperational).toBe(true); + } + }); +}); + +// ── drift detection ─────────────────────────────────────────────────────── + +describe('acl-compiler: drift detection (detectDrift)', () => { + const map = grants(['person', 'p-admin', 'dashcaddy', true], ['person', 'p-admin', 'plex', true]); + const { policy, hash } = compileAndValidate(_fullInput()); + + function _fullInput() { + return { accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, adminUserIds: ['1001'], hostResource: RESOURCES[0] }; + } + + test('no drift: identical remote acls → drifted:false', () => { + const remote = { acls: JSON.parse(JSON.stringify(policy.acls)) }; + const res = detectDrift(hash, remote); + expect(res.drifted).toBe(false); + expect(res.remoteHash).toBe(hash); + }); + + test('drift: outside edit added a rule → drifted:true', () => { + const remote = { acls: [...policy.acls, { action: 'accept', src: ['outsider'], dst: ['plex.internal:32400'] }] }; + expect(detectDrift(hash, remote).drifted).toBe(true); + }); + + test('drift: rule removed remotely → drifted:true', () => { + const remote = { acls: policy.acls.slice(0, -1) }; + expect(detectDrift(hash, remote).drifted).toBe(true); + }); + + test('baseline sections changing is NOT drift (only acls hashed)', () => { + const remote = { acls: JSON.parse(JSON.stringify(policy.acls)), ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] }; + expect(detectDrift(hash, remote).drifted).toBe(false); + }); + + test('hash is order-stable (sorted keys) and content-sensitive', () => { + expect(hashAcls(policy.acls)).toBe(hash); + expect(hashAcls([])).not.toBe(hash); + // key order inside entries does not matter + const shuffled = policy.acls.map(e => ({ proto: e.proto, dst: e.dst, src: e.src, action: e.action })); + expect(hashAcls(shuffled)).toBe(hash); + }); +}); + +// ── compileAndValidate ──────────────────────────────────────────────────── + +describe('acl-compiler: compileAndValidate', () => { + test('happy path returns { policy, hash } and passes the guard', () => { + const map = grants(['person', 'p-admin', 'dashcaddy', true]); + const out = compileAndValidate({ + accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, + adminUserIds: ['1001'], hostResource: RESOURCES[0], + }); + expect(out.policy.acls.some(e => e.src.includes('1001'))).toBe(true); + expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + test('throws LockoutError (no TypeError leak) when the guard trips', () => { + const map = grants(['person', 'p-admin', 'plex', true]); + expect(() => compileAndValidate({ + accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, + adminUserIds: ['1001'], hostResource: RESOURCES[0], + })).toThrow(LockoutError); + }); +}); diff --git a/dashcaddy-api/__tests__/network-acl-push.test.js b/dashcaddy-api/__tests__/network-acl-push.test.js new file mode 100644 index 0000000..ec587cb --- /dev/null +++ b/dashcaddy-api/__tests__/network-acl-push.test.js @@ -0,0 +1,321 @@ +/** + * Tests for src/network/acl-push.js — DC-148. + * + * Hermetic: no network. The coord client is stubbed with the same + * makeFetch rigor as __tests__/tailscale-coord.test.js — a route map + * keyed by 'METHOD path' returning { status, body }, defaulting to 404. + * dataDir points at a fresh tmp dir per test so the last-known-good + * state file never leaks between tests. + */ + +'use strict'; + +/* eslint-disable require-await */ +// Helper stubs are async to match real client signatures without awaiting. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createAclPusher, AclPushError } = require('../src/network/acl-push'); +const { hashAcls } = require('../src/network/acl-compiler'); + +const POLICY = { + acls: [ + { action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:443'], proto: ['tcp', 'udp'] }, + { action: 'accept', src: ['dev-host-1'], dst: ['*:*'], proto: ['tcp', 'udp'] }, + ], + ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }], + tagOwners: { 'tag:dc-host': ['autogroup:admin'] }, +}; +const OTHER_POLICY = { + acls: [{ action: 'accept', src: ['9999'], dst: ['x:*'], proto: ['tcp', 'udp'] }], +}; + +/** Route-map fetch stub (tailscale-coord.test.js rigor). */ +function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) { + const calls = []; + const fn = jest.fn(async (method, apiPath, opts) => { + calls.push({ method, path: apiPath, opts }); + const match = routes[method + ' ' + apiPath]; + if (match) { + return { + status: match.status, + body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body), + headers: { 'content-type': 'application/json' }, + }; + } + return { status: defaultStatus, body: JSON.stringify(defaultBody) }; + }); + fn.calls = calls; + return fn; +} + +/** Coord-client-shaped stub for the coord injection path. */ +function makeCoord(routes) { + const fetchImpl = makeFetch(routes); + return { + fetchImpl, + request: async (method, apiPath, opts) => { + const res = await fetchImpl(method, '/api/v2' + apiPath, opts); + if (res.status >= 400) { + throw Object.assign(new Error(`coord ${res.status}`), { code: res.status === 400 ? 'bad_input' : 'http_error', status: res.status }); + } + try { return JSON.parse(res.body); } catch (_) { return res.body; } + }, + }; +} + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'dc-acl-push-')); +} + +function readState(dir) { + return JSON.parse(fs.readFileSync(path.join(dir, 'network-acl-state.json'), 'utf8')); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ── previewPolicy ───────────────────────────────────────────────────────── + +describe('acl-push: previewPolicy', () => { + test('posts the full policy to /acl/preview and returns the body', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [], warnings: [] } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() }); + const out = await pusher.previewPolicy(POLICY); + expect(out.feasible).toBe(true); + expect(fetchImpl.calls).toHaveLength(1); + const { method, path: p, opts } = fetchImpl.calls[0]; + expect(method).toBe('POST'); + expect(p).toBe('/api/v2/tailnet/-/acl/preview'); + expect(opts.body).toEqual(POLICY); // COMPLETE policy object, never partial + }); + + test('preview errors[] → AclPushError acl_rejected (refuse to push)', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl/preview': { + status: 200, + body: { feasible: false, errors: [{ text: 'port 99999 invalid' }] }, + }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() }); + await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ + name: 'AclPushError', code: 'acl_rejected', + }); + }); + + test('preview HTTP 400 → AclPushError (mapped)', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl/preview': { status: 400, body: { message: 'bad acl' } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() }); + await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ + name: 'AclPushError', code: 'acl_rejected', + }); + }); + + test('fetch throw → AclPushError coord_error (network failure mapped)', async () => { + const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); }); + const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir() }); + await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ + name: 'AclPushError', code: 'coord_error', + }); + }); +}); + +// ── pushPolicy ──────────────────────────────────────────────────────────── + +describe('acl-push: pushPolicy', () => { + test('posts the complete policy and stores last-known-good body + hash', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + const out = await pusher.pushPolicy(POLICY); + expect(out.pushed).toBe(true); + expect(out.hash).toBe(hashAcls(POLICY.acls)); + // request shape: complete object + expect(fetchImpl.calls[0].opts.body).toEqual(POLICY); + expect(fetchImpl.calls[0].opts.body.ssh).toBeDefined(); + expect(fetchImpl.calls[0].opts.body.tagOwners).toBeDefined(); + // state file persisted atomically with policy + hash + const state = readState(dir); + expect(state.policy).toEqual(POLICY); + expect(state.hash).toBe(hashAcls(POLICY.acls)); + expect(state.pushedAt).toBeTruthy(); + expect(pusher.getLastKnownGood().hash).toBe(hashAcls(POLICY.acls)); + }); + + test('failed push (500) stores nothing', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 500, body: { message: 'boom' } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await expect(pusher.pushPolicy(POLICY)).rejects.toMatchObject({ code: 'http_500' }); + expect(pusher.getLastKnownGood()).toBeNull(); + }); +}); + +// ── rollback ────────────────────────────────────────────────────────────── + +describe('acl-push: rollback', () => { + test('re-pushes the stored last-known-good policy', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await pusher.pushPolicy(POLICY); + fetchImpl.calls.length = 0; + const out = await pusher.rollback(); + expect(out.rolledBack).toBe(true); + expect(out.hash).toBe(hashAcls(POLICY.acls)); + // the rollback push carries the ORIGINAL stored body verbatim + expect(fetchImpl.calls[0].opts.body).toEqual(POLICY); + }); + + test('rollback without a stored policy throws no_last_known_good', async () => { + const pusher = createAclPusher({ fetchImpl: makeFetch({}), dataDir: tmpDir() }); + await expect(pusher.rollback()).rejects.toMatchObject({ code: 'no_last_known_good' }); + }); +}); + +// ── pushIfNoDrift ───────────────────────────────────────────────────────── + +describe('acl-push: pushIfNoDrift', () => { + test('no drift → previews then pushes and updates state', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/acl': { status: 200, body: POLICY }, + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await pusher.pushPolicy(POLICY); // seed stored hash + fetchImpl.calls.length = 0; + + const out = await pusher.pushIfNoDrift(POLICY); + expect(out).toEqual({ pushed: true, hash: hashAcls(POLICY.acls) }); + const paths = fetchImpl.calls.map(c => c.method + ' ' + c.path); + expect(paths).toEqual([ + 'GET /api/v2/tailnet/-/acl', + 'POST /api/v2/tailnet/-/acl/preview', + 'POST /api/v2/tailnet/-/acl', + ]); + }); + + test('drift (remote acls changed outside DashCaddy) → {drifted:true, pushed:false}, NO push', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + 'GET /api/v2/tailnet/-/acl': { status: 200, body: OTHER_POLICY }, // outside edit + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await pusher.pushPolicy(POLICY); + fetchImpl.calls.length = 0; + + const out = await pusher.pushIfNoDrift(POLICY); + expect(out).toEqual({ drifted: true, pushed: false }); + // only the GET happened — never preview, never a silent overwrite push + expect(fetchImpl.calls.map(c => c.method)).toEqual(['GET']); + }); + + test('baseline-only remote change is NOT drift (ssh edited outside)', async () => { + const dir = tmpDir(); + const editedBaseline = { ...POLICY, ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] }; + const fetchImpl = makeFetch({ + 'GET /api/v2/tailnet/-/acl': { status: 200, body: editedBaseline }, + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await pusher.pushPolicy(POLICY); + fetchImpl.calls.length = 0; + + const out = await pusher.pushIfNoDrift(POLICY); + expect(out.pushed).toBe(true); + }); + + test('first-ever push (no stored state) skips the drift check', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + const out = await pusher.pushIfNoDrift(POLICY); + expect(out.pushed).toBe(true); + expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST', 'POST']); // no GET + }); + + test('preview failure during pushIfNoDrift refuses the push (throws)', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: false, errors: [{ text: 'nope' }] } }, + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ code: 'acl_rejected' }); + expect(fetchImpl.calls.map(c => c.method)).toEqual(['POST']); // preview only, no push + }); + + test('remote read failure during drift check maps to AclPushError', async () => { + const dir = tmpDir(); + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/-/acl': { status: 200, body: 'ok' }, + 'GET /api/v2/tailnet/-/acl': { status: 500, body: { message: 'ctrl down' } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: dir }); + await pusher.pushPolicy(POLICY); + fetchImpl.calls.length = 0; + await expect(pusher.pushIfNoDrift(POLICY)).rejects.toMatchObject({ name: 'AclPushError' }); + }); +}); + +// ── coord injection + misc ──────────────────────────────────────────────── + +describe('acl-push: injectable coord client + config', () => { + test('coord.request is used instead of fetchImpl when both given', async () => { + const coord = makeCoord({ + 'POST /api/v2/tailnet/-/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + }); + const fetchImpl = jest.fn(); + const pusher = createAclPusher({ coord: { request: coord.request }, fetchImpl, dataDir: tmpDir() }); + const out = await pusher.previewPolicy(POLICY); + expect(out.feasible).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('coord error (TailscaleCoordError-like) is mapped with its code', async () => { + const coordErr = Object.assign(new Error('unauthorized'), { code: 'unauthorized', status: 401 }); + const coord = { request: async () => { throw coordErr; } }; + const pusher = createAclPusher({ coord, dataDir: tmpDir() }); + await expect(pusher.previewPolicy(POLICY)).rejects.toMatchObject({ code: 'unauthorized' }); + }); + + test('no coord and no fetchImpl → TypeError', () => { + expect(() => createAclPusher({ dataDir: tmpDir() })).toThrow(TypeError); + }); + + test('custom tailnet name is used in URLs', async () => { + const fetchImpl = makeFetch({ + 'POST /api/v2/tailnet/example.ts.net/acl/preview': { status: 200, body: { feasible: true, errors: [] } }, + }); + const pusher = createAclPusher({ fetchImpl, dataDir: tmpDir(), tailnet: 'example.ts.net' }); + await pusher.previewPolicy(POLICY); + expect(fetchImpl.calls[0].path).toBe('/api/v2/tailnet/example.ts.net/acl/preview'); + }); + + test('AclPushError is exported and operational', () => { + const e = new AclPushError('x', { code: 'test' }); + expect(e.name).toBe('AclPushError'); + expect(e.code).toBe('test'); + expect(e.isOperational).toBe(true); + }); +}); diff --git a/dashcaddy-api/src/network/acl-compiler.js b/dashcaddy-api/src/network/acl-compiler.js new file mode 100644 index 0000000..199f975 --- /dev/null +++ b/dashcaddy-api/src/network/acl-compiler.js @@ -0,0 +1,252 @@ +/** + * Tailscale ACL compiler + lockout guard — DC-148. + * + * Pure functions, NO I/O. Turns the DashCaddy access map (DC-145) plus the + * people store (DC-141) into a Tailscale HuJSON policy object, and enforces + * the two hard lockout rules from specs/PRIVATE-NETWORK-SPEC.md §4: + * + * (a) the last active admin must keep access to the DashCaddy host + * resource — refuse any policy that would deny EVERY active admin; + * (b) the DashCaddy host device must keep an egress rule. + * + * Compilation rules (normative, spec §2/§4): + * - DashCaddy groups NEVER map to Tailscale groups (free plan caps ACL + * groups at 3). Every src entry references a Tailscale user id + * (person.providerUserId) directly. + * - dst is `host:port` per resource grant. + * - Default-deny: no catch-all accept is ever emitted; a resource with no + * allow grant compiles to nothing. + * - Blocked/removed people compile to nothing. + * - Baseline non-DC sections (ssh, tagOwners, …) are passed through + * untouched; ONLY the `acls` section is DashCaddy-managed. + * + * Fleet-verified Tailscale gotchas honored here: + * - autogroup:member is valid ONLY as src — never emitted as dst. + * - POST /tailnet/{t}/acl must carry the COMPLETE policy (acls + ssh + + * tagOwners) — compilePolicy returns the whole merged object for that. + */ + +'use strict'; + +const crypto = require('crypto'); + +const MANAGED_SECTION = 'acls'; + +/** Thrown by assertNoLockout / compileAndValidate when a compiled policy would lock admins out. */ +class LockoutError extends Error { + constructor(reason) { + super(`ACL lockout guard: ${reason}`); + this.name = 'LockoutError'; + this.code = 'DC_ACL_LOCKOUT'; + this.reason = reason; + this.statusCode = 409; + this.isOperational = true; + } +} + +/** Stable hash of the DC-managed section: sha256 of canonical (sorted-key) JSON. */ +function hashAcls(acls) { + const canonical = _canonicalJson(acls === undefined ? null : acls); + return 'sha256:' + crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function _canonicalJson(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(_canonicalJson).join(',') + ']'; + const keys = Object.keys(value).sort(); + return '{' + keys.map(k => JSON.stringify(k) + ':' + _canonicalJson(value[k])).join(',') + '}'; +} + +/** Effective allowed resources for one person, mirroring DC-145 semantics on a raw map (pure). */ +function _effectiveAllowed(grants, person) { + const groupSet = new Set((person.groups || []).filter(g => typeof g === 'string')); + const resources = {}; + for (const g of grants) { + if (!g || !g.principal) continue; + if (g.principal.type === 'group' && groupSet.has(g.principal.id)) { + resources[g.resource] = g.allowed; // union of group grants + } + } + for (const g of grants) { + if (!g || !g.principal) continue; + if (g.principal.type === 'person' && g.principal.id === person.id) { + resources[g.resource] = g.allowed; // person-level OVERRIDES group + } + } + return resources; +} + +function _resourceTargets(resource) { + const host = resource.host || resource.name; + const ports = Array.isArray(resource.ports) && resource.ports.length > 0 + ? resource.ports + : ['*']; + return ports.map(p => `${host}:${p}`); +} + +function _hostEgressEntry(hostDeviceId) { + // Host device keeps egress to everything. src is a device id (valid src); + // dst is the catch-all — the ONLY wildcard dst we ever emit, and it belongs + // to the trusted DashCaddy host device itself, not to any user. + return { action: 'accept', src: [hostDeviceId], dst: ['*:*'], proto: ['tcp', 'udp'] }; +} + +/** + * compilePolicy({ accessMap, people, resources, hostDeviceId, baseline }) → HuJSON policy object. + * + * accessMap { grants: [...] } from the DC-145 store (shape only — pure input) + * people Person[] (DC-141 records: id, providerUserId, groups, status) + * resources [{ name, host?, ports? }] — host defaults to name, ports to ['*'] + * hostDeviceId Tailscale device id of the DashCaddy host (egress rule src) + * baseline optional current remote policy — every non-`acls` key + * (ssh, tagOwners, …) is copied through VERBATIM + */ +function _validateCompileInputs({ accessMap, people, resources, hostDeviceId }) { + if (!accessMap || typeof accessMap !== 'object') { + throw new TypeError('compilePolicy requires accessMap'); + } + if (!Array.isArray(people)) throw new TypeError('compilePolicy requires people array'); + if (!Array.isArray(resources)) throw new TypeError('compilePolicy requires resources array'); + if (typeof hostDeviceId !== 'string' || hostDeviceId.length === 0) { + throw new TypeError('compilePolicy requires hostDeviceId'); + } +} + +/** One ACL entry per (allowed person × allowed resource) pair. */ +function _compileUserEntries(grants, people, byName) { + const acls = []; + for (const person of people) { + if (!person || person.status !== 'active') continue; // blocked/removed → nothing + const userId = person.providerUserId; + if (typeof userId !== 'string' || userId.length === 0) continue; + const allowed = _effectiveAllowed(grants, person); + for (const [resourceName, isAllowed] of Object.entries(allowed)) { + if (isAllowed !== true) continue; // default-deny: only explicit allows emit rules + const resource = byName.get(resourceName); + if (!resource) continue; // grant for an unknown resource → nothing + acls.push({ + action: 'accept', + src: [userId], // per-USER src, never a custom group + dst: _resourceTargets(resource), + proto: ['tcp', 'udp'], + }); + } + } + return acls; +} + +function compilePolicy({ accessMap, people, resources, hostDeviceId, baseline } = {}) { + _validateCompileInputs({ accessMap, people, resources, hostDeviceId }); + + const grants = Array.isArray(accessMap.grants) ? accessMap.grants : []; + const byName = new Map(resources.map(r => [r.name, r])); + const acls = _compileUserEntries(grants, people, byName); + acls.push(_hostEgressEntry(hostDeviceId)); + + // Whole-policy output: baseline sections pass through untouched; `acls` is ours. + const policy = {}; + if (baseline && typeof baseline === 'object' && !Array.isArray(baseline)) { + for (const [k, v] of Object.entries(baseline)) { + if (k !== MANAGED_SECTION) policy[k] = v; + } + } + policy[MANAGED_SECTION] = acls; + return policy; +} + +function _dstMatchesHost(dstEntries, hostResource) { + const host = typeof hostResource === 'string' ? hostResource : hostResource && hostResource.host; + const ports = hostResource && Array.isArray(hostResource.ports) && hostResource.ports.length > 0 + ? hostResource.ports.map(String) + : null; + for (const d of dstEntries) { + const sep = d.lastIndexOf(':'); + if (sep < 0) continue; + const h = d.slice(0, sep); + const p = d.slice(sep + 1); + if (h !== host) continue; + if (ports === null || p === '*' || ports.includes(p)) return true; + } + return false; +} + +function _hasHostEgress(policy, hostDeviceId) { + for (const entry of policy.acls || []) { + if (entry && entry.action === 'accept' + && Array.isArray(entry.src) && entry.src.includes(hostDeviceId) + && Array.isArray(entry.dst) && entry.dst.includes('*:*')) { + return true; + } + } + return false; +} + +/** + * assertNoLockout(policy, { adminUserIds, hostResource, hostDeviceId }) + * + * Throws LockoutError when: + * - the host device's egress rule is absent (hostDeviceId given), or + * - EVERY active admin user id would be denied the host resource + * (adminUserIds empty also throws — no admin at all is a lockout). + */ +function assertNoLockout(policy, { adminUserIds, hostResource, hostDeviceId } = {}) { + if (!policy || typeof policy !== 'object' || !Array.isArray(policy.acls)) { + throw new TypeError('assertNoLockout requires a compiled policy'); + } + if (typeof hostDeviceId === 'string' && hostDeviceId.length > 0 && !_hasHostEgress(policy, hostDeviceId)) { + throw new LockoutError(`host device ${hostDeviceId} would lose egress (no *:* accept rule)`); + } + const admins = Array.isArray(adminUserIds) + ? adminUserIds.filter(u => typeof u === 'string' && u.length > 0) + : []; + if (admins.length === 0) { + throw new LockoutError('no active admin would retain access to the host resource'); + } + for (const admin of admins) { + for (const entry of policy.acls) { + if (entry && entry.action === 'accept' + && Array.isArray(entry.src) && entry.src.includes(admin) + && Array.isArray(entry.dst) && _dstMatchesHost(entry.dst, hostResource)) { + return true; // at least ONE admin keeps host access — not a lockout + } + } + } + throw new LockoutError( + `every active admin (${admins.join(', ')}) would be denied the host resource`); +} + +/** + * detectDrift(localPolicyHash, remotePolicy) → { drifted } + * + * Compares the stored post-push hash against a stable hash of the remote + * policy's DC-managed `acls` section. Non-DC sections never count as drift. + */ +function detectDrift(localPolicyHash, remotePolicy) { + const remoteHash = hashAcls(remotePolicy && remotePolicy.acls); + return { drifted: remoteHash !== localPolicyHash, remoteHash }; +} + +/** + * compileAndValidate(input) → { policy, hash } + * compilePolicy + assertNoLockout + stable hash of the managed section, + * ready for preview + push (acl-push.js). + */ +function compileAndValidate(input) { + const policy = compilePolicy(input); + assertNoLockout(policy, input && { + adminUserIds: input.adminUserIds, + hostResource: input.hostResource, + hostDeviceId: input.hostDeviceId, + }); + return { policy, hash: hashAcls(policy[MANAGED_SECTION]) }; +} + +module.exports = { + compilePolicy, + assertNoLockout, + detectDrift, + compileAndValidate, + hashAcls, + LockoutError, + MANAGED_SECTION, +}; diff --git a/dashcaddy-api/src/network/acl-push.js b/dashcaddy-api/src/network/acl-push.js new file mode 100644 index 0000000..f14d241 --- /dev/null +++ b/dashcaddy-api/src/network/acl-push.js @@ -0,0 +1,208 @@ +/** + * ACL push engine — DC-148 (the I/O half of the compiler). + * + * Owns every interaction with the Tailscale coordination plane plus the + * local last-known-good state file (data/network-acl-state.json, atomic + * write). Dependencies are injectable: a `coord` client (anything exposing + * request/_request-style 'POST path' + getAcl(), e.g. TailscaleCoordClient) + * or a raw fetch stub, and a logger. + * + * Safety sequence (spec §4, hard rules): + * previewPolicy POST /tailnet/{t}/acl/preview — ANY error refuses the push + * pushPolicy POST /tailnet/{t}/acl with the COMPLETE policy object, + * then atomically store last-known-good body + hash + * pushIfNoDrift compare stored hash vs remote acls hash first; drift ⇒ + * {drifted:true, pushed:false} — NEVER a silent overwrite + * rollback re-push the stored last-known-good policy + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { atomicWriteJSON } = require('../utils/atomic-write'); +const { hashAcls, detectDrift } = require('./acl-compiler'); + +const ACL_STATE_FILE = 'network-acl-state.json'; + +/** Error thrown for mapped coordination-plane failures; carries `code`. */ +class AclPushError extends Error { + constructor(message, { code = 'acl_push_failed', cause } = {}) { + super(message); + this.name = 'AclPushError'; + this.code = code; + this.cause = cause; + this.isOperational = true; + } +} + +function _readState(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + if (err && err.code === 'ENOENT') return null; + throw new AclPushError('failed to read network-acl-state.json', { code: 'state_read_failed', cause: err }); + } +} + +/** + * createAclPusher({ coord, fetchImpl, dataDir, log, tailnet }) + * + * coord client with .request(method, path, {body}) — the primary path + * fetchImpl fallback when no coord: async (method, path, {body}) → {status, body} + * tailnet tailnet name for URLs ('-' default) + */ +function createAclPusher(opts = {}) { + const coord = opts.coord || null; + const fetchImpl = opts.fetchImpl || null; + const dataDir = opts.dataDir || (global.__DC_ACL_STATE_DIR__ || process.cwd()); + const log = opts.log || { info() {}, warn() {}, error() {} }; + const tailnet = opts.tailnet || '-'; + const stateFile = path.join(dataDir, ACL_STATE_FILE); + + if (!coord && !fetchImpl) { + throw new TypeError('createAclPusher requires coord or fetchImpl'); + } + + // The coord client prefixes /api/v2 itself (_request); a raw fetch stub + // gets the full control-plane path. + const API_BASE = '/api/v2'; + + async function _call(method, apiPath, { body } = {}) { + if (coord && typeof coord.request === 'function') { + try { + return await coord.request(method, apiPath, { body }); + } catch (err) { + throw _mapError(`coord ${method} ${apiPath} failed`, err); + } + } + const fullPath = API_BASE + apiPath; + let res; + try { + res = await fetchImpl(method, fullPath, { body }); + } catch (err) { + throw _mapError(`fetch ${method} ${fullPath} failed`, err); + } + if (res.status >= 400) { + throw new AclPushError(`${method} ${fullPath} returned ${res.status}`, { + code: res.status === 400 ? 'acl_rejected' : `http_${res.status}`, + cause: res.body, + }); + } + // Match the coord client contract: parsed JSON when parseable, else raw. + if (typeof res.body === 'string') { + try { return JSON.parse(res.body); } catch (_) { return res.body; } + } + return res.body; + } + + function _mapError(message, err) { + if (err && err.name === 'AclPushError') return err; + let code = 'coord_error'; + if (err && typeof err.code === 'string' && err.code) { + code = err.code === 'bad_input' || err.status === 400 ? 'acl_rejected' : err.code; + } + return new AclPushError(`${message}: ${err && err.message}`, { code, cause: err }); + } + + /** + * Dry-run the policy against the tailnet. ANY preview error throws + * AclPushError — callers MUST refuse to push when this rejects. + * Returns the preview response body (Tailscale: { feasible, errors[] }). + */ + async function previewPolicy(policy) { + const body = await _call('POST', `/tailnet/${tailnet}/acl/preview`, { body: policy }); + if (body && Array.isArray(body.errors) && body.errors.length > 0) { + throw new AclPushError('ACL preview reported errors', { + code: 'acl_rejected', + cause: body.errors, + }); + } + return body; + } + + /** Read the remote policy (GET /tailnet/{t}/acl). */ + async function getRemotePolicy() { + return await _call('GET', `/tailnet/${tailnet}/acl`); + } + + function _storeLastKnownGood(policy, hash) { + const state = { + policy, + hash, + managedSection: 'acls', + pushedAt: new Date().toISOString(), + }; + atomicWriteJSON(stateFile, state); + log.info('network-acl', 'stored last-known-good policy', { hash }); + return state; + } + + /** + * Push the COMPLETE policy (never partial — caller merges baseline before + * compile). On success the full body + managed-section hash are atomically + * persisted as last-known-good. + */ + async function pushPolicy(policy) { + await _call('POST', `/tailnet/${tailnet}/acl`, { body: policy }); + const hash = hashAcls(policy.acls); + _storeLastKnownGood(policy, hash); + return { pushed: true, hash }; + } + + /** Read local last-known-good state (null when never pushed). */ + function getLastKnownGood() { + return _readState(stateFile); + } + + /** Re-push the stored last-known-good policy (one-click rollback). */ + async function rollback() { + const state = _readState(stateFile); + if (!state || !state.policy) { + throw new AclPushError('no last-known-good policy stored — cannot rollback', { code: 'no_last_known_good' }); + } + await _call('POST', `/tailnet/${tailnet}/acl`, { body: state.policy }); + return { rolledBack: true, hash: state.hash }; + } + + /** + * Compare stored hash vs remote acls before overwriting. Outside edits ⇒ + * { drifted: true, pushed: false } — the caller shows a drift banner and + * must NEVER silently overwrite. Only a clean (or first) push proceeds: + * preview → push → store. + */ + async function pushIfNoDrift(policy) { + const stored = _readState(stateFile); + if (stored && stored.hash) { + let remote; + try { + remote = await getRemotePolicy(); + } catch (err) { + throw _mapError('drift check failed (cannot read remote ACL)', err); + } + const { drifted } = detectDrift(stored.hash, remote); + if (drifted) { + log.warn('network-acl', 'drift detected — refusing to overwrite remote ACL', {}); + return { drifted: true, pushed: false }; + } + } + await previewPolicy(policy); // refuses (throws) on any preview error + return await pushPolicy(policy); + } + + return { + previewPolicy, + pushPolicy, + pushIfNoDrift, + rollback, + getRemotePolicy, + getLastKnownGood, + AclPushError, + }; +} + +module.exports = { + createAclPusher, + AclPushError, + ACL_STATE_FILE, +};