P7 r2 fixes: drift guard wired through pushIfNoDrift (acl_drift_detected ProviderError, never overwrites drifted remote) + unknown-device forward_auth audit + drift regression tests

This commit is contained in:
Hermes committed 2026-10-03 22:47:27 -07:00
1 parent a5ef5b345e
commit d2d0d73477
4 files changed
+84 -3

No files matched your search

@@ -42,17 +42,20 @@ describe('acl-sync adapter (createAclSync)', () => {
await expect(sync.compile(bad)).rejects.toBeInstanceOf(LockoutError); await expect(sync.compile(bad)).rejects.toBeInstanceOf(LockoutError);
}); });
test('preview/push delegate to the pusher; getState to getLastKnownGood', async () => { test('preview delegates to the pusher; push goes through the drift guard; getState to getLastKnownGood', async () => {
const pusher = { const pusher = {
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })), previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
pushIfNoDrift: jest.fn(async (policy) => ({ drifted: false, ...(await pusher.pushPolicy(policy)) })),
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'sha256:x' })), pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'sha256:x' })),
getLastKnownGood: jest.fn(() => ({ hash: 'sha256:x', policy: { acls: [] } })), getLastKnownGood: jest.fn(() => ({ hash: 'sha256:x', policy: { acls: [] } })),
}; };
const sync = createAclSync({ pusher }); const sync = createAclSync({ pusher });
await expect(sync.preview({ acls: [] })).resolves.toEqual({ feasible: true, errors: [] }); await expect(sync.preview({ acls: [] })).resolves.toEqual({ feasible: true, errors: [] });
await expect(sync.push({ acls: [] })).resolves.toEqual({ pushed: true, hash: 'sha256:x' }); await expect(sync.push({ acls: [] })).resolves.toEqual({ drifted: false, pushed: true, hash: 'sha256:x' });
expect(sync.getState()).toMatchObject({ hash: 'sha256:x' }); expect(sync.getState()).toMatchObject({ hash: 'sha256:x' });
expect(pusher.previewPolicy).toHaveBeenCalledWith({ acls: [] }); expect(pusher.previewPolicy).toHaveBeenCalledWith({ acls: [] });
// push now flows through the drift guard, not bare pushPolicy
expect(pusher.pushIfNoDrift).toHaveBeenCalledWith({ acls: [] });
expect(pusher.pushPolicy).toHaveBeenCalledWith({ acls: [] }); expect(pusher.pushPolicy).toHaveBeenCalledWith({ acls: [] });
}); });
@@ -78,3 +81,52 @@ describe('acl-sync adapter (createAclSync)', () => {
await expect(sync.preview({ acls: [] })).rejects.toBeInstanceOf(AclPushError); await expect(sync.preview({ acls: [] })).rejects.toBeInstanceOf(AclPushError);
}); });
}); });
// ── Drift-guard regression (judge r2 blocker 1) ──────────────────────────
describe('acl-sync drift guard (pushIfNoDrift wiring)', () => {
test('push: drifted remote policy → acl_drift_detected, pushPolicy NEVER called', async () => {
const policy = { acls: [] };
const pusher = {
previewPolicy: jest.fn(async () => ({ feasible: true, errors: [] })),
pushIfNoDrift: jest.fn(async () => ({ drifted: true, pushed: false })),
pushPolicy: jest.fn(async () => ({ pushed: true, hash: 'h' })),
getLastKnownGood: jest.fn(() => null),
};
const sync = createAclSync({ pusher });
await expect(sync.push(policy)).rejects.toMatchObject({
code: 'acl_drift_detected',
});
expect(pusher.pushIfNoDrift).toHaveBeenCalledTimes(1);
expect(pusher.pushPolicy).not.toHaveBeenCalled();
});
test('push: no drift → delegates to success shape', async () => {
const pusher = {
pushIfNoDrift: jest.fn(async () => ({ drifted: false, pushed: true, hash: 'abc' })),
pushPolicy: jest.fn(),
getLastKnownGood: jest.fn(() => null),
};
const sync = createAclSync({ pusher });
const result = await sync.push({ acls: [] });
expect(result).toEqual({ drifted: false, pushed: true, hash: 'abc' });
});
test('provider: tailscale syncAccess surfaces drifted remote as ProviderError acl_drift_detected (nothing pushed)', async () => {
const { createTailscaleProvider } = require('../src/network/providers/tailscale');
const { ProviderError } = require('../src/network/errors');
const engine = {
compile: jest.fn(async () => ({ policy: { acls: [] }, hash: 'h', usersAffected: 1 })),
preview: jest.fn(async () => ({ feasible: true, errors: [] })),
push: jest.fn(async () => { const e = new Error('drift'); e.code = 'acl_drift_detected'; e.drift = { drifted: true }; throw e; }),
getState: jest.fn(() => null),
};
const provider = createTailscaleProvider({ coordClient: { request: jest.fn() }, aclSync: engine });
await expect(provider.syncAccess(tsInput())).rejects.toMatchObject({
code: 'acl_drift_detected',
retryable: false,
});
await expect(provider.syncAccess(tsInput())).rejects.toBeInstanceOf(ProviderError);
expect(engine.push).toHaveBeenCalledTimes(2);
});
});
+13 -1
View File
@@ -92,7 +92,19 @@ function createAclSync({ coordClient = null, tailnet = '-', pusher = null, compi
* @returns {Promise<{pushed: true, hash: string}>} * @returns {Promise<{pushed: true, hash: string}>}
*/ */
async push(policy) { async push(policy) {
return push.pushPolicy(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). */ /** Local last-known-good state (null when never pushed). */
@@ -188,6 +188,13 @@ function createForwardAuthService({
// 1. Identity follows the device: whois the connecting IP. // 1. Identity follows the device: whois the connecting IP.
const identity = await whoisIdentity(sourceIp); const identity = await whoisIdentity(sourceIp);
if (!identity.personId) { if (!identity.personId) {
audit(auditLog, {
event: 'forward_auth_deny',
personId: null,
resource: null,
host: String(host || ''),
outcome: 'unknown-device',
});
return { return {
status: 401, status: 401,
headers: { 'WWW-Authenticate': 'ForwardAuth realm="dashcaddy-private-network"' }, headers: { 'WWW-Authenticate': 'ForwardAuth realm="dashcaddy-private-network"' },
@@ -417,6 +417,16 @@ function createTailscaleProvider({
try { try {
pushed = await engine.push(compiled.policy); pushed = await engine.push(compiled.policy);
} catch (err) { } catch (err) {
if (err && err.code === 'acl_drift_detected') {
// Drift guard (spec hard rule): the remote policy hash no longer
// matches our last-known-good — someone edited ACLs outside
// DashCaddy. NEVER silently overwrite. Surface as a distinct,
// non-retryable ProviderError carrying the drift details.
throw new ProviderError(
'tailscale: remote ACL policy drifted from DashCaddy last-known-good — refusing to overwrite; review the tailnet ACL console and re-import or force-sync',
{ code: 'acl_drift_detected', retryable: false, cause: err, drift: err.drift }
);
}
if (err instanceof AclPushError) { if (err instanceof AclPushError) {
// API-side failure: map the pusher's code through the shared // API-side failure: map the pusher's code through the shared
// vocabulary (acl_rejected → bad_input, rate-limited/http_* → // vocabulary (acl_rejected → bad_input, rate-limited/http_* →