feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -31,6 +31,9 @@ export interface RegistrationTransactionEvent {
|
||||
| "registration_send_compensation"
|
||||
| "login_send"
|
||||
| "login_complete"
|
||||
| "admin_login_send"
|
||||
| "admin_login_complete"
|
||||
| "secure_config_apply"
|
||||
| "session_issue"
|
||||
| "session_revoke"
|
||||
| "csrf_issue";
|
||||
@@ -38,6 +41,7 @@ export interface RegistrationTransactionEvent {
|
||||
}
|
||||
|
||||
interface RegistrationServiceOptions {
|
||||
adminAllowlistPepper?: Buffer;
|
||||
challengePepper: Buffer;
|
||||
clock?: () => number;
|
||||
codeGenerator?: () => string;
|
||||
@@ -151,6 +155,24 @@ export type LoginCompleteResult = Omit<RegistrationCompleteResult, "status"> & {
|
||||
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> {
|
||||
outcome: RegistrationTransactionEvent["outcome"];
|
||||
value: T;
|
||||
@@ -195,11 +217,13 @@ function constantTimeTextEqual(left: string, right: string) {
|
||||
export class RegistrationService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
||||
private adminAllowlistHashes = new Set<string>();
|
||||
|
||||
constructor(options: RegistrationServiceOptions) {
|
||||
assertSecret("invitePepper", options.invitePepper);
|
||||
assertSecret("challengePepper", options.challengePepper);
|
||||
assertSecret("sessionPepper", options.sessionPepper);
|
||||
if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper);
|
||||
this.options = {
|
||||
...options,
|
||||
clock: options.clock ?? Date.now,
|
||||
@@ -571,6 +595,317 @@ export class RegistrationService {
|
||||
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") {
|
||||
const now = this.options.clock();
|
||||
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 }) {
|
||||
const now = this.options.clock();
|
||||
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);
|
||||
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL")
|
||||
.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 };
|
||||
});
|
||||
}
|
||||
@@ -794,6 +1157,29 @@ export class RegistrationService {
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
|
||||
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 (
|
||||
rate_key TEXT PRIMARY KEY,
|
||||
window_started_at INTEGER NOT NULL,
|
||||
@@ -811,6 +1197,62 @@ export class RegistrationService {
|
||||
session_id TEXT REFERENCES sessions(session_id),
|
||||
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}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
|
||||
}
|
||||
@@ -909,7 +1364,7 @@ export class RegistrationService {
|
||||
|
||||
private assertChallengeSendAllowed(
|
||||
email: string,
|
||||
purpose: "register" | "login",
|
||||
purpose: "register" | "login" | "admin_login",
|
||||
clientKey: string,
|
||||
now: number,
|
||||
) {
|
||||
@@ -939,6 +1394,120 @@ export class RegistrationService {
|
||||
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(
|
||||
idempotencyKeyDigest: string,
|
||||
requestHash: string,
|
||||
|
||||
Reference in New Issue
Block a user