DC-148: Tailscale ACL compiler + lockout guard + preview/push/rollback + drift detection + tests
This commit is contained in:
4 files changed
+1065
No files matched your search
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user