285 lines
14 KiB
JavaScript
285 lines
14 KiB
JavaScript
/**
|
|
* Tests for src/network/acl-compiler.js — DC-148.
|
|
*
|
|
* Golden-policy cases, lockout adversarial cases (each hard rule), drift
|
|
* detection both ways, baseline-section passthrough. 100% pure — no I/O,
|
|
* no jest mocks needed.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/* eslint-disable require-await, no-unused-vars */
|
|
// require-await: helper stubs are async to match real signatures without awaiting.
|
|
// no-unused-vars: fixture records exist to document the people shape.
|
|
|
|
const {
|
|
compilePolicy,
|
|
assertNoLockout,
|
|
detectDrift,
|
|
compileAndValidate,
|
|
hashAcls,
|
|
LockoutError,
|
|
} = require('../src/network/acl-compiler');
|
|
|
|
// ── fixtures ──────────────────────────────────────────────────────────────
|
|
|
|
const ADMIN = { id: 'p-admin', providerUserId: '1001', groups: ['Admin'], status: 'active' };
|
|
const WIFE = { id: 'p-wife', providerUserId: '1002', groups: ['Family'], status: 'active' };
|
|
const KID = { id: 'p-kid', providerUserId: '1003', groups: ['Family'], status: 'active' };
|
|
const BLOCKED = { id: 'p-bad', providerUserId: '1004', groups: ['Family'], status: 'blocked' };
|
|
const REMOVED = { id: 'p-gone', providerUserId: '1005', groups: ['Admin'], status: 'removed' };
|
|
|
|
const RESOURCES = [
|
|
{ name: 'dashcaddy', host: 'dashcaddy.internal', ports: ['80', '443', '8080'] },
|
|
{ name: 'plex', host: 'plex.internal', ports: ['32400'] },
|
|
{ name: 'nas', host: 'nas.internal' }, // ports default → ['*']
|
|
];
|
|
|
|
const HOST_DEVICE = 'dev-host-123';
|
|
|
|
function grants(...list) {
|
|
return { grants: list.map(([type, id, resource, allowed]) =>
|
|
({ principal: { type, id }, resource, allowed })) };
|
|
}
|
|
|
|
const BASELINE = {
|
|
ssh: [{ action: 'check', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['autogroup:nonroot'] }],
|
|
tagOwners: { 'tag:dc-host': ['autogroup:admin'] },
|
|
};
|
|
|
|
// ── golden-policy: compilePolicy ──────────────────────────────────────────
|
|
|
|
describe('acl-compiler: golden policies', () => {
|
|
test('single admin with all-resource grants → per-user src entries + host egress', () => {
|
|
const map = grants(
|
|
['person', 'p-admin', 'dashcaddy', true],
|
|
['person', 'p-admin', 'plex', true],
|
|
['person', 'p-admin', 'nas', true],
|
|
);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(policy.acls).toEqual([
|
|
{ action: 'accept', src: ['1001'], dst: ['dashcaddy.internal:80', 'dashcaddy.internal:443', 'dashcaddy.internal:8080'], proto: ['tcp', 'udp'] },
|
|
{ action: 'accept', src: ['1001'], dst: ['plex.internal:32400'], proto: ['tcp', 'udp'] },
|
|
{ action: 'accept', src: ['1001'], dst: ['nas.internal:*'], proto: ['tcp', 'udp'] },
|
|
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
|
]);
|
|
});
|
|
|
|
test('family group grant expands to each active member (no Tailscale groups)', () => {
|
|
const map = grants(['group', 'Family', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN, WIFE, KID, BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
const srcs = policy.acls.map(e => e.src[0]);
|
|
expect(srcs).toContain('1002');
|
|
expect(srcs).toContain('1003');
|
|
expect(srcs).not.toContain('1004'); // blocked compiles to nothing
|
|
expect(srcs).not.toContain('1005'); // removed compiles to nothing
|
|
// No group object is ever emitted in dst (free-plan rule)
|
|
for (const e of policy.acls) {
|
|
for (const d of e.dst) expect(d.startsWith('group:')).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('blocked person is absent even with an explicit personal allow grant', () => {
|
|
const map = grants(
|
|
['person', 'p-bad', 'plex', true],
|
|
['group', 'Family', 'plex', true],
|
|
);
|
|
const policy = compilePolicy({ accessMap: map, people: [BLOCKED], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(policy.acls).toEqual([
|
|
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
|
]);
|
|
});
|
|
|
|
test('person-level override beats group grant (deny and allow)', () => {
|
|
const map = grants(
|
|
['group', 'Family', 'plex', true],
|
|
['person', 'p-kid', 'plex', false], // kid explicitly denied despite Family
|
|
['group', 'Family', 'nas', false],
|
|
['person', 'p-wife', 'nas', true], // wife explicitly allowed despite Family deny
|
|
);
|
|
const policy = compilePolicy({ accessMap: map, people: [WIFE, KID], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
const wife = policy.acls.filter(e => e.src.includes('1002'));
|
|
const kid = policy.acls.filter(e => e.src.includes('1003'));
|
|
expect(wife.map(e => e.dst[0]).sort()).toEqual(['nas.internal:*', 'plex.internal:32400']); // nas via override + plex via group
|
|
expect(kid).toEqual([]); // plex denied by override, nas denied by group
|
|
});
|
|
|
|
test('default-deny: ungranted resources and people compile to nothing', () => {
|
|
const map = grants(['group', 'Family', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
// only the host egress rule — no catch-all accept, nothing for ADMIN
|
|
expect(policy.acls).toEqual([
|
|
{ action: 'accept', src: [HOST_DEVICE], dst: ['*:*'], proto: ['tcp', 'udp'] },
|
|
]);
|
|
const flat = JSON.stringify(policy.acls);
|
|
expect(flat.includes('*:*')).toBe(true); // host device egress only
|
|
expect(policy.acls.filter(e => e.src.includes('1001'))).toEqual([]);
|
|
});
|
|
|
|
test('grant for an unknown resource compiles to nothing', () => {
|
|
const map = grants(['person', 'p-admin', 'ghost-app', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(policy.acls).toHaveLength(1); // host egress only
|
|
});
|
|
|
|
test('person without providerUserId compiles to nothing', () => {
|
|
const nobody = { ...ADMIN, id: 'p-noid', providerUserId: '' };
|
|
const map = grants(['person', 'p-noid', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [nobody], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(policy.acls).toHaveLength(1); // host egress only
|
|
});
|
|
|
|
test('baseline ssh + tagOwners pass through untouched; acls replaced', () => {
|
|
const map = grants(['person', 'p-admin', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: BASELINE });
|
|
expect(policy.ssh).toEqual(BASELINE.ssh);
|
|
expect(policy.tagOwners).toEqual(BASELINE.tagOwners);
|
|
expect(Object.keys(policy).sort()).toEqual(['acls', 'ssh', 'tagOwners']);
|
|
});
|
|
|
|
test('baseline acls section is dropped (fully DC-managed)', () => {
|
|
const withOldAcls = { ...BASELINE, acls: [{ action: 'accept', src: ['stale'], dst: ['x:*'] }] };
|
|
const map = grants(['person', 'p-admin', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, baseline: withOldAcls });
|
|
expect(policy.acls.some(e => e.src.includes('stale'))).toBe(false);
|
|
});
|
|
|
|
test('input validation throws TypeError', () => {
|
|
expect(() => compilePolicy()).toThrow(TypeError);
|
|
expect(() => compilePolicy({ accessMap: {} })).toThrow(TypeError);
|
|
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [] })).toThrow(TypeError);
|
|
expect(() => compilePolicy({ accessMap: { grants: [] }, people: [], resources: [] })).toThrow(TypeError);
|
|
});
|
|
});
|
|
|
|
// ── lockout adversarial ───────────────────────────────────────────────────
|
|
|
|
describe('acl-compiler: lockout guard (assertNoLockout)', () => {
|
|
const hostResource = RESOURCES[0]; // dashcaddy
|
|
|
|
test('last-admin-denied throws LockoutError', () => {
|
|
// admin granted plex only — dashcaddy host resource NOT granted
|
|
const map = grants(['person', 'p-admin', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).toThrow(LockoutError);
|
|
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource }))
|
|
.toThrow(/every active admin/);
|
|
});
|
|
|
|
test('host-egress-missing throws LockoutError', () => {
|
|
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
// simulate a policy that lost the egress rule
|
|
policy.acls = policy.acls.filter(e => !e.dst.includes('*:*'));
|
|
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource, hostDeviceId: HOST_DEVICE }))
|
|
.toThrow(/egress/);
|
|
});
|
|
|
|
test('two admins, one denied, one allowed → passes (not a lockout)', () => {
|
|
const admin2 = { id: 'p-admin2', providerUserId: '1006', groups: ['Admin'], status: 'active' };
|
|
const map = grants(
|
|
['person', 'p-admin', 'plex', true], // admin1: NO dashcaddy
|
|
['person', 'p-admin2', 'dashcaddy', true], // admin2: has dashcaddy
|
|
);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN, admin2], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(() => assertNoLockout(policy, { adminUserIds: ['1001', '1006'], hostResource })).not.toThrow();
|
|
});
|
|
|
|
test('empty adminUserIds throws (no admin at all is a lockout)', () => {
|
|
const map = grants(['person', 'p-wife', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [WIFE], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(LockoutError);
|
|
expect(() => assertNoLockout(policy, { adminUserIds: [], hostResource })).toThrow(/no active admin/);
|
|
});
|
|
|
|
test('admin allowed via group grant counts (Family granted dashcaddy)', () => {
|
|
const adminViaGroup = { ...ADMIN, groups: ['Family'] };
|
|
const map = grants(['group', 'Family', 'dashcaddy', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [adminViaGroup], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
expect(() => assertNoLockout(policy, { adminUserIds: ['1001'], hostResource })).not.toThrow();
|
|
});
|
|
|
|
test('requires a policy object (TypeError)', () => {
|
|
expect(() => assertNoLockout(null, {})).toThrow(TypeError);
|
|
expect(() => assertNoLockout({}, {})).toThrow(TypeError);
|
|
});
|
|
|
|
test('LockoutError shape: code, statusCode, name', () => {
|
|
const map = grants(['person', 'p-admin', 'plex', true]);
|
|
const policy = compilePolicy({ accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE });
|
|
try {
|
|
assertNoLockout(policy, { adminUserIds: ['1001'], hostResource });
|
|
throw new Error('should have thrown');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(LockoutError);
|
|
expect(err.name).toBe('LockoutError');
|
|
expect(err.code).toBe('DC_ACL_LOCKOUT');
|
|
expect(err.statusCode).toBe(409);
|
|
expect(err.isOperational).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── drift detection ───────────────────────────────────────────────────────
|
|
|
|
describe('acl-compiler: drift detection (detectDrift)', () => {
|
|
const map = grants(['person', 'p-admin', 'dashcaddy', true], ['person', 'p-admin', 'plex', true]);
|
|
const { policy, hash } = compileAndValidate(_fullInput());
|
|
|
|
function _fullInput() {
|
|
return { accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE, adminUserIds: ['1001'], hostResource: RESOURCES[0] };
|
|
}
|
|
|
|
test('no drift: identical remote acls → drifted:false', () => {
|
|
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)) };
|
|
const res = detectDrift(hash, remote);
|
|
expect(res.drifted).toBe(false);
|
|
expect(res.remoteHash).toBe(hash);
|
|
});
|
|
|
|
test('drift: outside edit added a rule → drifted:true', () => {
|
|
const remote = { acls: [...policy.acls, { action: 'accept', src: ['outsider'], dst: ['plex.internal:32400'] }] };
|
|
expect(detectDrift(hash, remote).drifted).toBe(true);
|
|
});
|
|
|
|
test('drift: rule removed remotely → drifted:true', () => {
|
|
const remote = { acls: policy.acls.slice(0, -1) };
|
|
expect(detectDrift(hash, remote).drifted).toBe(true);
|
|
});
|
|
|
|
test('baseline sections changing is NOT drift (only acls hashed)', () => {
|
|
const remote = { acls: JSON.parse(JSON.stringify(policy.acls)), ssh: [{ action: 'accept', src: ['x'], dst: ['y'] }] };
|
|
expect(detectDrift(hash, remote).drifted).toBe(false);
|
|
});
|
|
|
|
test('hash is order-stable (sorted keys) and content-sensitive', () => {
|
|
expect(hashAcls(policy.acls)).toBe(hash);
|
|
expect(hashAcls([])).not.toBe(hash);
|
|
// key order inside entries does not matter
|
|
const shuffled = policy.acls.map(e => ({ proto: e.proto, dst: e.dst, src: e.src, action: e.action }));
|
|
expect(hashAcls(shuffled)).toBe(hash);
|
|
});
|
|
});
|
|
|
|
// ── compileAndValidate ────────────────────────────────────────────────────
|
|
|
|
describe('acl-compiler: compileAndValidate', () => {
|
|
test('happy path returns { policy, hash } and passes the guard', () => {
|
|
const map = grants(['person', 'p-admin', 'dashcaddy', true]);
|
|
const out = compileAndValidate({
|
|
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
|
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
|
});
|
|
expect(out.policy.acls.some(e => e.src.includes('1001'))).toBe(true);
|
|
expect(out.hash).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
});
|
|
|
|
test('throws LockoutError (no TypeError leak) when the guard trips', () => {
|
|
const map = grants(['person', 'p-admin', 'plex', true]);
|
|
expect(() => compileAndValidate({
|
|
accessMap: map, people: [ADMIN], resources: RESOURCES, hostDeviceId: HOST_DEVICE,
|
|
adminUserIds: ['1001'], hostResource: RESOURCES[0],
|
|
})).toThrow(LockoutError);
|
|
});
|
|
});
|