Files
auth-portal/packages/db/src/index.ts
T

283 lines
10 KiB
TypeScript

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema/index.js'
export type Sqlite = Database.Database
export type AppDb = ReturnType<typeof drizzle<typeof schema>>
export function resolveDatabasePath(databaseUrl: string): string {
return databaseUrl.startsWith('sqlite:')
? databaseUrl.slice('sqlite:'.length)
: databaseUrl
}
export function createDb(databaseUrl: string): { db: AppDb; sqlite: Sqlite } {
const path = resolveDatabasePath(databaseUrl)
const sqlite = new Database(path)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.pragma('synchronous = NORMAL')
// Без busy_timeout любая конкуренция за блокировку сразу даёт SQLITE_BUSY.
sqlite.pragma('busy_timeout = 5000')
sqlite.pragma('cache_size = -16000') // ~16 МиБ
sqlite.pragma('temp_store = MEMORY')
sqlite.pragma('mmap_size = 67108864') // 64 МиБ
sqlite.pragma('wal_autocheckpoint = 1000')
sqlite.pragma('journal_size_limit = 67108864')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } {
const sqlite = new Database(':memory:')
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
/** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */
// ВАЖНО: любое изменение baseMigrate()/migrateV1() требует увеличить SCHEMA_VERSION
// (+ новая migrateVN), иначе существующие БД со скипом по user_version его не применят.
export const SCHEMA_VERSION = 1
export function migrateSchema(sqlite: Sqlite): void {
const current = sqlite.pragma('user_version', { simple: true }) as number
if (current >= SCHEMA_VERSION) return
sqlite.transaction(() => {
baseMigrate(sqlite)
migrateV1(sqlite)
sqlite.pragma(`user_version = ${SCHEMA_VERSION}`)
})()
}
/** v1: дедупликация доступа, уникальные индексы и индексы под очистку/фильтры. */
function migrateV1(sqlite: Sqlite): void {
sqlite.exec(`
DELETE FROM user_apps WHERE rowid NOT IN (
SELECT MIN(rowid) FROM user_apps GROUP BY user_id, app_id
);
DELETE FROM user_permissions WHERE rowid NOT IN (
SELECT MIN(rowid) FROM user_permissions GROUP BY user_id, permission
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_apps ON user_apps(user_id, app_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_permissions ON user_permissions(user_id, permission);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires ON refresh_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_severity ON audit_log(severity, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_email ON audit_log(actor_email);
`)
}
function baseMigrate(sqlite: Sqlite): void {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
last_login_at TEXT,
last_login_ip TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_apps (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
app_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_permissions (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL
);
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,
event_id TEXT,
source_app TEXT NOT NULL DEFAULT 'portal',
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_refresh_sessions_token ON refresh_sessions(token_hash);
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);
`)
// Existing DBs: CREATE TABLE IF NOT EXISTS skips schema — ALTER before indexes on new cols.
const settingsCols = sqlite
.prepare(`PRAGMA table_info(portal_settings)`)
.all() as Array<{ name: string }>
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`)
}
if (!userCols.some((c) => c.name === 'last_login_ip')) {
sqlite.exec(`ALTER TABLE users ADD COLUMN last_login_ip TEXT`)
}
const sessionCols = sqlite
.prepare(`PRAGMA table_info(refresh_sessions)`)
.all() as Array<{ name: string }>
if (!sessionCols.some((c) => c.name === 'ip')) {
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN ip TEXT`)
}
if (!sessionCols.some((c) => c.name === 'user_agent')) {
sqlite.exec(`ALTER TABLE refresh_sessions ADD COLUMN user_agent 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_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;
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS oidc_clients (
id TEXT PRIMARY KEY NOT NULL,
client_id TEXT NOT NULL UNIQUE,
client_secret_hash TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uris_json TEXT NOT NULL,
scopes_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
id TEXT PRIMARY KEY NOT NULL,
code_hash TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
nonce TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS oidc_signing_keys (
kid TEXT PRIMARY KEY NOT NULL,
private_pem TEXT NOT NULL,
public_jwk_json TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
device_type TEXT,
backed_up INTEGER NOT NULL DEFAULT 0,
transports_json TEXT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
challenge TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
`)
}
/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */
export function closeDb(sqlite: Sqlite): void {
if (!sqlite.open) return
try {
if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)')
sqlite.pragma('optimize')
} finally {
sqlite.close()
}
}
export function healthCheck(sqlite: Sqlite): void {
sqlite.prepare('SELECT 1').get()
}
export * from './schema/index.js'
export * from './tx.js'
export * from './backup.js'
export * from './generation.js'
export * from './housekeeping.js'
export * from './users.js'
export * from './settings.js'
export * from './audit-log.js'
export * from './oidc.js'
export * from './webauthn.js'