feat(admin): журнал аудита с ротацией и апгрейд таблицы пользователей
Добавлен 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:
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export function setAppSwitcherConfig(
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
appSwitcherJson: json,
|
||||
auditRetentionDays: 90,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
@@ -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,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 }
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user