feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m46s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Добавлен POST /api/v1/ingest/audit, фильтры source_app/user_id, last_login_at; таблица пользователей по solution-users-1 с журналом в Sheet.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 13:24:28 +07:00
co-authored by Cursor
parent 26fc8253c3
commit 57e34ff5e9
27 changed files with 1983 additions and 639 deletions
+43 -5
View File
@@ -1,9 +1,10 @@
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { and, desc, eq, lt, or, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import {
DEFAULT_AUDIT_RETENTION_DAYS,
type AuditLogEntry,
type AuditSeverity,
type AuditSourceApp,
type AuditTargetType,
} from '@authportal/shared'
import type { AppDb } from './index.js'
@@ -12,6 +13,8 @@ import { auditLog, portalSettings } from './schema/index.js'
const SETTINGS_ID = 'main'
export type AppendAuditInput = {
eventId?: string | null
sourceApp?: AuditSourceApp
action: string
severity?: AuditSeverity
actorUserId?: string | null
@@ -22,6 +25,7 @@ export type AppendAuditInput = {
summary: string
details?: Record<string, unknown> | null
ip?: string | null
createdAt?: string | null
}
function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
@@ -35,6 +39,8 @@ function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
}
return {
id: row.id,
event_id: row.eventId,
source_app: (row.sourceApp as AuditSourceApp) || 'portal',
action: row.action,
severity: row.severity as AuditSeverity,
actor_user_id: row.actorUserId,
@@ -49,12 +55,28 @@ function mapRow(row: typeof auditLog.$inferSelect): AuditLogEntry {
}
}
/** Append an audit event. Callers should catch/swallow DB errors. */
export function appendAudit(db: AppDb, input: AppendAuditInput): void {
const now = new Date().toISOString()
/**
* Append an audit event.
* @returns true if inserted, false if duplicate event_id
*/
export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
const now = input.createdAt ?? new Date().toISOString()
const eventId = input.eventId ?? null
if (eventId) {
const existing = db
.select({ id: auditLog.id })
.from(auditLog)
.where(eq(auditLog.eventId, eventId))
.get()
if (existing) return false
}
db.insert(auditLog)
.values({
id: randomUUID(),
eventId,
sourceApp: input.sourceApp ?? 'portal',
action: input.action,
severity: input.severity ?? 'info',
actorUserId: input.actorUserId ?? null,
@@ -68,16 +90,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): void {
createdAt: now,
})
.run()
return true
}
export function listAudit(
db: AppDb,
opts: { action?: string; severity?: AuditSeverity; limit?: number } = {},
opts: {
action?: string
severity?: AuditSeverity
userId?: string
sourceApp?: AuditSourceApp
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))
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
if (opts.userId) {
conditions.push(
or(
eq(auditLog.actorUserId, opts.userId),
eq(auditLog.targetId, opts.userId),
)!,
)
}
const rows =
conditions.length > 0
+34 -3
View File
@@ -37,6 +37,7 @@ export function migrateSchema(sqlite: Sqlite): void {
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -69,6 +70,8 @@ export function migrateSchema(sqlite: Sqlite): void {
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY NOT NULL,
event_id TEXT,
source_app TEXT NOT NULL DEFAULT 'portal',
action TEXT NOT NULL,
severity TEXT NOT NULL,
actor_user_id TEXT,
@@ -87,17 +90,45 @@ export function migrateSchema(sqlite: Sqlite): void {
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);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
`)
// Existing DBs created before audit_retention_days
const cols = sqlite
const settingsCols = sqlite
.prepare(`PRAGMA table_info(portal_settings)`)
.all() as Array<{ name: string }>
if (!cols.some((c) => c.name === 'audit_retention_days')) {
if (!settingsCols.some((c) => c.name === 'audit_retention_days')) {
sqlite.exec(
`ALTER TABLE portal_settings ADD COLUMN audit_retention_days INTEGER NOT NULL DEFAULT 90`,
)
}
const userCols = sqlite
.prepare(`PRAGMA table_info(users)`)
.all() as Array<{ name: string }>
if (!userCols.some((c) => c.name === 'last_login_at')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT`)
}
const auditCols = sqlite
.prepare(`PRAGMA table_info(audit_log)`)
.all() as Array<{ name: string }>
if (!auditCols.some((c) => c.name === 'event_id')) {
sqlite.exec(`ALTER TABLE audit_log ADD COLUMN event_id TEXT`)
}
if (!auditCols.some((c) => c.name === 'source_app')) {
sqlite.exec(
`ALTER TABLE audit_log ADD COLUMN source_app TEXT NOT NULL DEFAULT 'portal'`,
)
}
sqlite.exec(`
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_target ON audit_log(target_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source_app, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log(event_id) WHERE event_id IS NOT NULL;
`)
}
export function healthCheck(sqlite: Sqlite): void {
+3
View File
@@ -7,6 +7,7 @@ export const users = sqliteTable('users', {
passwordHash: text('password_hash').notNull(),
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
disabled: integer('disabled', { mode: 'boolean' }).notNull().default(false),
lastLoginAt: text('last_login_at'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
})
@@ -46,6 +47,8 @@ export const portalSettings = sqliteTable('portal_settings', {
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
eventId: text('event_id'),
sourceApp: text('source_app').notNull().default('portal'),
action: text('action').notNull(),
severity: text('severity').notNull(),
actorUserId: text('actor_user_id'),
+8
View File
@@ -130,6 +130,14 @@ export function setUserAccess(
.run()
}
export function touchLastLogin(db: AppDb, userId: string): void {
const now = new Date().toISOString()
db.update(users)
.set({ lastLoginAt: now, updatedAt: now })
.where(eq(users.id, userId))
.run()
}
export function createRefreshSession(
db: AppDb,
userId: string,
+44 -1
View File
@@ -4,16 +4,27 @@ 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_SOURCE_APPS = [
'portal',
'vps',
'cfdm',
'bgp',
'fw',
] as const
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
export const AUDIT_TARGET_TYPES = [
'user',
'settings',
'session',
'system',
'app_resource',
] 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. */
/** Well-known portal-native action keys. */
export const AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
@@ -31,6 +42,8 @@ export const auditActionSchema = z.enum(AUDIT_ACTIONS)
export const auditLogEntrySchema = z.object({
id: z.string(),
event_id: z.string().nullable(),
source_app: auditSourceAppSchema,
action: z.string(),
severity: auditSeveritySchema,
actor_user_id: z.string().nullable(),
@@ -48,6 +61,8 @@ export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
export const auditListQuerySchema = z.object({
action: z.string().optional(),
severity: auditSeveritySchema.optional(),
user_id: z.string().optional(),
source_app: auditSourceAppSchema.optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
@@ -73,3 +88,31 @@ export const auditPurgeResponseSchema = z.object({
retention_days: z.number().int(),
})
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
export const ingestAuditEventSchema = z.object({
event_id: z.string().min(1).max(128),
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw']),
action: z.string().min(1).max(200),
severity: auditSeveritySchema.optional(),
actor_user_id: z.string().nullable().optional(),
actor_email: z.string().email().nullable().optional(),
actor_name: z.string().nullable().optional(),
target_type: auditTargetTypeSchema.nullable().optional(),
target_id: z.string().nullable().optional(),
summary: z.string().min(1).max(500),
details: z.record(z.string(), z.unknown()).nullable().optional(),
ip: z.string().nullable().optional(),
created_at: z.string().optional(),
})
export type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>
export const ingestAuditRequestSchema = z.object({
events: z.array(ingestAuditEventSchema).min(1).max(50),
})
export type IngestAuditRequest = z.infer<typeof ingestAuditRequestSchema>
export const ingestAuditResponseSchema = z.object({
accepted: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
})
export type IngestAuditResponse = z.infer<typeof ingestAuditResponseSchema>
+1
View File
@@ -370,6 +370,7 @@ export const adminUserSchema = z.object({
disabled: z.boolean(),
apps: z.array(appIdSchema),
permissions: z.array(z.string()),
last_login_at: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
})