mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(local): authorize reconciliation automation decisions
This commit is contained in:
+50
-6
@@ -88,6 +88,8 @@ export interface VerifyLegacyCrontabDecisionAuthorizationFileOptions {
|
||||
readonly expectedPlanDigest: string;
|
||||
readonly expectedInventoryDigest: string;
|
||||
readonly keyProvider: LocalSecretKeyProvider;
|
||||
readonly allowedModes?: readonly (0o400 | 0o600)[];
|
||||
readonly allowedParentModes?: readonly (0o500 | 0o700)[];
|
||||
readonly verifyReceipt: (
|
||||
receipt: unknown,
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
@@ -218,7 +220,47 @@ function currentUid(): number {
|
||||
return uid;
|
||||
}
|
||||
|
||||
function assertPrivateParent(filePath: string, uid: number): void {
|
||||
function verificationModes(
|
||||
value: readonly (0o400 | 0o600)[] | undefined,
|
||||
): readonly number[] {
|
||||
if (value === undefined) return Object.freeze([0o600]);
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > 2 ||
|
||||
value.some((mode) => mode !== 0o400 && mode !== 0o600) ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||
'allowed file modes are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze([...value]);
|
||||
}
|
||||
|
||||
function verificationParentModes(
|
||||
value: readonly (0o500 | 0o700)[] | undefined,
|
||||
): readonly number[] {
|
||||
if (value === undefined) return Object.freeze([0o700]);
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > 2 ||
|
||||
value.some((mode) => mode !== 0o500 && mode !== 0o700) ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||
'allowed parent modes are invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze([...value]);
|
||||
}
|
||||
|
||||
function assertPrivateParent(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
allowedModes: readonly number[],
|
||||
): void {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(path.dirname(filePath));
|
||||
@@ -232,7 +274,7 @@ function assertPrivateParent(filePath: string, uid: number): void {
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o700
|
||||
!allowedModes.includes(stat.mode & 0o777)
|
||||
) {
|
||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||
'parent must be an owner-only real directory',
|
||||
@@ -693,7 +735,7 @@ export async function publishLegacyCrontabDecisionAuthorizationFile(
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
const filePath = authorizationPath(options.filePath);
|
||||
const uid = currentUid();
|
||||
assertPrivateParent(filePath, uid);
|
||||
assertPrivateParent(filePath, uid, [0o700]);
|
||||
const fileHeader = header(options);
|
||||
const temporary = path.join(
|
||||
path.dirname(filePath),
|
||||
@@ -860,7 +902,9 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult | T> {
|
||||
const filePath = authorizationPath(options.filePath);
|
||||
const uid = currentUid();
|
||||
assertPrivateParent(filePath, uid);
|
||||
const allowedModes = verificationModes(options.allowedModes);
|
||||
const allowedParentModes = verificationParentModes(options.allowedParentModes);
|
||||
assertPrivateParent(filePath, uid, allowedParentModes);
|
||||
assertHeaderIdentity({
|
||||
decisionId: options.expectedDecisionId,
|
||||
profile: options.expectedProfile,
|
||||
@@ -874,7 +918,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
!allowedModes.includes(Number(before.mode) & 0o777) ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_AUTHORIZATION_FILE_BYTES)
|
||||
) {
|
||||
@@ -1032,7 +1076,7 @@ async function verifyLegacyCrontabDecisionAuthorizationFileInternal<T>(
|
||||
afterPath.mtimeNs !== opened.mtimeNs ||
|
||||
afterPath.ctimeNs !== opened.ctimeNs ||
|
||||
Number(afterPath.uid) !== uid ||
|
||||
(Number(afterPath.mode) & 0o777) !== 0o600
|
||||
!allowedModes.includes(Number(afterPath.mode) & 0o777)
|
||||
) {
|
||||
throw new LegacyCrontabDecisionAuthorizationFileError(
|
||||
'file identity changed during verification',
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
// Reconciliation Automation owns a plan-bound facade over legacy row decisions.
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import {
|
||||
publishLegacyCrontabDecisionAuthorizationFile,
|
||||
withVerifiedLegacyCrontabDecisionAuthorizationFile,
|
||||
type LegacyCrontabDecisionAuthorizationFileResult,
|
||||
type VerifiedLegacyCrontabDecisionAuthorizationFileScope,
|
||||
} from './legacyCrontabDecisionAuthorizationFile';
|
||||
import {
|
||||
createLegacyCrontabAdoptionDecisionReceipt,
|
||||
verifyLegacyCrontabAdoptionDecisionReceipt,
|
||||
type LegacyCrontabAdoptionDecision,
|
||||
} from './legacyCrontabDecisionReceipt';
|
||||
import type { LegacyCrontabAdoptionClassification } from './legacyCrontabAdoption';
|
||||
import { withPrivateLegacyCrontabAdoptionDecisionReviewFile } from './legacyCrontabDecisionReviewFile';
|
||||
|
||||
export type ReconciliationAutomationDecisionRequirementKind =
|
||||
| 'review_adopt'
|
||||
| 'review_skip_conflict'
|
||||
| 'manual_required';
|
||||
|
||||
export interface ReconciliationAutomationDecisionRequirement {
|
||||
readonly rowOrdinal: number;
|
||||
readonly sourceDigest: string;
|
||||
readonly classification: LegacyCrontabAdoptionClassification;
|
||||
readonly requirement: ReconciliationAutomationDecisionRequirementKind;
|
||||
}
|
||||
|
||||
export interface ReconciliationAutomationDecisionIdentity {
|
||||
readonly decisionId: string;
|
||||
readonly profile: 'edge' | 'standalone';
|
||||
readonly automationPlanDigest: string;
|
||||
readonly inventoryDigest: string;
|
||||
}
|
||||
|
||||
interface ReconciliationAutomationDecisionVerificationOptions
|
||||
extends ReconciliationAutomationDecisionIdentity {
|
||||
readonly authorizationPath: string;
|
||||
readonly sourceClient: DatabaseSync;
|
||||
readonly timezone: string | null;
|
||||
readonly keyProvider: LocalSecretKeyProvider;
|
||||
readonly observedAtMs: number;
|
||||
readonly openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>;
|
||||
readonly allowedModes?: readonly (0o400 | 0o600)[];
|
||||
readonly allowedParentModes?: readonly (0o500 | 0o700)[];
|
||||
}
|
||||
|
||||
export interface IssueReconciliationAutomationDecisionOptions
|
||||
extends ReconciliationAutomationDecisionVerificationOptions {
|
||||
readonly reviewFilePath: string;
|
||||
readonly reviewer: SecurityPrincipal;
|
||||
readonly issuedAtMs: number;
|
||||
readonly expiresAtMs: number;
|
||||
readonly confirmExternalAuthority: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RecoverReconciliationAutomationDecisionOptions
|
||||
extends ReconciliationAutomationDecisionVerificationOptions {
|
||||
readonly reviewFilePath: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationAutomationDecisionPublication {
|
||||
readonly authorization: LegacyCrontabDecisionAuthorizationFileResult;
|
||||
readonly reviewFileDigest: string;
|
||||
}
|
||||
|
||||
export interface VerifiedReconciliationAutomationDecisionScope
|
||||
extends VerifiedLegacyCrontabDecisionAuthorizationFileScope {
|
||||
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
|
||||
}
|
||||
|
||||
export class ReconciliationAutomationDecisionError extends Error {
|
||||
readonly code = 'RECONCILIATION_AUTOMATION_DECISION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Reconciliation Automation decision is invalid: ${message}`);
|
||||
this.name = 'ReconciliationAutomationDecisionError';
|
||||
}
|
||||
}
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CLASSIFICATIONS = Object.freeze([
|
||||
'lossless',
|
||||
'requires_shell_compatibility',
|
||||
'requires_manual_action',
|
||||
'malformed',
|
||||
] as const);
|
||||
const REQUIREMENTS = Object.freeze([
|
||||
'review_adopt',
|
||||
'review_skip_conflict',
|
||||
'manual_required',
|
||||
] as const);
|
||||
|
||||
function requirement(
|
||||
value: ReconciliationAutomationDecisionRequirement,
|
||||
): Readonly<ReconciliationAutomationDecisionRequirement> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join('\0') !==
|
||||
['classification', 'requirement', 'rowOrdinal', 'sourceDigest']
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
!Number.isSafeInteger(value.rowOrdinal) ||
|
||||
value.rowOrdinal < 1 ||
|
||||
!DIGEST_PATTERN.test(value.sourceDigest) ||
|
||||
!CLASSIFICATIONS.includes(value.classification) ||
|
||||
!REQUIREMENTS.includes(value.requirement)
|
||||
) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'plan requirement is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function iterator<T>(value: Iterable<T>, label: string): Iterator<T> {
|
||||
if (
|
||||
!value ||
|
||||
(typeof value !== 'object' && typeof value !== 'function') ||
|
||||
typeof value[Symbol.iterator] !== 'function'
|
||||
) {
|
||||
throw new ReconciliationAutomationDecisionError(`${label} is invalid`);
|
||||
}
|
||||
const selected = value[Symbol.iterator]();
|
||||
if (!selected || typeof selected.next !== 'function') {
|
||||
throw new ReconciliationAutomationDecisionError(`${label} is invalid`);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function decisionsBoundToPlan(
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>,
|
||||
): Iterable<LegacyCrontabAdoptionDecision> {
|
||||
if (typeof openRequirements !== 'function') {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'plan requirement factory is invalid',
|
||||
);
|
||||
}
|
||||
return (function* (): Iterable<LegacyCrontabAdoptionDecision> {
|
||||
const decisionIterator = iterator(decisions, 'decision stream');
|
||||
const requirementIterator = iterator(
|
||||
openRequirements(),
|
||||
'plan requirement stream',
|
||||
);
|
||||
let complete = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const nextDecision = decisionIterator.next();
|
||||
const nextRequirement = requirementIterator.next();
|
||||
if (nextDecision.done || nextRequirement.done) {
|
||||
if (nextDecision.done !== nextRequirement.done) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'decision and plan row counts differ',
|
||||
);
|
||||
}
|
||||
complete = true;
|
||||
return;
|
||||
}
|
||||
const expected = requirement(nextRequirement.value);
|
||||
const decision = nextDecision.value;
|
||||
if (
|
||||
decision.rowOrdinal !== expected.rowOrdinal ||
|
||||
decision.sourceDigest !== expected.sourceDigest
|
||||
) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'decision is detached from its plan row',
|
||||
);
|
||||
}
|
||||
if (
|
||||
expected.requirement !== 'review_adopt' &&
|
||||
decision.disposition !== 'skip'
|
||||
) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'conflict or manual row cannot be adopted',
|
||||
);
|
||||
}
|
||||
yield decision;
|
||||
}
|
||||
} finally {
|
||||
if (!complete) {
|
||||
try {
|
||||
decisionIterator.return?.();
|
||||
} catch {
|
||||
// Preserve the binding failure.
|
||||
}
|
||||
try {
|
||||
requirementIterator.return?.();
|
||||
} catch {
|
||||
// Preserve the binding failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function sameDecision(
|
||||
left: LegacyCrontabAdoptionDecision,
|
||||
right: LegacyCrontabAdoptionDecision,
|
||||
): boolean {
|
||||
return (
|
||||
left.rowOrdinal === right.rowOrdinal &&
|
||||
left.sourceDigest === right.sourceDigest &&
|
||||
left.disposition === right.disposition &&
|
||||
left.reason === right.reason
|
||||
);
|
||||
}
|
||||
|
||||
function verifiedOptions(
|
||||
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||
openRequirements: () => Iterable<ReconciliationAutomationDecisionRequirement>,
|
||||
) {
|
||||
return {
|
||||
filePath: options.authorizationPath,
|
||||
expectedDecisionId: options.decisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: options.automationPlanDigest,
|
||||
expectedInventoryDigest: options.inventoryDigest,
|
||||
keyProvider: options.keyProvider,
|
||||
...(options.allowedModes === undefined
|
||||
? {}
|
||||
: { allowedModes: options.allowedModes }),
|
||||
...(options.allowedParentModes === undefined
|
||||
? {}
|
||||
: { allowedParentModes: options.allowedParentModes }),
|
||||
verifyReceipt: (
|
||||
receipt: unknown,
|
||||
decisions: Iterable<LegacyCrontabAdoptionDecision>,
|
||||
) =>
|
||||
verifyLegacyCrontabAdoptionDecisionReceipt(
|
||||
options.sourceClient,
|
||||
options.timezone,
|
||||
receipt,
|
||||
decisionsBoundToPlan(decisions, openRequirements),
|
||||
options.observedAtMs,
|
||||
),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function issueReconciliationAutomationDecision(
|
||||
options: IssueReconciliationAutomationDecisionOptions,
|
||||
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||
try {
|
||||
return await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{
|
||||
filePath: options.reviewFilePath,
|
||||
expectedDecisionId: options.decisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: options.automationPlanDigest,
|
||||
expectedInventoryDigest: options.inventoryDigest,
|
||||
},
|
||||
async (review) => {
|
||||
const authorization =
|
||||
await publishLegacyCrontabDecisionAuthorizationFile({
|
||||
filePath: options.authorizationPath,
|
||||
decisionId: options.decisionId,
|
||||
profile: options.profile,
|
||||
planDigest: options.automationPlanDigest,
|
||||
inventoryDigest: options.inventoryDigest,
|
||||
decisions: decisionsBoundToPlan(
|
||||
review.decisions,
|
||||
options.openRequirements,
|
||||
),
|
||||
keyProvider: options.keyProvider,
|
||||
createReceipt: (decisions) =>
|
||||
createLegacyCrontabAdoptionDecisionReceipt(
|
||||
options.sourceClient,
|
||||
options.timezone,
|
||||
{
|
||||
decisionId: options.decisionId,
|
||||
profile: options.profile,
|
||||
planDigest: options.automationPlanDigest,
|
||||
inventoryDigest: options.inventoryDigest,
|
||||
reviewer: options.reviewer,
|
||||
issuedAtMs: options.issuedAtMs,
|
||||
expiresAtMs: options.expiresAtMs,
|
||||
},
|
||||
decisions,
|
||||
),
|
||||
async confirmExternalAuthority() {
|
||||
review.confirmIdentity();
|
||||
await options.confirmExternalAuthority();
|
||||
},
|
||||
});
|
||||
review.confirmIdentity();
|
||||
return Object.freeze({
|
||||
authorization,
|
||||
reviewFileDigest: review.evidence.fileDigest,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'authorization could not be issued',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverReconciliationAutomationDecision(
|
||||
options: RecoverReconciliationAutomationDecisionOptions,
|
||||
): Promise<Readonly<ReconciliationAutomationDecisionPublication>> {
|
||||
try {
|
||||
return await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{
|
||||
filePath: options.reviewFilePath,
|
||||
expectedDecisionId: options.decisionId,
|
||||
expectedProfile: options.profile,
|
||||
expectedPlanDigest: options.automationPlanDigest,
|
||||
expectedInventoryDigest: options.inventoryDigest,
|
||||
},
|
||||
async (review) =>
|
||||
withVerifiedLegacyCrontabDecisionAuthorizationFile(
|
||||
verifiedOptions(options, options.openRequirements),
|
||||
async (authorization) => {
|
||||
const reviewed = iterator(
|
||||
decisionsBoundToPlan(
|
||||
review.decisions,
|
||||
options.openRequirements,
|
||||
),
|
||||
'review decision stream',
|
||||
);
|
||||
const signed = iterator(
|
||||
authorization.decisions,
|
||||
'signed decision stream',
|
||||
);
|
||||
for (;;) {
|
||||
const left = reviewed.next();
|
||||
const right = signed.next();
|
||||
if (left.done || right.done) {
|
||||
if (left.done !== right.done) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'review and signed decision counts differ',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!sameDecision(left.value, right.value)) {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'review decision differs from signed authorization',
|
||||
);
|
||||
}
|
||||
}
|
||||
review.confirmIdentity();
|
||||
authorization.confirmIdentity();
|
||||
return Object.freeze({
|
||||
authorization: authorization.result,
|
||||
reviewFileDigest: review.evidence.fileDigest,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'authorization recovery failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withVerifiedReconciliationAutomationDecision<T>(
|
||||
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||
consumer: (
|
||||
scope: VerifiedReconciliationAutomationDecisionScope,
|
||||
) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (typeof consumer !== 'function') {
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'verified decision consumer is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await withVerifiedLegacyCrontabDecisionAuthorizationFile(
|
||||
verifiedOptions(options, options.openRequirements),
|
||||
(scope) =>
|
||||
consumer(
|
||||
Object.freeze({
|
||||
...scope,
|
||||
decisions: decisionsBoundToPlan(
|
||||
scope.decisions,
|
||||
options.openRequirements,
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ReconciliationAutomationDecisionError) throw error;
|
||||
throw new ReconciliationAutomationDecisionError(
|
||||
'authorization verification failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyReconciliationAutomationDecision(
|
||||
options: ReconciliationAutomationDecisionVerificationOptions,
|
||||
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
|
||||
return withVerifiedReconciliationAutomationDecision(options, (scope) => {
|
||||
for (const _decision of scope.decisions) {
|
||||
// Full consumption proves the second plan-bound stream as well.
|
||||
}
|
||||
scope.confirmIdentity();
|
||||
return scope.result;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user