mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
assertAuthenticatedPrincipalActive,
|
||||
normalizeAuthenticatedPrincipal,
|
||||
type AuthenticatedPrincipal,
|
||||
} from '../domain/authenticatedPrincipal';
|
||||
import { assertProjectPolicyProjectId } from '../domain/projectPolicy';
|
||||
import {
|
||||
OWNER_BOOTSTRAP_CHALLENGE_ID_BYTES,
|
||||
OWNER_BOOTSTRAP_DEFAULT_TTL_MS,
|
||||
OWNER_BOOTSTRAP_SYSTEM_SUBJECT,
|
||||
OWNER_BOOTSTRAP_TOKEN_BYTES,
|
||||
ProjectOwnerBootstrapUnauthorizedError,
|
||||
assertProjectOwnerBootstrapChallengeId,
|
||||
assertProjectOwnerBootstrapToken,
|
||||
assertProjectOwnerBootstrapTtl,
|
||||
digestProjectOwnerBootstrapToken,
|
||||
} from '../domain/projectOwnerBootstrap';
|
||||
import type { ProjectOwnerBootstrapRepository } from '../ports/projectOwnerBootstrapRepository';
|
||||
|
||||
export interface IssueProjectOwnerBootstrapRequest {
|
||||
projectId: string;
|
||||
issuer: AuthenticatedPrincipal;
|
||||
nowMs: number;
|
||||
ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface IssuedProjectOwnerBootstrapChallenge {
|
||||
projectId: string;
|
||||
challengeId: string;
|
||||
token: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ClaimProjectOwnerBootstrapRequest {
|
||||
projectId: string;
|
||||
challengeId: string;
|
||||
token: string;
|
||||
principal: AuthenticatedPrincipal;
|
||||
nowMs: number;
|
||||
}
|
||||
|
||||
export interface ProjectOwnerBootstrapRandomSource {
|
||||
bytes(size: number): Uint8Array;
|
||||
}
|
||||
|
||||
const CRYPTO_RANDOM_SOURCE: ProjectOwnerBootstrapRandomSource = {
|
||||
bytes: randomBytes,
|
||||
};
|
||||
|
||||
function encodeRandom(
|
||||
source: ProjectOwnerBootstrapRandomSource,
|
||||
size: number,
|
||||
): string {
|
||||
const bytes = source.bytes(size);
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== size) {
|
||||
throw new TypeError('Project owner bootstrap random source is invalid');
|
||||
}
|
||||
try {
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactRequestKeys(
|
||||
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 TypeError('Project owner bootstrap request shape is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectOwnerBootstrapService {
|
||||
constructor(
|
||||
private readonly repository: ProjectOwnerBootstrapRepository,
|
||||
private readonly randomSource: ProjectOwnerBootstrapRandomSource = CRYPTO_RANDOM_SOURCE,
|
||||
) {}
|
||||
|
||||
async issue(
|
||||
request: IssueProjectOwnerBootstrapRequest,
|
||||
): Promise<Readonly<IssuedProjectOwnerBootstrapChallenge>> {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError(
|
||||
'Project owner bootstrap issue request must be an object',
|
||||
);
|
||||
}
|
||||
assertExactRequestKeys(
|
||||
request,
|
||||
request.ttlMs === undefined
|
||||
? ['projectId', 'issuer', 'nowMs']
|
||||
: ['projectId', 'issuer', 'nowMs', 'ttlMs'],
|
||||
);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
const issuer = normalizeAuthenticatedPrincipal(request.issuer);
|
||||
assertAuthenticatedPrincipalActive(issuer, request.nowMs);
|
||||
if (
|
||||
issuer.subject.type !== OWNER_BOOTSTRAP_SYSTEM_SUBJECT.type ||
|
||||
issuer.subject.id !== OWNER_BOOTSTRAP_SYSTEM_SUBJECT.id ||
|
||||
issuer.assurance !== 'local_console'
|
||||
) {
|
||||
throw new ProjectOwnerBootstrapUnauthorizedError();
|
||||
}
|
||||
const ttlMs = request.ttlMs ?? OWNER_BOOTSTRAP_DEFAULT_TTL_MS;
|
||||
assertProjectOwnerBootstrapTtl(ttlMs);
|
||||
const expiresAtMs = request.nowMs + ttlMs;
|
||||
if (!Number.isSafeInteger(expiresAtMs)) {
|
||||
throw new TypeError('Project owner bootstrap expiry is invalid');
|
||||
}
|
||||
const challengeId = encodeRandom(
|
||||
this.randomSource,
|
||||
OWNER_BOOTSTRAP_CHALLENGE_ID_BYTES,
|
||||
);
|
||||
const token = encodeRandom(this.randomSource, OWNER_BOOTSTRAP_TOKEN_BYTES);
|
||||
assertProjectOwnerBootstrapChallengeId(challengeId);
|
||||
assertProjectOwnerBootstrapToken(token);
|
||||
const tokenDigest = digestProjectOwnerBootstrapToken(
|
||||
request.projectId,
|
||||
challengeId,
|
||||
token,
|
||||
);
|
||||
await this.repository.issue({
|
||||
projectId: request.projectId,
|
||||
challengeId,
|
||||
tokenDigest,
|
||||
issuedAtMs: request.nowMs,
|
||||
expiresAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
projectId: request.projectId,
|
||||
challengeId,
|
||||
token,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
async claim(request: ClaimProjectOwnerBootstrapRequest) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
throw new TypeError(
|
||||
'Project owner bootstrap claim request must be an object',
|
||||
);
|
||||
}
|
||||
assertExactRequestKeys(request, [
|
||||
'projectId',
|
||||
'challengeId',
|
||||
'token',
|
||||
'principal',
|
||||
'nowMs',
|
||||
]);
|
||||
assertProjectPolicyProjectId(request.projectId);
|
||||
assertProjectOwnerBootstrapChallengeId(request.challengeId);
|
||||
assertProjectOwnerBootstrapToken(request.token);
|
||||
const principal = normalizeAuthenticatedPrincipal(request.principal);
|
||||
assertAuthenticatedPrincipalActive(principal, request.nowMs);
|
||||
if (principal.subject.type !== 'user') {
|
||||
throw new ProjectOwnerBootstrapUnauthorizedError();
|
||||
}
|
||||
return this.repository.claim({
|
||||
projectId: request.projectId,
|
||||
challengeId: request.challengeId,
|
||||
tokenDigest: digestProjectOwnerBootstrapToken(
|
||||
request.projectId,
|
||||
request.challengeId,
|
||||
request.token,
|
||||
),
|
||||
subject: principal.subject,
|
||||
claimedAtMs: request.nowMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user