feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m37s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Добавлен audit log (solution-users-6) с retention N дней и hourly purge; таблица пользователей приведена к DNA solution-users-1 (Filters, avatar, sorting).

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 04:20:47 +07:00
co-authored by Cursor
parent e430abe98b
commit 26fc8253c3
30 changed files with 2959 additions and 59 deletions
+150
View File
@@ -0,0 +1,150 @@
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import {
DEFAULT_AUDIT_RETENTION_DAYS,
type AuditLogEntry,
type AuditSeverity,
type AuditTargetType,
} from '@authportal/shared'
import type { AppDb } from './index.js'
import { auditLog, portalSettings } from './schema/index.js'
const SETTINGS_ID = 'main'
export type AppendAuditInput = {
action: string
severity?: AuditSeverity
actorUserId?: string | null
actorEmail?: string | null
actorName?: string | null
targetType?: AuditTargetType | null
targetId?: string | null
summary: string
details?: Record<string, unknown> | null
ip?: string | null
}
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
let details: Record<string, unknown> | null = null
if (row.detailsJson) {
try {
details = JSON.parse(row.detailsJson) as Record<string, unknown>
} catch {
details = { raw: row.detailsJson }
}
}
return {
id: row.id,
action: row.action,
severity: row.severity as AuditSeverity,
actor_user_id: row.actorUserId,
actor_email: row.actorEmail,
actor_name: row.actorName,
target_type: (row.targetType as AuditTargetType | null) ?? null,
target_id: row.targetId,
summary: row.summary,
details,
ip: row.ip,
created_at: row.createdAt,
}
}
/** Append an audit event. Callers should catch/swallow DB errors. */
export function appendAudit(db: AppDb, input: AppendAuditInput): void {
const now = new Date().toISOString()
db.insert(auditLog)
.values({
id: randomUUID(),
action: input.action,
severity: input.severity ?? 'info',
actorUserId: input.actorUserId ?? null,
actorEmail: input.actorEmail ?? null,
actorName: input.actorName ?? null,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
summary: input.summary,
detailsJson: input.details ? JSON.stringify(input.details) : null,
ip: input.ip ?? null,
createdAt: now,
})
.run()
}
export function listAudit(
db: AppDb,
opts: { action?: string; severity?: AuditSeverity; limit?: number } = {},
): AuditLogEntry[] {
const limit = opts.limit ?? 200
const conditions = []
if (opts.action) conditions.push(eq(auditLog.action, opts.action))
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
const rows =
conditions.length > 0
? db
.select()
.from(auditLog)
.where(and(...conditions))
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.all()
: db
.select()
.from(auditLog)
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.all()
return rows.map(mapRow)
}
export function purgeAuditOlderThan(db: AppDb, days: number): number {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
const result = db
.delete(auditLog)
.where(lt(auditLog.createdAt, cutoff))
.run()
return result.changes
}
export function getAuditRetentionDays(db: AppDb): number {
const row = db
.select()
.from(portalSettings)
.where(eq(portalSettings.id, SETTINGS_ID))
.get()
return row?.auditRetentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS
}
export function setAuditRetentionDays(db: AppDb, days: number): number {
const now = new Date().toISOString()
const existing = db
.select()
.from(portalSettings)
.where(eq(portalSettings.id, SETTINGS_ID))
.get()
if (existing) {
db.update(portalSettings)
.set({ auditRetentionDays: days, updatedAt: now })
.where(eq(portalSettings.id, SETTINGS_ID))
.run()
} else {
db.insert(portalSettings)
.values({
id: SETTINGS_ID,
appSwitcherJson: null,
auditRetentionDays: days,
updatedAt: now,
})
.run()
}
return days
}
export function countAudit(db: AppDb): number {
const row = db
.select({ count: sql<number>`count(*)` })
.from(auditLog)
.get()
return Number(row?.count ?? 0)
}
+29
View File
@@ -63,13 +63,41 @@ export function migrateSchema(sqlite: Sqlite): void {
CREATE TABLE IF NOT EXISTS portal_settings (
id TEXT PRIMARY KEY NOT NULL,
app_switcher_json TEXT,
audit_retention_days INTEGER NOT NULL DEFAULT 90,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
actor_user_id TEXT,
actor_email TEXT,
actor_name TEXT,
target_type TEXT,
target_id TEXT,
summary TEXT NOT NULL,
details_json TEXT,
ip TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id);
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
`)
// Existing DBs created before audit_retention_days
const cols = sqlite
.prepare(`PRAGMA table_info(portal_settings)`)
.all() as Array<{ name: string }>
if (!cols.some((c) => c.name === 'audit_retention_days')) {
sqlite.exec(
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
)
}
}
export function healthCheck(sqlite: Sqlite): void {
@@ -79,3 +107,4 @@ export function healthCheck(sqlite: Sqlite): void {
export * from './schema/index.js'
export * from './users.js'
export * from './settings.js'
export * from './audit-log.js'
+16
View File
@@ -40,5 +40,21 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
export const portalSettings = sqliteTable('portal_settings', {
id: text('id').primaryKey(),
appSwitcherJson: text('app_switcher_json'),
auditRetentionDays: integer('audit_retention_days').notNull().default(90),
updatedAt: text('updated_at').notNull(),
})
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
action: text('action').notNull(),
severity: text('severity').notNull(),
actorUserId: text('actor_user_id'),
actorEmail: text('actor_email'),
actorName: text('actor_name'),
targetType: text('target_type'),
targetId: text('target_id'),
summary: text('summary').notNull(),
detailsJson: text('details_json'),
ip: text('ip'),
createdAt: text('created_at').notNull(),
})
+1
View File
@@ -46,6 +46,7 @@ export function setAppSwitcherConfig(
.values({
id: SETTINGS_ID,
appSwitcherJson: json,
auditRetentionDays: 90,
updatedAt: now,
})
.run()