mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
import {
|
|
MAX_PROJECT_ROLE_BINDING_VERSION,
|
|
assertProjectPolicyProjectId,
|
|
normalizePolicySubject,
|
|
normalizeProjectPermission,
|
|
type PolicySubject,
|
|
type ProjectPermission,
|
|
type ProjectPolicyFence,
|
|
} from './projectPolicy';
|
|
|
|
export const APPROVAL_RISKS = ['low', 'medium', 'high', 'critical'] as const;
|
|
export const APPROVAL_REQUEST_STATES = [
|
|
'pending',
|
|
'approved',
|
|
'rejected',
|
|
'consumed',
|
|
] as const;
|
|
export const APPROVAL_DECISIONS = ['approved', 'rejected'] as const;
|
|
export const APPROVED_ACTION_DISPATCH_STATES = ['pending'] as const;
|
|
|
|
export type ApprovalRisk = (typeof APPROVAL_RISKS)[number];
|
|
export type ApprovalRequestState = (typeof APPROVAL_REQUEST_STATES)[number];
|
|
export type ApprovalDecision = (typeof APPROVAL_DECISIONS)[number];
|
|
export type ApprovalRequestEffectiveStatus = ApprovalRequestState | 'expired';
|
|
export type ApprovedActionDispatchState =
|
|
(typeof APPROVED_ACTION_DISPATCH_STATES)[number];
|
|
|
|
export interface ApprovalActionBinding {
|
|
permission: ProjectPermission;
|
|
actionType: string;
|
|
actionRef: string;
|
|
actionDigest: string;
|
|
previewDigest: string;
|
|
}
|
|
|
|
export interface ApprovalRequestRecord {
|
|
id: string;
|
|
projectId: string;
|
|
version: number;
|
|
state: ApprovalRequestState;
|
|
action: ApprovalActionBinding;
|
|
risk: ApprovalRisk;
|
|
requestedBy: PolicySubject;
|
|
requestedAtMs: number;
|
|
expiresAtMs: number;
|
|
decisionId: string | null;
|
|
decision: ApprovalDecision | null;
|
|
decisionReasonCode: string | null;
|
|
decidedBy: PolicySubject | null;
|
|
decidedAtMs: number | null;
|
|
consumptionId: string | null;
|
|
dispatchId: string | null;
|
|
consumedBy: PolicySubject | null;
|
|
consumedAtMs: number | null;
|
|
}
|
|
|
|
export interface ApprovedActionDispatchRecord {
|
|
id: string;
|
|
approvalRequestId: string;
|
|
approvalRequestVersion: number;
|
|
projectId: string;
|
|
state: ApprovedActionDispatchState;
|
|
action: ApprovalActionBinding;
|
|
requestedBy: PolicySubject;
|
|
consumedBy: PolicySubject;
|
|
createdAtMs: number;
|
|
}
|
|
|
|
export const MAX_APPROVAL_REQUEST_ID_LENGTH = 64;
|
|
export const MAX_APPROVAL_MUTATION_ID_LENGTH = 64;
|
|
export const MAX_APPROVAL_ACTION_TYPE_LENGTH = 64;
|
|
export const MAX_APPROVAL_ACTION_REF_LENGTH = 255;
|
|
export const MAX_APPROVAL_REASON_CODE_LENGTH = 64;
|
|
export const MAX_APPROVAL_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
|
export const MAX_APPROVAL_REQUEST_VERSION = 2_147_483_647;
|
|
|
|
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
|
|
export class InvalidApprovalValueError extends TypeError {
|
|
constructor(message: string) {
|
|
super(`Approval value is invalid: ${message}`);
|
|
this.name = 'InvalidApprovalValueError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalRequestNotFoundError extends Error {
|
|
readonly code = 'APPROVAL_REQUEST_NOT_FOUND';
|
|
|
|
constructor() {
|
|
super('Approval request does not exist');
|
|
this.name = 'ApprovalRequestNotFoundError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalPolicyDeniedError extends Error {
|
|
readonly code = 'APPROVAL_POLICY_DENIED';
|
|
|
|
constructor() {
|
|
super('Approval operation is denied by project policy');
|
|
this.name = 'ApprovalPolicyDeniedError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalPolicyFenceConflictError extends Error {
|
|
readonly code = 'APPROVAL_POLICY_FENCE_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approval policy snapshot changed before the mutation committed');
|
|
this.name = 'ApprovalPolicyFenceConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalRequestVersionConflictError extends Error {
|
|
readonly code = 'APPROVAL_REQUEST_VERSION_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approval request version changed');
|
|
this.name = 'ApprovalRequestVersionConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalMutationConflictError extends Error {
|
|
readonly code = 'APPROVAL_MUTATION_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approval mutation does not match its previous request');
|
|
this.name = 'ApprovalMutationConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalRequestStateConflictError extends Error {
|
|
readonly code = 'APPROVAL_REQUEST_STATE_CONFLICT';
|
|
|
|
constructor() {
|
|
super('Approval request is not in the required state');
|
|
this.name = 'ApprovalRequestStateConflictError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalRequestExpiredError extends Error {
|
|
readonly code = 'APPROVAL_REQUEST_EXPIRED';
|
|
|
|
constructor() {
|
|
super('Approval request expired');
|
|
this.name = 'ApprovalRequestExpiredError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalHumanDecisionRequiredError extends Error {
|
|
readonly code = 'APPROVAL_HUMAN_DECISION_REQUIRED';
|
|
|
|
constructor() {
|
|
super('Approval decisions require an authenticated user subject');
|
|
this.name = 'ApprovalHumanDecisionRequiredError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalSelfDecisionError extends Error {
|
|
readonly code = 'APPROVAL_SELF_DECISION_REJECTED';
|
|
|
|
constructor() {
|
|
super('An approval requester cannot decide its own request');
|
|
this.name = 'ApprovalSelfDecisionError';
|
|
}
|
|
}
|
|
|
|
export class ApprovalUnavailableError extends Error {
|
|
readonly code = 'APPROVAL_UNAVAILABLE';
|
|
|
|
constructor() {
|
|
super('Approval storage is unavailable');
|
|
this.name = 'ApprovalUnavailableError';
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(
|
|
name: string,
|
|
value: object,
|
|
expected: readonly string[],
|
|
): void {
|
|
const keys = Object.keys(value).sort();
|
|
const canonical = [...expected].sort();
|
|
if (
|
|
keys.length !== canonical.length ||
|
|
keys.some((key, index) => key !== canonical[index])
|
|
) {
|
|
throw new InvalidApprovalValueError(`${name} shape is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertIdentifier(name: string, value: string, maximum: number): void {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.length > maximum ||
|
|
!IDENTIFIER_PATTERN.test(value)
|
|
) {
|
|
throw new InvalidApprovalValueError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function assertTimestamp(name: string, value: number): void {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new InvalidApprovalValueError(`${name} is invalid`);
|
|
}
|
|
}
|
|
|
|
function sameSubject(
|
|
left: Readonly<PolicySubject>,
|
|
right: Readonly<PolicySubject>,
|
|
): boolean {
|
|
return left.type === right.type && left.id === right.id;
|
|
}
|
|
|
|
export function assertApprovalRequestId(value: string): void {
|
|
assertIdentifier('request id', value, MAX_APPROVAL_REQUEST_ID_LENGTH);
|
|
}
|
|
|
|
export function assertApprovalMutationId(value: string): void {
|
|
assertIdentifier('mutation id', value, MAX_APPROVAL_MUTATION_ID_LENGTH);
|
|
}
|
|
|
|
export function assertApprovalReasonCode(value: string): void {
|
|
assertIdentifier(
|
|
'decision reason code',
|
|
value,
|
|
MAX_APPROVAL_REASON_CODE_LENGTH,
|
|
);
|
|
}
|
|
|
|
export function assertApprovalTimestamp(name: string, value: number): void {
|
|
assertTimestamp(name, value);
|
|
}
|
|
|
|
export function assertApprovalRequestVersion(value: number): void {
|
|
if (
|
|
!Number.isSafeInteger(value) ||
|
|
value < 1 ||
|
|
value > MAX_APPROVAL_REQUEST_VERSION
|
|
) {
|
|
throw new InvalidApprovalValueError('request version is invalid');
|
|
}
|
|
}
|
|
|
|
export function normalizeApprovalActionBinding(
|
|
value: ApprovalActionBinding,
|
|
): Readonly<ApprovalActionBinding> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovalValueError('action must be an object');
|
|
}
|
|
assertExactKeys('action', value, [
|
|
'permission',
|
|
'actionType',
|
|
'actionRef',
|
|
'actionDigest',
|
|
'previewDigest',
|
|
]);
|
|
const permission = normalizeProjectPermission(value.permission);
|
|
assertIdentifier(
|
|
'action type',
|
|
value.actionType,
|
|
MAX_APPROVAL_ACTION_TYPE_LENGTH,
|
|
);
|
|
assertIdentifier(
|
|
'action ref',
|
|
value.actionRef,
|
|
MAX_APPROVAL_ACTION_REF_LENGTH,
|
|
);
|
|
if (!DIGEST_PATTERN.test(value.actionDigest)) {
|
|
throw new InvalidApprovalValueError('action digest is invalid');
|
|
}
|
|
if (!DIGEST_PATTERN.test(value.previewDigest)) {
|
|
throw new InvalidApprovalValueError('preview digest is invalid');
|
|
}
|
|
return Object.freeze({
|
|
permission,
|
|
actionType: value.actionType,
|
|
actionRef: value.actionRef,
|
|
actionDigest: value.actionDigest,
|
|
previewDigest: value.previewDigest,
|
|
});
|
|
}
|
|
|
|
export function normalizeApprovalPolicyFence(
|
|
value: ProjectPolicyFence,
|
|
): Readonly<ProjectPolicyFence> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovalValueError('policy fence must be an object');
|
|
}
|
|
assertExactKeys('policy fence', value, ['projectVersion', 'bindingVersion']);
|
|
if (
|
|
!Number.isSafeInteger(value.projectVersion) ||
|
|
value.projectVersion < 1 ||
|
|
value.projectVersion > MAX_PROJECT_ROLE_BINDING_VERSION
|
|
) {
|
|
throw new InvalidApprovalValueError('project policy version is invalid');
|
|
}
|
|
if (
|
|
value.bindingVersion !== null &&
|
|
(!Number.isSafeInteger(value.bindingVersion) ||
|
|
value.bindingVersion < 1 ||
|
|
value.bindingVersion > MAX_PROJECT_ROLE_BINDING_VERSION)
|
|
) {
|
|
throw new InvalidApprovalValueError('binding policy version is invalid');
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
export function normalizeApprovalRequestRecord(
|
|
value: ApprovalRequestRecord,
|
|
): Readonly<ApprovalRequestRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovalValueError('request must be an object');
|
|
}
|
|
assertExactKeys('request', value, [
|
|
'id',
|
|
'projectId',
|
|
'version',
|
|
'state',
|
|
'action',
|
|
'risk',
|
|
'requestedBy',
|
|
'requestedAtMs',
|
|
'expiresAtMs',
|
|
'decisionId',
|
|
'decision',
|
|
'decisionReasonCode',
|
|
'decidedBy',
|
|
'decidedAtMs',
|
|
'consumptionId',
|
|
'dispatchId',
|
|
'consumedBy',
|
|
'consumedAtMs',
|
|
]);
|
|
assertApprovalRequestId(value.id);
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
assertApprovalRequestVersion(value.version);
|
|
if (!APPROVAL_REQUEST_STATES.includes(value.state)) {
|
|
throw new InvalidApprovalValueError('request state is invalid');
|
|
}
|
|
const action = normalizeApprovalActionBinding(value.action);
|
|
if (!APPROVAL_RISKS.includes(value.risk)) {
|
|
throw new InvalidApprovalValueError('risk is invalid');
|
|
}
|
|
const requestedBy = normalizePolicySubject(value.requestedBy);
|
|
assertTimestamp('requestedAtMs', value.requestedAtMs);
|
|
assertTimestamp('expiresAtMs', value.expiresAtMs);
|
|
if (
|
|
value.expiresAtMs <= value.requestedAtMs ||
|
|
value.expiresAtMs - value.requestedAtMs > MAX_APPROVAL_LIFETIME_MS
|
|
) {
|
|
throw new InvalidApprovalValueError('request lifetime is invalid');
|
|
}
|
|
|
|
const decisionValues = [
|
|
value.decisionId,
|
|
value.decision,
|
|
value.decisionReasonCode,
|
|
value.decidedBy,
|
|
value.decidedAtMs,
|
|
];
|
|
const hasDecision = decisionValues.every((candidate) => candidate !== null);
|
|
if (!hasDecision && decisionValues.some((candidate) => candidate !== null)) {
|
|
throw new InvalidApprovalValueError('decision tuple is incomplete');
|
|
}
|
|
let decidedBy: Readonly<PolicySubject> | null = null;
|
|
if (hasDecision) {
|
|
assertApprovalMutationId(value.decisionId!);
|
|
if (!APPROVAL_DECISIONS.includes(value.decision!)) {
|
|
throw new InvalidApprovalValueError('decision is invalid');
|
|
}
|
|
assertApprovalReasonCode(value.decisionReasonCode!);
|
|
decidedBy = normalizePolicySubject(value.decidedBy!);
|
|
assertTimestamp('decidedAtMs', value.decidedAtMs!);
|
|
if (
|
|
value.decidedAtMs! < value.requestedAtMs ||
|
|
value.decidedAtMs! >= value.expiresAtMs
|
|
) {
|
|
throw new InvalidApprovalValueError('decision timestamp is invalid');
|
|
}
|
|
}
|
|
|
|
const consumptionValues = [
|
|
value.consumptionId,
|
|
value.dispatchId,
|
|
value.consumedBy,
|
|
value.consumedAtMs,
|
|
];
|
|
const hasConsumption = consumptionValues.every(
|
|
(candidate) => candidate !== null,
|
|
);
|
|
if (
|
|
!hasConsumption &&
|
|
consumptionValues.some((candidate) => candidate !== null)
|
|
) {
|
|
throw new InvalidApprovalValueError('consumption tuple is incomplete');
|
|
}
|
|
let consumedBy: Readonly<PolicySubject> | null = null;
|
|
if (hasConsumption) {
|
|
assertApprovalMutationId(value.consumptionId!);
|
|
assertApprovalMutationId(value.dispatchId!);
|
|
consumedBy = normalizePolicySubject(value.consumedBy!);
|
|
assertTimestamp('consumedAtMs', value.consumedAtMs!);
|
|
if (
|
|
!hasDecision ||
|
|
value.decision !== 'approved' ||
|
|
value.consumedAtMs! < value.decidedAtMs! ||
|
|
value.consumedAtMs! >= value.expiresAtMs
|
|
) {
|
|
throw new InvalidApprovalValueError('consumption tuple is invalid');
|
|
}
|
|
}
|
|
|
|
if (
|
|
(value.state === 'pending' && (hasDecision || hasConsumption)) ||
|
|
(value.state === 'approved' &&
|
|
(!hasDecision || value.decision !== 'approved' || hasConsumption)) ||
|
|
(value.state === 'rejected' &&
|
|
(!hasDecision || value.decision !== 'rejected' || hasConsumption)) ||
|
|
(value.state === 'consumed' && !hasConsumption)
|
|
) {
|
|
throw new InvalidApprovalValueError('state tuple is inconsistent');
|
|
}
|
|
if (value.state === 'pending' && value.version !== 1) {
|
|
throw new InvalidApprovalValueError('pending request version is invalid');
|
|
}
|
|
if (
|
|
(value.state === 'approved' || value.state === 'rejected') &&
|
|
value.version !== 2
|
|
) {
|
|
throw new InvalidApprovalValueError('decided request version is invalid');
|
|
}
|
|
if (value.state === 'consumed' && value.version !== 3) {
|
|
throw new InvalidApprovalValueError('consumed request version is invalid');
|
|
}
|
|
|
|
return Object.freeze({
|
|
...value,
|
|
action,
|
|
requestedBy,
|
|
decidedBy,
|
|
consumedBy,
|
|
});
|
|
}
|
|
|
|
export function normalizeApprovedActionDispatchRecord(
|
|
value: ApprovedActionDispatchRecord,
|
|
): Readonly<ApprovedActionDispatchRecord> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidApprovalValueError('dispatch must be an object');
|
|
}
|
|
assertExactKeys('dispatch', value, [
|
|
'id',
|
|
'approvalRequestId',
|
|
'approvalRequestVersion',
|
|
'projectId',
|
|
'state',
|
|
'action',
|
|
'requestedBy',
|
|
'consumedBy',
|
|
'createdAtMs',
|
|
]);
|
|
assertApprovalMutationId(value.id);
|
|
assertApprovalRequestId(value.approvalRequestId);
|
|
assertApprovalRequestVersion(value.approvalRequestVersion);
|
|
if (value.approvalRequestVersion !== 3) {
|
|
throw new InvalidApprovalValueError('dispatch approval version is invalid');
|
|
}
|
|
assertProjectPolicyProjectId(value.projectId);
|
|
if (!APPROVED_ACTION_DISPATCH_STATES.includes(value.state)) {
|
|
throw new InvalidApprovalValueError('dispatch state is invalid');
|
|
}
|
|
const action = normalizeApprovalActionBinding(value.action);
|
|
const requestedBy = normalizePolicySubject(value.requestedBy);
|
|
const consumedBy = normalizePolicySubject(value.consumedBy);
|
|
assertTimestamp('dispatch createdAtMs', value.createdAtMs);
|
|
return Object.freeze({
|
|
...value,
|
|
action,
|
|
requestedBy,
|
|
consumedBy,
|
|
});
|
|
}
|
|
|
|
export function approvalRequestEffectiveStatus(
|
|
request: Readonly<ApprovalRequestRecord>,
|
|
nowMs: number,
|
|
): ApprovalRequestEffectiveStatus {
|
|
const normalized = normalizeApprovalRequestRecord(request);
|
|
assertTimestamp('nowMs', nowMs);
|
|
if (
|
|
nowMs >= normalized.expiresAtMs &&
|
|
(normalized.state === 'pending' || normalized.state === 'approved')
|
|
) {
|
|
return 'expired';
|
|
}
|
|
return normalized.state;
|
|
}
|
|
|
|
export function sameApprovalAction(
|
|
left: Readonly<ApprovalActionBinding>,
|
|
right: Readonly<ApprovalActionBinding>,
|
|
): boolean {
|
|
const normalizedLeft = normalizeApprovalActionBinding(left);
|
|
const normalizedRight = normalizeApprovalActionBinding(right);
|
|
return (
|
|
normalizedLeft.permission === normalizedRight.permission &&
|
|
normalizedLeft.actionType === normalizedRight.actionType &&
|
|
normalizedLeft.actionRef === normalizedRight.actionRef &&
|
|
normalizedLeft.actionDigest === normalizedRight.actionDigest &&
|
|
normalizedLeft.previewDigest === normalizedRight.previewDigest
|
|
);
|
|
}
|
|
|
|
export function sameApprovalSubject(
|
|
left: Readonly<PolicySubject>,
|
|
right: Readonly<PolicySubject>,
|
|
): boolean {
|
|
return sameSubject(
|
|
normalizePolicySubject(left),
|
|
normalizePolicySubject(right),
|
|
);
|
|
}
|