feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -3,6 +3,11 @@ import { readFileSync } from "node:fs";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AdminAuthenticatedUserSchema,
|
||||||
|
AdminLoginCompleteRequestSchema,
|
||||||
|
AdminLoginCompleteResponseSchema,
|
||||||
|
AdminLoginSendRequestSchema,
|
||||||
|
AdminSessionResponseSchema,
|
||||||
BootstrapResponseSchema,
|
BootstrapResponseSchema,
|
||||||
CorrelationIdSchema,
|
CorrelationIdSchema,
|
||||||
AuthenticatedUserSchema,
|
AuthenticatedUserSchema,
|
||||||
@@ -30,6 +35,8 @@ import {
|
|||||||
createErrorEnvelope,
|
createErrorEnvelope,
|
||||||
isCorrelationId,
|
isCorrelationId,
|
||||||
type BootstrapResponse,
|
type BootstrapResponse,
|
||||||
|
type AdminLoginCompleteRequest,
|
||||||
|
type AdminLoginSendRequest,
|
||||||
type LoginCompleteRequest,
|
type LoginCompleteRequest,
|
||||||
type LoginSendRequest,
|
type LoginSendRequest,
|
||||||
type RegistrationCompleteRequest,
|
type RegistrationCompleteRequest,
|
||||||
@@ -101,6 +108,8 @@ const contentSecurityPolicy = [
|
|||||||
].join("; ");
|
].join("; ");
|
||||||
const authFlowCookieName = "dada_auth_flow";
|
const authFlowCookieName = "dada_auth_flow";
|
||||||
const userSessionCookieName = "dada_session";
|
const userSessionCookieName = "dada_session";
|
||||||
|
const adminAuthFlowCookieName = "dada_admin_auth_flow";
|
||||||
|
const adminSessionCookieName = "dada_admin_session";
|
||||||
|
|
||||||
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||||
const header = headers["x-correlation-id"];
|
const header = headers["x-correlation-id"];
|
||||||
@@ -211,6 +220,11 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
ErrorDetailsSchema,
|
ErrorDetailsSchema,
|
||||||
ErrorEnvelopeSchema,
|
ErrorEnvelopeSchema,
|
||||||
AuthenticatedUserSchema,
|
AuthenticatedUserSchema,
|
||||||
|
AdminAuthenticatedUserSchema,
|
||||||
|
AdminLoginSendRequestSchema,
|
||||||
|
AdminLoginCompleteRequestSchema,
|
||||||
|
AdminLoginCompleteResponseSchema,
|
||||||
|
AdminSessionResponseSchema,
|
||||||
CreditSummarySchema,
|
CreditSummarySchema,
|
||||||
RegistrationSendRequestSchema,
|
RegistrationSendRequestSchema,
|
||||||
RegistrationSendResponseSchema,
|
RegistrationSendResponseSchema,
|
||||||
@@ -302,6 +316,140 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin-auth/login/send",
|
||||||
|
{
|
||||||
|
attachValidation: true,
|
||||||
|
schema: {
|
||||||
|
body: Type.Ref(AdminLoginSendRequestSchema),
|
||||||
|
operationId: "sendAdminLoginCode",
|
||||||
|
response: {
|
||||||
|
200: Type.Ref(RegistrationSendResponseSchema),
|
||||||
|
400: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
409: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
429: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
503: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
},
|
||||||
|
tags: ["Admin Authentication"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (request.validationError) return registrationValidationFailure(reply, request.id);
|
||||||
|
if (!options.registration) {
|
||||||
|
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||||
|
}
|
||||||
|
const existingFlow = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName);
|
||||||
|
const clientKey = existingFlow ?? randomBytes(32).toString("base64url");
|
||||||
|
try {
|
||||||
|
const body = request.body as AdminLoginSendRequest;
|
||||||
|
const result = await options.registration.sendAdminLoginCode({ clientKey, email: body.email });
|
||||||
|
if (!existingFlow) {
|
||||||
|
reply.header(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${adminAuthFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
|
||||||
|
registration_id: result.registrationId,
|
||||||
|
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return registrationFailure(reply, request.id, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/v1/admin-auth/login/complete",
|
||||||
|
{
|
||||||
|
attachValidation: true,
|
||||||
|
schema: {
|
||||||
|
body: Type.Ref(AdminLoginCompleteRequestSchema),
|
||||||
|
headers: Type.Ref(RegistrationCompleteHeadersSchema),
|
||||||
|
operationId: "completeAdminLogin",
|
||||||
|
response: {
|
||||||
|
200: Type.Ref(AdminLoginCompleteResponseSchema),
|
||||||
|
400: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
409: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
429: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
503: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
},
|
||||||
|
tags: ["Admin Authentication"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (request.validationError) return registrationValidationFailure(reply, request.id);
|
||||||
|
if (!options.registration) {
|
||||||
|
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||||
|
}
|
||||||
|
const clientKey = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName);
|
||||||
|
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
||||||
|
if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id);
|
||||||
|
try {
|
||||||
|
const body = request.body as AdminLoginCompleteRequest;
|
||||||
|
const result = options.registration.completeAdminLogin({
|
||||||
|
clientKey,
|
||||||
|
code: body.verification_code,
|
||||||
|
idempotencyKey,
|
||||||
|
registrationId: body.registration_id,
|
||||||
|
});
|
||||||
|
reply.header(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${adminSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
admin: {
|
||||||
|
role: result.admin.role,
|
||||||
|
status: result.admin.status,
|
||||||
|
user_id: result.admin.userId,
|
||||||
|
},
|
||||||
|
audience: result.audience,
|
||||||
|
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return registrationFailure(reply, request.id, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/api/v1/admin-auth/session",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
operationId: "getAdminSession",
|
||||||
|
response: {
|
||||||
|
200: Type.Ref(AdminSessionResponseSchema),
|
||||||
|
401: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
503: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
},
|
||||||
|
tags: ["Admin Authentication"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!options.registration) {
|
||||||
|
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||||
|
}
|
||||||
|
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||||
|
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
acknowledged_private_content_notice_version: null,
|
||||||
|
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
|
||||||
|
audience: "admin" as const,
|
||||||
|
authenticated: true as const,
|
||||||
|
csrf_token: options.registration.issueAdminCsrfToken(token!),
|
||||||
|
current_private_content_notice_version: null,
|
||||||
|
expires_at: new Date(session.expires_at).toISOString(),
|
||||||
|
notice_acknowledged: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/api/v1/auth/login/send",
|
"/api/v1/auth/login/send",
|
||||||
{
|
{
|
||||||
|
|||||||
+38
-4
@@ -1,19 +1,52 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { registrationNotice } from "@dada/shared-contracts";
|
||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
|
import { RegistrationService } from "./registration.js";
|
||||||
|
import { MockResendAdapter } from "./resend-adapter.js";
|
||||||
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
|
let registration: RegistrationService | undefined;
|
||||||
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
|
try {
|
||||||
|
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||||
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
|
.digest();
|
||||||
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
|
registration = new RegistrationService({
|
||||||
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
|
challengePepper: derivePepper("challenge-pepper"),
|
||||||
|
currentPrivacyNoticeVersion: registrationNotice.version,
|
||||||
|
databasePath: join(dataRoot, "db", "dada.sqlite3"),
|
||||||
|
invitePepper: derivePepper("invite-pepper"),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: derivePepper("session-pepper"),
|
||||||
|
});
|
||||||
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
|
} catch (error) {
|
||||||
|
registration?.close();
|
||||||
|
registration = undefined;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clients.adminAllowlistPepper.fill(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||||
const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {});
|
const app = await createApp({
|
||||||
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
|
...(registration ? { registration } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
await app.listen({
|
await app.listen({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -27,10 +60,11 @@ if (controlPipeIndex >= 0) {
|
|||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
registration?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const dataRoot = readConfiguredLocalDataRoot();
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") });
|
||||||
const logger = new StructuredJsonlLogger({
|
const logger = new StructuredJsonlLogger({
|
||||||
component: "api",
|
component: "api",
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ function now() {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auditExpiry(occurredAt: string) {
|
||||||
|
return new Date(Date.parse(occurredAt) + 180 * 24 * 60 * 60 * 1_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
function validatePositiveBytes(value: number, name: string) {
|
function validatePositiveBytes(value: number, name: string) {
|
||||||
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
|
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`);
|
||||||
}
|
}
|
||||||
@@ -222,12 +226,23 @@ export class ManagedStorage {
|
|||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
log_id TEXT PRIMARY KEY,
|
log_id TEXT PRIMARY KEY,
|
||||||
operation TEXT NOT NULL,
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
outcome TEXT NOT NULL,
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
target_ref TEXT NOT NULL,
|
target_ref TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
`);
|
`);
|
||||||
|
this.migrateLegacyAdminOperationLogs();
|
||||||
const initial = classifyCapacity(0, 0);
|
const initial = classifyCapacity(0, 0);
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT OR IGNORE INTO local_backend_storage_state
|
INSERT OR IGNORE INTO local_backend_storage_state
|
||||||
@@ -236,6 +251,54 @@ export class ManagedStorage {
|
|||||||
`).run(HARD_LIMIT_BYTES, initial.capacity_notice_level, initial.storage_status, now());
|
`).run(HARD_LIMIT_BYTES, initial.capacity_notice_level, initial.storage_status, now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateLegacyAdminOperationLogs() {
|
||||||
|
const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
|
||||||
|
if (columns.some((column) => column.name === "actor_type")) return;
|
||||||
|
const entries = this.database.prepare(`
|
||||||
|
SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs
|
||||||
|
`).all() as Array<{ created_at: string; log_id: string; operation: string; outcome: string; target_ref: string }>;
|
||||||
|
this.database.exec(`
|
||||||
|
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy;
|
||||||
|
CREATE TABLE admin_operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const insert = 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 (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const entry of entries) {
|
||||||
|
insert.run(
|
||||||
|
entry.log_id,
|
||||||
|
entry.operation,
|
||||||
|
entry.target_ref,
|
||||||
|
entry.outcome.startsWith("denied") ? "failed" : "succeeded",
|
||||||
|
JSON.stringify({ legacy_outcome: entry.outcome }),
|
||||||
|
entry.created_at,
|
||||||
|
auditExpiry(entry.created_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.database.exec(`
|
||||||
|
DROP TABLE admin_operation_logs_legacy;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.database.close();
|
this.database.close();
|
||||||
}
|
}
|
||||||
@@ -561,9 +624,14 @@ export class ManagedStorage {
|
|||||||
return row.count > 0;
|
return row.count > 0;
|
||||||
});
|
});
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
const occurredAt = now();
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)")
|
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId);
|
||||||
.run(randomUUID(), requestId, now());
|
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 (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), requestId, JSON.stringify({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -574,9 +642,14 @@ export class ManagedStorage {
|
|||||||
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
|
VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
|
||||||
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
|
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now());
|
||||||
}
|
}
|
||||||
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId);
|
const occurredAt = now();
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)")
|
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId);
|
||||||
.run(randomUUID(), requestId, now());
|
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 (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), requestId, JSON.stringify({ status: "queued" }), occurredAt, auditExpiry(occurredAt));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT");
|
||||||
@@ -605,8 +678,13 @@ export class ManagedStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
|
this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id);
|
||||||
this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)")
|
const occurredAt = now();
|
||||||
.run(randomUUID(), row.cleanup_id, now());
|
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 (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?)
|
||||||
|
`).run(randomUUID(), row.cleanup_id, JSON.stringify({ status: "completed" }), occurredAt, auditExpiry(occurredAt));
|
||||||
this.recordPhysicalMeasurement();
|
this.recordPhysicalMeasurement();
|
||||||
});
|
});
|
||||||
finish();
|
finish();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RegistrationErrorReason =
|
|||||||
| "login_registration_required"
|
| "login_registration_required"
|
||||||
| "account_suspended"
|
| "account_suspended"
|
||||||
| "login_admin_required"
|
| "login_admin_required"
|
||||||
|
| "admin_not_allowed"
|
||||||
| "resend_too_soon"
|
| "resend_too_soon"
|
||||||
| "too_many_attempts"
|
| "too_many_attempts"
|
||||||
| "csrf_invalid"
|
| "csrf_invalid"
|
||||||
@@ -68,6 +69,7 @@ export function registrationFieldError(reason: RegistrationErrorReason) {
|
|||||||
login_registration_required: { field: "email", message_key: "auth.login.registration_required" },
|
login_registration_required: { field: "email", message_key: "auth.login.registration_required" },
|
||||||
account_suspended: { field: "email", message_key: "auth.account.suspended" },
|
account_suspended: { field: "email", message_key: "auth.account.suspended" },
|
||||||
login_admin_required: { field: "email", message_key: "auth.login.admin_required" },
|
login_admin_required: { field: "email", message_key: "auth.login.admin_required" },
|
||||||
|
admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" },
|
||||||
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
|
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
|
||||||
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
|
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
|
||||||
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
|
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface RegistrationTransactionEvent {
|
|||||||
| "registration_send_compensation"
|
| "registration_send_compensation"
|
||||||
| "login_send"
|
| "login_send"
|
||||||
| "login_complete"
|
| "login_complete"
|
||||||
|
| "admin_login_send"
|
||||||
|
| "admin_login_complete"
|
||||||
|
| "secure_config_apply"
|
||||||
| "session_issue"
|
| "session_issue"
|
||||||
| "session_revoke"
|
| "session_revoke"
|
||||||
| "csrf_issue";
|
| "csrf_issue";
|
||||||
@@ -38,6 +41,7 @@ export interface RegistrationTransactionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RegistrationServiceOptions {
|
interface RegistrationServiceOptions {
|
||||||
|
adminAllowlistPepper?: Buffer;
|
||||||
challengePepper: Buffer;
|
challengePepper: Buffer;
|
||||||
clock?: () => number;
|
clock?: () => number;
|
||||||
codeGenerator?: () => string;
|
codeGenerator?: () => string;
|
||||||
@@ -151,6 +155,24 @@ export type LoginCompleteResult = Omit<RegistrationCompleteResult, "status"> & {
|
|||||||
status: "authenticated";
|
status: "authenticated";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AdminLoginCompleteResult {
|
||||||
|
admin: {
|
||||||
|
role: "super_admin";
|
||||||
|
status: "active";
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
audience: "admin";
|
||||||
|
sessionExpiresAt: number;
|
||||||
|
sessionToken: string;
|
||||||
|
status: "authenticated";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecureConfigCandidate {
|
||||||
|
adminAllowlistHashes: string[];
|
||||||
|
adminRecoveryHashes: string[];
|
||||||
|
secureConfigRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ImmediateResult<T> {
|
interface ImmediateResult<T> {
|
||||||
outcome: RegistrationTransactionEvent["outcome"];
|
outcome: RegistrationTransactionEvent["outcome"];
|
||||||
value: T;
|
value: T;
|
||||||
@@ -195,11 +217,13 @@ function constantTimeTextEqual(left: string, right: string) {
|
|||||||
export class RegistrationService {
|
export class RegistrationService {
|
||||||
readonly database: BetterSqlite3.Database;
|
readonly database: BetterSqlite3.Database;
|
||||||
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
||||||
|
private adminAllowlistHashes = new Set<string>();
|
||||||
|
|
||||||
constructor(options: RegistrationServiceOptions) {
|
constructor(options: RegistrationServiceOptions) {
|
||||||
assertSecret("invitePepper", options.invitePepper);
|
assertSecret("invitePepper", options.invitePepper);
|
||||||
assertSecret("challengePepper", options.challengePepper);
|
assertSecret("challengePepper", options.challengePepper);
|
||||||
assertSecret("sessionPepper", options.sessionPepper);
|
assertSecret("sessionPepper", options.sessionPepper);
|
||||||
|
if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper);
|
||||||
this.options = {
|
this.options = {
|
||||||
...options,
|
...options,
|
||||||
clock: options.clock ?? Date.now,
|
clock: options.clock ?? Date.now,
|
||||||
@@ -571,6 +595,317 @@ export class RegistrationService {
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applySecureConfig(candidate: SecureConfigCandidate) {
|
||||||
|
const now = this.options.clock();
|
||||||
|
const fail = (reason: string): never => {
|
||||||
|
this.recordConfigApplyFailure(reason, now);
|
||||||
|
throw new Error(reason);
|
||||||
|
};
|
||||||
|
if (!this.options.adminAllowlistPepper) return fail("admin_pepper_not_configured");
|
||||||
|
if (!Number.isSafeInteger(candidate.secureConfigRevision) || candidate.secureConfigRevision < 0) {
|
||||||
|
return fail("secure_config_revision_invalid");
|
||||||
|
}
|
||||||
|
const normalizeHashes = (values: string[], name: string) => {
|
||||||
|
if (!Array.isArray(values)) return fail(`${name}_invalid`);
|
||||||
|
const normalized = [...new Set(values.map((value) => value.toUpperCase()))];
|
||||||
|
if (normalized.some((value) => !/^[A-F0-9]{64}$/.test(value))) return fail("hmac_invalid");
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
const allowlist = normalizeHashes(candidate.adminAllowlistHashes, "admin_allowlist");
|
||||||
|
const recoveries = normalizeHashes(candidate.adminRecoveryHashes, "admin_recovery");
|
||||||
|
const allowlistSet = new Set(allowlist);
|
||||||
|
if (recoveries.some((value) => !allowlistSet.has(value))) return fail("admin_recovery_invalid");
|
||||||
|
|
||||||
|
const state = this.database.prepare(`
|
||||||
|
SELECT applied_revision FROM secure_config_apply_state WHERE singleton = 1
|
||||||
|
`).get() as { applied_revision: number } | undefined;
|
||||||
|
const appliedRevision = state?.applied_revision ?? 0;
|
||||||
|
if (candidate.secureConfigRevision === appliedRevision) {
|
||||||
|
this.adminAllowlistHashes = allowlistSet;
|
||||||
|
return { appliedRevision, status: "unchanged" as const };
|
||||||
|
}
|
||||||
|
if (candidate.secureConfigRevision !== appliedRevision + 1) return fail("secure_config_revision_out_of_sequence");
|
||||||
|
|
||||||
|
const ordinaryUsers = this.database.prepare(`
|
||||||
|
SELECT normalized_email FROM users WHERE role = 'user' AND status <> 'deleted'
|
||||||
|
`).all() as Array<{ normalized_email: string }>;
|
||||||
|
if (ordinaryUsers.some((user) => allowlistSet.has(this.adminAllowlistHmac(user.normalized_email)))) {
|
||||||
|
return fail("identity_conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.runImmediate("secure_config_apply", () => {
|
||||||
|
const recoverySet = new Set(recoveries);
|
||||||
|
const admins = this.database.prepare(`
|
||||||
|
SELECT u.user_id, u.normalized_email, u.status, COALESCE(a.allowed, 0) AS allowed
|
||||||
|
FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE u.role = 'super_admin' AND u.status <> 'deleted'
|
||||||
|
`).all() as Array<{
|
||||||
|
allowed: 0 | 1;
|
||||||
|
normalized_email: string;
|
||||||
|
status: "active" | "suspended";
|
||||||
|
user_id: string;
|
||||||
|
}>;
|
||||||
|
let revokedSessions = 0;
|
||||||
|
let recoveredAdmins = 0;
|
||||||
|
for (const admin of admins) {
|
||||||
|
const adminHash = this.adminAllowlistHmac(admin.normalized_email);
|
||||||
|
const allowed = allowlistSet.has(adminHash);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO admin_access (user_id, allowed) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET allowed = excluded.allowed
|
||||||
|
`).run(admin.user_id, allowed ? 1 : 0);
|
||||||
|
if (!allowed) {
|
||||||
|
revokedSessions += this.database.prepare(`
|
||||||
|
UPDATE sessions SET revoked_at = ?
|
||||||
|
WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL
|
||||||
|
`).run(now, admin.user_id).changes;
|
||||||
|
if (admin.allowed === 1) {
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: "backend_secure_config",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { access: "removed" },
|
||||||
|
beforeSummary: { access: "allowed" },
|
||||||
|
operationType: "admin_allowlist_remove",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: admin.user_id,
|
||||||
|
targetType: "admin_account",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
} else if (admin.status === "suspended" && recoverySet.has(adminHash)) {
|
||||||
|
this.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(admin.user_id);
|
||||||
|
this.database.prepare(`
|
||||||
|
DELETE FROM email_challenges WHERE email = ? AND purpose = 'admin_login'
|
||||||
|
`).run(admin.normalized_email);
|
||||||
|
recoveredAdmins += 1;
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: "backend_secure_config",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { status: "active" },
|
||||||
|
beforeSummary: { status: "suspended" },
|
||||||
|
operationType: "admin_recover",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: admin.user_id,
|
||||||
|
targetType: "admin_account",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: "backend_secure_config",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { allowlist_count: allowlist.length, recovered_admins: recoveredAdmins, revoked_sessions: revokedSessions },
|
||||||
|
beforeSummary: { allowlist_count: this.readAppliedAllowlistCount(), revision: appliedRevision },
|
||||||
|
operationType: "secure_config_apply",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: `revision:${candidate.secureConfigRevision}`,
|
||||||
|
targetType: "secure_config_revision",
|
||||||
|
}, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO secure_config_apply_state (singleton, applied_revision, allowlist_count, applied_at)
|
||||||
|
VALUES (1, ?, ?, ?)
|
||||||
|
ON CONFLICT(singleton) DO UPDATE SET
|
||||||
|
applied_revision = excluded.applied_revision,
|
||||||
|
allowlist_count = excluded.allowlist_count,
|
||||||
|
applied_at = excluded.applied_at
|
||||||
|
`).run(candidate.secureConfigRevision, allowlist.length, now);
|
||||||
|
return {
|
||||||
|
outcome: "committed",
|
||||||
|
value: { appliedRevision: candidate.secureConfigRevision, status: "applied" as const },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.adminAllowlistHashes = allowlistSet;
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : "secure_config_apply_failed";
|
||||||
|
this.recordConfigApplyFailure(reason, now);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAdminLoginCode(input: { clientKey: string; email: string }): Promise<RegistrationSendResult> {
|
||||||
|
const email = normalizeEmail(input.email);
|
||||||
|
const clientKey = normalizeProfileValue(input.clientKey, 160);
|
||||||
|
const now = this.options.clock();
|
||||||
|
const challengeId = randomUUID();
|
||||||
|
const code = this.options.codeGenerator();
|
||||||
|
if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits.");
|
||||||
|
|
||||||
|
const outcome = this.runImmediate<RegistrationSendResult | RegistrationError>("admin_login_send", () => {
|
||||||
|
if (!this.isAdminAllowlisted(email)) {
|
||||||
|
this.recordAdminLoginRejection("not_allowed", now);
|
||||||
|
return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") };
|
||||||
|
}
|
||||||
|
const user = this.database.prepare(`
|
||||||
|
SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed
|
||||||
|
FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE u.normalized_email = ? AND u.status <> 'deleted'
|
||||||
|
`).get(email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined;
|
||||||
|
if (user?.status === "suspended") {
|
||||||
|
this.recordAdminLoginRejection("suspended", now);
|
||||||
|
return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended") };
|
||||||
|
}
|
||||||
|
if (user && (user.role !== "super_admin" || user.allowed !== 1)) {
|
||||||
|
this.recordAdminLoginRejection("not_allowed", now);
|
||||||
|
return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") };
|
||||||
|
}
|
||||||
|
this.assertChallengeSendAllowed(email, "admin_login", clientKey, now);
|
||||||
|
this.recordRateSend(email, clientKey, now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO email_challenges (
|
||||||
|
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
||||||
|
resend_available_at, failure_count, consumed_at, created_at
|
||||||
|
) VALUES (?, ?, NULL, ?, 'admin_login', ?, ?, 0, NULL, ?)
|
||||||
|
`).run(
|
||||||
|
challengeId,
|
||||||
|
email,
|
||||||
|
this.challengeHmac(challengeId, code),
|
||||||
|
now + challengeLifetimeMilliseconds,
|
||||||
|
now + resendDelayMilliseconds,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
outcome: "committed",
|
||||||
|
value: {
|
||||||
|
challengeExpiresAt: now + challengeLifetimeMilliseconds,
|
||||||
|
registrationId: challengeId,
|
||||||
|
resendAvailableAt: now + resendDelayMilliseconds,
|
||||||
|
status: "verification_sent" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (outcome instanceof RegistrationError) throw outcome;
|
||||||
|
try {
|
||||||
|
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" });
|
||||||
|
} catch {
|
||||||
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
|
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
||||||
|
this.recordAdminLoginRejection("service_unavailable", now);
|
||||||
|
return { outcome: "committed", value: undefined };
|
||||||
|
});
|
||||||
|
throw new Error("AUTH_SERVICE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeAdminLogin(input: LoginCompleteInput): AdminLoginCompleteResult {
|
||||||
|
if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid");
|
||||||
|
const clientKey = normalizeProfileValue(input.clientKey, 160);
|
||||||
|
if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) {
|
||||||
|
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict");
|
||||||
|
}
|
||||||
|
const now = this.options.clock();
|
||||||
|
const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `admin-login-idempotency:${input.idempotencyKey}`);
|
||||||
|
const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({
|
||||||
|
clientKey,
|
||||||
|
code: input.code,
|
||||||
|
registrationId: input.registrationId,
|
||||||
|
}));
|
||||||
|
const outcome = this.runImmediate<AdminLoginCompleteResult | RegistrationError>("admin_login_complete", () => {
|
||||||
|
const previous = this.database.prepare(`
|
||||||
|
SELECT request_hash, outcome_code, failure_reason, user_id, session_id
|
||||||
|
FROM login_attempts WHERE idempotency_key_digest = ?
|
||||||
|
`).get(idempotencyDigest) as LoginAttemptRow | undefined;
|
||||||
|
if (previous) {
|
||||||
|
if (!constantTimeTextEqual(previous.request_hash, requestHash)) {
|
||||||
|
throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict");
|
||||||
|
}
|
||||||
|
if (previous.outcome_code === "failure") {
|
||||||
|
return {
|
||||||
|
outcome: "idempotent_replay",
|
||||||
|
value: new RegistrationError("AUTH_ENTRY_REJECTED", previous.failure_reason ?? "challenge_invalid"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
outcome: "idempotent_replay",
|
||||||
|
value: this.readAdminLoginResult(previous.user_id!, previous.session_id!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const challenge = this.database.prepare(`
|
||||||
|
SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at
|
||||||
|
FROM email_challenges WHERE challenge_id = ? AND purpose = 'admin_login'
|
||||||
|
`).get(input.registrationId) as ChallengeRow | undefined;
|
||||||
|
if (!challenge || challenge.consumed_at !== null) {
|
||||||
|
return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now);
|
||||||
|
}
|
||||||
|
if (!this.isAdminAllowlisted(challenge.email)) {
|
||||||
|
return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now);
|
||||||
|
}
|
||||||
|
const rate = this.readRateLimit(challenge.email, clientKey, now);
|
||||||
|
if (rate.blocked_until !== null && rate.blocked_until > now) {
|
||||||
|
return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now);
|
||||||
|
}
|
||||||
|
if (challenge.expires_at <= now) {
|
||||||
|
return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now);
|
||||||
|
}
|
||||||
|
if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) {
|
||||||
|
this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?")
|
||||||
|
.run(challenge.challenge_id);
|
||||||
|
const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now);
|
||||||
|
return this.recordAdminLoginFailure(
|
||||||
|
idempotencyDigest,
|
||||||
|
requestHash,
|
||||||
|
input.registrationId,
|
||||||
|
failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid",
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let user = this.database.prepare(`
|
||||||
|
SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed
|
||||||
|
FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE u.normalized_email = ? AND u.status <> 'deleted'
|
||||||
|
`).get(challenge.email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined;
|
||||||
|
if (user && (user.role !== "super_admin" || user.status !== "active" || user.allowed !== 1)) {
|
||||||
|
return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now);
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
const userId = randomUUID();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?)
|
||||||
|
`).run(userId, challenge.email, challenge.challenge_id, now);
|
||||||
|
this.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
||||||
|
user = { allowed: 1, role: "super_admin", status: "active", user_id: userId };
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: userId,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { role: "super_admin", status: "active" },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "admin_create",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: userId,
|
||||||
|
targetType: "admin_account",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL")
|
||||||
|
.run(now, challenge.challenge_id);
|
||||||
|
const issued = this.insertSession(user.user_id, "admin", now);
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO login_attempts (
|
||||||
|
idempotency_key_digest, request_hash, challenge_id, outcome_code,
|
||||||
|
failure_reason, user_id, session_id, created_at
|
||||||
|
) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?)
|
||||||
|
`).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now);
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: user.user_id,
|
||||||
|
actorType: "super_admin",
|
||||||
|
afterSummary: { audience: "admin" },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "admin_login",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: user.user_id,
|
||||||
|
targetType: "admin_session",
|
||||||
|
}, now);
|
||||||
|
return {
|
||||||
|
outcome: "committed",
|
||||||
|
value: this.readAdminLoginResult(user.user_id, issued.sessionId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (outcome instanceof RegistrationError) throw outcome;
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
issueAuthenticatedSession(userId: string, audience: "user" | "admin") {
|
issueAuthenticatedSession(userId: string, audience: "user" | "admin") {
|
||||||
const now = this.options.clock();
|
const now = this.options.clock();
|
||||||
return this.runImmediate("session_issue", () => {
|
return this.runImmediate("session_issue", () => {
|
||||||
@@ -614,6 +949,24 @@ export class RegistrationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueAdminCsrfToken(sessionToken: string) {
|
||||||
|
const now = this.options.clock();
|
||||||
|
const csrfToken = randomBytes(32).toString("base64url");
|
||||||
|
return this.runImmediate("csrf_issue", () => {
|
||||||
|
const session = this.database.prepare(`
|
||||||
|
SELECT s.session_id FROM sessions s
|
||||||
|
JOIN users u ON u.user_id = s.user_id
|
||||||
|
JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL
|
||||||
|
AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||||
|
`).get(digest(sessionToken), now) as { session_id: string } | undefined;
|
||||||
|
if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
|
||||||
|
this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?")
|
||||||
|
.run(digest(csrfToken), session.session_id);
|
||||||
|
return { outcome: "committed", value: csrfToken };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logoutUser(input: { csrfToken: string; sessionToken: string }) {
|
logoutUser(input: { csrfToken: string; sessionToken: string }) {
|
||||||
const now = this.options.clock();
|
const now = this.options.clock();
|
||||||
this.runImmediate("session_revoke", () => {
|
this.runImmediate("session_revoke", () => {
|
||||||
@@ -653,6 +1006,16 @@ export class RegistrationService {
|
|||||||
if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId);
|
if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId);
|
||||||
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL")
|
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL")
|
||||||
.run(now, userId);
|
.run(now, userId);
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: reason === "whitelist_removed" ? "backend_secure_config" : userId,
|
||||||
|
actorType: reason === "whitelist_removed" ? "system" : "super_admin",
|
||||||
|
afterSummary: { access: reason === "whitelist_removed" ? "removed" : reason },
|
||||||
|
beforeSummary: { access: "active" },
|
||||||
|
operationType: reason === "disabled" ? "admin_disable" : reason === "logout" ? "admin_logout" : "admin_allowlist_remove",
|
||||||
|
result: "succeeded",
|
||||||
|
targetRef: userId,
|
||||||
|
targetType: "admin_account",
|
||||||
|
}, now);
|
||||||
return { outcome: "committed", value: undefined };
|
return { outcome: "committed", value: undefined };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -794,6 +1157,29 @@ export class RegistrationService {
|
|||||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
|
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
|
||||||
allowed INTEGER NOT NULL CHECK (allowed IN (0, 1))
|
allowed INTEGER NOT NULL CHECK (allowed IN (0, 1))
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS secure_config_apply_state (
|
||||||
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||||
|
applied_revision INTEGER NOT NULL CHECK (applied_revision >= 0),
|
||||||
|
allowlist_count INTEGER NOT NULL CHECK (allowlist_count >= 0),
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||||
rate_key TEXT PRIMARY KEY,
|
rate_key TEXT PRIMARY KEY,
|
||||||
window_started_at INTEGER NOT NULL,
|
window_started_at INTEGER NOT NULL,
|
||||||
@@ -811,6 +1197,62 @@ export class RegistrationService {
|
|||||||
session_id TEXT REFERENCES sessions(session_id),
|
session_id TEXT REFERENCES sessions(session_id),
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
INSERT OR IGNORE INTO secure_config_apply_state (
|
||||||
|
singleton, applied_revision, allowlist_count, applied_at
|
||||||
|
) VALUES (1, 0, 0, 0);
|
||||||
|
`);
|
||||||
|
this.migrateLegacyAdminOperationLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateLegacyAdminOperationLogs() {
|
||||||
|
const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>;
|
||||||
|
if (columns.some((column) => column.name === "actor_type")) return;
|
||||||
|
const legacy = this.database.prepare(`
|
||||||
|
SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs
|
||||||
|
`).all() as Array<{ created_at: string | number; log_id: string; operation: string; outcome: string; target_ref: string }>;
|
||||||
|
this.database.exec(`
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_update;
|
||||||
|
DROP TRIGGER IF EXISTS admin_operation_logs_no_delete;
|
||||||
|
ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy;
|
||||||
|
CREATE TABLE admin_operation_logs (
|
||||||
|
log_id TEXT PRIMARY KEY,
|
||||||
|
actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')),
|
||||||
|
actor_ref TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_ref TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')),
|
||||||
|
before_summary TEXT,
|
||||||
|
after_summary TEXT,
|
||||||
|
occurred_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const insert = 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 (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const entry of legacy) {
|
||||||
|
const parsed = typeof entry.created_at === "number" ? entry.created_at : Date.parse(entry.created_at);
|
||||||
|
const occurredAt = Number.isFinite(parsed) ? parsed : this.options.clock();
|
||||||
|
insert.run(
|
||||||
|
entry.log_id,
|
||||||
|
entry.operation,
|
||||||
|
entry.target_ref,
|
||||||
|
entry.outcome.startsWith("denied") ? "failed" : "succeeded",
|
||||||
|
JSON.stringify({ legacy_outcome: entry.outcome }),
|
||||||
|
occurredAt,
|
||||||
|
occurredAt + 180 * 24 * 60 * 60 * 1_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.database.exec(`
|
||||||
|
DROP TABLE admin_operation_logs_legacy;
|
||||||
|
CREATE TRIGGER admin_operation_logs_no_update
|
||||||
|
BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
|
CREATE TRIGGER admin_operation_logs_no_delete
|
||||||
|
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,6 +1285,19 @@ export class RegistrationService {
|
|||||||
return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`);
|
return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private adminAllowlistHmac(email: string) {
|
||||||
|
if (!this.options.adminAllowlistPepper) throw new Error("admin_pepper_not_configured");
|
||||||
|
return createHmac("sha256", this.options.adminAllowlistPepper)
|
||||||
|
.update(email.trim().toLowerCase(), "utf8")
|
||||||
|
.digest("hex")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAdminAllowlisted(email: string) {
|
||||||
|
return Boolean(this.options.adminAllowlistPepper)
|
||||||
|
&& this.adminAllowlistHashes.has(this.adminAllowlistHmac(email));
|
||||||
|
}
|
||||||
|
|
||||||
private sessionToken(sessionId: string) {
|
private sessionToken(sessionId: string) {
|
||||||
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
|
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
|
||||||
}
|
}
|
||||||
@@ -909,7 +1364,7 @@ export class RegistrationService {
|
|||||||
|
|
||||||
private assertChallengeSendAllowed(
|
private assertChallengeSendAllowed(
|
||||||
email: string,
|
email: string,
|
||||||
purpose: "register" | "login",
|
purpose: "register" | "login" | "admin_login",
|
||||||
clientKey: string,
|
clientKey: string,
|
||||||
now: number,
|
now: number,
|
||||||
) {
|
) {
|
||||||
@@ -939,6 +1394,120 @@ export class RegistrationService {
|
|||||||
return { sessionExpiresAt, sessionId, sessionToken };
|
return { sessionExpiresAt, sessionId, sessionToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readAdminLoginResult(userId: string, sessionId: string): AdminLoginCompleteResult {
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT u.user_id, s.expires_at
|
||||||
|
FROM users u
|
||||||
|
JOIN admin_access a ON a.user_id = u.user_id
|
||||||
|
JOIN sessions s ON s.user_id = u.user_id
|
||||||
|
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active'
|
||||||
|
AND a.allowed = 1 AND s.session_id = ? AND s.audience = 'admin'
|
||||||
|
`).get(userId, sessionId) as { expires_at: number; user_id: string } | undefined;
|
||||||
|
if (!row) throw new RegistrationError("AUTH_ENTRY_REJECTED", "challenge_invalid");
|
||||||
|
return {
|
||||||
|
admin: { role: "super_admin", status: "active", userId: row.user_id },
|
||||||
|
audience: "admin",
|
||||||
|
sessionExpiresAt: row.expires_at,
|
||||||
|
sessionToken: this.sessionToken(sessionId),
|
||||||
|
status: "authenticated",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readAppliedAllowlistCount() {
|
||||||
|
const state = this.database.prepare(`
|
||||||
|
SELECT allowlist_count FROM secure_config_apply_state WHERE singleton = 1
|
||||||
|
`).get() as { allowlist_count: number } | undefined;
|
||||||
|
return state?.allowlist_count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordAdminAudit(input: {
|
||||||
|
actorRef: string;
|
||||||
|
actorType: "system" | "super_admin";
|
||||||
|
afterSummary: Record<string, unknown> | null;
|
||||||
|
beforeSummary: Record<string, unknown> | null;
|
||||||
|
operationType: string;
|
||||||
|
result: "succeeded" | "failed";
|
||||||
|
targetRef: string;
|
||||||
|
targetType: string;
|
||||||
|
}, now: number) {
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
randomUUID(),
|
||||||
|
input.actorType,
|
||||||
|
input.actorRef,
|
||||||
|
input.operationType,
|
||||||
|
input.targetType,
|
||||||
|
input.targetRef,
|
||||||
|
input.result,
|
||||||
|
input.beforeSummary === null ? null : JSON.stringify(input.beforeSummary),
|
||||||
|
input.afterSummary === null ? null : JSON.stringify(input.afterSummary),
|
||||||
|
now,
|
||||||
|
now + 180 * 24 * 60 * 60 * 1_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordAdminLoginRejection(reason: string, now: number) {
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: "admin_auth",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { reason },
|
||||||
|
beforeSummary: null,
|
||||||
|
operationType: "admin_login",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: "admin_login",
|
||||||
|
targetType: "admin_session",
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordAdminLoginFailure(
|
||||||
|
idempotencyKeyDigest: string,
|
||||||
|
requestHash: string,
|
||||||
|
challengeId: string,
|
||||||
|
reason: RegistrationErrorReason,
|
||||||
|
now: number,
|
||||||
|
) {
|
||||||
|
const failure = this.recordLoginFailure(idempotencyKeyDigest, requestHash, challengeId, reason, now);
|
||||||
|
this.recordAdminLoginRejection(reason === "admin_not_allowed" ? "not_allowed" : reason, now);
|
||||||
|
return failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordConfigApplyFailure(reason: string, now: number) {
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
this.recordAdminAudit({
|
||||||
|
actorRef: "backend_secure_config",
|
||||||
|
actorType: "system",
|
||||||
|
afterSummary: { reason: this.safeConfigFailureReason(reason) },
|
||||||
|
beforeSummary: { allowlist_count: this.readAppliedAllowlistCount() },
|
||||||
|
operationType: "secure_config_apply",
|
||||||
|
result: "failed",
|
||||||
|
targetRef: "candidate_revision",
|
||||||
|
targetType: "secure_config_revision",
|
||||||
|
}, now);
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||||
|
throw new Error("secure_config_failed_audit_unavailable", { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeConfigFailureReason(reason: string) {
|
||||||
|
const allowed = new Set([
|
||||||
|
"admin_pepper_not_configured",
|
||||||
|
"secure_config_revision_invalid",
|
||||||
|
"admin_allowlist_invalid",
|
||||||
|
"admin_recovery_invalid",
|
||||||
|
"hmac_invalid",
|
||||||
|
"secure_config_revision_out_of_sequence",
|
||||||
|
"identity_conflict",
|
||||||
|
]);
|
||||||
|
return allowed.has(reason) ? reason : "secure_config_apply_failed";
|
||||||
|
}
|
||||||
|
|
||||||
private recordLoginFailure(
|
private recordLoginFailure(
|
||||||
idempotencyKeyDigest: string,
|
idempotencyKeyDigest: string,
|
||||||
requestHash: string,
|
requestHash: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export interface RegistrationCodeMessage {
|
|||||||
challengeId: string;
|
challengeId: string;
|
||||||
code: string;
|
code: string;
|
||||||
email: string;
|
email: string;
|
||||||
purpose: "register" | "login";
|
purpose: "register" | "login" | "admin_login";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResendAdapter {
|
export interface ResendAdapter {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import type { SecureConfigCandidate } from "./registration.js";
|
||||||
|
|
||||||
|
export function readSecureConfigCandidate(path: string): SecureConfigCandidate {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||||
|
if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string")
|
||||||
|
|| parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) {
|
||||||
|
throw new Error("secure_config_integrity_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
adminAllowlistHashes: parsed.admin_allowlist_hashes as string[],
|
||||||
|
adminRecoveryHashes: parsed.admin_recovery_hashes as string[],
|
||||||
|
secureConfigRevision: parsed.secure_config_revision as number,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
|
|
||||||
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const;
|
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
|
||||||
|
|
||||||
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -26,8 +26,10 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
|
|||||||
|
|
||||||
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||||
|
const adminPepperValue = credentials["Dada/P0A/admin/pepper"];
|
||||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
for (const name of API_CREDENTIALS) credentials[name] = "";
|
||||||
if (!configured) throw new Error("API credential client initialization failed.");
|
if (!configured) throw new Error("API credential client initialization failed.");
|
||||||
|
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
:root {
|
||||||
|
color: #121212;
|
||||||
|
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #ffffff;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 8px 72px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-accent {
|
||||||
|
background: #eaff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-header {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #dedede;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-wordmark {
|
||||||
|
color: #111111;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
margin: 48px 20px 96px;
|
||||||
|
width: min(440px, calc(100vw - 40px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-kicker {
|
||||||
|
color: #606060;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel input {
|
||||||
|
border: 1px solid #b9b9b9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
height: 48px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel input:focus {
|
||||||
|
border-color: #111111;
|
||||||
|
box-shadow: 0 0 0 2px #eaff00;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel button {
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #111111;
|
||||||
|
width: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-code-field {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-error {
|
||||||
|
border-left: 3px solid #c93333;
|
||||||
|
color: #8d1717;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 20px 0 0;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-submit {
|
||||||
|
background: #151515;
|
||||||
|
border: 1px solid #151515;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 28px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-return {
|
||||||
|
color: #363636;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 24px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-auth-header {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-panel {
|
||||||
|
align-self: start;
|
||||||
|
margin-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-send {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useId, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import "./admin-auth.css";
|
||||||
|
|
||||||
|
interface ErrorEnvelopeBody {
|
||||||
|
error?: {
|
||||||
|
details?: { field_errors?: Array<{ message_key?: string }> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminErrorMessage(body: ErrorEnvelopeBody) {
|
||||||
|
const key = body.error?.details?.field_errors?.[0]?.message_key;
|
||||||
|
if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。";
|
||||||
|
if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。";
|
||||||
|
if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。";
|
||||||
|
if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。";
|
||||||
|
return "管理员登录暂时无法完成,请稍后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAuthPage() {
|
||||||
|
const emailId = useId();
|
||||||
|
const codeId = useId();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [registrationId, setRegistrationId] = useState<string>();
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
if (!emailValid || sending || countdown > 0) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin-auth/login/send", {
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string };
|
||||||
|
if (!response.ok || !body.registration_id) {
|
||||||
|
setError(adminErrorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegistrationId(body.registration_id);
|
||||||
|
setCountdown(60);
|
||||||
|
window.requestAnimationFrame(() => document.getElementById(codeId)?.focus());
|
||||||
|
} catch {
|
||||||
|
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeLogin(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/admin-auth/login/complete", {
|
||||||
|
body: JSON.stringify({ registration_id: registrationId, verification_code: code }),
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.json() as ErrorEnvelopeBody;
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(adminErrorMessage(body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign("/admin");
|
||||||
|
} catch {
|
||||||
|
setError("管理员登录暂时无法完成,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="admin-auth-page">
|
||||||
|
<div className="admin-auth-accent" aria-hidden="true" />
|
||||||
|
<header className="admin-auth-header">
|
||||||
|
<a className="admin-auth-wordmark" href="/" aria-label="Dada 普通用户登录">DADA</a>
|
||||||
|
</header>
|
||||||
|
<section className="admin-auth-panel">
|
||||||
|
<p className="admin-auth-kicker">ADMIN</p>
|
||||||
|
<h1 id="admin-auth-heading">管理员邮箱验证码登录</h1>
|
||||||
|
<form onSubmit={completeLogin} noValidate>
|
||||||
|
<label htmlFor={emailId}>管理员邮箱</label>
|
||||||
|
<div className="admin-auth-send-row">
|
||||||
|
<input
|
||||||
|
id={emailId}
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => {
|
||||||
|
setEmail(event.target.value);
|
||||||
|
setRegistrationId(undefined);
|
||||||
|
setCode("");
|
||||||
|
setCountdown(0);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="获取验证码"
|
||||||
|
className="admin-auth-send"
|
||||||
|
disabled={!emailValid || sending || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sending ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{registrationId ? (
|
||||||
|
<div className="admin-auth-code-field">
|
||||||
|
<label htmlFor={codeId}>验证码</label>
|
||||||
|
<input
|
||||||
|
id={codeId}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="admin-auth-error" role="alert">{error}</p> : null}
|
||||||
|
<button className="admin-auth-submit" disabled={!registrationId || code.length !== 6 || submitting} type="submit">
|
||||||
|
{submitting ? "登录中" : "登录后台"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a className="admin-auth-return" href="/">返回普通用户登录</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
|
import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
|
||||||
|
|
||||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||||
|
|
||||||
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminLoginCompleteResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<LoginCompleteResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
@@ -63,6 +72,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
|
|||||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AdminSessionResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||||
"app_version": string;
|
"app_version": string;
|
||||||
"dependencies": Array<{
|
"dependencies": Array<{
|
||||||
@@ -120,6 +136,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
|
|||||||
return response.json() as Promise<LogoutResponse>;
|
return response.json() as Promise<LogoutResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<RegistrationSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||||
|
|
||||||
|
export type AdminAuthenticatedUser = {
|
||||||
|
"role": "super_admin";
|
||||||
|
"status": "active";
|
||||||
|
"user_id": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginCompleteRequest = {
|
||||||
|
"registration_id": string;
|
||||||
|
"verification_code": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginCompleteResponse = {
|
||||||
|
"admin": AdminAuthenticatedUser;
|
||||||
|
"audience": "admin";
|
||||||
|
"session_expires_at": string;
|
||||||
|
"status": "authenticated";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminLoginSendRequest = {
|
||||||
|
"email": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSessionResponse = {
|
||||||
|
"acknowledged_private_content_notice_version": string | null;
|
||||||
|
"admin": AdminAuthenticatedUser;
|
||||||
|
"audience": "admin";
|
||||||
|
"authenticated": true;
|
||||||
|
"csrf_token": string;
|
||||||
|
"current_private_content_notice_version": string | null;
|
||||||
|
"expires_at": string;
|
||||||
|
"notice_acknowledged": boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthenticatedUser = {
|
export type AuthenticatedUser = {
|
||||||
"creator_name": string;
|
"creator_name": string;
|
||||||
"role": "user";
|
"role": "user";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||||
|
import { AdminAuthPage } from "./admin-auth.js";
|
||||||
import { UserAuthPage } from "./user-auth.js";
|
import { UserAuthPage } from "./user-auth.js";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
@@ -18,9 +19,12 @@ let authRevision = 0;
|
|||||||
|
|
||||||
function renderAuthenticationEntry() {
|
function renderAuthenticationEntry() {
|
||||||
authRevision += 1;
|
authRevision += 1;
|
||||||
|
const authenticationPage = window.location.pathname.startsWith("/admin")
|
||||||
|
? <AdminAuthPage key={authRevision} />
|
||||||
|
: <UserAuthPage key={authRevision} />;
|
||||||
appRoot.render(
|
appRoot.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<UserAuthPage key={authRevision} />
|
{authenticationPage}
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export function UserAuthPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="auth-content">
|
<section className="auth-content">
|
||||||
<a className="auth-admin-link" href="/admin">管理员登录</a>
|
<a className="auth-admin-link" href="/admin/login">管理员登录</a>
|
||||||
<div className="auth-panel">
|
<div className="auth-panel">
|
||||||
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,160 @@
|
|||||||
{
|
{
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"AdminAuthenticatedUser": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"role": {
|
||||||
|
"enum": [
|
||||||
|
"super_admin"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"active"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"role",
|
||||||
|
"status",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AdminLoginCompleteRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"registration_id": {
|
||||||
|
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"verification_code": {
|
||||||
|
"pattern": "^[0-9]{6}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"registration_id",
|
||||||
|
"verification_code"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AdminLoginCompleteResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"admin": {
|
||||||
|
"$ref": "#/components/schemas/AdminAuthenticatedUser"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"enum": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"session_expires_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"authenticated"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"admin",
|
||||||
|
"audience",
|
||||||
|
"session_expires_at",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AdminLoginSendRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"maxLength": 320,
|
||||||
|
"pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AdminSessionResponse": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"acknowledged_private_content_notice_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"$ref": "#/components/schemas/AdminAuthenticatedUser"
|
||||||
|
},
|
||||||
|
"audience": {
|
||||||
|
"enum": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"authenticated": {
|
||||||
|
"enum": [
|
||||||
|
true
|
||||||
|
],
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"csrf_token": {
|
||||||
|
"maxLength": 64,
|
||||||
|
"minLength": 43,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current_private_content_notice_version": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"notice_acknowledged": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"acknowledged_private_content_notice_version",
|
||||||
|
"admin",
|
||||||
|
"audience",
|
||||||
|
"authenticated",
|
||||||
|
"csrf_token",
|
||||||
|
"current_private_content_notice_version",
|
||||||
|
"expires_at",
|
||||||
|
"notice_acknowledged"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AuthenticatedUser": {
|
"AuthenticatedUser": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1564,6 +1718,199 @@
|
|||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/api/v1/admin-auth/login/complete": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "completeAdminLogin",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "idempotency-key",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"maxLength": 200,
|
||||||
|
"minLength": 32,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminLoginCompleteRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminLoginCompleteResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Admin Authentication"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin-auth/login/send": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "sendAdminLoginCode",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminLoginSendRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RegistrationSendResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Admin Authentication"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin-auth/session": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAdminSession",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AdminSessionResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Admin Authentication"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/auth/login/complete": {
|
"/api/v1/auth/login/complete": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "completeLogin",
|
"operationId": "completeLogin",
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -45,7 +45,9 @@
|
|||||||
"test:wp1-02": "node scripts/run-wp1-02-validation.mjs",
|
"test:wp1-02": "node scripts/run-wp1-02-validation.mjs",
|
||||||
"test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red",
|
"test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red",
|
||||||
"test:wp1-03": "node scripts/run-wp1-03-validation.mjs",
|
"test:wp1-03": "node scripts/run-wp1-03-validation.mjs",
|
||||||
"test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red"
|
"test:wp1-03:red": "node scripts/run-wp1-03-validation.mjs --phase red",
|
||||||
|
"test:wp1-04": "node scripts/run-wp1-04-validation.mjs",
|
||||||
|
"test:wp1-04:red": "node scripts/run-wp1-04-validation.mjs --phase red"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -108,6 +108,54 @@ export const LoginCompleteResponseSchema = Type.Object(
|
|||||||
{ additionalProperties: false, $id: "LoginCompleteResponse" },
|
{ additionalProperties: false, $id: "LoginCompleteResponse" },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const AdminLoginSendRequestSchema = Type.Object(
|
||||||
|
{
|
||||||
|
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "AdminLoginSendRequest" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminAuthenticatedUserSchema = Type.Object(
|
||||||
|
{
|
||||||
|
role: Type.Literal("super_admin"),
|
||||||
|
status: Type.Literal("active"),
|
||||||
|
user_id: Type.String({ pattern: uuidPattern }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "AdminAuthenticatedUser" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminLoginCompleteRequestSchema = Type.Object(
|
||||||
|
{
|
||||||
|
registration_id: Type.String({ pattern: uuidPattern }),
|
||||||
|
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "AdminLoginCompleteRequest" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminLoginCompleteResponseSchema = Type.Object(
|
||||||
|
{
|
||||||
|
admin: Type.Ref(AdminAuthenticatedUserSchema),
|
||||||
|
audience: Type.Literal("admin"),
|
||||||
|
session_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||||
|
status: Type.Literal("authenticated"),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "AdminLoginCompleteResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminSessionResponseSchema = Type.Object(
|
||||||
|
{
|
||||||
|
acknowledged_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
|
||||||
|
admin: Type.Ref(AdminAuthenticatedUserSchema),
|
||||||
|
audience: Type.Literal("admin"),
|
||||||
|
authenticated: Type.Literal(true),
|
||||||
|
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||||
|
current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
|
||||||
|
expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||||
|
notice_acknowledged: Type.Boolean(),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false, $id: "AdminSessionResponse" },
|
||||||
|
);
|
||||||
|
|
||||||
export const LogoutHeadersSchema = Type.Object(
|
export const LogoutHeadersSchema = Type.Object(
|
||||||
{
|
{
|
||||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||||
@@ -128,3 +176,5 @@ export type RegistrationCompleteResponse = Static<typeof RegistrationCompleteRes
|
|||||||
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
||||||
export type LoginSendRequest = Static<typeof LoginSendRequestSchema>;
|
export type LoginSendRequest = Static<typeof LoginSendRequestSchema>;
|
||||||
export type LoginCompleteRequest = Static<typeof LoginCompleteRequestSchema>;
|
export type LoginCompleteRequest = Static<typeof LoginCompleteRequestSchema>;
|
||||||
|
export type AdminLoginSendRequest = Static<typeof AdminLoginSendRequestSchema>;
|
||||||
|
export type AdminLoginCompleteRequest = Static<typeof AdminLoginCompleteRequestSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const phaseIndex = process.argv.indexOf("--phase");
|
||||||
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||||
|
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||||
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||||
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||||
|
const adminCase = "TDD-WP1-ADM-001-admin-auth-boundary";
|
||||||
|
const configCase = "TDD-WP1-CFG-001-secure-revision";
|
||||||
|
const directories = {
|
||||||
|
[adminCase]: resolve(runDirectory, "cases", adminCase),
|
||||||
|
[configCase]: resolve(runDirectory, "cases", configCase),
|
||||||
|
};
|
||||||
|
const playwrightDirectory = resolve(runDirectory, "playwright");
|
||||||
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||||
|
for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true });
|
||||||
|
|
||||||
|
const commandsToRun = phase === "red"
|
||||||
|
? [
|
||||||
|
["admin-integration", ["exec", "vitest", "run", "tests/integration/wp1-04-admin-auth.test.ts"]],
|
||||||
|
["config-integration", ["exec", "vitest", "run", "tests/integration/wp1-04-secure-config.test.ts"]],
|
||||||
|
["admin-api", ["exec", "vitest", "run", "tests/api/wp1-04-admin-auth.test.ts"]],
|
||||||
|
["admin-e2e", ["exec", "playwright", "test", "tests/e2e/admin-auth.spec.ts", "--config", "playwright.config.ts"]],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
["integration", ["test:integration"]],
|
||||||
|
["api", ["test:api"]],
|
||||||
|
["e2e", ["test:e2e"]],
|
||||||
|
["security", ["test:security"]],
|
||||||
|
["package", ["test:package"]],
|
||||||
|
["supervisor", ["exec", "dotnet", "run", "--project", "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", "--configuration", "Release"]],
|
||||||
|
["tdd-trace", ["validate:tdd-trace"]],
|
||||||
|
];
|
||||||
|
const environment = {
|
||||||
|
...process.env,
|
||||||
|
DADA_EVIDENCE_DIR_ADMIN: directories[adminCase],
|
||||||
|
DADA_EVIDENCE_DIR_CONFIG: directories[configCase],
|
||||||
|
DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory,
|
||||||
|
};
|
||||||
|
const commandResults = [];
|
||||||
|
for (const [name, args] of commandsToRun) {
|
||||||
|
const command = `pnpm ${args.join(" ")}`;
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const executable = process.env.ComSpec ?? "cmd.exe";
|
||||||
|
const execution = spawnSync(executable, ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||||
|
if (execution.stdout) process.stdout.write(execution.stdout);
|
||||||
|
if (execution.stderr) process.stderr.write(execution.stderr);
|
||||||
|
commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||||
|
}
|
||||||
|
|
||||||
|
function find(root, name) {
|
||||||
|
if (!existsSync(root)) return [];
|
||||||
|
return readdirSync(root).flatMap((entry) => {
|
||||||
|
const child = resolve(root, entry);
|
||||||
|
return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (phase === "green") {
|
||||||
|
const adminTrace = find(playwrightDirectory, "trace.zip")
|
||||||
|
.find((path) => path.replaceAll("\\", "/").includes("admin-auth"));
|
||||||
|
if (adminTrace) copyFileSync(adminTrace, resolve(directories[adminCase], "trace.zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of Object.values(directories)) {
|
||||||
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
const expectedEvidence = phase === "red"
|
||||||
|
? { [adminCase]: ["red-observation.json"], [configCase]: ["red-observation.json"] }
|
||||||
|
: {
|
||||||
|
[adminCase]: ["response.json", "db-diff.json", "external-calls.json", "trace.zip"],
|
||||||
|
[configCase]: ["config-result.json", "db-diff.json", "redaction.json"],
|
||||||
|
};
|
||||||
|
const commandState = phase === "red"
|
||||||
|
? commandResults.every((result) => result.exit_code !== 0)
|
||||||
|
: commandResults.every((result) => result.exit_code === 0);
|
||||||
|
if (phase === "red") {
|
||||||
|
for (const [testId, directory] of Object.entries(directories)) {
|
||||||
|
writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({
|
||||||
|
expected_failure: testId === adminCase ? "administrator boundary is absent" : "secure revision application is absent",
|
||||||
|
status: commandState ? "red_confirmed" : "failed",
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manifest = {
|
||||||
|
path: "tasks.manifest.json",
|
||||||
|
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
|
||||||
|
};
|
||||||
|
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||||
|
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||||
|
const results = Object.entries(directories).map(([testId, directory]) => {
|
||||||
|
const evidence_refs = expectedEvidence[testId];
|
||||||
|
const missing_evidence = evidence_refs.filter((file) => !existsSync(resolve(directory, file)));
|
||||||
|
const status = commandState && missing_evidence.length === 0 ? (phase === "red" ? "red_confirmed" : "passed") : "failed";
|
||||||
|
const result = {
|
||||||
|
acceptance_criteria: ["AC-49", "AC-50"],
|
||||||
|
automation: ["automated"],
|
||||||
|
commit,
|
||||||
|
evidence_refs,
|
||||||
|
layer: testId === adminCase ? ["API", "E2E", "DB", "SUPERVISOR"] : ["API", "DB", "SECURITY"],
|
||||||
|
manifest,
|
||||||
|
missing_evidence,
|
||||||
|
phase,
|
||||||
|
requirements: ["ADMIN-09", "AUTH-07"],
|
||||||
|
run_id: runId,
|
||||||
|
status,
|
||||||
|
task_id: "TASK-WP1-04",
|
||||||
|
test_id: testId,
|
||||||
|
work_package: "WP-1",
|
||||||
|
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||||
|
const passed = results.every((result) => result.status === targetStatus);
|
||||||
|
const summary = {
|
||||||
|
cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })),
|
||||||
|
phase,
|
||||||
|
run_id: runId,
|
||||||
|
status: passed ? targetStatus : "failed",
|
||||||
|
};
|
||||||
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
if (!passed) process.exit(1);
|
||||||
@@ -5,11 +5,13 @@ import { initializeWorkerCredentialClient, receiveWorkerCredentials } from "../a
|
|||||||
|
|
||||||
const marker = `wp0-${Date.now().toString(36)}`;
|
const marker = `wp0-${Date.now().toString(36)}`;
|
||||||
const api = await receiveApiCredentials(Readable.from([Buffer.from(JSON.stringify({
|
const api = await receiveApiCredentials(Readable.from([Buffer.from(JSON.stringify({
|
||||||
|
"Dada/P0A/admin/pepper": `${marker}-admin-pepper-value-00000000`,
|
||||||
"Dada/P0A/api/amap": `${marker}-map`,
|
"Dada/P0A/api/amap": `${marker}-map`,
|
||||||
"Dada/P0A/api/resend": `${marker}-mail`,
|
"Dada/P0A/api/resend": `${marker}-mail`,
|
||||||
}))]));
|
}))]));
|
||||||
initializeApiCredentialClients(api);
|
const apiClients = initializeApiCredentialClients(api);
|
||||||
if (Object.values(api).some(Boolean)) throw new Error("API credential receive object was not cleared after client initialization.");
|
if (Object.values(api).some(Boolean)) throw new Error("API credential receive object was not cleared after client initialization.");
|
||||||
|
apiClients.adminAllowlistPepper.fill(0);
|
||||||
|
|
||||||
const worker = await receiveWorkerCredentials(Readable.from([Buffer.from(JSON.stringify({
|
const worker = await receiveWorkerCredentials(Readable.from([Buffer.from(JSON.stringify({
|
||||||
"Dada/P0A/worker/ai-gateway": `${marker}-ai`,
|
"Dada/P0A/worker/ai-gateway": `${marker}-ai`,
|
||||||
@@ -17,4 +19,4 @@ const worker = await receiveWorkerCredentials(Readable.from([Buffer.from(JSON.st
|
|||||||
initializeWorkerCredentialClient(worker);
|
initializeWorkerCredentialClient(worker);
|
||||||
if (Object.values(worker).some(Boolean)) throw new Error("Worker credential receive object was not cleared after client initialization.");
|
if (Object.values(worker).some(Boolean)) throw new Error("Worker credential receive object was not cleared after client initialization.");
|
||||||
|
|
||||||
console.log(JSON.stringify({ api_scope: "resend_amap", receive_buffer: "cleared", status: "passed", worker_scope: "ai_gateway" }));
|
console.log(JSON.stringify({ api_scope: "resend_amap_admin_pepper", receive_buffer: "cleared", status: "passed", worker_scope: "ai_gateway" }));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
var security = await TestCredentialBoundaryAsync();
|
var security = await TestCredentialBoundaryAsync();
|
||||||
var supervisor = await TestSupervisorLifecycleAsync();
|
var supervisor = await TestSupervisorLifecycleAsync();
|
||||||
|
TestSecureConfigurationPersistence();
|
||||||
TestStructuredLogging();
|
TestStructuredLogging();
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
|
||||||
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
|
||||||
@@ -74,16 +75,46 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TestSecureConfigurationPersistence()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), $"dada-secure-config-{Guid.NewGuid():N}");
|
||||||
|
var path = Path.Combine(directory, "instance.json");
|
||||||
|
var email = "synthetic-admin@example.invalid";
|
||||||
|
var pepper = $"synthetic-pepper-{Guid.NewGuid():N}";
|
||||||
|
var digest = Convert.ToHexString(System.Security.Cryptography.HMACSHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(pepper),
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(email)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new InstanceConfigurationStore(path);
|
||||||
|
store.Save(new InstanceConfiguration(1, 7, null, null, [digest], [digest]));
|
||||||
|
var raw = File.ReadAllText(path);
|
||||||
|
True(raw.Contains("\"secure_config_revision\": 7", StringComparison.Ordinal), "secure config revision field");
|
||||||
|
True(raw.Contains("\"schema_version\": 1", StringComparison.Ordinal), "secure config schema field");
|
||||||
|
False(raw.Contains(email, StringComparison.OrdinalIgnoreCase), "allowlist email persisted");
|
||||||
|
False(raw.Contains(pepper, StringComparison.Ordinal), "admin pepper persisted");
|
||||||
|
False(File.Exists(path + ".tmp"), "secure config temporary file residual");
|
||||||
|
var loaded = store.Load();
|
||||||
|
Equal(7, loaded.Revision, "secure config revision round trip");
|
||||||
|
EqualSequence(new[] { digest }, loaded.AdminRecoveryHashes, "secure config recovery marker");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object> TestCredentialBoundaryAsync()
|
private static async Task<object> TestCredentialBoundaryAsync()
|
||||||
{
|
{
|
||||||
var store = new TestCredentialStore();
|
var store = new TestCredentialStore();
|
||||||
var marker = $"wp0-{Guid.NewGuid():N}";
|
var marker = $"wp0-{Guid.NewGuid():N}";
|
||||||
store.Write(CredentialCatalog.ApiResend, marker + "-mail");
|
store.Write(CredentialCatalog.ApiResend, marker + "-mail");
|
||||||
store.Write(CredentialCatalog.ApiAmap, marker + "-map");
|
store.Write(CredentialCatalog.ApiAmap, marker + "-map");
|
||||||
|
store.Write(CredentialCatalog.AdminPepper, marker + "-admin");
|
||||||
store.Write(CredentialCatalog.WorkerAiGateway, marker + "-ai");
|
store.Write(CredentialCatalog.WorkerAiGateway, marker + "-ai");
|
||||||
|
|
||||||
var apiProbe = await LaunchCredentialProbeAsync(ChildRole.Api, store);
|
var apiProbe = await LaunchCredentialProbeAsync(ChildRole.Api, store);
|
||||||
EqualSequence(new[] { CredentialCatalog.ApiAmap, CredentialCatalog.ApiResend }, apiProbe.Names.Order().ToArray(), "API credential scope");
|
EqualSequence(new[] { CredentialCatalog.AdminPepper, CredentialCatalog.ApiAmap, CredentialCatalog.ApiResend }, apiProbe.Names.Order().ToArray(), "API credential scope");
|
||||||
False(apiProbe.EnvironmentContainsMarker, "credential leaked into child environment");
|
False(apiProbe.EnvironmentContainsMarker, "credential leaked into child environment");
|
||||||
False(apiProbe.ArgumentsContainMarker, "credential leaked into child arguments");
|
False(apiProbe.ArgumentsContainMarker, "credential leaked into child arguments");
|
||||||
|
|
||||||
@@ -192,6 +223,7 @@ internal static class Program
|
|||||||
var childStore = new TestCredentialStore();
|
var childStore = new TestCredentialStore();
|
||||||
childStore.Write(CredentialCatalog.ApiResend, $"probe-{Guid.NewGuid():N}-mail");
|
childStore.Write(CredentialCatalog.ApiResend, $"probe-{Guid.NewGuid():N}-mail");
|
||||||
childStore.Write(CredentialCatalog.ApiAmap, $"probe-{Guid.NewGuid():N}-map");
|
childStore.Write(CredentialCatalog.ApiAmap, $"probe-{Guid.NewGuid():N}-map");
|
||||||
|
childStore.Write(CredentialCatalog.AdminPepper, $"probe-{Guid.NewGuid():N}-admin");
|
||||||
var childStart = new ProcessStartInfo(Environment.ProcessPath!);
|
var childStart = new ProcessStartInfo(Environment.ProcessPath!);
|
||||||
childStart.ArgumentList.Add("--managed-child");
|
childStart.ArgumentList.Add("--managed-child");
|
||||||
var managed = await ManagedChildProcess.StartAsync(childStart, ChildRole.Api, childStore);
|
var managed = await ManagedChildProcess.StartAsync(childStart, ChildRole.Api, childStore);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ internal static class CredentialCatalog
|
|||||||
|
|
||||||
internal static IReadOnlyList<string> RequiredFor(ChildRole role) => role switch
|
internal static IReadOnlyList<string> RequiredFor(ChildRole role) => role switch
|
||||||
{
|
{
|
||||||
ChildRole.Api => [ApiResend, ApiAmap],
|
ChildRole.Api => [ApiResend, ApiAmap, AdminPepper],
|
||||||
ChildRole.Worker => [WorkerAiGateway],
|
ChildRole.Worker => [WorkerAiGateway],
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(role)),
|
_ => throw new ArgumentOutOfRangeException(nameof(role)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Dada.Supervisor;
|
namespace Dada.Supervisor;
|
||||||
|
|
||||||
@@ -45,15 +46,15 @@ internal static class OfflineCommandRouter
|
|||||||
InstanceConfiguration next;
|
InstanceConfiguration next;
|
||||||
if (args is ["init", var initialDataRoot, var initialAssetRoot])
|
if (args is ["init", var initialDataRoot, var initialAssetRoot])
|
||||||
{
|
{
|
||||||
next = new InstanceConfiguration(current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes);
|
next = new InstanceConfiguration(1, current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes, []);
|
||||||
}
|
}
|
||||||
else if (args is ["data-root", var updatedDataRoot])
|
else if (args is ["data-root", var updatedDataRoot])
|
||||||
{
|
{
|
||||||
next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot) };
|
next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot), AdminRecoveryHashes = [] };
|
||||||
}
|
}
|
||||||
else if (args is ["asset-root", var updatedAssetRoot])
|
else if (args is ["asset-root", var updatedAssetRoot])
|
||||||
{
|
{
|
||||||
next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot) };
|
next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot), AdminRecoveryHashes = [] };
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -93,7 +94,7 @@ internal static class OfflineCommandRouter
|
|||||||
var current = configStore.Load();
|
var current = configStore.Load();
|
||||||
if (args is ["status"])
|
if (args is ["status"])
|
||||||
{
|
{
|
||||||
WriteResult("allowlist_status", true, current.AdminAllowlistHashes.Count);
|
WriteAllowlistResult("allowlist_status", current.AdminAllowlistHashes.Count, current.Revision);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage();
|
if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage();
|
||||||
@@ -103,8 +104,20 @@ internal static class OfflineCommandRouter
|
|||||||
var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized)));
|
var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized)));
|
||||||
var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal);
|
var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal);
|
||||||
if (action == "add") hashes.Add(digest); else hashes.Remove(digest);
|
if (action == "add") hashes.Add(digest); else hashes.Remove(digest);
|
||||||
configStore.Save(current with { Revision = current.Revision + 1, AdminAllowlistHashes = hashes.Order().ToArray() });
|
var recoveries = action == "add" ? new[] { digest } : Array.Empty<string>();
|
||||||
WriteResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", true, hashes.Count);
|
var orderedHashes = hashes.Order().ToArray();
|
||||||
|
if (action == "remove" && orderedHashes.SequenceEqual(current.AdminAllowlistHashes))
|
||||||
|
{
|
||||||
|
WriteAllowlistResult("allowlist_unchanged", orderedHashes.Length, current.Revision);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
configStore.Save(current with
|
||||||
|
{
|
||||||
|
Revision = current.Revision + 1,
|
||||||
|
AdminAllowlistHashes = orderedHashes,
|
||||||
|
AdminRecoveryHashes = recoveries,
|
||||||
|
});
|
||||||
|
WriteAllowlistResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", hashes.Count, current.Revision + 1);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +193,9 @@ internal static class OfflineCommandRouter
|
|||||||
private static void WriteResult(string code, bool success, int? revisionOrCount = null) =>
|
private static void WriteResult(string code, bool success, int? revisionOrCount = null) =>
|
||||||
Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions));
|
Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions));
|
||||||
|
|
||||||
|
private static void WriteAllowlistResult(string code, int count, int revision) =>
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(new { code, count, secure_config_revision = revision, success = true }, JsonOptions));
|
||||||
|
|
||||||
private static int Usage()
|
private static int Usage()
|
||||||
{
|
{
|
||||||
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false);
|
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false);
|
||||||
@@ -187,9 +203,15 @@ internal static class OfflineCommandRouter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record InstanceConfiguration(int Revision, string? LocalDataRoot, string? AssetRoot, IReadOnlyList<string> AdminAllowlistHashes)
|
internal sealed record InstanceConfiguration(
|
||||||
|
[property: JsonPropertyName("schema_version")] int SchemaVersion,
|
||||||
|
[property: JsonPropertyName("secure_config_revision")] int Revision,
|
||||||
|
string? LocalDataRoot,
|
||||||
|
string? AssetRoot,
|
||||||
|
IReadOnlyList<string> AdminAllowlistHashes,
|
||||||
|
IReadOnlyList<string> AdminRecoveryHashes)
|
||||||
{
|
{
|
||||||
internal static InstanceConfiguration Empty { get; } = new(0, null, null, []);
|
internal static InstanceConfiguration Empty { get; } = new(1, 0, null, null, [], []);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class InstanceConfigurationStore
|
internal sealed class InstanceConfigurationStore
|
||||||
@@ -202,12 +224,34 @@ internal sealed class InstanceConfigurationStore
|
|||||||
this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
internal InstanceConfiguration Load() => File.Exists(path)
|
internal InstanceConfiguration Load()
|
||||||
? JsonSerializer.Deserialize<InstanceConfiguration>(File.ReadAllText(path), JsonOptions) ?? InstanceConfiguration.Empty
|
{
|
||||||
: InstanceConfiguration.Empty;
|
if (!File.Exists(path)) return InstanceConfiguration.Empty;
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var configuration = JsonSerializer.Deserialize<InstanceConfiguration>(json, JsonOptions) ?? InstanceConfiguration.Empty;
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
if (configuration.Revision == 0 && document.RootElement.TryGetProperty("revision", out var legacyRevision))
|
||||||
|
{
|
||||||
|
configuration = configuration with { Revision = legacyRevision.GetInt32() };
|
||||||
|
}
|
||||||
|
return configuration with
|
||||||
|
{
|
||||||
|
SchemaVersion = configuration.SchemaVersion == 0 ? 1 : configuration.SchemaVersion,
|
||||||
|
AdminAllowlistHashes = configuration.AdminAllowlistHashes ?? [],
|
||||||
|
AdminRecoveryHashes = configuration.AdminRecoveryHashes ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
internal void Save(InstanceConfiguration configuration)
|
internal void Save(InstanceConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
if (configuration.SchemaVersion != 1 || configuration.Revision < 0) throw new InvalidOperationException("secure_config_invalid");
|
||||||
|
var validHash = new System.Text.RegularExpressions.Regex("^[A-F0-9]{64}$", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
|
||||||
|
if (configuration.AdminAllowlistHashes.Any(hash => !validHash.IsMatch(hash)) ||
|
||||||
|
configuration.AdminRecoveryHashes.Any(hash => !validHash.IsMatch(hash)) ||
|
||||||
|
configuration.AdminRecoveryHashes.Any(hash => !configuration.AdminAllowlistHashes.Contains(hash, StringComparer.Ordinal)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("secure_config_invalid");
|
||||||
|
}
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
var temporary = path + ".tmp";
|
var temporary = path + ".tmp";
|
||||||
File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine);
|
File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
||||||
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||||
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
||||||
|
startInfo.Environment["DADA_INSTANCE_CONFIG_PATH"] = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
|
||||||
startInfo.ArgumentList.Add(entry);
|
startInfo.ArgumentList.Add(entry);
|
||||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||||
child.StatusReceived += status =>
|
child.StatusReceived += status =>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-07-28T12:00:00.000Z");
|
||||||
|
const adminPepper = Buffer.alloc(32, 0xb1);
|
||||||
|
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-04-api-"));
|
||||||
|
roots.push(root);
|
||||||
|
const resend = new MockResendAdapter();
|
||||||
|
const registration = new RegistrationService({
|
||||||
|
adminAllowlistPepper: adminPepper,
|
||||||
|
challengePepper: Buffer.alloc(32, 0xb2),
|
||||||
|
clock: () => now,
|
||||||
|
codeGenerator: () => "418205",
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0xb3),
|
||||||
|
resend,
|
||||||
|
sessionPepper: Buffer.alloc(32, 0xb4),
|
||||||
|
});
|
||||||
|
services.push(registration);
|
||||||
|
return { registration, resend };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieValue(setCookie: string | string[] | undefined, name: string) {
|
||||||
|
const entries = Array.isArray(setCookie) ? setCookie : [setCookie ?? ""];
|
||||||
|
const match = entries.find((entry) => entry.startsWith(`${name}=`));
|
||||||
|
if (!match) throw new Error(`Cookie ${name} was not returned.`);
|
||||||
|
return match.split(";", 1)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TASK-WP1-04 admin auth API", () => {
|
||||||
|
it("rejects before Resend, creates an admin session, and isolates both audiences", async () => {
|
||||||
|
const { registration, resend } = createHarness();
|
||||||
|
const email = "api-admin@example.invalid";
|
||||||
|
registration.applySecureConfig({
|
||||||
|
adminAllowlistHashes: [createHmac("sha256", adminPepper).update(email).digest("hex").toUpperCase()],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 1,
|
||||||
|
});
|
||||||
|
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||||
|
|
||||||
|
const blocked = await app.inject({
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: { email: "blocked@example.invalid" },
|
||||||
|
url: "/api/v1/admin-auth/login/send",
|
||||||
|
});
|
||||||
|
expect(blocked.statusCode).toBe(409);
|
||||||
|
expect(blocked.json()).toMatchObject({ error: { details: { field_errors: [{ message_key: "admin.auth.not_allowed" }] } } });
|
||||||
|
expect(resend.calls).toHaveLength(0);
|
||||||
|
|
||||||
|
const sent = await app.inject({
|
||||||
|
headers,
|
||||||
|
method: "POST",
|
||||||
|
payload: { email },
|
||||||
|
url: "/api/v1/admin-auth/login/send",
|
||||||
|
});
|
||||||
|
expect(sent.statusCode).toBe(200);
|
||||||
|
const flowCookie = cookieValue(sent.headers["set-cookie"], "dada_admin_auth_flow");
|
||||||
|
const code = resend.readLatestCode(email);
|
||||||
|
const completed = await app.inject({
|
||||||
|
headers: { ...headers, cookie: flowCookie, "idempotency-key": "wp1-04-api-admin-complete-000000000001" },
|
||||||
|
method: "POST",
|
||||||
|
payload: { registration_id: sent.json().registration_id, verification_code: code },
|
||||||
|
url: "/api/v1/admin-auth/login/complete",
|
||||||
|
});
|
||||||
|
expect(completed.statusCode).toBe(200);
|
||||||
|
expect(completed.json()).toMatchObject({ audience: "admin", status: "authenticated", admin: { role: "super_admin" } });
|
||||||
|
const adminCookie = cookieValue(completed.headers["set-cookie"], "dada_admin_session");
|
||||||
|
|
||||||
|
const session = await app.inject({ headers: { ...headers, cookie: adminCookie }, method: "GET", url: "/api/v1/admin-auth/session" });
|
||||||
|
expect(session.statusCode).toBe(200);
|
||||||
|
expect(session.json()).toMatchObject({ audience: "admin", authenticated: true, notice_acknowledged: false });
|
||||||
|
|
||||||
|
const ordinaryUserId = randomUUID();
|
||||||
|
registration.database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, 'api-user@example.invalid', 'user', 'active', 1, ?, ?)
|
||||||
|
`).run(ordinaryUserId, randomUUID(), now);
|
||||||
|
const ordinary = registration.issueAuthenticatedSession(ordinaryUserId, "user");
|
||||||
|
const userAtAdmin = await app.inject({
|
||||||
|
headers: { ...headers, cookie: `dada_admin_session=${ordinary.sessionToken}` },
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/admin-auth/session",
|
||||||
|
});
|
||||||
|
expect(userAtAdmin.statusCode).toBe(401);
|
||||||
|
const adminAtUser = await app.inject({ headers: { ...headers, cookie: adminCookie }, method: "GET", url: "/api/v1/auth/session" });
|
||||||
|
expect(adminAtUser.statusCode).toBe(401);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { createServer, type ViteDevServer } from "vite";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
let vite: ViteDevServer;
|
||||||
|
let webUrl: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vite = await createServer({
|
||||||
|
configFile: resolve("apps/web/vite.config.ts"),
|
||||||
|
root: resolve("apps/web"),
|
||||||
|
server: { host: "127.0.0.1", port: 0 },
|
||||||
|
});
|
||||||
|
await vite.listen();
|
||||||
|
const address = vite.httpServer?.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||||
|
webUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
|
test("TDD-WP1-ADM-001 renders the isolated admin login and hands success to /admin", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/admin-auth/login/send", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_expires_at: "2026-07-28T12:10:00.000Z",
|
||||||
|
registration_id: "00000000-0000-4000-8000-000000000007",
|
||||||
|
resend_available_at: "2026-07-28T12:01:00.000Z",
|
||||||
|
status: "verification_sent",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await page.route("**/api/v1/admin-auth/login/complete", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({ audience: "admin", status: "authenticated" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto(`${webUrl}/admin/login`);
|
||||||
|
await expect(page.getByRole("heading", { name: "管理员邮箱验证码登录" })).toBeVisible();
|
||||||
|
await expect(page.locator(".auth-art")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: "返回普通用户登录" })).toHaveAttribute("href", "/");
|
||||||
|
await page.getByRole("textbox", { name: "管理员邮箱" }).fill("admin-ui@example.invalid");
|
||||||
|
const sendButton = page.getByRole("button", { name: "获取验证码" });
|
||||||
|
const widthBefore = (await sendButton.boundingBox())?.width;
|
||||||
|
await sendButton.click();
|
||||||
|
const codeInput = page.getByRole("textbox", { name: "验证码" });
|
||||||
|
await expect(codeInput).toBeVisible();
|
||||||
|
expect((await sendButton.boundingBox())?.width).toBe(widthBefore);
|
||||||
|
await codeInput.fill("418205");
|
||||||
|
await page.getByRole("button", { name: "登录后台" }).click();
|
||||||
|
await expect(page).toHaveURL(`${webUrl}/admin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TDD-WP1-ADM-001 shows a generic allowlist rejection without exposing identity state", async ({ page }) => {
|
||||||
|
await page.route("**/api/v1/admin-auth/login/send", (route) => route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
status: 409,
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
code: "AUTH_ENTRY_REJECTED",
|
||||||
|
correlation_id: "00000000-0000-4000-8000-000000000008",
|
||||||
|
details: { field_errors: [{ field: "email", message_key: "admin.auth.not_allowed" }] },
|
||||||
|
message_key: "auth.entry_rejected",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await page.goto(`${webUrl}/admin/login`);
|
||||||
|
await page.getByRole("textbox", { name: "管理员邮箱" }).fill("not-admin@example.invalid");
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("无法使用管理员入口");
|
||||||
|
await expect(page.getByRole("alert")).not.toContainText("普通用户");
|
||||||
|
await expect(page.getByRole("alert")).not.toContainText("白名单");
|
||||||
|
});
|
||||||
@@ -35,6 +35,6 @@ test("TDD-WP1-AUTH-003 keeps registration and login as separate keyboard entries
|
|||||||
await page.keyboard.press("ArrowLeft");
|
await page.keyboard.press("ArrowLeft");
|
||||||
await expect(loginTab).toBeFocused();
|
await expect(loginTab).toBeFocused();
|
||||||
await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0);
|
await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0);
|
||||||
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin");
|
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin/login");
|
||||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test("TDD-WP1-AUTH-003 renders Z0pf8 login states without silently switching ent
|
|||||||
await page.goto(webUrl);
|
await page.goto(webUrl);
|
||||||
await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible();
|
||||||
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin");
|
await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin/login");
|
||||||
await expect(page.getByLabel("邀请码")).toHaveCount(0);
|
await expect(page.getByLabel("邀请码")).toHaveCount(0);
|
||||||
await expect(page.getByLabel("创作署名")).toHaveCount(0);
|
await expect(page.getByLabel("创作署名")).toHaveCount(0);
|
||||||
await expect(page.getByLabel("社交 ID")).toHaveCount(0);
|
await expect(page.getByLabel("社交 ID")).toHaveCount(0);
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { RegistrationError, RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-07-28T10:00:00.000Z");
|
||||||
|
const adminPepper = Buffer.alloc(32, 0x91);
|
||||||
|
|
||||||
|
function allowlistHash(email: string) {
|
||||||
|
return createHmac("sha256", adminPepper).update(email.trim().toLowerCase(), "utf8").digest("hex").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-04-admin-"));
|
||||||
|
roots.push(root);
|
||||||
|
const resend = new MockResendAdapter();
|
||||||
|
const service = new RegistrationService({
|
||||||
|
adminAllowlistPepper: adminPepper,
|
||||||
|
challengePepper: Buffer.alloc(32, 0x92),
|
||||||
|
clock: () => now,
|
||||||
|
codeGenerator: () => "418205",
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0x93),
|
||||||
|
resend,
|
||||||
|
sessionPepper: Buffer.alloc(32, 0x94),
|
||||||
|
});
|
||||||
|
services.push(service);
|
||||||
|
return { resend, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvidence(file: string, value: unknown) {
|
||||||
|
const directory = process.env.DADA_EVIDENCE_DIR_ADMIN;
|
||||||
|
if (!directory) return;
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP1-ADM-001-admin-auth-boundary", () => {
|
||||||
|
it("keeps admin authentication allowlisted, multi-admin, isolated, and recoverable only by secure config", async () => {
|
||||||
|
const { resend, service } = createHarness();
|
||||||
|
const adminEmails = ["admin-one@example.invalid", "admin-two@example.invalid"];
|
||||||
|
const hashes = adminEmails.map(allowlistHash);
|
||||||
|
service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: hashes,
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sendAdminLoginCode({ clientKey: "blocked-client", email: "blocked@example.invalid" }))
|
||||||
|
.rejects.toMatchObject({ reason: "admin_not_allowed" });
|
||||||
|
expect(resend.calls).toHaveLength(0);
|
||||||
|
|
||||||
|
const admins = [];
|
||||||
|
for (const [index, email] of adminEmails.entries()) {
|
||||||
|
const sent = await service.sendAdminLoginCode({ clientKey: `admin-client-${index}`, email });
|
||||||
|
const completed = service.completeAdminLogin({
|
||||||
|
clientKey: `admin-client-${index}`,
|
||||||
|
code: resend.readLatestCode(email),
|
||||||
|
idempotencyKey: `wp1-04-admin-login-${index}`.padEnd(40, "0"),
|
||||||
|
registrationId: sent.registrationId,
|
||||||
|
});
|
||||||
|
expect(completed).toMatchObject({ audience: "admin", status: "authenticated", admin: { role: "super_admin" } });
|
||||||
|
admins.push(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordinaryUserId = randomUUID();
|
||||||
|
service.database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, 'ordinary@example.invalid', 'user', 'active', 1, ?, ?)
|
||||||
|
`).run(ordinaryUserId, randomUUID(), now);
|
||||||
|
const ordinarySession = service.issueAuthenticatedSession(ordinaryUserId, "user");
|
||||||
|
expect(service.readAdminSession(ordinarySession.sessionToken)).toBeUndefined();
|
||||||
|
expect(service.readUserSession(admins[0].sessionToken)).toBeUndefined();
|
||||||
|
|
||||||
|
for (const admin of admins) service.revokeAdminSessions(admin.admin.userId, "disabled");
|
||||||
|
await expect(service.sendAdminLoginCode({ clientKey: "disabled-client", email: adminEmails[0] }))
|
||||||
|
.rejects.toMatchObject({ reason: "account_suspended" });
|
||||||
|
service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: hashes,
|
||||||
|
adminRecoveryHashes: hashes,
|
||||||
|
secureConfigRevision: 2,
|
||||||
|
});
|
||||||
|
await expect(service.sendAdminLoginCode({ clientKey: "recovered-client", email: adminEmails[0] }))
|
||||||
|
.resolves.toMatchObject({ status: "verification_sent" });
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
adminAccess: service.database.prepare("SELECT COUNT(*) AS count FROM admin_access WHERE allowed = 1").get().count,
|
||||||
|
adminCredits: service.database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count FROM credit_accounts c JOIN users u ON u.user_id = c.user_id WHERE u.role = 'super_admin'
|
||||||
|
`).get().count,
|
||||||
|
admins: service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'super_admin'").get().count,
|
||||||
|
ordinaryUsers: service.database.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'user'").get().count,
|
||||||
|
};
|
||||||
|
expect(counts).toEqual({ adminAccess: 2, adminCredits: 0, admins: 2, ordinaryUsers: 1 });
|
||||||
|
const audits = service.database.prepare("SELECT actor_type, actor_ref, result FROM admin_operation_logs ORDER BY occurred_at").all();
|
||||||
|
expect(audits.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(audits.every((entry: any) => ["system", "super_admin"].includes(entry.actor_type))).toBe(true);
|
||||||
|
expect(JSON.stringify(audits)).not.toContain("@example.invalid");
|
||||||
|
|
||||||
|
writeEvidence("response.json", {
|
||||||
|
admin_count: counts.admins,
|
||||||
|
audiences_isolated: true,
|
||||||
|
non_allowlisted_status: "rejected_before_send",
|
||||||
|
recovery_source: "secure_config_revision_2",
|
||||||
|
status: "passed",
|
||||||
|
});
|
||||||
|
writeEvidence("db-diff.json", { after: counts, admin_sessions_revoked_before_recovery: true, status: "passed" });
|
||||||
|
writeEvidence("external-calls.json", {
|
||||||
|
calls: resend.calls.map((call) => ({ purpose: call.purpose, recipient_kind: "synthetic_admin" })),
|
||||||
|
non_allowlisted_calls: 0,
|
||||||
|
status: "passed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { createHmac, randomUUID } from "node:crypto";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||||
|
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||||
|
import { readSecureConfigCandidate } from "../../apps/api/src/secure-config.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const services: RegistrationService[] = [];
|
||||||
|
const now = Date.parse("2026-07-28T11:00:00.000Z");
|
||||||
|
const adminPepper = Buffer.alloc(32, 0xa1);
|
||||||
|
|
||||||
|
function hash(email: string) {
|
||||||
|
return createHmac("sha256", adminPepper).update(email.trim().toLowerCase(), "utf8").digest("hex").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(withPepper = true) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "dada-wp1-04-config-"));
|
||||||
|
roots.push(root);
|
||||||
|
const service = new RegistrationService({
|
||||||
|
...(withPepper ? { adminAllowlistPepper: adminPepper } : {}),
|
||||||
|
challengePepper: Buffer.alloc(32, 0xa2),
|
||||||
|
clock: () => now,
|
||||||
|
codeGenerator: () => "418205",
|
||||||
|
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
||||||
|
databasePath: join(root, "dada.sqlite3"),
|
||||||
|
invitePepper: Buffer.alloc(32, 0xa3),
|
||||||
|
resend: new MockResendAdapter(),
|
||||||
|
sessionPepper: Buffer.alloc(32, 0xa4),
|
||||||
|
});
|
||||||
|
services.push(service);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeEvidence(file: string, value: unknown) {
|
||||||
|
const directory = process.env.DADA_EVIDENCE_DIR_CONFIG;
|
||||||
|
if (!directory) return;
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const service of services.splice(0)) service.close();
|
||||||
|
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TDD-WP1-CFG-001-secure-revision", () => {
|
||||||
|
it("applies one complete revision atomically and rolls invalid candidates back without sensitive persistence", async () => {
|
||||||
|
const service = createService();
|
||||||
|
const firstEmail = "first-admin@example.invalid";
|
||||||
|
const replacementEmail = "replacement-admin@example.invalid";
|
||||||
|
const firstHash = hash(firstEmail);
|
||||||
|
const replacementHash = hash(replacementEmail);
|
||||||
|
const configPath = join(roots[0]!, "instance.json");
|
||||||
|
writeFileSync(configPath, `${JSON.stringify({
|
||||||
|
admin_allowlist_hashes: [firstHash],
|
||||||
|
admin_recovery_hashes: [],
|
||||||
|
schema_version: 1,
|
||||||
|
secure_config_revision: 1,
|
||||||
|
})}\n`);
|
||||||
|
expect(readSecureConfigCandidate(configPath)).toEqual({
|
||||||
|
adminAllowlistHashes: [firstHash],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: [firstHash],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 1,
|
||||||
|
})).toMatchObject({ appliedRevision: 1, status: "applied" });
|
||||||
|
const resend = service.options.resend as MockResendAdapter;
|
||||||
|
const sent = await service.sendAdminLoginCode({ clientKey: "first-admin-client", email: firstEmail });
|
||||||
|
const loggedIn = service.completeAdminLogin({
|
||||||
|
clientKey: "first-admin-client",
|
||||||
|
code: resend.readLatestCode(firstEmail),
|
||||||
|
idempotencyKey: "wp1-04-config-first-login-00000000001",
|
||||||
|
registrationId: sent.registrationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: [replacementHash],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 2,
|
||||||
|
})).toMatchObject({ appliedRevision: 2, status: "applied" });
|
||||||
|
expect(service.readAdminSession(loggedIn.sessionToken)).toBeUndefined();
|
||||||
|
|
||||||
|
const ordinaryUserId = randomUUID();
|
||||||
|
service.database.prepare(`
|
||||||
|
INSERT INTO users (
|
||||||
|
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||||
|
registration_id, created_at
|
||||||
|
) VALUES (?, 'ordinary-conflict@example.invalid', 'user', 'active', 1, ?, ?)
|
||||||
|
`).run(ordinaryUserId, randomUUID(), now);
|
||||||
|
const conflictHash = hash("ordinary-conflict@example.invalid");
|
||||||
|
expect(() => service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: [replacementHash, conflictHash],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 3,
|
||||||
|
})).toThrow(/identity_conflict/);
|
||||||
|
expect(() => service.applySecureConfig({
|
||||||
|
adminAllowlistHashes: ["not-a-valid-hmac"],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 3,
|
||||||
|
})).toThrow(/hmac_invalid/);
|
||||||
|
|
||||||
|
const withoutPepper = createService(false);
|
||||||
|
expect(() => withoutPepper.applySecureConfig({
|
||||||
|
adminAllowlistHashes: [replacementHash],
|
||||||
|
adminRecoveryHashes: [],
|
||||||
|
secureConfigRevision: 1,
|
||||||
|
})).toThrow(/admin_pepper_not_configured/);
|
||||||
|
|
||||||
|
const state = service.database.prepare("SELECT * FROM secure_config_apply_state WHERE singleton = 1").get() as any;
|
||||||
|
expect(state).toMatchObject({ allowlist_count: 1, applied_revision: 2 });
|
||||||
|
expect(Object.keys(state)).not.toContain("admin_allowlist_hashes");
|
||||||
|
const audits = service.database.prepare(`
|
||||||
|
SELECT actor_type, actor_ref, operation_type, result, before_summary, after_summary
|
||||||
|
FROM admin_operation_logs ORDER BY occurred_at
|
||||||
|
`).all();
|
||||||
|
expect(audits).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ actor_ref: "backend_secure_config", actor_type: "system", result: "succeeded" }),
|
||||||
|
expect.objectContaining({ actor_ref: "backend_secure_config", actor_type: "system", result: "failed" }),
|
||||||
|
]));
|
||||||
|
const retention = service.database.prepare(`
|
||||||
|
SELECT occurred_at, expires_at FROM admin_operation_logs ORDER BY occurred_at LIMIT 1
|
||||||
|
`).get() as { expires_at: number; occurred_at: number };
|
||||||
|
expect(retention.expires_at - retention.occurred_at).toBe(180 * 24 * 60 * 60 * 1_000);
|
||||||
|
expect(() => service.database.prepare(`
|
||||||
|
UPDATE admin_operation_logs SET result = 'failed' WHERE log_id = (SELECT log_id FROM admin_operation_logs LIMIT 1)
|
||||||
|
`).run()).toThrow(/admin_operation_logs_immutable/);
|
||||||
|
expect(() => service.database.prepare(`
|
||||||
|
DELETE FROM admin_operation_logs WHERE log_id = (SELECT log_id FROM admin_operation_logs LIMIT 1)
|
||||||
|
`).run()).toThrow(/admin_operation_logs_immutable/);
|
||||||
|
const redactionProbe = JSON.stringify({ audits, state });
|
||||||
|
for (const forbidden of [firstEmail, replacementEmail, firstHash, replacementHash, adminPepper.toString("hex")]) {
|
||||||
|
expect(redactionProbe).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEvidence("config-result.json", {
|
||||||
|
applied_revision: state.applied_revision,
|
||||||
|
failed_candidates: ["identity_conflict", "hmac_invalid", "admin_pepper_not_configured"],
|
||||||
|
status: "passed",
|
||||||
|
});
|
||||||
|
writeEvidence("db-diff.json", {
|
||||||
|
after: { allowlist_count: state.allowlist_count, applied_revision: state.applied_revision },
|
||||||
|
removed_admin_sessions_revoked: true,
|
||||||
|
rejected_revision_advanced: false,
|
||||||
|
status: "passed",
|
||||||
|
});
|
||||||
|
writeEvidence("redaction.json", {
|
||||||
|
forbidden_values_absent: true,
|
||||||
|
stored_secure_state_fields: Object.keys(state).sort(),
|
||||||
|
status: "passed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user