DC-153: provider.syncAccess enforcement wiring (tailscale ACL push + wireguard nftables apply) + contract tests
This commit is contained in:
6 files changed
+820
-32
No files matched your search
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user