DC-153: provider.syncAccess enforcement wiring (tailscale ACL push + wireguard nftables apply) + contract tests

This commit is contained in:
Hermes committed 2026-10-03 22:34:02 -07:00
1 parent 88f7600693
commit 44567031eb
6 files changed
+820 -32

No files matched your search

+141 -11
View File
@@ -29,6 +29,9 @@
const { execFile } = require('child_process');
const { promisify } = require('util');
const { ProviderError } = require('../errors');
const { LockoutError } = require('../acl-compiler');
const { AclPushError } = require('../acl-push');
const { createAclSync } = require('../acl-sync');
const execFileAsyncDefault = promisify(execFile);
const CLI_TIMEOUT_MS = 5000;
@@ -80,17 +83,38 @@ function toProviderError(err) {
* require it lazily). Used for getStatus(); may be a jest mock.
* @param {Function} deps.execFileImpl - optional execFile(cb) override for
* the whois CLI call (tests inject a stub instead of spawning processes).
* @param {object} [deps.aclSync] - the ACL enforcement engine
* (src/network/acl-sync.js surface: compile/preview/push/getState).
* Default: built lazily from the attached coord client (or an explicit
* { coordClient, tailnet } pair) via createAclSync — see syncAccess.
* @param {string} [deps.tailnet='-'] - tailnet name for ACL URLs when the
* engine is default-constructed.
*/
function createTailscaleProvider({
coordClient = null,
localManager,
execFileImpl,
aclSync = null,
tailnet = '-',
} = {}) {
// Lazy default require keeps jest.mock('.../tailscale-manager') effective.
/* eslint-disable global-require -- see above */
const manager = localManager || require('../../managers/tailscale-manager');
const execFn = execFileImpl || execFileAsyncDefault;
let coord = coordClient;
let aclSyncDep = aclSync;
/**
* The ACL enforcement engine. Explicit dep wins; otherwise it is built
* lazily (first syncAccess) from the CURRENT coord client, so
* attachCoordClient() after construction still works. Building eagerly
* when no client is attached yet would bake in a dead engine.
*/
function aclSyncEngine() {
if (aclSyncDep) return aclSyncDep;
aclSyncDep = createAclSync({ coordClient: requireCoord('syncAccess'), tailnet });
return aclSyncDep;
}
function requireCoord() {
if (!coord) {
@@ -306,18 +330,116 @@ function createTailscaleProvider({
},
/**
* Compile + push ACLs. NOT in this provider yet — the ACL compiler is
* DC-148 and will be injected (hash-guarded push, lockout guard, dry-run
* preview). Feature modules must treat this code as "coming soon", not
* as a silent no-op.
* Compile + dry-run + push the tailnet ACL policy (DC-153; engines from
* DC-148 via src/network/acl-sync.js). Safety sequence, spec §4:
* 1. compileAndValidate — LockoutError → ProviderError 'lockout_guard'
* (retryable:false), nothing compiled-past let alone pushed
* 2. previewPolicy — ANY preview error → ProviderError
* 'acl_preview_failed', NOTHING pushed
* 3. pushPolicy — API failure maps through the usual coord vocabulary;
* success → { pushed: true, hash, usersAffected }
*
* @param {object} [input]
* @param {object} input.accessMap - { grants: [...] } (DC-145 store)
* @param {Array} input.people - DC-141 person records
* @param {Array} input.resources - resource records (host for the
* DashCaddy app is the lockout-guard hostResource)
* @param {string} input.hostDeviceId - Tailscale device id of this host
* @param {string[]} [input.adminUserIds] - provider user ids of active
* admins (defaults to providerUserId of active 'Admin'-group people)
* @param {object} [input.hostResource] - lockout-guard host resource
* @returns {Promise<{pushed:true, hash:string, usersAffected:number}>}
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the ACL compiler (DC-148)', {
code: 'not_implemented_in_provider',
retryable: false,
})
);
async syncAccess(input = {}) {
const engine = aclSyncEngine();
const { accessMap, people, resources, hostDeviceId } = input;
// Default admin set: active members of the 'Admin' group (DC-141
// bootstrap-admin convention) that have a provider user id.
const adminUserIds = Array.isArray(input.adminUserIds)
? input.adminUserIds
: (people || [])
.filter(
(p) =>
p &&
p.status === 'active' &&
Array.isArray(p.groups) &&
p.groups.includes('Admin') &&
typeof p.providerUserId === 'string' &&
p.providerUserId.length > 0
)
.map((p) => p.providerUserId);
// Default host resource: the DashCaddy app resource (the admin UI the
// lockout guard protects). Caller may pass an explicit hostResource.
const hostResource =
input.hostResource ||
(() => {
const dc = (resources || []).find(
(r) => r && (r.name === 'dashcaddy' || r.type === 'app')
);
return dc ? { host: dc.host || dc.name, ports: dc.ports || ['*'] } : undefined;
})();
let compiled;
try {
compiled = await engine.compile({
accessMap,
people,
resources,
hostDeviceId,
adminUserIds,
hostResource,
});
} catch (err) {
if (err instanceof LockoutError) {
throw new ProviderError('tailscale: ' + err.message, {
code: 'lockout_guard',
retryable: false,
cause: err,
});
}
throw toProviderError(err);
}
try {
// ANY preview error refuses the push (AclPushError from acl-push).
await engine.preview(compiled.policy);
} catch (err) {
if (err instanceof AclPushError) {
throw new ProviderError(
'tailscale: ACL preview failed — refusing to push (' + err.message + ')',
{ code: 'acl_preview_failed', retryable: false, cause: err }
);
}
throw toProviderError(err);
}
let pushed;
try {
pushed = await engine.push(compiled.policy);
} catch (err) {
if (err instanceof AclPushError) {
// API-side failure: map the pusher's code through the shared
// vocabulary (acl_rejected → bad_input, rate-limited/http_* →
// retryable server-ish codes).
let code = 'server_error';
if (err.code === 'acl_rejected') code = 'bad_input';
else if (err.code === 'rate_limited') code = 'rate_limited';
const opts = { code, cause: err };
opts.retryable =
err.code === 'rate_limited' ||
String(err.code).startsWith('http_429') ||
code === 'server_error';
throw new ProviderError('tailscale: ACL push failed — ' + err.message, opts);
}
throw toProviderError(err);
}
return {
pushed: true,
hash: pushed.hash !== undefined ? pushed.hash : compiled.hash,
usersAffected:
pushed.usersAffected !== undefined ? pushed.usersAffected : compiled.usersAffected,
};
},
/**
@@ -383,6 +505,14 @@ function createTailscaleProvider({
/** Late-bind the coord client (context wiring lands with routes/network.js). */
attachCoordClient(client) {
coord = client;
// A default-built engine captured the OLD client — drop it so the
// next syncAccess rebuilds from the new one.
if (!aclSync) aclSyncDep = null;
},
/** Late-bind the ACL enforcement engine (tests / engine swaps). */
attachAclSync(engine) {
aclSyncDep = engine;
},
};