DC-148: Tailscale ACL compiler + lockout guard + preview/push/rollback + drift detection + tests

This commit is contained in:
Hermes committed 2026-10-03 21:26:29 -07:00
1 parent b7df8f5fe0
commit 486d0e8d11
4 files changed
+1065

No files matched your search

+252
View File
@@ -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,
};
+208
View File
@@ -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,
};