Files
qinglong/test/back/projectPolicy.test.cjs

600 lines
16 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { Sequelize } = require('sequelize');
const {
defineSchemaMigrationModel,
} = require('../../back/data/schemaMigration');
const {
PROJECT_ROLE_BINDING_CURRENT_INDEX,
PROJECT_ROLE_BINDING_MUTATION_INDEX,
PROJECT_ROLE_BINDING_SUBJECT_INDEX,
PROJECT_ROLE_BINDING_TABLE,
PROJECT_SLUG_INDEX,
PROJECT_TABLE,
projectPolicyMigration,
} = require('../../back/migrations/0017-project-policy');
const { runMigrations } = require('../../back/migrations/runner');
const {
LegacySequelizeProjectPolicyRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/projectPolicyRepository');
const {
ProjectPolicyArtifactReadAuthorizer,
} = require('../../back/runtime/adapters/policy/projectPolicyArtifactReadAuthorizer');
const {
ProjectPolicyEngine,
} = require('../../back/runtime/application/projectPolicyEngine');
const {
LocalArtifactReadService,
} = require('../../back/runtime/application/localArtifactReadService');
const {
ProjectPolicyUnavailableError,
ProjectRoleBindingMutationConflictError,
ProjectRoleBindingVersionConflictError,
normalizeProjectPermission,
normalizeProjectRoleBindingRecord,
} = require('../../back/runtime/domain/projectPolicy');
const PROJECT_ID = 'default';
const CHANGED_BY = Object.freeze({ type: 'user', id: 'local-owner' });
async function setup(t, storage = ':memory:') {
const database = new Sequelize({
dialect: 'sqlite',
storage,
logging: false,
});
t.after(() => database.close());
await runMigrations({
database,
migrationModel: defineSchemaMigrationModel(database),
migrations: [projectPolicyMigration],
logger: { info() {} },
});
return {
database,
repository: new LegacySequelizeProjectPolicyRepository(database),
};
}
function binding({
subject = { type: 'user', id: 'user-1' },
version = 1,
role = 'viewer',
state = 'active',
mutationId = 'mutation-1',
createdAtMs = version,
} = {}) {
return {
projectId: PROJECT_ID,
subject,
version,
state,
...(state === 'active' ? { role } : {}),
mutationId,
changedBy: CHANGED_BY,
createdAtMs,
};
}
async function append(repository, value, expectedCurrentVersion) {
return repository.append({
expectedCurrentVersion,
binding: value,
});
}
test('migration creates an ownerless default Project and bounded lookup indexes', async (t) => {
const { database, repository } = await setup(t);
const projects = await database
.getQueryInterface()
.select(null, PROJECT_TABLE);
assert.deepEqual(projects, [
{
id: 'default',
name: 'Default',
slug: 'default',
status: 'active',
version: 1,
created_at_ms: 0,
updated_at_ms: 0,
},
]);
assert.deepEqual(
await repository.resolve(PROJECT_ID, { type: 'user', id: 'local-owner' }),
{
project: {
id: 'default',
name: 'Default',
slug: 'default',
status: 'active',
version: 1,
createdAtMs: 0,
updatedAtMs: 0,
},
},
);
const projectIndexes = new Set(
(await database.getQueryInterface().showIndex(PROJECT_TABLE)).map(
(index) => index.name,
),
);
const bindingIndexes = new Set(
(
await database.getQueryInterface().showIndex(PROJECT_ROLE_BINDING_TABLE)
).map((index) => index.name),
);
assert.ok(projectIndexes.has(PROJECT_SLUG_INDEX));
assert.ok(bindingIndexes.has(PROJECT_ROLE_BINDING_CURRENT_INDEX));
assert.ok(bindingIndexes.has(PROJECT_ROLE_BINDING_MUTATION_INDEX));
assert.ok(bindingIndexes.has(PROJECT_ROLE_BINDING_SUBJECT_INDEX));
});
test('normalizes only declared permissions and exact tool identities', () => {
assert.equal(normalizeProjectPermission('artifact.read'), 'artifact.read');
assert.equal(
normalizeProjectPermission('tool.call:github.issue.read'),
'tool.call:github.issue.read',
);
for (const value of [
'logs',
'artifact.write',
'tool.call:*',
'tool.call:',
'tool.call:bad value',
]) {
assert.throws(
() => normalizeProjectPermission(value),
/permission is invalid/,
);
}
assert.throws(
() =>
normalizeProjectRoleBindingRecord({
...binding({ state: 'revoked' }),
role: 'viewer',
}),
/shape is invalid/,
);
assert.throws(
() =>
normalizeProjectRoleBindingRecord({
...binding(),
permissions: ['artifact.read'],
}),
/shape is invalid/,
);
});
test('appends immutable role versions, replays mutations and resolves latest state', async (t) => {
const { repository } = await setup(t);
const first = binding();
assert.deepEqual(await append(repository, first, 0), {
status: 'inserted',
binding: first,
});
assert.deepEqual(await append(repository, first, 0), {
status: 'existing',
binding: first,
});
await assert.rejects(
append(repository, { ...first, role: 'operator' }, 0),
ProjectRoleBindingMutationConflictError,
);
await assert.rejects(
append(repository, binding({ mutationId: 'stale-mutation' }), 0),
ProjectRoleBindingVersionConflictError,
);
const second = binding({
version: 2,
role: 'operator',
mutationId: 'mutation-2',
});
assert.equal((await append(repository, second, 1)).status, 'inserted');
assert.deepEqual(
(await repository.resolve(PROJECT_ID, first.subject)).binding,
second,
);
const revoked = binding({
version: 3,
state: 'revoked',
mutationId: 'mutation-3',
});
assert.equal((await append(repository, revoked, 2)).status, 'inserted');
assert.deepEqual(
(await repository.resolve(PROJECT_ID, first.subject)).binding,
revoked,
);
});
test('serializes concurrent first assignments so only one current version wins', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-policy-db-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const storage = path.join(root, 'database.sqlite');
const { repository } = await setup(t, storage);
const secondDatabase = new Sequelize({
dialect: 'sqlite',
storage,
logging: false,
});
t.after(() => secondDatabase.close());
const secondRepository = new LegacySequelizeProjectPolicyRepository(
secondDatabase,
);
const subject = { type: 'api_app', id: 'app-1' };
const results = await Promise.allSettled([
append(
secondRepository,
binding({ subject, role: 'viewer', mutationId: 'concurrent-a' }),
0,
),
append(
repository,
binding({ subject, role: 'operator', mutationId: 'concurrent-b' }),
0,
),
]);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
const rejected = results.find((result) => result.status === 'rejected');
assert.ok(rejected.reason instanceof ProjectRoleBindingVersionConflictError);
const snapshot = await repository.resolve(PROJECT_ID, subject);
assert.equal(snapshot.binding.version, 1);
assert.ok(['viewer', 'operator'].includes(snapshot.binding.role));
});
test('defaults to deny and applies viewer, operator, archive and revocation rules', async (t) => {
const { database, repository } = await setup(t);
const engine = new ProjectPolicyEngine(repository);
const viewer = { type: 'user', id: 'viewer-1' };
assert.deepEqual(
await engine.decide({
subject: viewer,
projectId: 'missing',
permission: 'artifact.read',
}),
{ effect: 'deny', reasons: ['project_not_found'] },
);
assert.deepEqual(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'artifact.read',
}),
{ effect: 'deny', reasons: ['subject_unbound'] },
);
await append(
repository,
binding({ subject: viewer, role: 'viewer', mutationId: 'viewer-bind' }),
0,
);
assert.equal(
(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'artifact.read',
})
).effect,
'allow',
);
assert.deepEqual(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'run.start',
}),
{ effect: 'deny', reasons: ['permission_missing'] },
);
const operator = binding({
subject: viewer,
version: 2,
role: 'operator',
mutationId: 'viewer-promote',
});
await append(repository, operator, 1);
assert.equal(
(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'run.start',
})
).effect,
'allow',
);
await database
.getQueryInterface()
.bulkUpdate(
PROJECT_TABLE,
{ status: 'archived', version: 2, updated_at_ms: 2 },
{ id: PROJECT_ID },
);
assert.deepEqual(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'run.start',
}),
{ effect: 'deny', reasons: ['project_archived'] },
);
assert.equal(
(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'artifact.read',
})
).effect,
'allow',
);
await append(
repository,
binding({
subject: viewer,
version: 3,
state: 'revoked',
mutationId: 'viewer-revoke',
}),
2,
);
assert.deepEqual(
await engine.decide({
subject: viewer,
projectId: PROJECT_ID,
permission: 'artifact.read',
}),
{ effect: 'deny', reasons: ['subject_unbound'] },
);
});
test('requires approval for Agent mutations while allowing bound reads', async (t) => {
const { repository } = await setup(t);
const engine = new ProjectPolicyEngine(repository);
const agent = { type: 'agent', id: 'agent-1' };
await append(
repository,
binding({ subject: agent, role: 'operator', mutationId: 'agent-bind' }),
0,
);
assert.deepEqual(
await engine.decide({
subject: agent,
projectId: PROJECT_ID,
permission: 'run.start',
}),
{
effect: 'require_approval',
reasons: ['agent_action_requires_approval'],
},
);
assert.deepEqual(
await engine.decide({
subject: agent,
projectId: PROJECT_ID,
permission: 'tool.call:github.issue.read',
}),
{
effect: 'require_approval',
reasons: ['agent_action_requires_approval'],
},
);
assert.equal(
(
await engine.decide({
subject: agent,
projectId: PROJECT_ID,
permission: 'artifact.read',
})
).effect,
'allow',
);
});
test('enforces the owner, admin, operator and viewer permission matrix', async (t) => {
const { repository } = await setup(t);
const engine = new ProjectPolicyEngine(repository);
const cases = [
{
role: 'owner',
allow: [
'project.manage',
'policy.manage',
'task.delete',
'approval.recover',
],
deny: [],
},
{
role: 'admin',
allow: [
'policy.manage',
'task.delete',
'secret.manage',
'approval.recover',
],
deny: ['project.manage'],
},
{
role: 'operator',
allow: ['task.update', 'run.start', 'secret.use'],
deny: [
'task.delete',
'secret.manage',
'policy.manage',
'approval.recover',
],
},
{
role: 'viewer',
allow: ['project.read', 'task.read', 'run.read', 'artifact.read'],
deny: ['task.update', 'run.start', 'secret.use', 'approval.recover'],
},
];
for (const [index, item] of cases.entries()) {
const subject = { type: 'user', id: `${item.role}-user` };
await append(
repository,
binding({
subject,
role: item.role,
mutationId: `matrix-${index}`,
}),
0,
);
for (const permission of item.allow) {
assert.equal(
(await engine.decide({ subject, projectId: PROJECT_ID, permission }))
.effect,
'allow',
`${item.role} should allow ${permission}`,
);
}
for (const permission of item.deny) {
assert.equal(
(await engine.decide({ subject, projectId: PROJECT_ID, permission }))
.effect,
'deny',
`${item.role} should deny ${permission}`,
);
}
}
});
test('Artifact authorizer delegates only the bound subject, Project and permission', async () => {
const calls = [];
const authorizer = new ProjectPolicyArtifactReadAuthorizer({
async decide(value) {
calls.push(value);
return { effect: 'require_approval', reasons: ['test'] };
},
});
const effect = await authorizer.authorize({
action: 'artifact.read',
subject: { type: 'api_app', id: 'app-1' },
projectId: PROJECT_ID,
runId: '019f7600-0000-7000-8000-000000000001',
logArtifactId: `local-${'e'.repeat(30)}`,
});
assert.equal(effect, 'require_approval');
assert.deepEqual(calls, [
{
subject: { type: 'api_app', id: 'app-1' },
projectId: PROJECT_ID,
permission: 'artifact.read',
},
]);
await assert.rejects(
authorizer.authorize({
action: 'artifact.delete',
subject: { type: 'api_app', id: 'app-1' },
projectId: PROJECT_ID,
runId: '019f7600-0000-7000-8000-000000000001',
logArtifactId: `local-${'e'.repeat(30)}`,
}),
/action is invalid/,
);
});
test('real Policy Core keeps Artifact bytes unreachable until a viewer binding exists', async (t) => {
const { repository } = await setup(t);
const subject = { type: 'api_app', id: 'artifact-client' };
const runId = '019f7600-0000-7000-8000-000000000001';
const attemptId = '019f7600-0000-7000-8000-000000000002';
const logArtifactId = `local-${'e'.repeat(30)}`;
let byteReads = 0;
const service = new LocalArtifactReadService(
{
async find() {
return {
projectId: PROJECT_ID,
runId,
attemptId,
logArtifactId,
};
},
},
new ProjectPolicyArtifactReadAuthorizer(
new ProjectPolicyEngine(repository),
),
{
async read() {
byteReads += 1;
return {
status: 'available',
content: Buffer.from('log'),
start: 0,
endExclusive: 3,
totalBytes: 3,
};
},
},
{
async read() {
return null;
},
},
);
const request = {
subject,
projectId: PROJECT_ID,
runId,
logArtifactId,
range: { offset: 0, length: 1024 },
};
assert.deepEqual(await service.read(request), {
status: 'forbidden',
effect: 'deny',
});
assert.equal(byteReads, 0);
await append(
repository,
binding({ subject, role: 'viewer', mutationId: 'artifact-viewer' }),
0,
);
const available = await service.read(request);
assert.equal(available.status, 'available');
assert.equal(available.content.toString(), 'log');
assert.equal(byteReads, 1);
});
test('fails closed for corrupt current bindings and non-SQLite adapters', async (t) => {
const { database, repository } = await setup(t);
const current = binding();
await append(repository, current, 0);
await database.getQueryInterface().bulkUpdate(
PROJECT_ROLE_BINDING_TABLE,
{ role: null },
{
project_id: current.projectId,
subject_type: current.subject.type,
subject_id: current.subject.id,
version: current.version,
},
);
await assert.rejects(
repository.resolve(PROJECT_ID, current.subject),
ProjectPolicyUnavailableError,
);
assert.throws(
() =>
new LegacySequelizeProjectPolicyRepository({
getDialect() {
return 'postgres';
},
}),
/SQLite-only/,
);
});