feat: added logging system

This commit is contained in:
shuaiplus
2026-05-14 02:42:15 +08:00
parent 17ceec45b1
commit 3e4c104e1d
34 changed files with 3179 additions and 66 deletions
+209
View File
@@ -0,0 +1,209 @@
import type { Env } from '../types';
import { generateUUID } from '../utils/uuid';
import { StorageService } from './storage';
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditEventInput {
actorUserId?: string | null;
action: string;
category: AuditLogCategory;
level?: AuditLogLevel;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
const MAX_METADATA_BYTES = 2048;
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
const AUDIT_CLEANUP_PROBABILITY = 0.02;
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
retentionDays: 90,
maxEntries: null,
};
let lastAuditCleanupAt = 0;
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
const ALLOWED_METADATA_KEYS = new Set([
'method',
'path',
'ip',
'userAgent',
'email',
'targetEmail',
'grantType',
'webSession',
'deviceIdentifier',
'deviceType',
'reason',
'status',
'verifyDevices',
'changed',
'removed',
'updated',
'deleted',
'removedTrusted',
'removedSessions',
'removedDevices',
'requested',
'count',
'requestedCount',
'type',
'folderId',
'cipherId',
'size',
'users',
'ciphers',
'attachments',
'skippedAttachments',
'skippedReason',
'replaceExisting',
'provider',
'fileName',
'fileBytes',
'bytes',
'compressedBytes',
'includesAttachments',
'destinationName',
'destinationId',
'destinationType',
'destinationCount',
'scheduledDestinationCount',
'retentionDays',
'maxEntries',
'remotePath',
'trigger',
'prunedFileCount',
'pruneError',
'uploadVerificationAttempts',
'error',
'expiresInHours',
'checksumMismatchAccepted',
]);
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
const parsed = Math.floor(Number(value));
return allowed.includes(parsed) ? parsed : null;
}
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
if (retentionDays) return { retentionDays, maxEntries: null };
if (maxEntries) return { retentionDays: null, maxEntries };
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
return { retentionDays: null, maxEntries: null };
}
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
return { retentionDays: null, maxEntries: null };
}
return {
...DEFAULT_AUDIT_LOG_SETTINGS,
};
}
export function auditRequestMetadata(request: Request): Record<string, unknown> {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
userAgent: request.headers.get('User-Agent') || null,
};
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
if (value === undefined || value === null || value === '') continue;
if (SENSITIVE_KEY_RE.test(key)) continue;
if (Array.isArray(value)) {
clean[key] = value.length;
continue;
}
if (typeof value === 'object') continue;
clean[key] = value;
}
return clean;
}
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
try {
return normalizeAuditLogSettings(JSON.parse(raw));
} catch {
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
}
}
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
const normalized = normalizeAuditLogSettings(settings);
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
await applyAuditLogRetention(storage, normalized);
return normalized;
}
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
const current = settings || await getAuditLogSettings(storage);
if (current.retentionDays) {
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
await storage.pruneAuditLogs(before);
}
if (current.maxEntries) {
await storage.pruneAuditLogsToMax(current.maxEntries);
}
}
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
const now = Date.now();
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
lastAuditCleanupAt = now;
await applyAuditLogRetention(storage);
}
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
const metadata = sanitizeMetadata(event.metadata || {});
let metadataJson = JSON.stringify(metadata);
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
metadataJson = JSON.stringify({ truncated: true });
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: event.actorUserId ?? null,
action: event.action,
category: event.category,
level: event.level || 'info',
targetType: event.targetType ?? null,
targetId: event.targetId ?? null,
metadata: metadataJson,
createdAt: new Date().toISOString(),
});
await maybePruneAuditLogs(storage);
}
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
try {
await insertAuditEvent(storage, event);
} catch (error) {
console.error('audit log write failed', error);
}
}
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
await writeAuditEvent(new StorageService(env.DB), event);
}
+33 -9
View File
@@ -23,6 +23,22 @@ export interface VerifiedAccessContext {
user: User;
}
export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired'
| 'user_missing'
| 'user_inactive'
| 'device_missing'
| 'device_session_mismatch';
export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
userId?: string | null;
deviceIdentifier?: string | null;
};
export class AuthService {
private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
@@ -223,17 +239,18 @@ export class AuthService {
}
// Refresh access token
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null;
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(record.userId);
if (!user) return null;
if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
let device: { identifier: string; sessionStamp: string } | null = null;
@@ -241,16 +258,23 @@ export class AuthService {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const accessToken = await this.generateAccessToken(user, device);
return { accessToken, user, device };
return { ok: true, accessToken, user, device };
}
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const result = await this.refreshAccessTokenDetailed(refreshToken);
return result.ok ? result : null;
}
}
+121 -2
View File
@@ -1,5 +1,72 @@
import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db
.prepare(
@@ -77,8 +144,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+7 -1
View File
@@ -82,10 +82,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
+22 -1
View File
@@ -18,12 +18,17 @@ import {
saveUser as saveStoredUser,
} from './storage-user-repo';
import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
@@ -117,7 +122,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2';
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
// D1-backed storage.
// Contract:
@@ -279,6 +284,22 @@ export class StorageService {
await createStoredAuditLog(this.db, log);
}
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
// --- Domain rules ---
async getUserDomainSettings(userId: string) {