/** * ACL sync adapter (DC-153) — the glue between the Tailscale provider's * syncAccess() contract and the DC-148 enforcement engines * (acl-compiler.compileAndValidate + acl-push previewPolicy/pushPolicy). * * The provider talks to enforcement ONLY through this surface: * * compile(input) → { policy, hash, usersAffected } * input: { accessMap, people, resources, hostDeviceId, * adminUserIds, hostResource } * Runs the pure compiler + lockout guard. LockoutError propagates * untouched — the provider maps it to ProviderError 'lockout_guard'. * `usersAffected` = distinct non-host srcs in the compiled acls. * * preview(policy) → preview body (throws AclPushError on ANY preview * error — callers MUST refuse to push when this rejects) * * push(policy) → { pushed: true, hash } (AclPushError on API failure) * * getState() → last-known-good record or null (delegates to the pusher) * * Dependencies are injectable per the DC-153 refactor: pass a `pusher` * (anything with previewPolicy/pushPolicy/getLastKnownGood — the * createAclPusher() output shape) or a `coordClient` + `tailnet` (a * pusher is built from them), and optionally a `compileAndValidate` * override. Defaults are the real modules, so * createAclSync({ coordClient, tailnet }) alone is production wiring. */ 'use strict'; const { compileAndValidate: compileAndValidateDefault } = require('./acl-compiler'); const { createAclPusher } = require('./acl-push'); /** * Count distinct user srcs in the compiled acls, excluding the DashCaddy * host device (its egress rule src is a device id, not a user). * @param {Array} acls - compiled acl entries * @param {string} hostDeviceId * @returns {number} */ function _countUsersAffected(acls, hostDeviceId) { const users = new Set(); for (const entry of acls || []) { for (const src of (entry && entry.src) || []) { if (src !== hostDeviceId) users.add(src); } } return users.size; } /** * @param {object} [deps] * @param {object} [deps.coordClient] - client with .request(method, path, * {body}); a lazy proxy is fine (resolved per call) * @param {string} [deps.tailnet='-'] - tailnet name for ACL URLs * @param {object} [deps.pusher] - createAclPusher() output (or mock with * previewPolicy/pushPolicy/getLastKnownGood); wins over coordClient * @param {Function} [deps.compileAndValidate] - pure compiler override */ function createAclSync({ coordClient = null, tailnet = '-', pusher = null, compileAndValidate } = {}) { const push = pusher || createAclPusher({ coord: coordClient, tailnet }); const compileAndValidateFn = compileAndValidate || compileAndValidateDefault; return { /** * Compile + lockout-guard + hash. Synchronous engine, async surface * (the provider contract is all-async; mocks may be async too). * @returns {Promise<{policy: object, hash: string, usersAffected: number}>} * @throws {LockoutError} when the compiled policy would lock out admins */ async compile(input) { const { policy, hash } = compileAndValidateFn(input); return { policy, hash, usersAffected: _countUsersAffected(policy && policy.acls, input && input.hostDeviceId), }; }, /** * Dry-run the policy against the tailnet. Rejects (AclPushError) on * ANY preview error — the caller must refuse to push. */ async preview(policy) { return push.previewPolicy(policy); }, /** * Push the COMPLETE policy; on success the pusher persists the * last-known-good policy + hash atomically. * @returns {Promise<{pushed: true, hash: string}>} */ async push(policy) { // Drift guard (spec hard rule): never silently overwrite remote ACL // edits made outside DashCaddy. pushIfNoDrift compares the stored // last-known-good hash against the remote acls hash first; a drifted // remote resolves {drifted:true, pushed:false} which the caller must // surface (provider maps it to ProviderError 'acl_drift_detected'). const result = await push.pushIfNoDrift(policy); if (result && result.drifted && !result.pushed) { const err = new Error('remote ACL policy drifted from last-known-good; refusing to overwrite'); err.code = 'acl_drift_detected'; err.drift = result; throw err; } return result; }, /** Local last-known-good state (null when never pushed). */ getState() { return push.getLastKnownGood(); }, }; } module.exports = { createAclSync };