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()
+75
View File
@@ -0,0 +1,75 @@
import { z } from 'zod'
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
export const AUDIT_TARGET_TYPES = [
'user',
'settings',
'session',
'system',
] as const
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
/** Well-known action keys written by the API. */
export const AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
'auth.logout',
'user.create',
'user.update',
'user.delete',
'user.access_update',
'app_switcher.update',
'audit.settings_update',
'audit.purge',
] as const
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
export const auditLogEntrySchema = z.object({
id: z.string(),
action: z.string(),
severity: auditSeveritySchema,
actor_user_id: z.string().nullable(),
actor_email: z.string().nullable(),
actor_name: z.string().nullable(),
target_type: auditTargetTypeSchema.nullable(),
target_id: z.string().nullable(),
summary: z.string(),
details: z.record(z.string(), z.unknown()).nullable(),
ip: z.string().nullable(),
created_at: z.string(),
})
export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
export const auditListQuerySchema = z.object({
action: z.string().optional(),
severity: auditSeveritySchema.optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
export const DEFAULT_AUDIT_RETENTION_DAYS = 90
export const MIN_AUDIT_RETENTION_DAYS = 7
export const MAX_AUDIT_RETENTION_DAYS = 3650
export const auditSettingsSchema = z.object({
retention_days: z
.number()
.int()
.min(MIN_AUDIT_RETENTION_DAYS)
.max(MAX_AUDIT_RETENTION_DAYS),
})
export type AuditSettings = z.infer<typeof auditSettingsSchema>
export const putAuditSettingsSchema = auditSettingsSchema
export type PutAuditSettings = z.infer<typeof putAuditSettingsSchema>
export const auditPurgeResponseSchema = z.object({
deleted: z.number().int().nonnegative(),
retention_days: z.number().int(),
})
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
+1
View File
@@ -1,3 +1,4 @@
export * from './contracts/auth.js'
export * from './contracts/app-switcher.js'
export * from './contracts/audit.js'
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}