mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add short-lived security administration command
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createClusterAdministrationCommandRunner,
|
||||
normalizeClusterAdministrationCommand,
|
||||
publishClusterAdministrationCredentialDelivery,
|
||||
} = require('@qinglong/cluster-admin/administration-command');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: { type: 'user', id: 'security-owner' },
|
||||
authenticationId: 'assertion:security-command-1',
|
||||
authenticatedAtMs: 900,
|
||||
expiresAtMs: 2_000,
|
||||
assurance: 'multi_factor',
|
||||
});
|
||||
const SUBJECT = Object.freeze({ type: 'api_app', id: 'automation-client' });
|
||||
const PATHS = Object.freeze({
|
||||
commandFile: '/private/command.json',
|
||||
assertionFile: '/private/assertion.jwt',
|
||||
keysetFile: '/private/keyset.json',
|
||||
pepperFile: '/private/pepper',
|
||||
});
|
||||
|
||||
function identityCommand(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.register',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174301',
|
||||
requestId: 'security-identity-register-1',
|
||||
expectedCurrentVersion: 0,
|
||||
subject: SUBJECT,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function credentialCommand(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.issue',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174302',
|
||||
requestId: 'security-credential-issue-1',
|
||||
expectedCurrentVersion: 0,
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authority(overrides = {}) {
|
||||
const calls = [];
|
||||
let closes = 0;
|
||||
const credential = {
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
secretDigest: 'digest',
|
||||
pepperKeyId: 'legacy-v1',
|
||||
state: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
};
|
||||
const value = {
|
||||
administration: {
|
||||
async registerIdentity(request) {
|
||||
calls.push(['identity.register', request]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
identity: {
|
||||
subject: request.subject,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
updatedAtMs: 1_000,
|
||||
},
|
||||
mutation: {},
|
||||
};
|
||||
},
|
||||
async enableIdentity() {
|
||||
throw new Error('unexpected enable');
|
||||
},
|
||||
async disableIdentity() {
|
||||
throw new Error('unexpected disable');
|
||||
},
|
||||
async issueCredential(request) {
|
||||
calls.push(['credential.issue', request]);
|
||||
return {
|
||||
status: 'inserted',
|
||||
credential,
|
||||
mutation: {},
|
||||
token: 'ql3c_automation-primary_private-secret',
|
||||
};
|
||||
},
|
||||
async rotateCredential() {
|
||||
throw new Error('unexpected rotate');
|
||||
},
|
||||
async revokeCredential() {
|
||||
throw new Error('unexpected revoke');
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
async list(query) {
|
||||
calls.push(['audit.list', query]);
|
||||
return { records: [], nextCursor: null };
|
||||
},
|
||||
},
|
||||
async close() {
|
||||
closes += 1;
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { value, calls, closes: () => closes };
|
||||
}
|
||||
|
||||
function runner(command, authorityValue, published = []) {
|
||||
const buffers = [];
|
||||
const pepper = 'A'.repeat(43);
|
||||
const files = new Map([
|
||||
[PATHS.commandFile, `${JSON.stringify(command)}\n`],
|
||||
[PATHS.assertionFile, 'signed.assertion.value'],
|
||||
[PATHS.pepperFile, pepper],
|
||||
]);
|
||||
const authentications = [];
|
||||
const opens = [];
|
||||
const instance = createClusterAdministrationCommandRunner({
|
||||
async openAuthority(environment, candidatePepper) {
|
||||
opens.push({ environment, pepper: candidatePepper });
|
||||
return authorityValue;
|
||||
},
|
||||
async authenticate(keysetFile, assertion) {
|
||||
authentications.push({ keysetFile, assertion });
|
||||
return PRINCIPAL;
|
||||
},
|
||||
readFile(filePath) {
|
||||
const value = files.get(filePath);
|
||||
if (value === undefined) throw new Error(`unexpected file: ${filePath}`);
|
||||
const buffer = Buffer.from(value);
|
||||
buffers.push(buffer);
|
||||
return buffer;
|
||||
},
|
||||
publishDelivery(filePath, bytes) {
|
||||
published.push({ filePath, bytes: Buffer.from(bytes) });
|
||||
},
|
||||
});
|
||||
return { instance, buffers, authentications, opens };
|
||||
}
|
||||
|
||||
test('executes one strongly authenticated identity mutation and closes authority', async () => {
|
||||
const target = authority();
|
||||
const execution = runner(identityCommand(), target.value);
|
||||
|
||||
const result = await execution.instance.run(PATHS, { deployment: 'test' });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'identity.register',
|
||||
status: 'inserted',
|
||||
subject: SUBJECT,
|
||||
version: 1,
|
||||
identityStatus: 'active',
|
||||
});
|
||||
assert.deepEqual(execution.authentications, [
|
||||
{
|
||||
keysetFile: PATHS.keysetFile,
|
||||
assertion: 'signed.assertion.value',
|
||||
},
|
||||
]);
|
||||
assert.equal(execution.opens[0].pepper, 'A'.repeat(43));
|
||||
assert.equal(target.calls[0][1].principal, PRINCIPAL);
|
||||
assert.equal(target.closes(), 1);
|
||||
assert.equal(
|
||||
execution.buffers.every((value) => value.every((byte) => byte === 0)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes a credential token only to the private delivery boundary', async () => {
|
||||
const target = authority();
|
||||
const published = [];
|
||||
const execution = runner(credentialCommand(), target.value, published);
|
||||
const paths = { ...PATHS, deliveryFile: '/private/delivery.json' };
|
||||
|
||||
const result = await execution.instance.run(paths, {});
|
||||
|
||||
assert.equal(result.operation, 'credential.issue');
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.equal('token' in result, false);
|
||||
assert.deepEqual(result.delivery.fileName, 'delivery.json');
|
||||
assert.match(result.delivery.digest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(published.length, 1);
|
||||
const delivery = JSON.parse(published[0].bytes.toString('utf8'));
|
||||
assert.equal(delivery.token, 'ql3c_automation-primary_private-secret');
|
||||
assert.equal(delivery.mutationId, credentialCommand().request.mutationId);
|
||||
assert.equal(target.closes(), 1);
|
||||
});
|
||||
|
||||
test('does not recreate lost token material during exact credential replay', async () => {
|
||||
const base = authority();
|
||||
base.value.administration.issueCredential = async () => ({
|
||||
status: 'existing',
|
||||
credential: {
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
state: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
mutation: {},
|
||||
token: null,
|
||||
});
|
||||
const published = [];
|
||||
const execution = runner(credentialCommand(), base.value, published);
|
||||
|
||||
const result = await execution.instance.run(
|
||||
{ ...PATHS, deliveryFile: '/private/delivery.json' },
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 'existing');
|
||||
assert.equal('delivery' in result, false);
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(base.closes(), 1);
|
||||
});
|
||||
|
||||
test('revokes a credential without requiring or publishing a delivery file', async () => {
|
||||
const target = authority();
|
||||
target.value.administration.revokeCredential = async (request) => ({
|
||||
status: 'inserted',
|
||||
credential: {
|
||||
credentialId: request.credentialId,
|
||||
subject: request.subject,
|
||||
state: 'revoked',
|
||||
version: 2,
|
||||
createdAtMs: 1_000,
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 2_000,
|
||||
},
|
||||
mutation: {},
|
||||
});
|
||||
const published = [];
|
||||
const execution = runner(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
request: {
|
||||
mutationId: '123e4567-e89b-42d3-a456-426614174303',
|
||||
requestId: 'security-credential-revoke-1',
|
||||
expectedCurrentVersion: 1,
|
||||
credentialId: 'automation-primary',
|
||||
subject: SUBJECT,
|
||||
},
|
||||
},
|
||||
target.value,
|
||||
published,
|
||||
);
|
||||
|
||||
const result = await execution.instance.run(PATHS, {});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'credential.revoke',
|
||||
status: 'inserted',
|
||||
subject: SUBJECT,
|
||||
credentialId: 'automation-primary',
|
||||
version: 2,
|
||||
state: 'revoked',
|
||||
});
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(target.closes(), 1);
|
||||
});
|
||||
|
||||
test('keeps audit query bounded and rejects widened command shapes before admission', async () => {
|
||||
const query = {
|
||||
schemaVersion: 1,
|
||||
operation: 'audit.list',
|
||||
request: { limit: 25, filter: { outcome: 'allowed' } },
|
||||
};
|
||||
const target = authority();
|
||||
const execution = runner(query, target.value);
|
||||
const result = await execution.instance.run(PATHS, {});
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: 1,
|
||||
operation: 'audit.list',
|
||||
page: { records: [], nextCursor: null },
|
||||
});
|
||||
assert.deepEqual(target.calls, [
|
||||
['audit.list', { limit: 25, filter: { outcome: 'allowed' } }],
|
||||
]);
|
||||
assert.throws(
|
||||
() => normalizeClusterAdministrationCommand({ ...query, debug: true }),
|
||||
/command shape is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterAdministrationCommand({
|
||||
...query,
|
||||
request: { limit: 201, filter: {} },
|
||||
}),
|
||||
/audit query is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened path authority before reading a command file', async () => {
|
||||
let reads = 0;
|
||||
const instance = createClusterAdministrationCommandRunner({
|
||||
async openAuthority() {
|
||||
throw new Error('must not open authority');
|
||||
},
|
||||
async authenticate() {
|
||||
throw new Error('must not authenticate');
|
||||
},
|
||||
readFile() {
|
||||
reads += 1;
|
||||
throw new Error('must not read');
|
||||
},
|
||||
publishDelivery() {
|
||||
throw new Error('must not publish');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
instance.run({ ...PATHS, ambientCredential: true }, {}),
|
||||
/command paths shape is invalid/,
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('publishes a 0600 no-replace delivery and leaves an existing target intact', (t) => {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-security-delivery-'),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'credential.json');
|
||||
const bytes = Buffer.from('{"token":"secret"}\n');
|
||||
|
||||
publishClusterAdministrationCredentialDelivery(filePath, bytes);
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
|
||||
assert.deepEqual(fs.readFileSync(filePath), bytes);
|
||||
assert.throws(
|
||||
() =>
|
||||
publishClusterAdministrationCredentialDelivery(
|
||||
filePath,
|
||||
Buffer.from('{"token":"replacement"}\n'),
|
||||
),
|
||||
/could not be published/,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(filePath), bytes);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
createClusterApprovalIdentityKeysetFile,
|
||||
createClusterModelProviderCredentialIdentityKeysetFile,
|
||||
createClusterRunIdentityKeysetFile,
|
||||
createClusterSecurityAdministrationIdentityKeysetFile,
|
||||
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
||||
|
||||
const NOW_MS = 1_700_000_000_000;
|
||||
@@ -249,6 +250,38 @@ function runAssertion(key, overrides = {}) {
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
function securityAdministrationAssertion(key, overrides = {}) {
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-security-administration+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const now = Math.floor(NOW_MS / 1000);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: 'qinglong3-security-administration',
|
||||
auth_time: now - 10,
|
||||
exp: now + 120,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: `security-administration-assertion-${key.kid}`,
|
||||
ql3_purpose: 'security-administration',
|
||||
sub: 'security-owner-1',
|
||||
...overrides,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = `${header}.${payload}`;
|
||||
return `${signed}.${sign(
|
||||
null,
|
||||
Buffer.from(signed, 'ascii'),
|
||||
key.privateKey,
|
||||
).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function atomicWrite(filePath, document) {
|
||||
const nextPath = `${filePath}.next`;
|
||||
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
|
||||
@@ -337,10 +370,9 @@ test('loads an automation keyset with a purpose isolated from other management p
|
||||
type: 'user',
|
||||
id: 'automation-operator-1',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(workerAssertion(key)).authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
await assert.rejects(provider.bind(workerAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
@@ -358,14 +390,19 @@ test('loads an Approval keyset isolated by type, purpose and audience', async ()
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider.bind(approvalAssertion(key)).authenticate();
|
||||
const principal = await provider
|
||||
.bind(approvalAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'approval-owner-1',
|
||||
});
|
||||
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider.bind(automationAssertion(key)).authenticate(),
|
||||
{
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
},
|
||||
);
|
||||
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
@@ -435,6 +472,40 @@ test('loads a Run keyset isolated from every other management purpose', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('loads a Security Administration keyset isolated from other management purposes', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const key = reviewedKey('security-administration-key-1');
|
||||
await atomicWrite(filePath, {
|
||||
...keyset(1, [key]),
|
||||
audience: 'qinglong3-security-administration',
|
||||
});
|
||||
const provider = createClusterSecurityAdministrationIdentityKeysetFile({
|
||||
filePath,
|
||||
now: () => NOW_MS,
|
||||
});
|
||||
const principal = await provider
|
||||
.bind(securityAdministrationAssertion(key))
|
||||
.authenticate();
|
||||
assert.deepEqual(principal.subject, {
|
||||
type: 'user',
|
||||
id: 'security-owner-1',
|
||||
});
|
||||
await assert.rejects(provider.bind(runAssertion(key)).authenticate(), {
|
||||
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
||||
});
|
||||
await assert.rejects(
|
||||
provider
|
||||
.bind(
|
||||
securityAdministrationAssertion(key, {
|
||||
ql3_purpose: 'run-management',
|
||||
}),
|
||||
)
|
||||
.authenticate(),
|
||||
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('supports overlap rotation then immediately revokes the previous key', async () => {
|
||||
await fixture(async ({ filePath }) => {
|
||||
const first = reviewedKey('issuer-key-1');
|
||||
|
||||
@@ -336,7 +336,7 @@ function validContextFixture(t) {
|
||||
|
||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 12);
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 13);
|
||||
assert.equal(
|
||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||
@@ -354,6 +354,7 @@ test('catalog exposes only reviewed product entrypoints from the same package',
|
||||
);
|
||||
assert.equal(
|
||||
command.binary.includes('-client') ||
|
||||
command.binary === 'ql3-security-admin' ||
|
||||
command.binary === 'ql3-copilot-mcp' ||
|
||||
command.binary === 'ql3-copilot-console' ||
|
||||
command.binary === 'ql3-copilot-evidence-verify',
|
||||
@@ -391,6 +392,7 @@ test('help and version are bounded installation-derived product facts', () => {
|
||||
/\n evidence-verify\s+verify one redacted Console evidence/,
|
||||
);
|
||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||
assert.match(help, /\n security\s+administer identities, API credentials/);
|
||||
assert.equal(help.includes('plugin-package-manage'), false);
|
||||
assert.equal(
|
||||
loadQingLong3ClusterProductVersion(moduleDirectory),
|
||||
@@ -923,6 +925,11 @@ test('binary exposes help/version and delegates without a shell', () => {
|
||||
assert.match(delegatedHelp.stdout, /^Usage: ql3-run-client /);
|
||||
assert.equal(delegatedHelp.stderr, '');
|
||||
|
||||
const securityHelp = runCli(['security', '--help']);
|
||||
assert.equal(securityHelp.status, 0);
|
||||
assert.match(securityHelp.stdout, /^Usage: ql3-security-admin /);
|
||||
assert.equal(securityHelp.stderr, '');
|
||||
|
||||
const rejected = runCli(['../../tmp/not-a-command']);
|
||||
assert.equal(rejected.status, 64);
|
||||
assert.equal(rejected.stdout, '');
|
||||
|
||||
Reference in New Issue
Block a user