484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
import type {
|
|
AssetReleaseManifestItem,
|
|
AssetReleaseManifestProjection,
|
|
AssetReleaseReader,
|
|
} from "@dada/asset-release-manifest";
|
|
|
|
import { auditRetentionMilliseconds, serializeAuditSummary } from "./audit-policy.js";
|
|
import type { RegistrationService } from "./registration.js";
|
|
|
|
export type PreviewBatchStatus = "active" | "closed";
|
|
export type PreviewGrantStatus = "active" | "revoked" | "expired";
|
|
|
|
export interface PreviewBatchView {
|
|
batchId: string;
|
|
createdAt: number;
|
|
createdBy: string;
|
|
name: string;
|
|
status: PreviewBatchStatus;
|
|
}
|
|
|
|
export interface PreviewGrantView {
|
|
batchId: string;
|
|
expiresAt: number;
|
|
grantId: string;
|
|
grantedAt: number;
|
|
grantedBy: string;
|
|
status: PreviewGrantStatus;
|
|
userId: string;
|
|
}
|
|
|
|
export class PreviewGrantError extends Error {
|
|
constructor(
|
|
public readonly reason:
|
|
| "admin_invalid"
|
|
| "batch_closed"
|
|
| "batch_not_found"
|
|
| "grant_not_found"
|
|
| "invalid_expiry"
|
|
| "invalid_request"
|
|
| "resource_not_found"
|
|
| "user_not_eligible",
|
|
) {
|
|
super(reason);
|
|
this.name = "PreviewGrantError";
|
|
}
|
|
}
|
|
|
|
interface PreviewGrantServiceOptions {
|
|
assetReleases: AssetReleaseReader;
|
|
clock?: () => number;
|
|
registration: RegistrationService;
|
|
}
|
|
|
|
interface PreviewManifestItemMapping {
|
|
releaseVersion: string;
|
|
resourceId: string;
|
|
userId: string;
|
|
}
|
|
|
|
function isUuid(value: string) {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
}
|
|
|
|
function assertText(value: string, name: string) {
|
|
const normalized = value.trim();
|
|
if (!normalized || normalized.length > 160) throw new PreviewGrantError("invalid_request");
|
|
if (name === "batchId" && !isUuid(normalized)) throw new PreviewGrantError("invalid_request");
|
|
return normalized;
|
|
}
|
|
|
|
function manifestHash(items: readonly AssetReleaseManifestItem[], releaseVersion: string) {
|
|
return createHash("sha256")
|
|
.update(JSON.stringify({
|
|
items,
|
|
release_version: releaseVersion,
|
|
schema_version: "AssetReleaseManifest/v1",
|
|
}))
|
|
.digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Owns the P0-A preview grant state. Preview URLs are deliberately ephemeral:
|
|
* the random item id is kept only in this process and every read rechecks the
|
|
* persisted grant, so revocation and expiry take effect without cache busting.
|
|
*/
|
|
export class AssetPreviewGrantService {
|
|
readonly database: RegistrationService["database"];
|
|
readonly options: Required<Pick<PreviewGrantServiceOptions, "clock">> & PreviewGrantServiceOptions;
|
|
private readonly itemMappings = new Map<string, PreviewManifestItemMapping>();
|
|
|
|
constructor(options: PreviewGrantServiceOptions) {
|
|
this.database = options.registration.database;
|
|
this.options = { ...options, clock: options.clock ?? Date.now };
|
|
this.migrate();
|
|
}
|
|
|
|
createBatch(input: { adminUserId: string; batchId?: string; name: string }): PreviewBatchView {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
const name = assertText(input.name, "name");
|
|
const batchId = input.batchId ? assertText(input.batchId, "batchId") : randomUUID();
|
|
const now = this.options.clock();
|
|
this.assertAdmin(adminUserId, now);
|
|
this.immediate(() => {
|
|
this.database.prepare(`
|
|
INSERT INTO test_batches (batch_id, name, status, created_by, created_at, closed_at)
|
|
VALUES (?, ?, 'active', ?, ?, NULL)
|
|
`).run(batchId, name, adminUserId, now);
|
|
this.audit({
|
|
actorRef: adminUserId,
|
|
afterSummary: { batch_id: batchId, status: "active" },
|
|
beforeSummary: null,
|
|
operationType: "preview_batch_create",
|
|
targetRef: batchId,
|
|
targetType: "preview_batch",
|
|
}, now);
|
|
});
|
|
return { batchId, createdAt: now, createdBy: adminUserId, name, status: "active" };
|
|
}
|
|
|
|
closeBatch(input: { adminUserId: string; batchId: string }): PreviewBatchView {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
const batchId = assertText(input.batchId, "batchId");
|
|
const now = this.options.clock();
|
|
this.assertAdmin(adminUserId, now);
|
|
return this.immediate(() => {
|
|
const batch = this.readBatch(batchId);
|
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
|
if (batch.status === "active") {
|
|
this.database.prepare("UPDATE test_batches SET status = 'closed', closed_at = ? WHERE batch_id = ?").run(now, batchId);
|
|
this.audit({
|
|
actorRef: adminUserId,
|
|
afterSummary: { batch_id: batchId, status: "closed" },
|
|
beforeSummary: { batch_id: batchId, status: batch.status },
|
|
operationType: "preview_batch_close",
|
|
targetRef: batchId,
|
|
targetType: "preview_batch",
|
|
}, now);
|
|
}
|
|
return { ...batch, status: "closed" as const };
|
|
});
|
|
}
|
|
|
|
addBatchItems(input: {
|
|
adminUserId: string;
|
|
batchId: string;
|
|
releaseVersion: string;
|
|
resourceIds: readonly string[];
|
|
}) {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
const batchId = assertText(input.batchId, "batchId");
|
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
|
const resourceIds = [...new Set(input.resourceIds.map((resourceId) => assertText(resourceId, "resourceId")))];
|
|
if (resourceIds.length === 0) throw new PreviewGrantError("invalid_request");
|
|
const now = this.options.clock();
|
|
this.assertAdmin(adminUserId, now);
|
|
for (const resourceId of resourceIds) {
|
|
if (!this.options.assetReleases.read("internal_preview_asset", releaseVersion, resourceId)) {
|
|
throw new PreviewGrantError("resource_not_found");
|
|
}
|
|
}
|
|
this.immediate(() => {
|
|
const batch = this.readBatch(batchId);
|
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
|
if (batch.status !== "active") throw new PreviewGrantError("batch_closed");
|
|
const insert = this.database.prepare(`
|
|
INSERT OR IGNORE INTO test_batch_items (test_batch_id, release_version, resource_id)
|
|
VALUES (?, ?, ?)
|
|
`);
|
|
for (const resourceId of resourceIds) insert.run(batchId, releaseVersion, resourceId);
|
|
this.audit({
|
|
actorRef: adminUserId,
|
|
afterSummary: { batch_id: batchId, item_count: resourceIds.length, release_version: releaseVersion },
|
|
beforeSummary: null,
|
|
operationType: "preview_batch_items_add",
|
|
targetRef: batchId,
|
|
targetType: "preview_batch",
|
|
}, now);
|
|
});
|
|
return { batchId, releaseVersion, resourceIds };
|
|
}
|
|
|
|
grant(input: {
|
|
adminUserId: string;
|
|
batchId: string;
|
|
expiresAt: number;
|
|
userId: string;
|
|
}): PreviewGrantView {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
const batchId = assertText(input.batchId, "batchId");
|
|
const userId = assertText(input.userId, "userId");
|
|
if (!isUuid(userId)) throw new PreviewGrantError("invalid_request");
|
|
const now = this.options.clock();
|
|
if (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= now) throw new PreviewGrantError("invalid_expiry");
|
|
this.assertAdmin(adminUserId, now);
|
|
return this.immediate(() => {
|
|
const batch = this.readBatch(batchId);
|
|
if (!batch) throw new PreviewGrantError("batch_not_found");
|
|
if (batch.status !== "active") throw new PreviewGrantError("batch_closed");
|
|
const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined;
|
|
if (!user || user.role !== "user" || user.status !== "active") throw new PreviewGrantError("user_not_eligible");
|
|
const grantId = randomUUID();
|
|
this.database.prepare(`
|
|
INSERT INTO asset_preview_grants (
|
|
grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
|
) VALUES (?, ?, ?, ?, ?, ?, 'active')
|
|
`).run(grantId, userId, batchId, adminUserId, now, input.expiresAt);
|
|
this.audit({
|
|
actorRef: adminUserId,
|
|
afterSummary: { batch_id: batchId, expires_at: input.expiresAt, grant_id: grantId, status: "active", user_id: userId },
|
|
beforeSummary: null,
|
|
operationType: "preview_grant_create",
|
|
targetRef: grantId,
|
|
targetType: "preview_grant",
|
|
}, now);
|
|
return {
|
|
batchId,
|
|
expiresAt: input.expiresAt,
|
|
grantId,
|
|
grantedAt: now,
|
|
grantedBy: adminUserId,
|
|
status: "active" as const,
|
|
userId,
|
|
};
|
|
});
|
|
}
|
|
|
|
revoke(input: { adminUserId: string; grantId: string }): PreviewGrantView {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
const grantId = assertText(input.grantId, "grantId");
|
|
const now = this.options.clock();
|
|
this.assertAdmin(adminUserId, now);
|
|
return this.immediate(() => {
|
|
this.expireDue(now);
|
|
const grant = this.readGrant(grantId);
|
|
if (!grant) throw new PreviewGrantError("grant_not_found");
|
|
if (grant.status === "active") {
|
|
this.database.prepare("UPDATE asset_preview_grants SET status = 'revoked' WHERE grant_id = ? AND status = 'active'").run(grantId);
|
|
this.audit({
|
|
actorRef: adminUserId,
|
|
afterSummary: { grant_id: grantId, status: "revoked" },
|
|
beforeSummary: { grant_id: grantId, status: grant.status },
|
|
operationType: "preview_grant_revoke",
|
|
targetRef: grantId,
|
|
targetType: "preview_grant",
|
|
}, now);
|
|
}
|
|
return { ...grant, status: "revoked" as const };
|
|
});
|
|
}
|
|
|
|
listBatches(input: { adminUserId: string }): PreviewBatchView[] {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
this.assertAdmin(adminUserId, this.options.clock());
|
|
return (this.database.prepare(`
|
|
SELECT batch_id, name, status, created_by, created_at
|
|
FROM test_batches ORDER BY created_at DESC, batch_id DESC
|
|
`).all() as Array<{ batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus }>).map((row) => ({
|
|
batchId: row.batch_id,
|
|
createdAt: row.created_at,
|
|
createdBy: row.created_by,
|
|
name: row.name,
|
|
status: row.status,
|
|
}));
|
|
}
|
|
|
|
listGrants(input: { adminUserId: string; batchId?: string; userId?: string }): PreviewGrantView[] {
|
|
const adminUserId = assertText(input.adminUserId, "adminUserId");
|
|
this.assertAdmin(adminUserId, this.options.clock());
|
|
const batchId = input.batchId ? assertText(input.batchId, "batchId") : undefined;
|
|
const userId = input.userId ? assertText(input.userId, "userId") : undefined;
|
|
const now = this.options.clock();
|
|
return this.immediate(() => {
|
|
this.expireDue(now);
|
|
const rows = this.database.prepare(`
|
|
SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
|
FROM asset_preview_grants
|
|
WHERE (? IS NULL OR test_batch_id = ?) AND (? IS NULL OR user_id = ?)
|
|
ORDER BY granted_at DESC, grant_id DESC
|
|
`).all(batchId ?? null, batchId ?? null, userId ?? null, userId ?? null) as Array<{
|
|
expires_at: number; grant_id: string; granted_at: number; granted_by: string;
|
|
status: PreviewGrantStatus; test_batch_id: string; user_id: string;
|
|
}>;
|
|
return rows.map((row) => ({
|
|
batchId: row.test_batch_id,
|
|
expiresAt: row.expires_at,
|
|
grantId: row.grant_id,
|
|
grantedAt: row.granted_at,
|
|
grantedBy: row.granted_by,
|
|
status: row.status,
|
|
userId: row.user_id,
|
|
}));
|
|
});
|
|
}
|
|
|
|
projectManifest(input: { releaseVersion: string; userId: string }): AssetReleaseManifestProjection | undefined {
|
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
|
const userId = assertText(input.userId, "userId");
|
|
const base = this.options.assetReleases.project("internal_preview_asset", releaseVersion);
|
|
if (!base) return undefined;
|
|
const authorized = base.items.filter((item) => this.authorizeAsset({ releaseVersion, resourceId: item.resource_id, userId }));
|
|
if (authorized.length === 0) return undefined;
|
|
const items = authorized.map((item) => {
|
|
const manifestItemId = randomUUID();
|
|
const mapped: AssetReleaseManifestItem = {
|
|
...item,
|
|
resource_id: manifestItemId,
|
|
url: `/api/v1/assets/preview/${releaseVersion}/${manifestItemId}`,
|
|
};
|
|
this.itemMappings.set(manifestItemId, {
|
|
releaseVersion,
|
|
resourceId: item.resource_id,
|
|
userId,
|
|
});
|
|
return mapped;
|
|
});
|
|
return Object.freeze({
|
|
items: Object.freeze(items.map((item) => Object.freeze(item))),
|
|
manifest_sha256: manifestHash(items, releaseVersion),
|
|
release_version: releaseVersion,
|
|
schema_version: "AssetReleaseManifest/v1" as const,
|
|
});
|
|
}
|
|
|
|
authorizeAsset(input: { releaseVersion: string; resourceId: string; userId: string }) {
|
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
|
const resourceId = assertText(input.resourceId, "resourceId");
|
|
const userId = assertText(input.userId, "userId");
|
|
const now = this.options.clock();
|
|
return this.immediate(() => {
|
|
this.expireDue(now);
|
|
const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?").get(userId) as { role: string; status: string } | undefined;
|
|
if (!user || user.role !== "user" || user.status !== "active") return false;
|
|
const row = this.database.prepare(`
|
|
SELECT 1 AS authorized
|
|
FROM asset_preview_grants g
|
|
JOIN test_batch_items i ON i.test_batch_id = g.test_batch_id
|
|
WHERE g.user_id = ? AND g.status = 'active' AND g.expires_at > ?
|
|
AND i.release_version = ? AND i.resource_id = ?
|
|
LIMIT 1
|
|
`).get(userId, now, releaseVersion, resourceId) as { authorized: 1 } | undefined;
|
|
return Boolean(row);
|
|
});
|
|
}
|
|
|
|
readManifestItem(input: { manifestItemId: string; releaseVersion: string; userId: string }) {
|
|
const manifestItemId = assertText(input.manifestItemId, "manifestItemId");
|
|
const releaseVersion = assertText(input.releaseVersion, "releaseVersion");
|
|
const userId = assertText(input.userId, "userId");
|
|
const mapping = this.itemMappings.get(manifestItemId);
|
|
if (!mapping || mapping.releaseVersion !== releaseVersion || mapping.userId !== userId) return undefined;
|
|
if (!this.authorizeAsset({ releaseVersion, resourceId: mapping.resourceId, userId })) {
|
|
this.itemMappings.delete(manifestItemId);
|
|
return undefined;
|
|
}
|
|
const resource = this.options.assetReleases.read("internal_preview_asset", releaseVersion, mapping.resourceId);
|
|
return resource ? { ...resource, resourceId: manifestItemId } : undefined;
|
|
}
|
|
|
|
private migrate() {
|
|
this.database.exec(`
|
|
CREATE TABLE IF NOT EXISTS test_batches (
|
|
batch_id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 160),
|
|
status TEXT NOT NULL CHECK (status IN ('active', 'closed')),
|
|
created_by TEXT NOT NULL REFERENCES users(user_id),
|
|
created_at INTEGER NOT NULL,
|
|
closed_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS test_batch_items (
|
|
test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id),
|
|
release_version TEXT NOT NULL,
|
|
resource_id TEXT NOT NULL,
|
|
PRIMARY KEY (test_batch_id, release_version, resource_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS asset_preview_grants (
|
|
grant_id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
|
test_batch_id TEXT NOT NULL REFERENCES test_batches(batch_id),
|
|
granted_by TEXT NOT NULL REFERENCES users(user_id),
|
|
granted_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL CHECK (expires_at > granted_at),
|
|
status TEXT NOT NULL CHECK (status IN ('active', 'revoked', 'expired'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS asset_preview_grants_user_status
|
|
ON asset_preview_grants(user_id, status, expires_at);
|
|
`);
|
|
}
|
|
|
|
private immediate<T>(action: () => T): T {
|
|
this.database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const value = action();
|
|
this.database.exec("COMMIT");
|
|
return value;
|
|
} catch (error) {
|
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private assertAdmin(adminUserId: string, now: number) {
|
|
const admin = this.database.prepare(`
|
|
SELECT 1 AS allowed FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
|
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
|
`).get(adminUserId) as { allowed: 1 } | undefined;
|
|
if (!admin) throw new PreviewGrantError("admin_invalid");
|
|
void now;
|
|
}
|
|
|
|
private readBatch(batchId: string): PreviewBatchView | undefined {
|
|
const row = this.database.prepare(`
|
|
SELECT batch_id, name, status, created_by, created_at
|
|
FROM test_batches WHERE batch_id = ?
|
|
`).get(batchId) as { batch_id: string; created_at: number; created_by: string; name: string; status: PreviewBatchStatus } | undefined;
|
|
return row ? {
|
|
batchId: row.batch_id,
|
|
createdAt: row.created_at,
|
|
createdBy: row.created_by,
|
|
name: row.name,
|
|
status: row.status,
|
|
} : undefined;
|
|
}
|
|
|
|
private readGrant(grantId: string): PreviewGrantView | undefined {
|
|
const row = this.database.prepare(`
|
|
SELECT grant_id, user_id, test_batch_id, granted_by, granted_at, expires_at, status
|
|
FROM asset_preview_grants WHERE grant_id = ?
|
|
`).get(grantId) as {
|
|
expires_at: number; grant_id: string; granted_at: number; granted_by: string;
|
|
status: PreviewGrantStatus; test_batch_id: string; user_id: string;
|
|
} | undefined;
|
|
return row ? {
|
|
batchId: row.test_batch_id,
|
|
expiresAt: row.expires_at,
|
|
grantId: row.grant_id,
|
|
grantedAt: row.granted_at,
|
|
grantedBy: row.granted_by,
|
|
status: row.status,
|
|
userId: row.user_id,
|
|
} : undefined;
|
|
}
|
|
|
|
private expireDue(now: number) {
|
|
const rows = this.database.prepare(`
|
|
SELECT grant_id, user_id, test_batch_id FROM asset_preview_grants
|
|
WHERE status = 'active' AND expires_at <= ?
|
|
`).all(now) as Array<{ grant_id: string; test_batch_id: string; user_id: string }>;
|
|
if (rows.length === 0) return;
|
|
this.database.prepare("UPDATE asset_preview_grants SET status = 'expired' WHERE status = 'active' AND expires_at <= ?").run(now);
|
|
for (const row of rows) {
|
|
this.audit({
|
|
actorRef: "preview_grant_expiry",
|
|
afterSummary: { grant_id: row.grant_id, status: "expired" },
|
|
beforeSummary: { grant_id: row.grant_id, status: "active" },
|
|
operationType: "preview_grant_expire",
|
|
targetRef: row.grant_id,
|
|
targetType: "preview_grant",
|
|
}, now, "system");
|
|
}
|
|
}
|
|
|
|
private audit(input: {
|
|
actorRef: string;
|
|
afterSummary: Record<string, unknown> | null;
|
|
beforeSummary: Record<string, unknown> | null;
|
|
operationType: string;
|
|
targetRef: string;
|
|
targetType: string;
|
|
}, now: number, actorType: "super_admin" | "system" = "super_admin") {
|
|
this.database.prepare(`
|
|
INSERT INTO admin_operation_logs (
|
|
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
|
result, before_summary, after_summary, occurred_at, expires_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?)
|
|
`).run(
|
|
randomUUID(), actorType, input.actorRef, input.operationType, input.targetType, input.targetRef,
|
|
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary),
|
|
now, now + auditRetentionMilliseconds,
|
|
);
|
|
}
|
|
}
|