mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:02:22 +08:00
feat(ql3): add secret-backed console automation
This commit is contained in:
@@ -52,7 +52,9 @@ export type LocalProfileStorageBootstrapResult =
|
||||
readonly runAttemptLogRetention: LocalSqliteRuntimeDatabase['runAttemptLogRetention'];
|
||||
readonly runLostRetry: LocalSqliteRuntimeDatabase['runLostRetry'];
|
||||
readonly localSecrets: LocalSqliteRuntimeDatabase['localSecrets'];
|
||||
readonly localSecretMetadata: LocalSqliteRuntimeDatabase['localSecretMetadata'];
|
||||
readonly localSecretAdministration: LocalSqliteRuntimeDatabase['localSecretAdministration'];
|
||||
readonly localSecretAdministrationForCredential: LocalSqliteRuntimeDatabase['localSecretAdministrationForCredential'];
|
||||
readonly projectPolicy: LocalSqliteRuntimeDatabase['projectPolicy'];
|
||||
readonly securityAudit: LocalSqliteRuntimeDatabase['securityAudit'];
|
||||
readonly apiCredentials: LocalSqliteRuntimeDatabase['apiCredentials'];
|
||||
@@ -153,7 +155,10 @@ export async function bootstrapLocalProfileStorage(
|
||||
runAttemptLogRetention: database.runAttemptLogRetention,
|
||||
runLostRetry: database.runLostRetry,
|
||||
localSecrets: database.localSecrets,
|
||||
localSecretMetadata: database.localSecretMetadata,
|
||||
localSecretAdministration: database.localSecretAdministration,
|
||||
localSecretAdministrationForCredential:
|
||||
database.localSecretAdministrationForCredential,
|
||||
projectPolicy: database.projectPolicy,
|
||||
securityAudit: database.securityAudit,
|
||||
apiCredentials: database.apiCredentials,
|
||||
|
||||
@@ -17,7 +17,10 @@ import type { LocalRunStartupRecoverySource } from '@qinglong/runtime-core/local
|
||||
import type { LocalDispatchStore } from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { LocalExecutionControlSource } from '@qinglong/runtime-core/local-execution-control';
|
||||
import type { LocalCompletionReceiptJournal } from '@qinglong/runtime-core/local-completion-receipt-journal';
|
||||
import type { LocalSecretEnvelopeRepository } from '@qinglong/runtime-core/local-secret';
|
||||
import type {
|
||||
LocalSecretEnvelopeRepository,
|
||||
LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
import type { LocalSecretAdministrationRepository } from '@qinglong/runtime-core/local-secret-administration';
|
||||
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
||||
@@ -49,6 +52,7 @@ import type {
|
||||
ProjectToolDefinitionSnapshotSourceRepository,
|
||||
} from '@qinglong/runtime-core/project-tool-definition-snapshot';
|
||||
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||
import { LocalSqliteSecretMetadataRepository } from '../security/secretMetadataRepository';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteTaskDefinitionRepository } from '../task-definition/taskDefinitionRepository';
|
||||
@@ -113,7 +117,11 @@ export interface LocalSqliteRuntimeDatabase {
|
||||
readonly runAttemptLogRetention: LocalSqliteRunAttemptLogRetentionRepository;
|
||||
readonly runLostRetry: LocalSqliteRunLostRetryRepository;
|
||||
readonly localSecrets: LocalSecretEnvelopeRepository;
|
||||
readonly localSecretMetadata: LocalSecretMetadataSource;
|
||||
readonly localSecretAdministration: LocalSecretAdministrationRepository;
|
||||
localSecretAdministrationForCredential(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): Promise<LocalSecretAdministrationRepository>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
@@ -184,6 +192,9 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
const runRuntimeCapabilities =
|
||||
createLocalSqliteRunRuntimeCapabilities(authority);
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const localSecretMetadata = new LocalSqliteSecretMetadataRepository(
|
||||
authority,
|
||||
);
|
||||
const taskDefinitions = new LocalSqliteTaskDefinitionRepository(
|
||||
authority,
|
||||
taskSpecSemanticRegistry,
|
||||
@@ -326,7 +337,23 @@ export async function openLocalSqliteRuntimeDatabase(
|
||||
runAttemptLogRetention,
|
||||
runLostRetry,
|
||||
localSecrets: securityAuthority,
|
||||
localSecretMetadata,
|
||||
localSecretAdministration: securityAuthority,
|
||||
async localSecretAdministrationForCredential(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
const { confirmLocalSqliteAuthenticatedUserCredentialFence } =
|
||||
await import('../administration/packageManagement.js');
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
return new LocalSqliteSecurityAuthorityStore(authority, {
|
||||
beforeAuthorizedLocalSecretMutation() {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(
|
||||
authority,
|
||||
fence,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
projectPolicy,
|
||||
securityAudit: securityAuthority,
|
||||
apiCredentials,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
LocalSecretMetadataUnavailableError,
|
||||
MAX_LOCAL_SECRET_BATCH_SIZE,
|
||||
assertLocalSecretName,
|
||||
assertLocalSecretProjectId,
|
||||
assertLocalSecretVersion,
|
||||
type LocalSecretMetadata,
|
||||
type LocalSecretMetadataPage,
|
||||
type LocalSecretMetadataSource,
|
||||
} from '@qinglong/runtime-core/local-secret';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function metadata(row: Row): Readonly<LocalSecretMetadata> {
|
||||
try {
|
||||
const projectId = text(row, 'projectId');
|
||||
const name = text(row, 'name');
|
||||
const currentVersion = integer(row, 'currentVersion');
|
||||
const createdAtMs = integer(row, 'createdAtMs');
|
||||
assertLocalSecretProjectId(projectId);
|
||||
assertLocalSecretName(name);
|
||||
assertLocalSecretVersion(currentVersion);
|
||||
if (createdAtMs < 0) throw new Error('invalid Secret timestamp');
|
||||
return Object.freeze({ projectId, name, currentVersion, createdAtMs });
|
||||
} catch (error) {
|
||||
if (error instanceof LocalSecretMetadataUnavailableError) throw error;
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalSqliteSecretMetadataRepository
|
||||
implements LocalSecretMetadataSource
|
||||
{
|
||||
constructor(private readonly authority: LocalSqliteOperationAuthority) {
|
||||
if (!(authority instanceof LocalSqliteOperationAuthority)) {
|
||||
throw new TypeError('Local Secret metadata authority is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
listLocalSecretMetadata(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: Readonly<{ readonly name: string }>;
|
||||
}): Promise<Readonly<LocalSecretMetadataPage>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => !['after', 'limit', 'projectId'].includes(key),
|
||||
) ||
|
||||
!Object.hasOwn(options, 'limit') ||
|
||||
!Object.hasOwn(options, 'projectId') ||
|
||||
!Number.isSafeInteger(options.limit) ||
|
||||
options.limit < 1 ||
|
||||
options.limit > MAX_LOCAL_SECRET_BATCH_SIZE ||
|
||||
(options.after !== undefined &&
|
||||
(!options.after ||
|
||||
typeof options.after !== 'object' ||
|
||||
Array.isArray(options.after) ||
|
||||
Object.keys(options.after).join('') !== 'name'))
|
||||
) {
|
||||
throw new TypeError('Local Secret metadata list options are invalid');
|
||||
}
|
||||
assertLocalSecretProjectId(options.projectId);
|
||||
if (options.after) assertLocalSecretName(options.after.name);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`SELECT secret."project_id" AS "projectId",
|
||||
secret."secret_name" AS "name",
|
||||
secret."version" AS "currentVersion",
|
||||
secret."created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS secret
|
||||
WHERE secret."project_id" = ?
|
||||
AND secret."secret_name" > ?
|
||||
AND secret."version" = (
|
||||
SELECT MAX(current."version")
|
||||
FROM "QingLong3LocalSecretEnvelopes" AS current
|
||||
WHERE current."project_id" = secret."project_id"
|
||||
AND current."secret_name" = secret."secret_name"
|
||||
)
|
||||
ORDER BY secret."secret_name"
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
options.projectId,
|
||||
options.after?.name ?? '',
|
||||
options.limit + 1,
|
||||
) as Row[] | undefined;
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
const truncated = rows.length > options.limit;
|
||||
const secrets = Object.freeze(
|
||||
rows.slice(0, options.limit).map(metadata),
|
||||
);
|
||||
const last = secrets.at(-1);
|
||||
return Object.freeze({
|
||||
secrets,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? { next: Object.freeze({ name: last.name }) }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw new LocalSecretMetadataUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new LocalSecretMetadataUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { migrateLocalSqlitePath } = require('../dist/migration/migration.js');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('../dist/runtime/runtimeDatabase.js');
|
||||
|
||||
function insertSecret(database, name, version, createdAtMs) {
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalSecretEnvelopes"
|
||||
("project_id", "secret_name", "version", "mutation_id", "key_id",
|
||||
"algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms")
|
||||
VALUES ('default', ?, ?, ?, 'active-key', 'aes-256-gcm', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
name,
|
||||
version,
|
||||
`00000000-0000-4000-8000-${String(createdAtMs).padStart(12, '0')}`,
|
||||
Buffer.alloc(12, version),
|
||||
Buffer.from(`cipher-${name}-${version}`),
|
||||
Buffer.alloc(16, version),
|
||||
createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
test('lists only current Secret metadata with a stable bounded cursor', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-meta-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const options = {
|
||||
directory,
|
||||
profile: 'edge',
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
};
|
||||
await migrateLocalSqlitePath(options);
|
||||
const database = new DatabaseSync(options.databasePath);
|
||||
insertSecret(database, 'alpha', 1, 101);
|
||||
insertSecret(database, 'alpha', 2, 102);
|
||||
insertSecret(database, 'beta', 1, 103);
|
||||
insertSecret(database, 'gamma', 1, 104);
|
||||
database.close();
|
||||
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(options);
|
||||
const first = await runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
});
|
||||
assert.deepEqual(first, {
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'alpha',
|
||||
currentVersion: 2,
|
||||
createdAtMs: 102,
|
||||
},
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'beta',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 103,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
next: { name: 'beta' },
|
||||
});
|
||||
assert.equal(JSON.stringify(first).includes('cipher'), false);
|
||||
assert.equal(JSON.stringify(first).includes('key'), false);
|
||||
assert.deepEqual(
|
||||
await runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 2,
|
||||
after: first.next,
|
||||
}),
|
||||
{
|
||||
secrets: [
|
||||
{
|
||||
projectId: 'default',
|
||||
name: 'gamma',
|
||||
currentVersion: 1,
|
||||
createdAtMs: 104,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
|
||||
await runtime.close();
|
||||
await assert.rejects(
|
||||
runtime.localSecretMetadata.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 1,
|
||||
}),
|
||||
{ name: 'LocalSecretMetadataUnavailableError' },
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened and over-budget metadata queries before storage', async () => {
|
||||
const source = Object.create(
|
||||
require('../dist/security/secretMetadataRepository.js')
|
||||
.LocalSqliteSecretMetadataRepository.prototype,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
source.listLocalSecretMetadata({
|
||||
projectId: 'default',
|
||||
limit: 65,
|
||||
}),
|
||||
/options are invalid/u,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user