118 lines
4.5 KiB
JavaScript
118 lines
4.5 KiB
JavaScript
/**
|
|
* 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 };
|