Files
qinglong/packages/ql3-local-sqlite/test/securityAuditQuery.test.cjs
T

193 lines
5.9 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 {
LocalSqliteSecurityAuditQueryRepository,
} = require('@qinglong/local-sqlite/security-audit-query');
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
const {
LocalSqliteOperationAuthority,
} = require('@qinglong/local-sqlite/operation-authority');
const {
LocalSecurityAuditQueryAuthorizationFenceConflictError,
} = require('@qinglong/runtime-core/local-security-audit-query');
async function fixture(t) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-security-audit-query-'),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const databasePath = path.join(directory, 'qinglong3.sqlite');
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const client = new DatabaseSync(databasePath);
client.exec('PRAGMA foreign_keys = ON');
const now = 10_000;
client
.prepare(
`INSERT INTO "QingLong3Projects" (
"id", "name", "slug", "status", "version",
"created_at_ms", "updated_at_ms"
) VALUES ('project-alpha', 'Project Alpha', 'project-alpha',
'active', 1, ?, ?)`,
)
.run(now - 200, now - 200);
client
.prepare(
`INSERT INTO "QingLong3ProjectRoleBindings" (
"project_id", "subject_type", "subject_id", "version", "state",
"role", "mutation_id", "changed_by_type", "changed_by_id",
"created_at_ms"
) VALUES (
'default', 'user', 'owner-user', 1, 'active', 'owner', ?,
'user', 'owner-user', ?
)`,
)
.run('96000000-0000-4000-8000-000000000001', now - 100);
const insertAudit = client.prepare(
`INSERT INTO "QingLong3SecurityAuditEvents" (
"event_id", "request_id", "operation_id", "project_id",
"subject_type", "subject_id", "authentication_id", "outcome",
"reasons_json", "fence_project_version", "fence_binding_version",
"occurred_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const [eventId, occurredAtMs, outcome, subjectId] of [
['97000000-0000-4000-8000-000000000003', 9_003, 'denied', 'planner'],
['97000000-0000-4000-8000-000000000002', 9_002, 'denied', 'planner'],
['97000000-0000-4000-8000-000000000001', 9_001, 'denied', 'planner'],
['97000000-0000-4000-8000-000000000000', 9_000, 'allowed', 'planner'],
]) {
insertAudit.run(
eventId,
`request-${occurredAtMs}`,
'tool.invoke',
'project-alpha',
'agent',
subjectId,
`private-auth-${occurredAtMs}`,
outcome,
'["policy_result"]',
1,
1,
occurredAtMs,
);
}
const authority = new LocalSqliteOperationAuthority(client);
t.after(() => authority.close());
return {
client,
repository: new LocalSqliteSecurityAuditQueryRepository(
authority,
() => {},
),
};
}
function authorization(overrides = {}) {
return {
authorityProjectId: 'default',
actor: { type: 'user', id: 'owner-user' },
fence: { projectVersion: 1, bindingVersion: 1 },
...overrides,
};
}
function queryAudit(eventId) {
return {
eventId,
requestId: `audit-query-${eventId.at(-1)}`,
operationId: 'security.audit.list',
projectId: 'default',
subject: { type: 'user', id: 'owner-user' },
authenticationId: 'local_security_audit:test',
outcome: 'allowed',
reasons: ['instance_authority_security_audit_query'],
fence: { projectVersion: 1, bindingVersion: 1 },
occurredAtMs: 10_000 + Number(eventId.at(-1)),
};
}
test('filters and keyset-pages a pre-audit snapshot with an exact has-more cursor', async (t) => {
const value = await fixture(t);
const filter = {
projectId: 'project-alpha',
subject: { type: 'agent', id: 'planner' },
outcome: 'denied',
};
const first = await value.repository.listAuthorized({
query: { limit: 2, filter },
authorization: authorization(),
audit: queryAudit('98000000-0000-4000-8000-000000000001'),
});
assert.deepEqual(
first.records.map((record) => record.eventId),
[
'97000000-0000-4000-8000-000000000003',
'97000000-0000-4000-8000-000000000002',
],
);
assert.deepEqual(first.nextCursor, {
occurredAtMs: 9_002,
eventId: '97000000-0000-4000-8000-000000000002',
});
assert.equal(
first.records.some(
(record) => record.eventId === '98000000-0000-4000-8000-000000000001',
),
false,
);
const second = await value.repository.listAuthorized({
query: { limit: 2, before: first.nextCursor, filter },
authorization: authorization(),
audit: queryAudit('98000000-0000-4000-8000-000000000002'),
});
assert.deepEqual(
second.records.map((record) => record.eventId),
['97000000-0000-4000-8000-000000000001'],
);
assert.equal(second.nextCursor, null);
assert.equal(
value.client
.prepare(
`SELECT count(*) AS "count"
FROM "QingLong3SecurityAuditEvents"
WHERE "operation_id" = 'security.audit.list'
AND "outcome" = 'allowed'`,
)
.get().count,
2,
);
});
test('rejects a foreign instance authority before reading or auditing rows', async (t) => {
const value = await fixture(t);
await assert.rejects(
value.repository.listAuthorized({
query: { limit: 1, filter: {} },
authorization: authorization({
authorityProjectId: 'project-alpha',
}),
audit: {
...queryAudit('99000000-0000-4000-8000-000000000001'),
projectId: 'project-alpha',
},
}),
LocalSecurityAuditQueryAuthorizationFenceConflictError,
);
assert.equal(
value.client
.prepare(
`SELECT count(*) AS "count"
FROM "QingLong3SecurityAuditEvents"
WHERE "event_id" = ?`,
)
.get('99000000-0000-4000-8000-000000000001').count,
0,
);
});