feat(admin): ingest аудита из apps и users 1:1 с Sheet журнала
Добавлен 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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user