Files
qinglong/packages/ql3-local-admin/test/secretAdministration.test.cjs
T

326 lines
9.3 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
LocalSecretAuthorizationFenceConflictError,
} = require('@qinglong/runtime-core/local-secret-administration');
const {
LocalSecretMutationConflictError,
} = require('@qinglong/runtime-core/local-secret');
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
const {
openLocalSqliteRuntimeDatabase,
} = require('@qinglong/local-sqlite/runtime');
const {
LocalSecretKeyringFileProvider,
provisionLocalSecretKeyring,
} = require('@qinglong/local-secret');
const {
LocalSecretAdministrationAuthenticationError,
LocalSecretAdministrationAuthorizationError,
LocalSecretAdministrationUnavailableError,
createLocalSecretAdministrationService,
} = require('@qinglong/local-admin/secret-administration');
const NOW = 1_760_000_000_000;
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-admin-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return {
directory,
profile: 'edge',
databasePath: path.join(directory, 'qinglong3.sqlite'),
keyringPath: path.join(directory, 'secret-keyring.json'),
};
}
function principal(overrides = {}) {
return {
subject: { type: 'user', id: 'user-owner' },
authenticationId: 'local-console-1',
authenticatedAtMs: NOW - 1_000,
expiresAtMs: NOW + 60_000,
assurance: 'local_console',
...overrides,
};
}
function request(mutationId, overrides = {}) {
return {
projectId: 'default',
name: 'TOKEN',
plaintext: 'never-persist-this-plaintext',
mutationId,
requestId: `request-${mutationId.slice(-4)}`,
expectedCurrentVersion: 0,
principal: principal(),
...overrides,
};
}
async function openStore(t) {
const value = fixture(t);
await migrateLocalSqlitePath(value);
await provisionLocalSecretKeyring(value.keyringPath);
const runtime = await openLocalSqliteRuntimeDatabase(value);
t.after(() => runtime.close());
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
return { ...value, runtime, keys };
}
async function bind(runtime, role, version = 1, state = 'active') {
return runtime.projectPolicy.append({
expectedCurrentVersion: version - 1,
binding: {
projectId: 'default',
subject: { type: 'user', id: 'user-owner' },
version,
state,
...(state === 'active' ? { role } : {}),
mutationId: `binding-${version}`,
changedBy: { type: 'system', id: 'owner-bootstrap' },
createdAtMs: NOW + version,
},
});
}
function service(value, overrides = {}) {
return createLocalSecretAdministrationService(
overrides.projectPolicy ?? value.runtime.projectPolicy,
overrides.mutations ?? value.runtime.localSecretAdministration,
overrides.audit ?? value.runtime.securityAudit,
overrides.keys ?? value.keys,
{ now: () => NOW, nonceFactory: () => Buffer.alloc(12, 7) },
);
}
test('owner writes one encrypted envelope and allowed audit atomically', async (t) => {
const value = await openStore(t);
assert.equal(
(await value.runtime.projectPolicy.resolve('default', principal().subject))
.binding,
undefined,
);
await bind(value.runtime, 'owner');
const mutationId = '00000000-0000-4000-8000-000000000001';
const result = await service(value).put(request(mutationId));
assert.equal(result.status, 'inserted');
assert.equal(result.version, 1);
const database = new DatabaseSync(value.databasePath, { readOnly: true });
try {
const secret = database
.prepare(
`SELECT version, ciphertext FROM "QingLong3LocalSecretEnvelopes"
WHERE mutation_id = ?`,
)
.get(mutationId);
const audit = database
.prepare(
`SELECT operation_id, outcome, subject_id, fence_project_version,
fence_binding_version
FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?`,
)
.get(mutationId);
assert.equal(secret.version, 1);
assert.equal(
Buffer.from(secret.ciphertext).includes(
Buffer.from('never-persist-this-plaintext'),
),
false,
);
assert.deepEqual(
{ ...audit },
{
operation_id: 'secret.create',
outcome: 'allowed',
subject_id: 'user-owner',
fence_project_version: 1,
fence_binding_version: 1,
},
);
} finally {
database.close();
}
});
test('denial is audited before key access and ownerless default stays closed', async (t) => {
const value = await openStore(t);
let keyReads = 0;
const keys = {
async active() {
keyReads += 1;
throw new Error('must not run');
},
async resolve() {
keyReads += 1;
throw new Error('must not run');
},
};
const mutationId = '00000000-0000-4000-8000-000000000002';
await assert.rejects(
service(value, { keys }).put(request(mutationId)),
LocalSecretAdministrationAuthorizationError,
);
assert.equal(keyReads, 0);
const database = new DatabaseSync(value.databasePath, { readOnly: true });
try {
assert.equal(
database
.prepare(
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
WHERE event_id = ?`,
)
.get(mutationId).outcome,
'denied',
);
assert.equal(
database
.prepare(
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
)
.get().count,
0,
);
} finally {
database.close();
}
});
test('weak authentication is audited without Policy or key access', async (t) => {
const value = await openStore(t);
let policyReads = 0;
let keyReads = 0;
const projectPolicy = {
async resolve() {
policyReads += 1;
throw new Error('must not run');
},
append: value.runtime.projectPolicy.append,
};
const keys = {
async active() {
keyReads += 1;
throw new Error('must not run');
},
async resolve() {
keyReads += 1;
throw new Error('must not run');
},
};
const mutationId = '00000000-0000-4000-8000-000000000003';
await assert.rejects(
service(value, { projectPolicy, keys }).put(
request(mutationId, {
principal: principal({ assurance: 'single_factor' }),
}),
),
LocalSecretAdministrationAuthenticationError,
);
assert.equal(policyReads, 0);
assert.equal(keyReads, 0);
});
test('revocation after Policy decision is fenced inside the write transaction', async (t) => {
const value = await openStore(t);
await bind(value.runtime, 'admin');
let revoked = false;
const projectPolicy = {
async resolve(projectId, subject) {
const snapshot = await value.runtime.projectPolicy.resolve(
projectId,
subject,
);
if (!revoked) {
revoked = true;
await bind(value.runtime, undefined, 2, 'revoked');
}
return snapshot;
},
append: (...args) => value.runtime.projectPolicy.append(...args),
};
const mutationId = '00000000-0000-4000-8000-000000000004';
await assert.rejects(
service(value, { projectPolicy }).put(request(mutationId)),
LocalSecretAuthorizationFenceConflictError,
);
const database = new DatabaseSync(value.databasePath, { readOnly: true });
try {
assert.equal(
database
.prepare(
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
)
.get().count,
0,
);
assert.equal(
database
.prepare(
`SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"
WHERE event_id = ?`,
)
.get(mutationId).count,
0,
);
} finally {
database.close();
}
});
test('audit insertion failure rolls the encrypted envelope back', async (t) => {
const value = await openStore(t);
await bind(value.runtime, 'owner');
const database = new DatabaseSync(value.databasePath);
database.exec(`
CREATE TRIGGER reject_secret_audit
BEFORE INSERT ON "QingLong3SecurityAuditEvents"
WHEN NEW.operation_id IN ('secret.create', 'secret.rotate')
BEGIN
SELECT RAISE(ABORT, 'audit unavailable');
END
`);
database.close();
await assert.rejects(
service(value).put(request('00000000-0000-4000-8000-000000000005')),
LocalSecretAdministrationUnavailableError,
);
const check = new DatabaseSync(value.databasePath, { readOnly: true });
try {
assert.equal(
check
.prepare(
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
)
.get().count,
0,
);
assert.equal(
check
.prepare('SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"')
.get().count,
0,
);
} finally {
check.close();
}
});
test('semantic replay returns no plaintext and conflicting replay fails closed', async (t) => {
const value = await openStore(t);
await bind(value.runtime, 'owner');
const mutationId = '00000000-0000-4000-8000-000000000006';
const admin = service(value);
assert.equal((await admin.put(request(mutationId))).status, 'inserted');
assert.equal((await admin.put(request(mutationId))).status, 'existing');
await assert.rejects(
admin.put(request(mutationId, { plaintext: 'different' })),
LocalSecretMutationConflictError,
);
});