fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
out: './migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL?.replace(/^sqlite:/, '') ?? './data/app.db',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@authportal/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations",
|
||||
"package.json"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:perf": "vitest run --config vitest.perf.config.ts",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authportal/shared": "workspace:*",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { and, desc, eq, gte, lt, or, sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||
type AuditKind,
|
||||
type AuditLogEntry,
|
||||
type AuditSeverity,
|
||||
type AuditSourceApp,
|
||||
type AuditTargetType,
|
||||
} from '@authportal/shared'
|
||||
import type { AppDb } from './index.js'
|
||||
import { auditLog, portalSettings } from './schema/index.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
|
||||
const SETTINGS_ID = 'main'
|
||||
|
||||
export type AppendAuditInput = {
|
||||
eventId?: string | null
|
||||
sourceApp?: AuditSourceApp
|
||||
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
|
||||
createdAt?: 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,
|
||||
event_id: row.eventId,
|
||||
source_app: (row.sourceApp as AuditSourceApp) || 'portal',
|
||||
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.
|
||||
* @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
|
||||
|
||||
// Дубликат event_id отсекается уникальным индексом: без SELECT и гонок.
|
||||
const res = db
|
||||
.insert(auditLog)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
eventId,
|
||||
sourceApp: input.sourceApp ?? 'portal',
|
||||
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,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
return res.changes > 0
|
||||
}
|
||||
|
||||
/** Пачка событий в одной транзакции — один коммит вместо N. */
|
||||
export function appendAuditBatch(
|
||||
db: AppDb,
|
||||
inputs: AppendAuditInput[],
|
||||
): { accepted: number; duplicates: number; failed: number } {
|
||||
let accepted = 0
|
||||
let duplicates = 0
|
||||
let failed = 0
|
||||
withTransaction(db, () => {
|
||||
for (const input of inputs) {
|
||||
try {
|
||||
// SAVEPOINT: сбой одного события не откатывает остальные.
|
||||
const ok = withTransaction(db, () => appendAudit(db, input))
|
||||
if (ok) accepted += 1
|
||||
else duplicates += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
return { accepted, duplicates, failed }
|
||||
}
|
||||
|
||||
export function listAudit(
|
||||
db: AppDb,
|
||||
opts: {
|
||||
action?: string
|
||||
severity?: AuditSeverity
|
||||
userId?: string
|
||||
actorEmail?: string
|
||||
sourceApp?: AuditSourceApp
|
||||
kind?: AuditKind
|
||||
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.kind === 'logins') {
|
||||
// Диапазон по индексу action: 'auth.' <= action < 'auth/' ('/' идёт сразу после '.').
|
||||
conditions.push(gte(auditLog.action, 'auth.'), lt(auditLog.action, 'auth/'))
|
||||
} else if (opts.kind === 'changes') {
|
||||
conditions.push(
|
||||
or(lt(auditLog.action, 'auth.'), gte(auditLog.action, 'auth/'))!,
|
||||
)
|
||||
}
|
||||
if (opts.actorEmail && opts.userId) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()),
|
||||
eq(auditLog.actorUserId, opts.userId),
|
||||
eq(auditLog.targetId, opts.userId),
|
||||
)!,
|
||||
)
|
||||
} else if (opts.actorEmail) {
|
||||
conditions.push(eq(auditLog.actorEmail, opts.actorEmail.toLowerCase()))
|
||||
} else if (opts.userId) {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(auditLog.actorUserId, opts.userId),
|
||||
eq(auditLog.targetId, opts.userId),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import type { Sqlite } from './index.js'
|
||||
|
||||
const BACKUP_RE = /^app-\d{8}-\d{6}-\d{3}\.db$/
|
||||
|
||||
function stamp(d: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
return (
|
||||
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
||||
`-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}` +
|
||||
`-${p(d.getUTCMilliseconds(), 3)}`
|
||||
)
|
||||
}
|
||||
|
||||
/** PRAGMA integrity_check для файла БД; true, если результат 'ok'. */
|
||||
export function checkIntegrity(path: string): boolean {
|
||||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
return db.pragma('integrity_check', { simple: true }) === 'ok'
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function listBackups(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => BACKUP_RE.test(f))
|
||||
.sort()
|
||||
.reverse()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Согласованный онлайн-бэкап (better-sqlite3 backup API работает с WAL без
|
||||
* остановки сервиса), проверка integrity_check, ротация по числу копий.
|
||||
*/
|
||||
export async function backupDatabase(
|
||||
sqlite: Sqlite,
|
||||
opts: { dir: string; keep: number; now?: Date },
|
||||
): Promise<{ file: string; removed: string[] }> {
|
||||
mkdirSync(opts.dir, { recursive: true })
|
||||
const name = `app-${stamp(opts.now ?? new Date())}.db`
|
||||
const finalPath = join(opts.dir, name)
|
||||
const partial = `${finalPath}.partial`
|
||||
try {
|
||||
await sqlite.backup(partial)
|
||||
if (!checkIntegrity(partial)) {
|
||||
throw new Error('backup integrity_check failed')
|
||||
}
|
||||
renameSync(partial, finalPath)
|
||||
} catch (err) {
|
||||
rmSync(partial, { force: true })
|
||||
throw err
|
||||
}
|
||||
const removed: string[] = []
|
||||
for (const old of listBackups(opts.dir).slice(Math.max(1, opts.keep))) {
|
||||
rmSync(join(opts.dir, old), { force: true })
|
||||
removed.push(old)
|
||||
}
|
||||
return { file: finalPath, removed }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Счётчик «поколения» данных пользователей: растёт при любой записи в
|
||||
* users/user_apps/user_permissions через функции этого пакета. Кэши верхнего
|
||||
* уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу.
|
||||
*/
|
||||
let userDataGeneration = 0
|
||||
|
||||
export function getUserDataGeneration(): number {
|
||||
return userDataGeneration
|
||||
}
|
||||
|
||||
export function bumpUserDataGeneration(): void {
|
||||
userDataGeneration += 1
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import type { AppDb } from './index.js'
|
||||
|
||||
const BATCH = 1000
|
||||
|
||||
/** Удаляет пачками по BATCH строк, чтобы не держать писательскую блокировку надолго. */
|
||||
function deleteInBatches(
|
||||
db: AppDb,
|
||||
table: string,
|
||||
where: ReturnType<typeof sql>,
|
||||
): number {
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const res = db.run(
|
||||
sql`DELETE FROM ${sql.raw(table)} WHERE rowid IN (
|
||||
SELECT rowid FROM ${sql.raw(table)} WHERE ${where} LIMIT ${BATCH}
|
||||
)`,
|
||||
)
|
||||
total += res.changes
|
||||
if (res.changes < BATCH) return total
|
||||
}
|
||||
}
|
||||
|
||||
/** Сессии, истёкшие или отозванные раньше cutoff (ISO). */
|
||||
export function purgeStaleRefreshSessions(db: AppDb, cutoffIso: string): number {
|
||||
return deleteInBatches(
|
||||
db,
|
||||
'refresh_sessions',
|
||||
sql`expires_at < ${cutoffIso} OR (revoked_at IS NOT NULL AND revoked_at < ${cutoffIso})`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Коды авторизации: истёкшие раньше cutoff, либо использованные раньше cutoff. */
|
||||
export function purgeStaleOidcAuthCodes(db: AppDb, cutoffIso: string): number {
|
||||
return deleteInBatches(
|
||||
db,
|
||||
'oidc_auth_codes',
|
||||
sql`expires_at < ${cutoffIso} OR (used_at IS NOT NULL AND used_at < ${cutoffIso})`,
|
||||
)
|
||||
}
|
||||
|
||||
export function purgeExpiredChallenges(db: AppDb, nowIso: string): number {
|
||||
return deleteInBatches(db, 'webauthn_challenges', sql`expires_at < ${nowIso}`)
|
||||
}
|
||||
|
||||
export type HousekeepingResult = {
|
||||
sessions: number
|
||||
authCodes: number
|
||||
challenges: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна проходка очистки. Записи хранятся ещё retentionDays после
|
||||
* истечения/отзыва — для разбора инцидентов в списке сессий.
|
||||
*/
|
||||
export function runHousekeeping(
|
||||
db: AppDb,
|
||||
opts: { retentionDays: number; now?: Date } = { retentionDays: 7 },
|
||||
): HousekeepingResult {
|
||||
const now = opts.now ?? new Date()
|
||||
const cutoff = new Date(
|
||||
now.getTime() - opts.retentionDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString()
|
||||
return {
|
||||
sessions: purgeStaleRefreshSessions(db, cutoff),
|
||||
authCodes: purgeStaleOidcAuthCodes(db, cutoff),
|
||||
challenges: purgeExpiredChallenges(db, now.toISOString()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from './schema/index.js'
|
||||
import { migratePortalRevocations } from './portal-revocations.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 = 2
|
||||
|
||||
export function migrateSchema(sqlite: Sqlite): void {
|
||||
const current = sqlite.pragma('user_version', { simple: true }) as number
|
||||
if (current >= SCHEMA_VERSION) return
|
||||
sqlite.transaction(() => {
|
||||
if (current < 1) { baseMigrate(sqlite); migrateV1(sqlite) }
|
||||
if (current < 2) migratePortalRevocations(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'
|
||||
|
||||
export * from './portal-revocations.js'
|
||||
@@ -0,0 +1,217 @@
|
||||
import { and, desc, eq, gte, isNull } from 'drizzle-orm'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
import { oidcAuthCodes, oidcClients, oidcSigningKeys } from './schema/index.js'
|
||||
import { hashToken } from './users.js'
|
||||
|
||||
export type OidcClientRow = typeof oidcClients.$inferSelect
|
||||
export type OidcAuthCodeRow = typeof oidcAuthCodes.$inferSelect
|
||||
export type OidcSigningKeyRow = typeof oidcSigningKeys.$inferSelect
|
||||
|
||||
export function parseJsonStringArray(raw: string): string[] {
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.filter((x): x is string => typeof x === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function listOidcClients(db: AppDb): OidcClientRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(oidcClients)
|
||||
.all()
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
}
|
||||
|
||||
export function getOidcClientById(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): OidcClientRow | undefined {
|
||||
return db.select().from(oidcClients).where(eq(oidcClients.id, id)).get()
|
||||
}
|
||||
|
||||
export function getOidcClientByClientId(
|
||||
db: AppDb,
|
||||
clientId: string,
|
||||
): OidcClientRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(oidcClients)
|
||||
.where(eq(oidcClients.clientId, clientId))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function createOidcClient(
|
||||
db: AppDb,
|
||||
input: {
|
||||
name: string
|
||||
clientSecretHash: string
|
||||
redirectUris: string[]
|
||||
scopes: string[]
|
||||
enabled: boolean
|
||||
clientId?: string
|
||||
},
|
||||
): OidcClientRow {
|
||||
const now = new Date().toISOString()
|
||||
const id = randomUUID()
|
||||
const clientId = input.clientId ?? randomUUID()
|
||||
db.insert(oidcClients)
|
||||
.values({
|
||||
id,
|
||||
clientId,
|
||||
clientSecretHash: input.clientSecretHash,
|
||||
name: input.name,
|
||||
redirectUrisJson: JSON.stringify(input.redirectUris),
|
||||
scopesJson: JSON.stringify(input.scopes),
|
||||
enabled: input.enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
return getOidcClientById(db, id)!
|
||||
}
|
||||
|
||||
export function updateOidcClient(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
patch: {
|
||||
name?: string
|
||||
redirectUris?: string[]
|
||||
scopes?: string[]
|
||||
enabled?: boolean
|
||||
clientSecretHash?: string
|
||||
},
|
||||
): OidcClientRow | undefined {
|
||||
const existing = getOidcClientById(db, id)
|
||||
if (!existing) return undefined
|
||||
const now = new Date().toISOString()
|
||||
db.update(oidcClients)
|
||||
.set({
|
||||
name: patch.name ?? existing.name,
|
||||
redirectUrisJson: patch.redirectUris
|
||||
? JSON.stringify(patch.redirectUris)
|
||||
: existing.redirectUrisJson,
|
||||
scopesJson: patch.scopes
|
||||
? JSON.stringify(patch.scopes)
|
||||
: existing.scopesJson,
|
||||
enabled: patch.enabled ?? existing.enabled,
|
||||
clientSecretHash: patch.clientSecretHash ?? existing.clientSecretHash,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(oidcClients.id, id))
|
||||
.run()
|
||||
return getOidcClientById(db, id)
|
||||
}
|
||||
|
||||
export function deleteOidcClient(db: AppDb, id: string): boolean {
|
||||
const result = db.delete(oidcClients).where(eq(oidcClients.id, id)).run()
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function generateOidcClientSecret(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function createOidcAuthCode(
|
||||
db: AppDb,
|
||||
input: {
|
||||
rawCode: string
|
||||
clientId: string
|
||||
userId: string
|
||||
redirectUri: string
|
||||
scope: string
|
||||
nonce?: string | null
|
||||
codeChallenge?: string | null
|
||||
codeChallengeMethod?: string | null
|
||||
expiresAt: Date
|
||||
},
|
||||
): string {
|
||||
const id = randomUUID()
|
||||
db.insert(oidcAuthCodes)
|
||||
.values({
|
||||
id,
|
||||
codeHash: hashToken(input.rawCode),
|
||||
clientId: input.clientId,
|
||||
userId: input.userId,
|
||||
redirectUri: input.redirectUri,
|
||||
scope: input.scope,
|
||||
nonce: input.nonce ?? null,
|
||||
codeChallenge: input.codeChallenge ?? null,
|
||||
codeChallengeMethod: input.codeChallengeMethod ?? null,
|
||||
expiresAt: input.expiresAt.toISOString(),
|
||||
usedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
export function consumeOidcAuthCode(
|
||||
db: AppDb,
|
||||
rawCode: string,
|
||||
): OidcAuthCodeRow | undefined {
|
||||
const now = new Date().toISOString()
|
||||
// Атомарно: UPDATE … RETURNING исключает двойное использование кода.
|
||||
return db
|
||||
.update(oidcAuthCodes)
|
||||
.set({ usedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(oidcAuthCodes.codeHash, hashToken(rawCode)),
|
||||
isNull(oidcAuthCodes.usedAt),
|
||||
gte(oidcAuthCodes.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getActiveOidcSigningKey(
|
||||
db: AppDb,
|
||||
): OidcSigningKeyRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(oidcSigningKeys)
|
||||
.where(eq(oidcSigningKeys.active, true))
|
||||
.orderBy(desc(oidcSigningKeys.createdAt))
|
||||
.limit(1)
|
||||
.get()
|
||||
}
|
||||
|
||||
export function listOidcSigningKeys(db: AppDb): OidcSigningKeyRow[] {
|
||||
return db.select().from(oidcSigningKeys).all()
|
||||
}
|
||||
|
||||
export function insertOidcSigningKey(
|
||||
db: AppDb,
|
||||
input: {
|
||||
kid: string
|
||||
privatePem: string
|
||||
publicJwkJson: string
|
||||
},
|
||||
): OidcSigningKeyRow {
|
||||
withTransaction(db, () => {
|
||||
db.update(oidcSigningKeys)
|
||||
.set({ active: false })
|
||||
.where(eq(oidcSigningKeys.active, true))
|
||||
.run()
|
||||
db.insert(oidcSigningKeys)
|
||||
.values({
|
||||
kid: input.kid,
|
||||
privatePem: input.privatePem,
|
||||
publicJwkJson: input.publicJwkJson,
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
})
|
||||
return db
|
||||
.select()
|
||||
.from(oidcSigningKeys)
|
||||
.where(eq(oidcSigningKeys.kid, input.kid))
|
||||
.get()!
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { AppDb, Sqlite } from './index.js'
|
||||
|
||||
const enabled = new WeakSet<AppDb>()
|
||||
export type PortalRevocationType = 'permissions.changed' | 'user.disabled' | 'sessions.revoked'
|
||||
export const portalRevocationLimit = 4096
|
||||
|
||||
export function migratePortalRevocations(sqlite: Sqlite): void {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE portal_revocation_target (id INTEGER PRIMARY KEY CHECK(id=1), target_sha256 TEXT NOT NULL);
|
||||
CREATE TABLE portal_revocation_outbox (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE,
|
||||
body TEXT NOT NULL CHECK(length(body)<=4096), occurred_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
/** Explicit startup opt-in. FULL precedes all enabled mutations, never an async hook. */
|
||||
export function enablePortalRevocations(db: AppDb, target: string): void {
|
||||
db.$client.pragma('synchronous = FULL')
|
||||
if (db.$client.pragma('synchronous', { simple: true }) !== 2) throw new Error('PORTAL_REVOCATION_DURABILITY_UNAVAILABLE')
|
||||
const fingerprint = createHash('sha256').update(target).digest('hex')
|
||||
db.$client.transaction(() => {
|
||||
const existing = db.$client.prepare('SELECT target_sha256 FROM portal_revocation_target WHERE id=1').get() as { target_sha256: string } | undefined
|
||||
if (existing && existing.target_sha256 !== fingerprint) throw new Error('PORTAL_REVOCATION_TARGET_CHANGED')
|
||||
if (!existing) db.$client.prepare('INSERT INTO portal_revocation_target(id,target_sha256) VALUES(1,?)').run(fingerprint)
|
||||
})()
|
||||
enabled.add(db)
|
||||
}
|
||||
|
||||
/** Called INSIDE the same synchronous transaction as the genuine user mutation. */
|
||||
export function enqueuePortalRevocation(db: AppDb, userId: string, type: PortalRevocationType): void {
|
||||
if (!enabled.has(db)) return
|
||||
if (!db.$client.inTransaction) throw new Error('PORTAL_REVOCATION_TRANSACTION_REQUIRED')
|
||||
const count = db.$client.prepare('SELECT count(*) AS count FROM portal_revocation_outbox').get() as { count: number }
|
||||
if (count.count >= portalRevocationLimit) throw new Error('PORTAL_REVOCATION_OUTBOX_FULL')
|
||||
const eventOccurredAt = Date.now(), eventId = randomUUID()
|
||||
const body = JSON.stringify({ version: 2, eventId, userId, type, eventOccurredAt })
|
||||
if (!Number.isSafeInteger(eventOccurredAt) || eventOccurredAt <= 0 || Buffer.byteLength(body) > 4096) throw new Error('PORTAL_REVOCATION_EVENT_INVALID')
|
||||
// No user FK: a genuine user deletion must not cascade-delete its own revocation.
|
||||
db.$client.prepare('INSERT INTO portal_revocation_outbox(event_id,body,occurred_at) VALUES(?,?,?)').run(eventId, body, eventOccurredAt)
|
||||
}
|
||||
|
||||
export type PortalRevocationDelivery = { sequence: number; body: string; attempts: number }
|
||||
export function nextPortalRevocation(db: AppDb): PortalRevocationDelivery | undefined {
|
||||
// Stable FIFO; a failed oldest event cannot be starved by subsequent mutations.
|
||||
const row = db.$client.prepare('SELECT sequence,body,attempts,next_attempt_at FROM portal_revocation_outbox ORDER BY sequence LIMIT 1').get() as (PortalRevocationDelivery & { next_attempt_at: number }) | undefined
|
||||
return row && row.next_attempt_at <= Date.now() ? row : undefined
|
||||
}
|
||||
export function completePortalRevocation(db: AppDb, event: PortalRevocationDelivery, delivered: boolean): void {
|
||||
if (delivered) db.$client.prepare('DELETE FROM portal_revocation_outbox WHERE sequence=? AND body=?').run(event.sequence, event.body)
|
||||
else db.$client.prepare('UPDATE portal_revocation_outbox SET attempts=min(attempts+1,30),next_attempt_at=? WHERE sequence=? AND body=?')
|
||||
.run(Date.now() + Math.min(60000, 1000 * 2 ** Math.min(event.attempts, 6)), event.sequence, event.body)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
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'),
|
||||
lastLoginIp: text('last_login_ip'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
})
|
||||
|
||||
export const userApps = sqliteTable('user_apps', {
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
appId: text('app_id').notNull(),
|
||||
})
|
||||
|
||||
export const userPermissions = sqliteTable('user_permissions', {
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
permission: text('permission').notNull(),
|
||||
})
|
||||
|
||||
export const refreshSessions = sqliteTable('refresh_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
revokedAt: text('revoked_at'),
|
||||
ip: text('ip'),
|
||||
userAgent: text('user_agent'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
/** Singleton row id = 'main' */
|
||||
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(),
|
||||
eventId: text('event_id'),
|
||||
sourceApp: text('source_app').notNull().default('portal'),
|
||||
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(),
|
||||
})
|
||||
|
||||
export const oidcClients = sqliteTable('oidc_clients', {
|
||||
id: text('id').primaryKey(),
|
||||
clientId: text('client_id').notNull().unique(),
|
||||
clientSecretHash: text('client_secret_hash').notNull(),
|
||||
name: text('name').notNull(),
|
||||
redirectUrisJson: text('redirect_uris_json').notNull(),
|
||||
scopesJson: text('scopes_json').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
})
|
||||
|
||||
export const oidcAuthCodes = sqliteTable('oidc_auth_codes', {
|
||||
id: text('id').primaryKey(),
|
||||
codeHash: text('code_hash').notNull().unique(),
|
||||
clientId: text('client_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
redirectUri: text('redirect_uri').notNull(),
|
||||
scope: text('scope').notNull(),
|
||||
nonce: text('nonce'),
|
||||
codeChallenge: text('code_challenge'),
|
||||
codeChallengeMethod: text('code_challenge_method'),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
usedAt: text('used_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
export const oidcSigningKeys = sqliteTable('oidc_signing_keys', {
|
||||
kid: text('kid').primaryKey(),
|
||||
privatePem: text('private_pem').notNull(),
|
||||
publicJwkJson: text('public_jwk_json').notNull(),
|
||||
active: integer('active', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
export const webauthnCredentials = sqliteTable('webauthn_credentials', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
credentialId: text('credential_id').notNull().unique(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
counter: integer('counter').notNull().default(0),
|
||||
deviceType: text('device_type'),
|
||||
backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false),
|
||||
transportsJson: text('transports_json'),
|
||||
name: text('name').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
})
|
||||
|
||||
export const webauthnChallenges = sqliteTable('webauthn_challenges', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
challenge: text('challenge').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
defaultAppSwitcherConfig,
|
||||
normalizeAppSwitcherConfig,
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@authportal/shared'
|
||||
import type { AppDb } from './index.js'
|
||||
import { portalSettings } from './schema/index.js'
|
||||
|
||||
const SETTINGS_ID = 'main'
|
||||
|
||||
/** Разобранный конфиг на соединение; сбрасывается в setAppSwitcherConfig. */
|
||||
const switcherCache = new WeakMap<AppDb, AppSwitcherConfig>()
|
||||
|
||||
export function getAppSwitcherConfig(db: AppDb): AppSwitcherConfig {
|
||||
const hit = switcherCache.get(db)
|
||||
// клон: вызывающий код вправе мутировать результат
|
||||
if (hit) return structuredClone(hit)
|
||||
const config = loadAppSwitcherConfig(db)
|
||||
switcherCache.set(db, config)
|
||||
return structuredClone(config)
|
||||
}
|
||||
|
||||
/** Сброс кэша (если БД правили в обход setAppSwitcherConfig). */
|
||||
export function invalidateAppSwitcherConfig(db: AppDb): void {
|
||||
switcherCache.delete(db)
|
||||
}
|
||||
|
||||
function loadAppSwitcherConfig(db: AppDb): AppSwitcherConfig {
|
||||
const row = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
if (!row?.appSwitcherJson) return defaultAppSwitcherConfig()
|
||||
try {
|
||||
return parseAppSwitcherConfig(JSON.parse(row.appSwitcherJson))
|
||||
} catch {
|
||||
return defaultAppSwitcherConfig()
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppSwitcherConfig(
|
||||
db: AppDb,
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
const now = new Date().toISOString()
|
||||
const json = JSON.stringify(normalized)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
if (existing) {
|
||||
db.update(portalSettings)
|
||||
.set({ appSwitcherJson: json, updatedAt: now })
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(portalSettings)
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
appSwitcherJson: json,
|
||||
auditRetentionDays: 90,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
switcherCache.delete(db)
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AppDb } from './index.js'
|
||||
|
||||
/**
|
||||
* Выполняет fn в транзакции better-sqlite3. Вложенные вызовы превращаются
|
||||
* в SAVEPOINT, поэтому безопасно вызывать из функций, уже работающих в транзакции.
|
||||
*/
|
||||
export function withTransaction<T>(db: AppDb, fn: () => T): T {
|
||||
return db.$client.transaction(fn)()
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { bumpUserDataGeneration } from './generation.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
import { enqueuePortalRevocation } from './portal-revocations.js'
|
||||
import {
|
||||
refreshSessions,
|
||||
userApps,
|
||||
userPermissions,
|
||||
users,
|
||||
} from './schema/index.js'
|
||||
|
||||
export type UserRow = typeof users.$inferSelect
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function listUsers(db: AppDb): UserRow[] {
|
||||
return db.select().from(users).all()
|
||||
}
|
||||
|
||||
/** Есть ли хотя бы один пользователь (LIMIT 1 вместо загрузки всей таблицы). */
|
||||
export function hasAnyUser(db: AppDb): boolean {
|
||||
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined
|
||||
}
|
||||
|
||||
export function listAdmins(db: AppDb): UserRow[] {
|
||||
return db.select().from(users).where(eq(users.isAdmin, true)).all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовленные запросы горячего пути: компиляция SQL один раз на соединение
|
||||
* вместо prepare на каждый вызов.
|
||||
*/
|
||||
function buildPrepared(db: AppDb) {
|
||||
return {
|
||||
userById: db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, sql.placeholder('id')))
|
||||
.prepare(),
|
||||
apps: db
|
||||
.select({ appId: userApps.appId })
|
||||
.from(userApps)
|
||||
.where(eq(userApps.userId, sql.placeholder('userId')))
|
||||
.prepare(),
|
||||
permissions: db
|
||||
.select({ permission: userPermissions.permission })
|
||||
.from(userPermissions)
|
||||
.where(eq(userPermissions.userId, sql.placeholder('userId')))
|
||||
.prepare(),
|
||||
sessionByHash: db
|
||||
.select()
|
||||
.from(refreshSessions)
|
||||
.where(eq(refreshSessions.tokenHash, sql.placeholder('hash')))
|
||||
.prepare(),
|
||||
}
|
||||
}
|
||||
|
||||
const preparedByDb = new WeakMap<AppDb, ReturnType<typeof buildPrepared>>()
|
||||
|
||||
function prepared(db: AppDb) {
|
||||
let p = preparedByDb.get(db)
|
||||
if (!p) {
|
||||
p = buildPrepared(db)
|
||||
preparedByDb.set(db, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
export function getUserById(db: AppDb, id: string): UserRow | undefined {
|
||||
return prepared(db).userById.get({ id })
|
||||
}
|
||||
|
||||
export function getUserByEmail(
|
||||
db: AppDb,
|
||||
email: string,
|
||||
): UserRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email.toLowerCase()))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getUserApps(db: AppDb, userId: string): string[] {
|
||||
return prepared(db)
|
||||
.apps.all({ userId })
|
||||
.map((r) => r.appId)
|
||||
}
|
||||
|
||||
export function getUserPermissions(db: AppDb, userId: string): string[] {
|
||||
return prepared(db)
|
||||
.permissions.all({ userId })
|
||||
.map((r) => r.permission)
|
||||
}
|
||||
|
||||
/** Apps всех пользователей одним запросом — для списков без N+1. */
|
||||
export function listUserAppsBatch(db: AppDb): Map<string, string[]> {
|
||||
const rows = db.select().from(userApps).all()
|
||||
const byUser = new Map<string, string[]>()
|
||||
for (const row of rows) {
|
||||
const list = byUser.get(row.userId)
|
||||
if (list) list.push(row.appId)
|
||||
else byUser.set(row.userId, [row.appId])
|
||||
}
|
||||
return byUser
|
||||
}
|
||||
|
||||
/** Permissions всех пользователей одним запросом — для списков без N+1. */
|
||||
export function listUserPermissionsBatch(db: AppDb): Map<string, string[]> {
|
||||
const rows = db.select().from(userPermissions).all()
|
||||
const byUser = new Map<string, string[]>()
|
||||
for (const row of rows) {
|
||||
const list = byUser.get(row.userId)
|
||||
if (list) list.push(row.permission)
|
||||
else byUser.set(row.userId, [row.permission])
|
||||
}
|
||||
return byUser
|
||||
}
|
||||
|
||||
export function createUser(
|
||||
db: AppDb,
|
||||
input: {
|
||||
email: string
|
||||
name: string
|
||||
passwordHash: string
|
||||
isAdmin: boolean
|
||||
},
|
||||
): UserRow {
|
||||
const now = new Date().toISOString()
|
||||
const id = randomUUID()
|
||||
db.insert(users)
|
||||
.values({
|
||||
id,
|
||||
email: input.email.toLowerCase(),
|
||||
name: input.name,
|
||||
passwordHash: input.passwordHash,
|
||||
isAdmin: input.isAdmin,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
bumpUserDataGeneration()
|
||||
return getUserById(db, id)!
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
patch: {
|
||||
email?: string
|
||||
name?: string
|
||||
passwordHash?: string
|
||||
isAdmin?: boolean
|
||||
disabled?: boolean
|
||||
},
|
||||
): UserRow | undefined {
|
||||
return withTransaction(db, () => {
|
||||
const existing = getUserById(db, id)
|
||||
if (!existing) return undefined
|
||||
const now = new Date().toISOString()
|
||||
db.update(users)
|
||||
.set({
|
||||
email: patch.email?.toLowerCase() ?? existing.email,
|
||||
name: patch.name ?? existing.name,
|
||||
passwordHash: patch.passwordHash ?? existing.passwordHash,
|
||||
isAdmin: patch.isAdmin ?? existing.isAdmin,
|
||||
disabled: patch.disabled ?? existing.disabled,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.run()
|
||||
bumpUserDataGeneration()
|
||||
if (patch.disabled === true && !existing.disabled) enqueuePortalRevocation(db, id, 'user.disabled')
|
||||
else if (patch.isAdmin !== undefined && patch.isAdmin !== existing.isAdmin) enqueuePortalRevocation(db, id, 'permissions.changed')
|
||||
return getUserById(db, id)
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteUser(db: AppDb, id: string): boolean {
|
||||
return withTransaction(db, () => {
|
||||
const result = db.delete(users).where(eq(users.id, id)).run()
|
||||
if (result.changes > 0) enqueuePortalRevocation(db, id, 'user.disabled')
|
||||
bumpUserDataGeneration()
|
||||
return result.changes > 0
|
||||
})
|
||||
}
|
||||
|
||||
export function setUserAccess(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
apps: string[],
|
||||
permissions: string[],
|
||||
): void {
|
||||
const uniqApps = [...new Set(apps)]
|
||||
const uniqPerms = [...new Set(permissions)]
|
||||
withTransaction(db, () => {
|
||||
const changed = JSON.stringify(getUserApps(db, userId).sort()) !== JSON.stringify([...uniqApps].sort())
|
||||
|| JSON.stringify(getUserPermissions(db, userId).sort()) !== JSON.stringify([...uniqPerms].sort())
|
||||
db.delete(userApps).where(eq(userApps.userId, userId)).run()
|
||||
db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run()
|
||||
if (uniqApps.length > 0) {
|
||||
db.insert(userApps)
|
||||
.values(uniqApps.map((appId) => ({ userId, appId })))
|
||||
.run()
|
||||
}
|
||||
if (uniqPerms.length > 0) {
|
||||
db.insert(userPermissions)
|
||||
.values(uniqPerms.map((permission) => ({ userId, permission })))
|
||||
.run()
|
||||
}
|
||||
db.update(users)
|
||||
.set({ updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, userId))
|
||||
.run()
|
||||
if (changed) enqueuePortalRevocation(db, userId, 'permissions.changed')
|
||||
})
|
||||
bumpUserDataGeneration()
|
||||
}
|
||||
|
||||
/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */
|
||||
export function searchDirectoryUsers(
|
||||
db: AppDb,
|
||||
query: string,
|
||||
limit = 50,
|
||||
): Array<{ id: string; email: string; name: string }> {
|
||||
const q = query.trim().toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`)
|
||||
const pattern = `%${q}%`
|
||||
return db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.disabled, false),
|
||||
q
|
||||
? or(
|
||||
sql`lower(${users.email}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
sql`lower(${users.name}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
sql`lower(${users.id}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(users.email)
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function touchLastLogin(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
ip?: string | null,
|
||||
): void {
|
||||
const now = new Date().toISOString()
|
||||
db.update(users)
|
||||
.set({
|
||||
lastLoginAt: now,
|
||||
lastLoginIp: ip ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function createRefreshSession(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
rawToken: string,
|
||||
expiresAt: Date,
|
||||
meta?: { ip?: string | null; userAgent?: string | null },
|
||||
): string {
|
||||
const id = randomUUID()
|
||||
db.insert(refreshSessions)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
tokenHash: hashToken(rawToken),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
revokedAt: null,
|
||||
ip: meta?.ip ?? null,
|
||||
userAgent: meta?.userAgent ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
export function revokeRefreshSession(db: AppDb, rawToken: string): void {
|
||||
withTransaction(db, () => {
|
||||
const session = db.select().from(refreshSessions).where(eq(refreshSessions.tokenHash, hashToken(rawToken))).get()
|
||||
const now = new Date().toISOString()
|
||||
db.update(refreshSessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
|
||||
.run()
|
||||
if (session && !session.revokedAt) enqueuePortalRevocation(db, session.userId, 'sessions.revoked')
|
||||
})
|
||||
}
|
||||
|
||||
/** Active refresh session → user (for OIDC authorize cookie). */
|
||||
export function getUserByRefreshToken(
|
||||
db: AppDb,
|
||||
rawToken: string,
|
||||
): UserRow | undefined {
|
||||
const now = new Date().toISOString()
|
||||
const session = prepared(db).sessionByHash.get({
|
||||
hash: hashToken(rawToken),
|
||||
})
|
||||
if (!session || session.revokedAt) return undefined
|
||||
if (session.expiresAt < now) return undefined
|
||||
const user = getUserById(db, session.userId)
|
||||
if (!user || user.disabled) return undefined
|
||||
return user
|
||||
}
|
||||
|
||||
export type ActiveSessionRow = {
|
||||
id: string
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export function listActiveSessions(
|
||||
db: AppDb,
|
||||
opts: { userId?: string } = {},
|
||||
): ActiveSessionRow[] {
|
||||
const now = new Date().toISOString()
|
||||
return db
|
||||
.select({
|
||||
id: refreshSessions.id,
|
||||
userId: refreshSessions.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
ip: refreshSessions.ip,
|
||||
userAgent: refreshSessions.userAgent,
|
||||
createdAt: refreshSessions.createdAt,
|
||||
expiresAt: refreshSessions.expiresAt,
|
||||
})
|
||||
.from(refreshSessions)
|
||||
.innerJoin(users, eq(refreshSessions.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(refreshSessions.revokedAt),
|
||||
gte(refreshSessions.expiresAt, now),
|
||||
opts.userId ? eq(refreshSessions.userId, opts.userId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(refreshSessions.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function revokeSessionById(db: AppDb, sessionId: string): boolean {
|
||||
return withTransaction(db, () => {
|
||||
const row = db
|
||||
.select()
|
||||
.from(refreshSessions)
|
||||
.where(eq(refreshSessions.id, sessionId))
|
||||
.get()
|
||||
if (!row || row.revokedAt) return false
|
||||
db.update(refreshSessions)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(refreshSessions.id, sessionId))
|
||||
.run()
|
||||
enqueuePortalRevocation(db, row.userId, 'sessions.revoked')
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
|
||||
return withTransaction(db, () => {
|
||||
const now = new Date().toISOString()
|
||||
// Активная сессия = не отозвана и не истекла; один UPDATE вместо цикла.
|
||||
const result = db
|
||||
.update(refreshSessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(refreshSessions.userId, userId),
|
||||
isNull(refreshSessions.revokedAt),
|
||||
gt(refreshSessions.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
if (getUserById(db, userId)) enqueuePortalRevocation(db, userId, 'sessions.revoked')
|
||||
return result.changes
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
|
||||
|
||||
export type WebauthnCredentialRow = typeof webauthnCredentials.$inferSelect
|
||||
export type WebauthnChallengeRow = typeof webauthnChallenges.$inferSelect
|
||||
export type WebauthnChallengePurpose = 'register' | 'authenticate'
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
export function purgeExpiredWebauthnChallenges(db: AppDb): void {
|
||||
const now = new Date().toISOString()
|
||||
db.delete(webauthnChallenges)
|
||||
.where(lt(webauthnChallenges.expiresAt, now))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function createWebauthnChallenge(
|
||||
db: AppDb,
|
||||
input: {
|
||||
purpose: WebauthnChallengePurpose
|
||||
challenge: string
|
||||
userId?: string | null
|
||||
},
|
||||
): WebauthnChallengeRow {
|
||||
purgeExpiredWebauthnChallenges(db)
|
||||
const now = new Date()
|
||||
const id = randomUUID()
|
||||
db.insert(webauthnChallenges)
|
||||
.values({
|
||||
id,
|
||||
userId: input.userId ?? null,
|
||||
purpose: input.purpose,
|
||||
challenge: input.challenge,
|
||||
expiresAt: new Date(now.getTime() + CHALLENGE_TTL_MS).toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
.run()
|
||||
return getWebauthnChallengeById(db, id)!
|
||||
}
|
||||
|
||||
export function getWebauthnChallengeById(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): WebauthnChallengeRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnChallenges)
|
||||
.where(eq(webauthnChallenges.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function consumeWebauthnChallenge(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
purpose: WebauthnChallengePurpose,
|
||||
userId?: string | null,
|
||||
): WebauthnChallengeRow | undefined {
|
||||
const row = getWebauthnChallengeById(db, id)
|
||||
if (!row) return undefined
|
||||
if (row.purpose !== purpose) return undefined
|
||||
if (row.expiresAt < new Date().toISOString()) {
|
||||
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||
return undefined
|
||||
}
|
||||
if (userId != null && row.userId && row.userId !== userId) return undefined
|
||||
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||
return row
|
||||
}
|
||||
|
||||
export function listWebauthnCredentials(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
): WebauthnCredentialRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.orderBy(desc(webauthnCredentials.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function countWebauthnCredentials(db: AppDb, userId: string): number {
|
||||
const row = db
|
||||
.select({ total: sql<number>`count(*)` })
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.get()
|
||||
return Number(row?.total ?? 0)
|
||||
}
|
||||
|
||||
/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */
|
||||
export function countWebauthnCredentialsBatch(db: AppDb): Map<string, number> {
|
||||
const rows = db
|
||||
.select({
|
||||
userId: webauthnCredentials.userId,
|
||||
total: sql<number>`count(*)`,
|
||||
})
|
||||
.from(webauthnCredentials)
|
||||
.groupBy(webauthnCredentials.userId)
|
||||
.all()
|
||||
return new Map(rows.map((r) => [r.userId, Number(r.total)]))
|
||||
}
|
||||
|
||||
export function getWebauthnCredentialById(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getWebauthnCredentialByCredentialId(
|
||||
db: AppDb,
|
||||
credentialId: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function createWebauthnCredential(
|
||||
db: AppDb,
|
||||
input: {
|
||||
userId: string
|
||||
credentialId: string
|
||||
publicKey: string
|
||||
counter: number
|
||||
deviceType?: string | null
|
||||
backedUp?: boolean
|
||||
transports?: string[]
|
||||
name: string
|
||||
},
|
||||
): WebauthnCredentialRow {
|
||||
const id = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
db.insert(webauthnCredentials)
|
||||
.values({
|
||||
id,
|
||||
userId: input.userId,
|
||||
credentialId: input.credentialId,
|
||||
publicKey: input.publicKey,
|
||||
counter: input.counter,
|
||||
deviceType: input.deviceType ?? null,
|
||||
backedUp: input.backedUp ?? false,
|
||||
transportsJson: input.transports
|
||||
? JSON.stringify(input.transports)
|
||||
: null,
|
||||
name: input.name,
|
||||
createdAt: now,
|
||||
lastUsedAt: null,
|
||||
})
|
||||
.run()
|
||||
return getWebauthnCredentialById(db, id)!
|
||||
}
|
||||
|
||||
export function updateWebauthnCredentialName(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
const existing = getWebauthnCredentialById(db, id)
|
||||
if (!existing || existing.userId !== userId) return undefined
|
||||
db.update(webauthnCredentials)
|
||||
.set({ name })
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
return getWebauthnCredentialById(db, id)
|
||||
}
|
||||
|
||||
export function touchWebauthnCredential(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
counter: number,
|
||||
): void {
|
||||
db.update(webauthnCredentials)
|
||||
.set({
|
||||
counter,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(webauthnCredentials.id, id))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function deleteWebauthnCredential(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
const result = db
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteWebauthnCredentialsForUser(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
): number {
|
||||
const result = db
|
||||
.delete(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.run()
|
||||
return result.changes
|
||||
}
|
||||
|
||||
export function parseTransportsJson(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.filter((x): x is string => typeof x === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
backupDatabase,
|
||||
checkIntegrity,
|
||||
closeDb,
|
||||
createDb,
|
||||
createUser,
|
||||
getUserByEmail,
|
||||
listBackups,
|
||||
migrateSchema,
|
||||
} from '../src/index.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
|
||||
afterEach(() => {
|
||||
for (const s of dbs.splice(0)) closeDb(s)
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fileDb() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
|
||||
dirs.push(dir)
|
||||
const r = createDb(join(dir, 'app.db'))
|
||||
migrateSchema(r.sqlite)
|
||||
dbs.push(r.sqlite)
|
||||
return { dir, ...r }
|
||||
}
|
||||
|
||||
describe('backupDatabase', () => {
|
||||
it('создаёт консистентную копию с данными из WAL', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: 'a@x.io', name: 'A', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
|
||||
expect(existsSync(res.file)).toBe(true)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
|
||||
const copy = new Database(res.file, { readonly: true })
|
||||
const row = copy.prepare('SELECT email FROM users').get()
|
||||
copy.close()
|
||||
expect(row).toEqual({ email: 'a@x.io' })
|
||||
})
|
||||
|
||||
it('бэкап во время записи остаётся валидным', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
const writer = setInterval(() => {
|
||||
try {
|
||||
createUser(db, {
|
||||
email: `u${Math.random()}@x.io`,
|
||||
name: 'U',
|
||||
passwordHash: 'h',
|
||||
isAdmin: false,
|
||||
})
|
||||
} catch {
|
||||
/* БД могла закрыться */
|
||||
}
|
||||
}, 1)
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 3 })
|
||||
clearInterval(writer)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
})
|
||||
|
||||
it('ротация оставляет keep последних', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
const bdir = join(dir, 'b')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await backupDatabase(sqlite, {
|
||||
dir: bdir,
|
||||
keep: 2,
|
||||
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
|
||||
})
|
||||
}
|
||||
const left = listBackups(bdir)
|
||||
expect(left).toHaveLength(2)
|
||||
expect(left[0]).toContain('000004')
|
||||
})
|
||||
|
||||
it('восстановленная копия открывается и содержит данные', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: 'r@x.io', name: 'R', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 1 })
|
||||
const restored = createDb(res.file)
|
||||
dbs.push(restored.sqlite)
|
||||
migrateSchema(restored.sqlite)
|
||||
expect(getUserByEmail(restored.db, 'r@x.io')?.name).toBe('R')
|
||||
})
|
||||
|
||||
it('битый файл не проходит integrity_check', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
|
||||
dirs.push(dir)
|
||||
const p = join(dir, 'bad.db')
|
||||
writeFileSync(p, 'not a sqlite file'.repeat(100))
|
||||
expect(() => checkIntegrity(p)).toThrow()
|
||||
})
|
||||
|
||||
it('ошибка бэкапа не оставляет .partial', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
closeDb(sqlite)
|
||||
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
|
||||
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
appendAudit,
|
||||
appendAuditBatch,
|
||||
closeDb,
|
||||
consumeOidcAuthCode,
|
||||
countWebauthnCredentials,
|
||||
createDb,
|
||||
createOidcAuthCode,
|
||||
createRefreshSession,
|
||||
createUser,
|
||||
createWebauthnCredential,
|
||||
getActiveOidcSigningKey,
|
||||
getUserApps,
|
||||
getUserById,
|
||||
getUserPermissions,
|
||||
insertOidcSigningKey,
|
||||
listActiveSessions,
|
||||
listAudit,
|
||||
migrateSchema,
|
||||
revokeSessionById,
|
||||
runHousekeeping,
|
||||
searchDirectoryUsers,
|
||||
setUserAccess,
|
||||
withTransaction,
|
||||
type AppDb,
|
||||
type Sqlite,
|
||||
} from '../src/index.js'
|
||||
|
||||
const open: Sqlite[] = []
|
||||
const dirs: string[] = []
|
||||
|
||||
function memDb(): { db: AppDb; sqlite: Sqlite } {
|
||||
const r = createDb('sqlite::memory:')
|
||||
migrateSchema(r.sqlite)
|
||||
open.push(r.sqlite)
|
||||
return r
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of open.splice(0)) closeDb(s)
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function mkUser(db: AppDb, email: string, extra: { name?: string } = {}) {
|
||||
return createUser(db, {
|
||||
email,
|
||||
name: extra.name ?? email.split('@')[0]!,
|
||||
passwordHash: 'x',
|
||||
isAdmin: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('createDb / closeDb', () => {
|
||||
it('выставляет pragma на файловой БД', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-db-'))
|
||||
dirs.push(dir)
|
||||
const { sqlite } = createDb(join(dir, 'app.db'))
|
||||
open.push(sqlite)
|
||||
expect(sqlite.pragma('journal_mode', { simple: true })).toBe('wal')
|
||||
expect(sqlite.pragma('busy_timeout', { simple: true })).toBe(5000)
|
||||
expect(sqlite.pragma('synchronous', { simple: true })).toBe(1)
|
||||
expect(sqlite.pragma('temp_store', { simple: true })).toBe(2)
|
||||
expect(sqlite.pragma('cache_size', { simple: true })).toBe(-16000)
|
||||
expect(sqlite.pragma('foreign_keys', { simple: true })).toBe(1)
|
||||
})
|
||||
|
||||
it('closeDb идемпотентен', () => {
|
||||
const { sqlite } = memDb()
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateSchema', () => {
|
||||
it('ставит user_version и повторный запуск — no-op', () => {
|
||||
const { sqlite } = memDb()
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
|
||||
expect(() => migrateSchema(sqlite)).not.toThrow()
|
||||
})
|
||||
|
||||
it('создаёт новые индексы', () => {
|
||||
const { sqlite } = memDb()
|
||||
const names = (
|
||||
sqlite
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='index'`)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((r) => r.name)
|
||||
for (const n of [
|
||||
'uq_user_apps',
|
||||
'uq_user_permissions',
|
||||
'idx_refresh_sessions_expires',
|
||||
'idx_oidc_auth_codes_expires',
|
||||
'idx_audit_log_severity',
|
||||
'idx_audit_log_actor_email',
|
||||
]) {
|
||||
expect(names).toContain(n)
|
||||
}
|
||||
})
|
||||
|
||||
it('мигрирует «старую» схему: дедупликация и недостающие колонки', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
open.push(sqlite)
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.exec(`
|
||||
CREATE TABLE 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,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE user_apps (user_id TEXT NOT NULL, app_id TEXT NOT NULL);
|
||||
CREATE TABLE user_permissions (user_id TEXT NOT NULL, permission TEXT NOT NULL);
|
||||
INSERT INTO users VALUES ('u1','a@b.c','A','h',0,0,'t','t');
|
||||
INSERT INTO user_apps VALUES ('u1','dns'),('u1','dns'),('u1','vps');
|
||||
INSERT INTO user_permissions VALUES ('u1','p'),('u1','p');
|
||||
`)
|
||||
migrateSchema(sqlite)
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) c FROM user_apps').get(),
|
||||
).toEqual({ c: 2 })
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) c FROM user_permissions').get(),
|
||||
).toEqual({ c: 1 })
|
||||
const cols = (
|
||||
sqlite.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>
|
||||
).map((c) => c.name)
|
||||
expect(cols).toContain('last_login_at')
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
|
||||
expect(() =>
|
||||
sqlite.prepare(`INSERT INTO user_apps VALUES ('u1','dns')`).run(),
|
||||
).toThrow(/UNIQUE/)
|
||||
})
|
||||
|
||||
it('откатывается целиком при ошибке', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
open.push(sqlite)
|
||||
// users — это view-подобная помеха: таблица с несовместимой схемой user_apps без user_id
|
||||
sqlite.exec(`CREATE TABLE user_apps (foo TEXT)`)
|
||||
expect(() => migrateSchema(sqlite)).toThrow()
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withTransaction / setUserAccess', () => {
|
||||
it('откатывает все изменения при исключении', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
setUserAccess(db, u.id, ['dns'], ['p1'])
|
||||
expect(() =>
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['vps', 'bgp'], ['p2'])
|
||||
throw new Error('boom')
|
||||
}),
|
||||
).toThrow('boom')
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
expect(getUserPermissions(db, u.id)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('схлопывает дубликаты во входе', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
setUserAccess(db, u.id, ['dns', 'dns'], ['p', 'p'])
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
expect(getUserPermissions(db, u.id)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('вложенная транзакция откатывается как savepoint', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['dns'], [])
|
||||
try {
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['vps'], [])
|
||||
throw new Error('inner')
|
||||
})
|
||||
} catch {
|
||||
/* ожидаемо */
|
||||
}
|
||||
})
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('сессии и каталог', () => {
|
||||
it('listActiveSessions: только активные, фильтр по пользователю, сортировка', () => {
|
||||
const { db } = memDb()
|
||||
const a = mkUser(db, 'a@x.io')
|
||||
const b = mkUser(db, 'b@x.io')
|
||||
const future = new Date(Date.now() + 3600_000)
|
||||
const past = new Date(Date.now() - 3600_000)
|
||||
const s1 = createRefreshSession(db, a.id, 't1', future)
|
||||
createRefreshSession(db, a.id, 't2', past)
|
||||
const s3 = createRefreshSession(db, b.id, 't3', future)
|
||||
const s4 = createRefreshSession(db, b.id, 't4', future)
|
||||
revokeSessionById(db, s4)
|
||||
|
||||
const all = listActiveSessions(db).map((s) => s.id)
|
||||
expect(new Set(all)).toEqual(new Set([s1, s3]))
|
||||
expect(listActiveSessions(db, { userId: a.id }).map((s) => s.id)).toEqual([
|
||||
s1,
|
||||
])
|
||||
})
|
||||
|
||||
it('searchDirectoryUsers: без passwordHash, без disabled, LIMIT, экранирование', () => {
|
||||
const { db } = memDb()
|
||||
for (let i = 0; i < 60; i++) mkUser(db, `user${i}@x.io`)
|
||||
const weird = mkUser(db, 'pct@x.io', { name: '100%' })
|
||||
const dis = mkUser(db, 'off@x.io')
|
||||
db.$client.prepare('UPDATE users SET disabled=1 WHERE id=?').run(dis.id)
|
||||
|
||||
const page = searchDirectoryUsers(db, '', 50)
|
||||
expect(page).toHaveLength(50)
|
||||
expect(Object.keys(page[0]!).sort()).toEqual(['email', 'id', 'name'])
|
||||
expect(searchDirectoryUsers(db, 'off@x.io')).toEqual([])
|
||||
expect(searchDirectoryUsers(db, 'USER7@').map((u) => u.email)).toEqual([
|
||||
'user7@x.io',
|
||||
])
|
||||
expect(searchDirectoryUsers(db, '%').map((u) => u.id)).toEqual([weird.id])
|
||||
expect(searchDirectoryUsers(db, weird.id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('countWebauthnCredentials считает COUNT(*)', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
expect(countWebauthnCredentials(db, u.id)).toBe(0)
|
||||
for (const id of ['c1', 'c2']) {
|
||||
createWebauthnCredential(db, {
|
||||
userId: u.id,
|
||||
credentialId: id,
|
||||
publicKey: 'k',
|
||||
counter: 0,
|
||||
name: id,
|
||||
})
|
||||
}
|
||||
expect(countWebauthnCredentials(db, u.id)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OIDC', () => {
|
||||
function code(db: AppDb, userId: string, raw: string, expiresAt: Date) {
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: raw,
|
||||
clientId: 'c',
|
||||
userId,
|
||||
redirectUri: 'https://x/cb',
|
||||
scope: 'openid',
|
||||
expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
it('consumeOidcAuthCode: одноразовый, просроченный отклоняется', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
code(db, u.id, 'ok', new Date(Date.now() + 60_000))
|
||||
code(db, u.id, 'old', new Date(Date.now() - 60_000))
|
||||
expect(consumeOidcAuthCode(db, 'ok')?.userId).toBe(u.id)
|
||||
expect(consumeOidcAuthCode(db, 'ok')).toBeUndefined()
|
||||
expect(consumeOidcAuthCode(db, 'old')).toBeUndefined()
|
||||
expect(consumeOidcAuthCode(db, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('insertOidcSigningKey оставляет один активный ключ — новый', () => {
|
||||
const { db } = memDb()
|
||||
insertOidcSigningKey(db, { kid: 'k1', privatePem: 'p', publicJwkJson: '{}' })
|
||||
insertOidcSigningKey(db, { kid: 'k2', privatePem: 'p', publicJwkJson: '{}' })
|
||||
expect(getActiveOidcSigningKey(db)?.kid).toBe('k2')
|
||||
const active = db.$client
|
||||
.prepare('SELECT COUNT(*) c FROM oidc_signing_keys WHERE active=1')
|
||||
.get()
|
||||
expect(active).toEqual({ c: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('audit', () => {
|
||||
it('appendAudit: дубликат event_id → false', () => {
|
||||
const { db } = memDb()
|
||||
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
|
||||
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
|
||||
})
|
||||
|
||||
it('appendAuditBatch: принимает, пропускает дубли, не теряет остальные', () => {
|
||||
const { db } = memDb()
|
||||
const events = Array.from({ length: 500 }, (_, i) => ({
|
||||
eventId: `e${i % 400}`,
|
||||
action: 'x.y',
|
||||
summary: 's',
|
||||
}))
|
||||
const res = appendAuditBatch(db, events)
|
||||
expect(res).toEqual({ accepted: 400, duplicates: 100, failed: 0 })
|
||||
expect(listAudit(db, { limit: 1000 })).toHaveLength(400)
|
||||
})
|
||||
|
||||
it('listAudit kind=logins/changes делит по префиксу auth.', () => {
|
||||
const { db } = memDb()
|
||||
appendAudit(db, { action: 'auth.login', summary: 's' })
|
||||
appendAudit(db, { action: 'auth.login_failed', summary: 's' })
|
||||
appendAudit(db, { action: 'user.create', summary: 's' })
|
||||
appendAudit(db, { action: 'audit.purge', summary: 's' })
|
||||
appendAudit(db, { action: 'authz.other', summary: 's' })
|
||||
const logins = listAudit(db, { kind: 'logins' }).map((e) => e.action)
|
||||
const changes = listAudit(db, { kind: 'changes' }).map((e) => e.action)
|
||||
expect(logins.sort()).toEqual(['auth.login', 'auth.login_failed'])
|
||||
expect(changes.sort()).toEqual(['audit.purge', 'authz.other', 'user.create'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHousekeeping', () => {
|
||||
it('удаляет старые истёкшие/отозванные, оставляя активные и свежие', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
const day = 86_400_000
|
||||
const active = createRefreshSession(db, u.id, 'a', new Date(Date.now() + day))
|
||||
createRefreshSession(db, u.id, 'oldexp', new Date(Date.now() - 10 * day))
|
||||
createRefreshSession(db, u.id, 'freshexp', new Date(Date.now() - day))
|
||||
const oldRevoked = createRefreshSession(
|
||||
db,
|
||||
u.id,
|
||||
'oldrev',
|
||||
new Date(Date.now() + day),
|
||||
)
|
||||
db.$client
|
||||
.prepare('UPDATE refresh_sessions SET revoked_at=? WHERE id=?')
|
||||
.run(new Date(Date.now() - 10 * day).toISOString(), oldRevoked)
|
||||
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: 'stale',
|
||||
clientId: 'c',
|
||||
userId: u.id,
|
||||
redirectUri: 'r',
|
||||
scope: 's',
|
||||
expiresAt: new Date(Date.now() - 10 * day),
|
||||
})
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: 'live',
|
||||
clientId: 'c',
|
||||
userId: u.id,
|
||||
redirectUri: 'r',
|
||||
scope: 's',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
})
|
||||
db.$client
|
||||
.prepare(
|
||||
`INSERT INTO webauthn_challenges (id,purpose,challenge,expires_at,created_at)
|
||||
VALUES ('c1','register','x',?,?)`,
|
||||
)
|
||||
.run(new Date(Date.now() - 1000).toISOString(), new Date().toISOString())
|
||||
|
||||
const res = runHousekeeping(db, { retentionDays: 7 })
|
||||
expect(res).toEqual({ sessions: 2, authCodes: 1, challenges: 1 })
|
||||
const left = db.$client
|
||||
.prepare('SELECT id FROM refresh_sessions')
|
||||
.all() as Array<{ id: string }>
|
||||
expect(left).toHaveLength(2)
|
||||
expect(left.map((r) => r.id)).toContain(active)
|
||||
|
||||
expect(runHousekeeping(db, { retentionDays: 7 })).toEqual({
|
||||
sessions: 0,
|
||||
authCodes: 0,
|
||||
challenges: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('удаляет пачками > 1000 строк', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
const ins = db.$client.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
)
|
||||
db.$client.transaction(() => {
|
||||
for (let i = 0; i < 2500; i++) {
|
||||
ins.run(`s${i}`, u.id, `h${i}`, '2000-01-01T00:00:00.000Z', 't')
|
||||
}
|
||||
})()
|
||||
expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('подготовленные запросы', () => {
|
||||
it('горячие запросы не вызывают prepare повторно и дают прежние результаты', () => {
|
||||
const { db, sqlite } = memDb()
|
||||
const u = mkUser(db, 'p@x.io')
|
||||
setUserAccess(db, u.id, ['dns', 'vps'], ['dns:dns:read'])
|
||||
// прогрев: первая подготовка
|
||||
getUserById(db, u.id)
|
||||
getUserApps(db, u.id)
|
||||
getUserPermissions(db, u.id)
|
||||
|
||||
const calls: string[] = []
|
||||
const orig = sqlite.prepare.bind(sqlite)
|
||||
sqlite.prepare = ((sql: string) => {
|
||||
calls.push(sql)
|
||||
return orig(sql)
|
||||
}) as typeof sqlite.prepare
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(getUserById(db, u.id)?.email).toBe('p@x.io')
|
||||
expect(getUserApps(db, u.id).sort()).toEqual(['dns', 'vps'])
|
||||
expect(getUserPermissions(db, u.id)).toEqual(['dns:dns:read'])
|
||||
}
|
||||
expect(getUserById(db, 'missing')).toBeUndefined()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('независимые соединения не делят подготовленные запросы', () => {
|
||||
const a = memDb()
|
||||
const b = memDb()
|
||||
const ua = mkUser(a.db, 'a@x.io')
|
||||
expect(getUserById(a.db, ua.id)).toBeDefined()
|
||||
expect(getUserById(b.db, ua.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendAuditBatch,
|
||||
closeDb,
|
||||
createDb,
|
||||
createUser,
|
||||
getUserApps,
|
||||
getUserById,
|
||||
listActiveSessions,
|
||||
listAudit,
|
||||
migrateSchema,
|
||||
runHousekeeping,
|
||||
searchDirectoryUsers,
|
||||
} from '../../src/index.js'
|
||||
|
||||
const { db, sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
afterAll(() => closeDb(sqlite))
|
||||
|
||||
function timed<T>(fn: () => T): { ms: number; value: T } {
|
||||
const t0 = performance.now()
|
||||
const value = fn()
|
||||
return { ms: performance.now() - t0, value }
|
||||
}
|
||||
|
||||
// Бюджеты щедрые (CI-раннеры шумные): ловим деградацию на порядок, а не на проценты.
|
||||
describe('бюджеты производительности БД', () => {
|
||||
const users = Array.from({ length: 200 }, (_, i) =>
|
||||
createUser(db, {
|
||||
email: `u${i}@x.io`,
|
||||
name: `User ${i}`,
|
||||
passwordHash: 'h',
|
||||
isAdmin: false,
|
||||
}),
|
||||
)
|
||||
|
||||
it('вставка 100k сессий и фильтр активных по пользователю < 150 мс', () => {
|
||||
const ins = sqlite.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
)
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString()
|
||||
const past = '2000-01-01T00:00:00.000Z'
|
||||
sqlite.transaction(() => {
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
ins.run(`s${i}`, users[i % users.length]!.id, `h${i}`, i % 10 === 0 ? future : past, `t${i}`)
|
||||
}
|
||||
})()
|
||||
listActiveSessions(db, { userId: users[0]!.id }) // прогрев
|
||||
const { ms, value } = timed(() => listActiveSessions(db, { userId: users[0]!.id }))
|
||||
console.log(`listActiveSessions(user) на 100k: ${ms.toFixed(1)} мс, ${value.length} строк`)
|
||||
expect(value.length).toBeGreaterThan(0)
|
||||
expect(ms).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('housekeeping 100k истёкших сессий < 3 с', () => {
|
||||
const { ms, value } = timed(() => runHousekeeping(db, { retentionDays: 1 }))
|
||||
console.log(`runHousekeeping: ${ms.toFixed(0)} мс, удалено ${value.sessions}`)
|
||||
expect(value.sessions).toBeGreaterThan(80_000)
|
||||
expect(ms).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('пакетный ingest 10k событий одной транзакцией < 1.5 с', () => {
|
||||
const events = Array.from({ length: 10_000 }, (_, i) => ({
|
||||
eventId: `e${i}`,
|
||||
action: i % 2 ? 'auth.login' : 'user.update',
|
||||
summary: 'bench',
|
||||
}))
|
||||
const { ms, value } = timed(() => appendAuditBatch(db, events))
|
||||
console.log(`appendAuditBatch(10k): ${ms.toFixed(0)} мс`)
|
||||
expect(value.accepted).toBe(10_000)
|
||||
expect(ms).toBeLessThan(1500)
|
||||
})
|
||||
|
||||
it('listAudit по kind использует индекс: < 50 мс на 10k', () => {
|
||||
listAudit(db, { kind: 'logins', limit: 100 })
|
||||
const { ms, value } = timed(() => listAudit(db, { kind: 'logins', limit: 100 }))
|
||||
console.log(`listAudit(kind=logins): ${ms.toFixed(1)} мс`)
|
||||
expect(value.length).toBe(100)
|
||||
expect(ms).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('10k горячих запросов профиля (prepared) < 400 мс', () => {
|
||||
const id = users[0]!.id
|
||||
const { ms } = timed(() => {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
getUserById(db, id)
|
||||
getUserApps(db, id)
|
||||
}
|
||||
})
|
||||
console.log(`20k prepared-запросов: ${ms.toFixed(0)} мс`)
|
||||
expect(ms).toBeLessThan(400)
|
||||
})
|
||||
|
||||
it('поиск каталога по 200 пользователям < 20 мс', () => {
|
||||
searchDirectoryUsers(db, 'user 1')
|
||||
const { ms } = timed(() => searchDirectoryUsers(db, 'user 1'))
|
||||
expect(ms).toBeLessThan(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMemoryDb, migrateSchema, closeDb, createUser, getUserById,
|
||||
getUserPermissions, setUserAccess, updateUser, deleteUser,
|
||||
createRefreshSession, revokeRefreshSession, revokeSessionById, revokeAllSessionsForUser,
|
||||
enablePortalRevocations,
|
||||
} from '../src/index.js'
|
||||
|
||||
const target = 'https://mm.example.invalid/_internal/mcp/portal/revocations'
|
||||
describe('integrated portal revocation outbox', () => {
|
||||
it('opts in explicitly and retains deletion event independently of user FK', () => {
|
||||
const { db, sqlite } = createMemoryDb(); migrateSchema(sqlite)
|
||||
try {
|
||||
const user = createUser(db, {email:'owned@example.invalid',name:'owned',passwordHash:'synthetic',isAdmin:false})
|
||||
setUserAccess(db,user.id,['mm'],['old'])
|
||||
expect(sqlite.prepare('SELECT count(*) AS n FROM portal_revocation_outbox').get()).toEqual({n:0})
|
||||
enablePortalRevocations(db,target)
|
||||
setUserAccess(db,user.id,['mm'],['new']); deleteUser(db,user.id)
|
||||
const rows = sqlite.prepare('SELECT body FROM portal_revocation_outbox ORDER BY sequence').all() as {body:string}[]
|
||||
expect(rows.map(row=>JSON.parse(row.body).type)).toEqual(['permissions.changed','user.disabled'])
|
||||
for (const row of rows) expect(JSON.parse(row.body)).toMatchObject({version:2,userId:user.id})
|
||||
expect(getUserById(db,user.id)).toBeUndefined()
|
||||
} finally { closeDb(sqlite) }
|
||||
})
|
||||
it('enqueue failure prevents success of every genuine access/disable/delete/session mutation', () => {
|
||||
const { db, sqlite } = createMemoryDb(); migrateSchema(sqlite)
|
||||
try {
|
||||
const user = createUser(db,{email:'atomic@example.invalid',name:'owned',passwordHash:'synthetic',isAdmin:false})
|
||||
setUserAccess(db,user.id,['mm'],['old'])
|
||||
const session = createRefreshSession(db,user.id,'owned-refresh',new Date(Date.now()+60000))
|
||||
enablePortalRevocations(db,target)
|
||||
sqlite.exec("CREATE TRIGGER deny_revocation BEFORE INSERT ON portal_revocation_outbox BEGIN SELECT RAISE(ABORT,'OWNED_ENQUEUE_FAILURE'); END")
|
||||
for (const mutation of [
|
||||
()=>setUserAccess(db,user.id,[],[]), ()=>updateUser(db,user.id,{isAdmin:true}),
|
||||
()=>updateUser(db,user.id,{disabled:true}), ()=>deleteUser(db,user.id),
|
||||
()=>revokeSessionById(db,session), ()=>revokeRefreshSession(db,'owned-refresh'),
|
||||
()=>revokeAllSessionsForUser(db,user.id),
|
||||
]) {
|
||||
expect(mutation).toThrow('OWNED_ENQUEUE_FAILURE')
|
||||
expect(getUserById(db,user.id)).toMatchObject({isAdmin:false,disabled:false})
|
||||
expect(getUserPermissions(db,user.id)).toEqual(['old'])
|
||||
expect(sqlite.prepare('SELECT revoked_at FROM refresh_sessions WHERE id=?').get(session)).toEqual({revoked_at:null})
|
||||
expect(sqlite.prepare('SELECT count(*) AS n FROM portal_revocation_outbox').get()).toEqual({n:0})
|
||||
}
|
||||
} finally { closeDb(sqlite) }
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
reporter: ['text-summary', 'lcov'],
|
||||
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
|
||||
thresholds: { lines: 50 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Бюджеты производительности БД (не входят в обычный test): pnpm --filter @authportal/db test:perf
|
||||
export default defineConfig({
|
||||
test: { include: ['test/perf/**/*.perf.ts'], testTimeout: 60_000 },
|
||||
})
|
||||
Reference in new issue
Block a user