mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:21:50 +08:00
607 lines
17 KiB
JavaScript
607 lines
17 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 { QueryTypes, Sequelize } = require('sequelize');
|
|
const {
|
|
defineSchemaMigrationModel,
|
|
} = require('../../back/data/schemaMigration');
|
|
const {
|
|
PROJECT_ROLE_BINDING_TABLE,
|
|
PROJECT_TABLE,
|
|
projectPolicyMigration,
|
|
} = require('../../back/migrations/0017-project-policy');
|
|
const {
|
|
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX,
|
|
PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE,
|
|
PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX,
|
|
projectOwnerBootstrapMigration,
|
|
} = require('../../back/migrations/0018-project-owner-bootstrap');
|
|
const { runMigrations } = require('../../back/migrations/runner');
|
|
const {
|
|
LegacySequelizeProjectOwnerBootstrapRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/projectOwnerBootstrapRepository');
|
|
const {
|
|
LegacySequelizeProjectPolicyRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/projectPolicyRepository');
|
|
const {
|
|
ProjectOwnerBootstrapService,
|
|
} = require('../../back/runtime/application/projectOwnerBootstrapService');
|
|
const {
|
|
AuthenticatedPrincipalExpiredError,
|
|
normalizeAuthenticatedPrincipal,
|
|
} = require('../../back/runtime/domain/authenticatedPrincipal');
|
|
const {
|
|
ProjectOwnerBootstrapChallengeActiveError,
|
|
ProjectOwnerBootstrapClaimRejectedError,
|
|
ProjectOwnerBootstrapProjectInactiveError,
|
|
ProjectOwnerBootstrapProjectNotFoundError,
|
|
ProjectOwnerBootstrapProjectNotPristineError,
|
|
ProjectOwnerBootstrapUnauthorizedError,
|
|
ProjectOwnerBootstrapUnavailableError,
|
|
} = require('../../back/runtime/domain/projectOwnerBootstrap');
|
|
|
|
const PROJECT_ID = 'default';
|
|
const NOW = 100_000;
|
|
const TTL_MS = 60_000;
|
|
|
|
function principal(subject = { type: 'user', id: 'owner-1' }, overrides = {}) {
|
|
return {
|
|
subject,
|
|
authenticationId: 'auth-1',
|
|
authenticatedAtMs: 0,
|
|
expiresAtMs: NOW + TTL_MS * 10,
|
|
assurance: 'multi_factor',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function localConsolePrincipal(overrides = {}) {
|
|
return principal(
|
|
{ type: 'system', id: 'owner-bootstrap' },
|
|
{ assurance: 'local_console', ...overrides },
|
|
);
|
|
}
|
|
|
|
function deterministicRandomSource(seed = 1) {
|
|
let next = seed;
|
|
return {
|
|
bytes(size) {
|
|
const value = Buffer.alloc(size, next);
|
|
next += 1;
|
|
return value;
|
|
},
|
|
};
|
|
}
|
|
|
|
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, projectOwnerBootstrapMigration],
|
|
logger: { info() {} },
|
|
});
|
|
const repository = new LegacySequelizeProjectOwnerBootstrapRepository(
|
|
database,
|
|
);
|
|
return {
|
|
database,
|
|
repository,
|
|
service: new ProjectOwnerBootstrapService(
|
|
repository,
|
|
deterministicRandomSource(),
|
|
),
|
|
};
|
|
}
|
|
|
|
test('normalizes an exact, bounded and live authenticated principal', () => {
|
|
const value = principal();
|
|
assert.deepEqual(normalizeAuthenticatedPrincipal(value), value);
|
|
assert.throws(
|
|
() => normalizeAuthenticatedPrincipal({ ...value, scopes: ['*'] }),
|
|
/shape is invalid/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeAuthenticatedPrincipal({
|
|
...value,
|
|
authenticationId: 'auth with spaces',
|
|
}),
|
|
/authenticationId is invalid/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeAuthenticatedPrincipal({
|
|
...value,
|
|
expiresAtMs: value.authenticatedAtMs,
|
|
}),
|
|
/lifetime is invalid/,
|
|
);
|
|
});
|
|
|
|
test('migration records versioned challenges and both bounded lookup indexes', async (t) => {
|
|
const { database, service } = await setup(t);
|
|
const issued = await service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
assert.match(issued.challengeId, /^[A-Za-z0-9_-]{22}$/);
|
|
assert.match(issued.token, /^[A-Za-z0-9_-]{43}$/);
|
|
assert.equal(issued.expiresAtMs, NOW + TTL_MS);
|
|
|
|
const rows = await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].version, 1);
|
|
assert.equal(rows[0].challenge_id, issued.challengeId);
|
|
assert.match(rows[0].token_digest, /^[0-9a-f]{64}$/);
|
|
assert.notEqual(rows[0].token_digest, issued.token);
|
|
assert.equal(JSON.stringify(rows).includes(issued.token), false);
|
|
assert.equal(rows[0].consumed_at_ms, null);
|
|
assert.equal(
|
|
(
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_ROLE_BINDING_TABLE)
|
|
).length,
|
|
0,
|
|
);
|
|
const indexes = new Set(
|
|
(
|
|
await database
|
|
.getQueryInterface()
|
|
.showIndex(PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE)
|
|
).map((index) => index.name),
|
|
);
|
|
assert.ok(indexes.has(PROJECT_OWNER_BOOTSTRAP_CHALLENGE_ID_INDEX));
|
|
assert.ok(indexes.has(PROJECT_OWNER_BOOTSTRAP_CURRENT_INDEX));
|
|
});
|
|
|
|
test('only an active local-console bootstrap principal can issue', async (t) => {
|
|
const { service } = await setup(t);
|
|
for (const issuer of [
|
|
principal(),
|
|
localConsolePrincipal({ assurance: 'service' }),
|
|
principal(
|
|
{ type: 'system', id: 'different-system' },
|
|
{ assurance: 'local_console' },
|
|
),
|
|
]) {
|
|
await assert.rejects(
|
|
service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer,
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
}),
|
|
ProjectOwnerBootstrapUnauthorizedError,
|
|
);
|
|
}
|
|
await assert.rejects(
|
|
service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal({ expiresAtMs: NOW }),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
}),
|
|
AuthenticatedPrincipalExpiredError,
|
|
);
|
|
await assert.rejects(
|
|
service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
subject: { type: 'user', id: 'injected-owner' },
|
|
}),
|
|
/request shape is invalid/,
|
|
);
|
|
});
|
|
|
|
test('does not replace a live challenge and versions a replacement only after expiry', async (t) => {
|
|
const { database, service } = await setup(t);
|
|
const request = {
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
};
|
|
const first = await service.issue(request);
|
|
await assert.rejects(
|
|
service.issue({ ...request, nowMs: NOW + 1 }),
|
|
ProjectOwnerBootstrapChallengeActiveError,
|
|
);
|
|
const second = await service.issue({
|
|
...request,
|
|
nowMs: NOW + TTL_MS,
|
|
});
|
|
assert.notEqual(second.challengeId, first.challengeId);
|
|
const rows = await database.query(
|
|
`SELECT version, challenge_id FROM "${PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE}" ORDER BY version`,
|
|
{ type: QueryTypes.SELECT },
|
|
);
|
|
assert.deepEqual(rows, [
|
|
{ version: 1, challenge_id: first.challengeId },
|
|
{ version: 2, challenge_id: second.challengeId },
|
|
]);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: first.challengeId,
|
|
token: first.token,
|
|
principal: principal(),
|
|
nowMs: NOW + TTL_MS + 1,
|
|
}),
|
|
ProjectOwnerBootstrapClaimRejectedError,
|
|
);
|
|
});
|
|
|
|
test('claims one owner atomically and permits only exact idempotent replay', async (t) => {
|
|
const { database, service } = await setup(t);
|
|
const issued = await service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
const request = {
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
};
|
|
const claimed = await service.claim(request);
|
|
assert.equal(claimed.status, 'claimed');
|
|
assert.deepEqual(claimed.binding, {
|
|
projectId: PROJECT_ID,
|
|
subject: { type: 'user', id: 'owner-1' },
|
|
version: 1,
|
|
state: 'active',
|
|
role: 'owner',
|
|
mutationId: `owner-bootstrap:${issued.challengeId}`,
|
|
changedBy: { type: 'system', id: 'owner-bootstrap' },
|
|
createdAtMs: NOW + 1,
|
|
});
|
|
const replay = await service.claim({ ...request, nowMs: NOW + 2 });
|
|
assert.equal(replay.status, 'existing');
|
|
assert.deepEqual(replay.binding, claimed.binding);
|
|
|
|
const policyRepository = new LegacySequelizeProjectPolicyRepository(database);
|
|
assert.deepEqual(
|
|
(await policyRepository.resolve(PROJECT_ID, principal().subject)).binding,
|
|
claimed.binding,
|
|
);
|
|
const challenge = (
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE)
|
|
)[0];
|
|
assert.equal(Number(challenge.consumed_at_ms), NOW + 1);
|
|
assert.equal(challenge.claimed_subject_type, 'user');
|
|
assert.equal(challenge.claimed_subject_id, 'owner-1');
|
|
|
|
await assert.rejects(
|
|
service.claim({
|
|
...request,
|
|
principal: principal({ type: 'user', id: 'owner-2' }),
|
|
nowMs: NOW + 2,
|
|
}),
|
|
ProjectOwnerBootstrapClaimRejectedError,
|
|
);
|
|
});
|
|
|
|
test('rejects expired, malformed, mismatched and non-user claims without leaking the token', async (t) => {
|
|
const { service } = await setup(t);
|
|
const issued = await service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: Buffer.alloc(32, 99).toString('base64url'),
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
(error) => {
|
|
assert.ok(error instanceof ProjectOwnerBootstrapClaimRejectedError);
|
|
assert.equal(error.message.includes(issued.token), false);
|
|
return true;
|
|
},
|
|
);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + TTL_MS,
|
|
}),
|
|
ProjectOwnerBootstrapClaimRejectedError,
|
|
);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal({ type: 'api_app', id: 'app-1' }),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
ProjectOwnerBootstrapUnauthorizedError,
|
|
);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: `${issued.token}x`,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
(error) => {
|
|
assert.equal(error.message.includes(issued.token), false);
|
|
return true;
|
|
},
|
|
);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
subject: { type: 'user', id: 'injected-owner' },
|
|
}),
|
|
/request shape is invalid/,
|
|
);
|
|
});
|
|
|
|
test('refuses bootstrap whenever the Project already has any role binding', async (t) => {
|
|
const first = await setup(t);
|
|
await first.database.query(
|
|
`INSERT INTO "${PROJECT_ROLE_BINDING_TABLE}"
|
|
(project_id, subject_type, subject_id, version, state, role,
|
|
mutation_id, changed_by_type, changed_by_id, created_at_ms)
|
|
VALUES
|
|
('default', 'user', 'existing', 1, 'active', 'viewer',
|
|
'existing-binding', 'system', 'owner-bootstrap', 1)`,
|
|
);
|
|
await assert.rejects(
|
|
first.service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
}),
|
|
ProjectOwnerBootstrapProjectNotPristineError,
|
|
);
|
|
|
|
const second = await setup(t);
|
|
const issued = await second.service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
await second.database.query(
|
|
`INSERT INTO "${PROJECT_ROLE_BINDING_TABLE}"
|
|
(project_id, subject_type, subject_id, version, state, role,
|
|
mutation_id, changed_by_type, changed_by_id, created_at_ms)
|
|
VALUES
|
|
('default', 'user', 'existing', 1, 'active', 'viewer',
|
|
'existing-binding', 'system', 'owner-bootstrap', 1)`,
|
|
);
|
|
await assert.rejects(
|
|
second.service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
ProjectOwnerBootstrapProjectNotPristineError,
|
|
);
|
|
});
|
|
|
|
test('fails closed for missing and archived Projects', async (t) => {
|
|
const missing = await setup(t);
|
|
await assert.rejects(
|
|
missing.service.issue({
|
|
projectId: 'missing',
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
}),
|
|
ProjectOwnerBootstrapProjectNotFoundError,
|
|
);
|
|
|
|
const archived = await setup(t);
|
|
const issued = await archived.service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
await archived.database.query(
|
|
`UPDATE "${PROJECT_TABLE}" SET status = 'archived' WHERE id = 'default'`,
|
|
);
|
|
await assert.rejects(
|
|
archived.service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
ProjectOwnerBootstrapProjectInactiveError,
|
|
);
|
|
});
|
|
|
|
test('rolls back challenge consumption when owner binding insertion fails', async (t) => {
|
|
const { database, service } = await setup(t);
|
|
const issued = await service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
await database.query(
|
|
`CREATE TRIGGER reject_bootstrap_owner
|
|
BEFORE INSERT ON "${PROJECT_ROLE_BINDING_TABLE}"
|
|
WHEN NEW.mutation_id LIKE 'owner-bootstrap:%'
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'forced bootstrap binding failure');
|
|
END`,
|
|
);
|
|
const request = {
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
};
|
|
await assert.rejects(
|
|
service.claim(request),
|
|
ProjectOwnerBootstrapUnavailableError,
|
|
);
|
|
let challenge = (
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE)
|
|
)[0];
|
|
assert.equal(challenge.consumed_at_ms, null);
|
|
assert.equal(
|
|
(
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_ROLE_BINDING_TABLE)
|
|
).length,
|
|
0,
|
|
);
|
|
|
|
await database.query('DROP TRIGGER reject_bootstrap_owner');
|
|
assert.equal((await service.claim(request)).status, 'claimed');
|
|
challenge = (
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE)
|
|
)[0];
|
|
assert.equal(Number(challenge.consumed_at_ms), NOW + 1);
|
|
});
|
|
|
|
test('rolls back when SQLite silently ignores challenge consumption', async (t) => {
|
|
const { database, service } = await setup(t);
|
|
const issued = await service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
await database.query(
|
|
`CREATE TRIGGER ignore_bootstrap_consumption
|
|
BEFORE UPDATE ON "${PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE}"
|
|
WHEN NEW.consumed_at_ms IS NOT NULL
|
|
BEGIN
|
|
SELECT RAISE(IGNORE);
|
|
END`,
|
|
);
|
|
await assert.rejects(
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal(),
|
|
nowMs: NOW + 1,
|
|
}),
|
|
ProjectOwnerBootstrapUnavailableError,
|
|
);
|
|
const challenge = (
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_OWNER_BOOTSTRAP_CHALLENGE_TABLE)
|
|
)[0];
|
|
assert.equal(challenge.consumed_at_ms, null);
|
|
assert.equal(
|
|
(
|
|
await database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_ROLE_BINDING_TABLE)
|
|
).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('serializes claims from separate SQLite connections so exactly one owner wins', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-bootstrap-db-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const storage = path.join(root, 'database.sqlite');
|
|
const first = await setup(t, storage);
|
|
const issued = await first.service.issue({
|
|
projectId: PROJECT_ID,
|
|
issuer: localConsolePrincipal(),
|
|
nowMs: NOW,
|
|
ttlMs: TTL_MS,
|
|
});
|
|
const secondDatabase = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage,
|
|
logging: false,
|
|
});
|
|
t.after(() => secondDatabase.close());
|
|
const secondService = new ProjectOwnerBootstrapService(
|
|
new LegacySequelizeProjectOwnerBootstrapRepository(secondDatabase),
|
|
deterministicRandomSource(50),
|
|
);
|
|
const claim = (service, ownerId) =>
|
|
service.claim({
|
|
projectId: PROJECT_ID,
|
|
challengeId: issued.challengeId,
|
|
token: issued.token,
|
|
principal: principal({ type: 'user', id: ownerId }),
|
|
nowMs: NOW + 1,
|
|
});
|
|
const results = await Promise.allSettled([
|
|
claim(first.service, 'owner-a'),
|
|
claim(secondService, 'owner-b'),
|
|
]);
|
|
assert.equal(
|
|
results.filter((result) => result.status === 'fulfilled').length,
|
|
1,
|
|
);
|
|
const rejected = results.find((result) => result.status === 'rejected');
|
|
assert.ok(rejected.reason instanceof ProjectOwnerBootstrapClaimRejectedError);
|
|
const bindings = await first.database
|
|
.getQueryInterface()
|
|
.select(null, PROJECT_ROLE_BINDING_TABLE);
|
|
assert.equal(bindings.length, 1);
|
|
assert.equal(bindings[0].role, 'owner');
|
|
assert.ok(['owner-a', 'owner-b'].includes(bindings[0].subject_id));
|
|
});
|
|
|
|
test('rejects non-SQLite bootstrap repositories', () => {
|
|
assert.throws(
|
|
() =>
|
|
new LegacySequelizeProjectOwnerBootstrapRepository({
|
|
getDialect() {
|
|
return 'postgres';
|
|
},
|
|
}),
|
|
/SQLite-only/,
|
|
);
|
|
});
|