/** * 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, };