fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+21
View File
@@ -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: '^_' },
],
},
},
])
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@authportal/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"files": [
"dist",
"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",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
@@ -0,0 +1,155 @@
import { z } from 'zod'
import {
APP_IDS,
APPS,
appAuthModeSchema,
appIdSchema,
type AppAuthMode,
type AppId,
type AppMeta,
} from './auth.js'
export const appSwitcherIconSchema = z.enum([
'server',
'cloud',
'globe',
'dashboard',
'chart',
])
export const appSwitcherEntrySchema = z.object({
id: appIdSchema,
name: z.string().min(1),
subtitle: z.string().optional(),
url: z.string().url(),
icon: appSwitcherIconSchema,
shortcut: z.string().optional(),
enabled: z.boolean(),
sort: z.number().int().optional(),
/** App-scoped tenant (e.g. EvoBGP UUID) — goes into JWT, not public switcher. */
tenantId: z.string().optional(),
/** jwt = fragment SSO; oidc = open base URL. */
authMode: appAuthModeSchema.optional(),
})
export const appSwitcherConfigSchema = z.object({
menuLabel: z.string().min(1),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
const DEFAULT_ICONS: Record<AppId, AppSwitcherIconName> = {
cfdm: 'cloud',
vps: 'server',
bgp: 'globe',
fw: 'server',
dns: 'globe',
cdn: 'cloud',
mm: 'dashboard',
}
const DEFAULT_AUTH_MODE: Record<AppId, AppAuthMode> = {
cfdm: 'jwt',
vps: 'jwt',
bgp: 'jwt',
fw: 'jwt',
dns: 'oidc',
cdn: 'jwt',
mm: 'jwt',
}
/** Seed / fallback when DB is empty. */
export function defaultAppSwitcherConfig(): AppSwitcherConfig {
return {
menuLabel: 'Приложения',
apps: APPS.map((app, index) => ({
id: app.id,
name: app.title,
subtitle: app.description,
url: app.url,
icon: DEFAULT_ICONS[app.id],
enabled: true,
sort: index,
authMode: DEFAULT_AUTH_MODE[app.id],
})),
}
}
export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig {
const parsed = appSwitcherConfigSchema.safeParse(raw)
if (!parsed.success) return defaultAppSwitcherConfig()
return normalizeAppSwitcherConfig(parsed.data)
}
/** Ensure all APP_IDS present; sort; drop unknown. */
export function normalizeAppSwitcherConfig(
config: AppSwitcherConfig,
): AppSwitcherConfig {
const byId = new Map(config.apps.map((a) => [a.id, a]))
const defaults = defaultAppSwitcherConfig()
const apps = APP_IDS.map((id, index) => {
const existing = byId.get(id)
const fallback = defaults.apps.find((a) => a.id === id)!
return {
...fallback,
...existing,
id,
sort: existing?.sort ?? index,
enabled: existing?.enabled ?? true,
icon: existing?.icon ?? fallback.icon,
tenantId: existing?.tenantId?.trim() || undefined,
authMode:
existing?.authMode ??
fallback.authMode ??
DEFAULT_AUTH_MODE[id],
}
}).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
return {
menuLabel: config.menuLabel || 'Приложения',
apps,
}
}
/** Public GET must not expose tenant IDs. */
export function publicAppSwitcherConfig(
config: AppSwitcherConfig,
): AppSwitcherConfig {
const normalized = normalizeAppSwitcherConfig(config)
return {
menuLabel: normalized.menuLabel,
apps: normalized.apps.map(({ tenantId: _tid, ...rest }) => rest),
}
}
/** Map appId → tenantId for JWT (only apps the user may access). */
export function tenantsClaimForUser(
config: AppSwitcherConfig,
userApps: readonly string[],
): Record<string, string> {
const allowed = new Set(userApps)
const out: Record<string, string> = {}
for (const app of normalizeAppSwitcherConfig(config).apps) {
if (!allowed.has(app.id)) continue
const tid = app.tenantId?.trim()
if (tid) out[app.id] = tid
}
return out
}
/** AppMeta list with URLs from switcher store (for /apps + catalog). */
export function appsMetaFromSwitcher(config: AppSwitcherConfig): AppMeta[] {
const normalized = normalizeAppSwitcherConfig(config)
return normalized.apps
.filter((a) => a.enabled !== false)
.map((a) => ({
id: a.id,
title: a.name,
description: a.subtitle ?? APPS.find((x) => x.id === a.id)?.description ?? '',
url: a.url,
authMode: a.authMode ?? DEFAULT_AUTH_MODE[a.id],
}))
}
+136
View File
@@ -0,0 +1,136 @@
import { z } from 'zod'
export const AUDIT_SEVERITIES = ['info', 'warning', 'critical'] as const
export type AuditSeverity = (typeof AUDIT_SEVERITIES)[number]
export const auditSeveritySchema = z.enum(AUDIT_SEVERITIES)
export const AUDIT_SOURCE_APPS = [
'portal',
'vps',
'cfdm',
'bgp',
'fw',
'dns',
'cdn',
'mm',
] as const
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
export const AUDIT_TARGET_TYPES = [
'user',
'settings',
'session',
'system',
'app_resource',
'credential',
] as const
export type AuditTargetType = (typeof AUDIT_TARGET_TYPES)[number]
export const auditTargetTypeSchema = z.enum(AUDIT_TARGET_TYPES)
/** Well-known portal-native action keys. */
export const AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.sso_handoff',
'user.create',
'user.update',
'user.delete',
'user.access_update',
'app_switcher.update',
'audit.settings_update',
'audit.purge',
] as const
export type AuditAction = (typeof AUDIT_ACTIONS)[number]
export const auditActionSchema = z.enum(AUDIT_ACTIONS)
export const AUDIT_KINDS = ['logins', 'changes'] as const
export type AuditKind = (typeof AUDIT_KINDS)[number]
export const auditKindSchema = z.enum(AUDIT_KINDS)
export const AUTH_AUDIT_ACTIONS = [
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.sso_handoff',
] as const
export const auditLogEntrySchema = z.object({
id: z.string(),
event_id: z.string().nullable(),
source_app: auditSourceAppSchema,
action: z.string(),
severity: auditSeveritySchema,
actor_user_id: z.string().nullable(),
actor_email: z.string().nullable(),
actor_name: z.string().nullable(),
target_type: auditTargetTypeSchema.nullable(),
target_id: z.string().nullable(),
summary: z.string(),
details: z.record(z.string(), z.unknown()).nullable(),
ip: z.string().nullable(),
created_at: z.string(),
})
export type AuditLogEntry = z.infer<typeof auditLogEntrySchema>
export const auditListQuerySchema = z.object({
action: z.string().optional(),
severity: auditSeveritySchema.optional(),
user_id: z.string().optional(),
actor_email: z.string().email().optional(),
source_app: auditSourceAppSchema.optional(),
kind: auditKindSchema.optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
export type AuditListQuery = z.infer<typeof auditListQuerySchema>
export const DEFAULT_AUDIT_RETENTION_DAYS = 90
export const MIN_AUDIT_RETENTION_DAYS = 7
export const MAX_AUDIT_RETENTION_DAYS = 3650
export const auditSettingsSchema = z.object({
retention_days: z
.number()
.int()
.min(MIN_AUDIT_RETENTION_DAYS)
.max(MAX_AUDIT_RETENTION_DAYS),
})
export type AuditSettings = z.infer<typeof auditSettingsSchema>
export const putAuditSettingsSchema = auditSettingsSchema
export type PutAuditSettings = z.infer<typeof putAuditSettingsSchema>
export const auditPurgeResponseSchema = z.object({
deleted: z.number().int().nonnegative(),
retention_days: z.number().int(),
})
export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
export const ingestAuditEventSchema = z.object({
event_id: z.string().min(1).max(128),
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw', 'dns', 'cdn', 'mm']),
action: z.string().min(1).max(200),
severity: auditSeveritySchema.optional(),
actor_user_id: z.string().nullable().optional(),
actor_email: z.string().email().nullable().optional(),
actor_name: z.string().nullable().optional(),
target_type: auditTargetTypeSchema.nullable().optional(),
target_id: z.string().nullable().optional(),
summary: z.string().min(1).max(500),
details: z.record(z.string(), z.unknown()).nullable().optional(),
ip: z.string().nullable().optional(),
created_at: z.string().optional(),
})
export type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>
export const ingestAuditRequestSchema = z.object({
events: z.array(ingestAuditEventSchema).min(1).max(50),
})
export type IngestAuditRequest = z.infer<typeof ingestAuditRequestSchema>
export const ingestAuditResponseSchema = z.object({
accepted: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
})
export type IngestAuditResponse = z.infer<typeof ingestAuditResponseSchema>
+541
View File
@@ -0,0 +1,541 @@
import { z } from 'zod'
export const APP_IDS = ['cfdm', 'vps', 'bgp', 'fw', 'dns', 'cdn', 'mm'] as const
export type AppId = (typeof APP_IDS)[number]
export const appIdSchema = z.enum(APP_IDS)
export const APP_AUTH_MODES = ['jwt', 'oidc'] as const
export type AppAuthMode = (typeof APP_AUTH_MODES)[number]
export const appAuthModeSchema = z.enum(APP_AUTH_MODES)
export const PERMISSION_ACTIONS = ['read', 'write', 'admin', 'approve', 'pause'] as const
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]
export const permissionActionSchema = z.enum(PERMISSION_ACTIONS)
export const PERMISSION_ACTION_LABELS: Readonly<Record<PermissionAction, string>> = Object.freeze({
read: 'Чтение', write: 'Редактирование', admin: 'Администрирование',
approve: 'Одобрение планов', pause: 'Пауза автоматизации',
})
export function isIndependentPermissionSection(appId: AppId, sectionId: string): boolean {
return appId === 'mm' && sectionId === 'mcp'
}
/** Toggle ONE explicitly supported independent key; never cascade or normalize unrelated grants. */
export function withIndependentPermission(granted: readonly string[], appId: AppId, sectionId: string, action: PermissionAction, enabled: boolean): string[] {
const section = PERMISSION_CATALOG.find(app => app.appId === appId)?.sections.find(section => section.id === sectionId)
if (!isIndependentPermissionSection(appId, sectionId) || !section?.actions.includes(action)) throw new Error('Unsupported independent permission')
const key = permissionKey(appId, sectionId, action)
return enabled ? granted.includes(key) ? [...granted] : [...granted, key] : granted.filter(value => value !== key)
}
export type AppMeta = {
id: AppId
title: string
description: string
url: string
/** jwt = fragment SSO; oidc = open base URL (external OIDC RP). */
authMode: AppAuthMode
}
export const APPS: AppMeta[] = [
{
id: 'cfdm',
title: 'Cloudflare Domain Manager',
description: 'Домены, DNS, сертификаты, группы и сервисы',
url: 'https://cfdm.shnt.top',
authMode: 'jwt',
},
{
id: 'vps',
title: 'VPS Tracker',
description: 'Серверы, аккаунты, платежи и синхронизация',
url: 'https://vps.shnt.top',
authMode: 'jwt',
},
{
id: 'bgp',
title: 'EvoBGP',
description: 'Модули, сеть, операции и мониторинг BGP',
url: 'https://bgp.shnt.top',
authMode: 'jwt',
},
{
id: 'fw',
title: 'EvoFirewall',
description: 'Централизованный firewall: агенты, списки, политики',
url: 'https://fw.shnt.top',
authMode: 'jwt',
},
{
id: 'dns',
title: 'Technitium DNS',
description: 'DNS-сервер: зоны, DHCP, блокировки (OIDC SSO)',
url: 'https://dns.shnt.top',
authMode: 'oidc',
},
{
id: 'cdn',
title: 'CDN Manager',
description: 'Флот DNS: ноды, алиасы, sync Cloudflare',
url: 'https://cdn.shnt.top',
authMode: 'jwt',
},
{
id: 'mm',
title: 'MikrotikManager',
description: 'MikroTik: серверы, фильтры, BGP, uptime',
url: 'https://mm.shnt.top',
authMode: 'jwt',
},
]
export type CatalogSection = {
id: string
title: string
description: string
actions: PermissionAction[]
}
export type AppPermissionCatalog = {
appId: AppId
title: string
sections: CatalogSection[]
}
function section(
id: string,
title: string,
description: string,
actions: PermissionAction[] = ['read', 'write'],
): CatalogSection {
return { id, title, description, actions }
}
export const PERMISSION_CATALOG: AppPermissionCatalog[] = [
{
appId: 'cfdm',
title: 'Cloudflare Domain Manager',
sections: [
section('domains', 'Домены', 'Список и карточки доменов'),
section('dns', 'DNS', 'DNS-записи'),
section('certificates', 'Сертификаты', 'TLS-сертификаты'),
section('groups', 'Группы', 'Группы доменов'),
section('services', 'Сервисы', 'Сервисы и привязки'),
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
],
},
{
appId: 'vps',
title: 'VPS Tracker',
sections: [
section('dashboard', 'Дашборд', 'Обзор и KPI', ['read']),
section('vps', 'VPS', 'Виртуальные серверы'),
section('accounts', 'Аккаунты', 'Аккаунты провайдеров'),
section('payments', 'Платежи', 'Платежи и баланс'),
section('sync', 'Синхронизация', 'Запуск sync', ['write']),
section('spaces', 'Пространства', 'Управление пространствами и выдачей доступа', [
'admin',
]),
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
],
},
{
appId: 'bgp',
title: 'EvoBGP',
sections: [
section('dashboard', 'Панель', 'KPI и обзор', ['read']),
section('modules', 'Модули', 'Модули префиксов'),
section('lookup', 'Проверка', 'IP/домен в списках и community', ['read']),
section('network', 'Сеть', 'BGP-пиры и спикеры'),
section('directories', 'Справочники', 'Communities и DoH'),
section('operations', 'Операции', 'Ревизии (read/write) и apply/rollback (admin)', [
'read',
'write',
'admin',
]),
section('schedule', 'Задачи', 'Расписание refresh'),
section('monitoring', 'Мониторинг', 'Health и BIRD', ['read']),
section('access', 'Доступ', 'API-ключи', ['admin']),
section('tenant_settings', 'Настройки BIRD', 'Tenant BIRD config', [
'admin',
]),
section('settings', 'Настройки UI', 'Токен и подключение', ['read']),
],
},
{
appId: 'fw',
title: 'EvoFirewall',
sections: [
section('dashboard', 'Дашборд', 'KPI и обзор', ['read']),
section('agents', 'Агенты', 'Linux / MikroTik клиенты'),
section('lists', 'Списки IP', 'static / JSON / domains / EvoBGP'),
section('policies', 'Правила', 'Allow/deny политики и overrides'),
section('stats', 'Статистика', 'Пакеты и история', ['read']),
section('settings', 'Настройки', 'Enroll seed и интеграции', ['admin']),
],
},
{
appId: 'dns',
title: 'Technitium DNS',
sections: [
section('console', 'Консоль', 'Полный доступ Administrators', ['admin']),
section('dns', 'DNS', 'Зоны и записи (DNS Administrators)'),
section('dhcp', 'DHCP', 'DHCP scopes (DHCP Administrators)'),
section('settings', 'Настройки', 'SSO и системные настройки', ['admin']),
],
},
{
appId: 'cdn',
title: 'CDN Manager',
sections: [
section('dashboard', 'Панель', 'KPI и обзор', ['read']),
section('nodes', 'Ноды', 'Канонические хосты A/AAAA'),
section('aliases', 'Алиасы', 'CNAME и retarget'),
section('zones', 'Зоны', 'Cloudflare zones и BIND export'),
section('sync', 'Синхронизация', 'Pull/diff/apply DNS', ['write']),
section('topology', 'Топология', 'Схема флота', ['read']),
section('settings', 'Настройки', 'Naming, TTL, Cloudflare', ['admin']),
],
},
{
appId: 'mm',
title: 'MikrotikManager',
sections: [
section('dashboard', 'Дашборд', 'Обзор и KPI', ['read']),
section('servers', 'Серверы', 'MikroTik / CHR хосты'),
section('filters', 'Фильтры', 'Firewall и address-list'),
section('bgp', 'BGP', 'BGP-сессии и префиксы'),
section('uptime', 'Uptime', 'Пинги и speed-probes'),
section('traffic', 'Трафик', 'Интерфейсы и графики'),
section('alerts', 'Алерты', 'Правила и Telegram'),
section('backups', 'Бэкапы', 'Конфиги и расписание'),
section('certificates', 'Сертификаты', 'ACME / TLS'),
section('network', 'Сеть', 'GRE, OSPF, пути'),
section('users', 'Пользователи MM', 'Учётные записи и привязки интерфейсов в MikrotikManager', ['read', 'write']),
section('mcp', 'MCP', 'Одобрение и пауза назначаются отдельно; администрирование включает чтение', ['read', 'approve', 'pause', 'admin']),
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
],
},
]
export function permissionKey(
appId: AppId,
sectionId: string,
action: PermissionAction,
): string {
return `${appId}:${sectionId}:${action}`
}
/** Hierarchy: admin ⊃ write ⊃ read within the same section. */
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
/**
* Allowlist entries: `.shnt.top`, `localhost`, full origins `http://localhost:5173`,
* or token `private` for RFC1918 LAN hosts (192.168/10/172.16-31).
*/
export function isReturnToAllowed(
returnTo: string,
allowlistCsv: string,
): boolean {
let url: URL
try {
url = new URL(returnTo)
} catch {
return false
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
const entries = allowlistCsv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const allowPrivate = entries.some((e) => e.toLowerCase() === 'private')
if (allowPrivate && isPrivateHostname(url.hostname)) return true
for (const entry of entries) {
if (entry.toLowerCase() === 'private') continue
if (entry.startsWith('.')) {
const suffix = entry.slice(1)
if (url.hostname === suffix || url.hostname.endsWith(entry)) return true
continue
}
if (entry.includes('://')) {
try {
if (url.origin === new URL(entry).origin) return true
} catch {
/* ignore */
}
continue
}
if (url.hostname === entry || url.hostname.endsWith(`.${entry}`)) {
return true
}
}
return false
}
function isPrivateHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.local')) return true
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname)
if (!m) return false
const a = Number(m[1])
const b = Number(m[2])
if (a === 10) return true
if (a === 192 && b === 168) return true
if (a === 172 && b >= 16 && b <= 31) return true
return false
}
/** Decode JWT payload without verifying signature (claims sync only). */
export function readJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json) as Record<string, unknown>
} catch {
return null
}
}
/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */
export function readJwtExp(token: string): number | null {
const payload = readJwtPayload(token)
if (!payload) return null
return typeof payload.exp === 'number' ? payload.exp : null
}
/** True when token is missing exp or exp is in the past (30s clock-skew grace). */
export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean {
const exp = readJwtExp(token)
if (exp == null) return true
return exp * 1000 < nowMs - 30_000
}
export function buildSsoRedirectUrl(
returnTo: string,
accessToken: string,
expiresAt: string,
): string {
const hash = new URLSearchParams({
access_token: accessToken,
expires_at: expiresAt,
})
const base = returnTo.split('#')[0] ?? returnTo
return `${base}#${hash.toString()}`
}
export function allPermissionKeys(): string[] {
const keys: string[] = []
for (const app of PERMISSION_CATALOG) {
for (const sec of app.sections) {
for (const action of sec.actions) {
keys.push(permissionKey(app.appId, sec.id, action))
}
}
}
return keys
}
/** Old bgp section ids → current catalog (peers/apply/settings renamed). */
const LEGACY_PERMISSION_MAP: Record<string, string> = {
'bgp:peers:read': 'bgp:network:read',
'bgp:peers:write': 'bgp:network:write',
'bgp:apply:write': 'bgp:operations:admin',
'bgp:settings:admin': 'bgp:tenant_settings:admin',
}
/**
* Map legacy keys and drop anything not in the current catalog.
* Safe for JWT issue and admin save after catalog changes.
*/
export function normalizePermissionKeys(
permissions: readonly string[],
): string[] {
const allowed = new Set(allPermissionKeys())
const out = new Set<string>()
for (const raw of permissions) {
const mapped = LEGACY_PERMISSION_MAP[raw] ?? raw
if (allowed.has(mapped)) {
out.add(mapped)
}
}
return [...out].sort()
}
export const permissionKeySchema = z
.string()
.regex(/^(?:[a-z]+:[a-z0-9_]+:(?:read|write|admin)|mm:mcp:(?:approve|pause))$/)
export const meResponseSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
is_admin: z.boolean(),
apps: z.array(appIdSchema),
permissions: z.array(z.string()),
})
export type MeResponse = z.infer<typeof meResponseSchema>
export const loginRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
return_to: z.string().url().optional(),
})
export type LoginRequest = z.infer<typeof loginRequestSchema>
export const loginResponseSchema = z.object({
access_token: z.string(),
expires_at: z.string(),
token_type: z.literal('Bearer'),
user: meResponseSchema,
})
export type LoginResponse = z.infer<typeof loginResponseSchema>
export const accessTokenClaimsSchema = z.object({
sub: z.string(),
email: z.string().email(),
name: z.string(),
apps: z.array(z.string()),
permissions: z.array(z.string()),
is_admin: z.boolean().optional(),
iss: z.string(),
aud: z.string().optional(),
exp: z.number(),
iat: z.number(),
})
export type AccessTokenClaims = z.infer<typeof accessTokenClaimsSchema>
export const catalogResponseSchema = z.object({
apps: z.array(
z.object({
id: appIdSchema,
title: z.string(),
description: z.string(),
url: z.string(),
authMode: appAuthModeSchema.optional(),
}),
),
permissions: z.array(
z.object({
appId: appIdSchema,
title: z.string(),
sections: z.array(
z.object({
id: z.string(),
title: z.string(),
description: z.string(),
actions: z.array(permissionActionSchema),
}),
),
}),
),
})
export type CatalogResponse = z.infer<typeof catalogResponseSchema>
export const adminUserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
is_admin: z.boolean(),
disabled: z.boolean(),
apps: z.array(appIdSchema),
permissions: z.array(z.string()),
last_login_at: z.string().nullable(),
last_login_ip: z.string().nullable(),
passkey_count: z.number().int().nonnegative().default(0),
created_at: z.string(),
updated_at: z.string(),
})
export type AdminUser = z.infer<typeof adminUserSchema>
export const passkeyCredentialSchema = z.object({
id: z.string(),
name: z.string(),
created_at: z.string(),
last_used_at: z.string().nullable(),
device_type: z.string().nullable(),
})
export type PasskeyCredential = z.infer<typeof passkeyCredentialSchema>
export const webauthnOptionsResponseSchema = z.object({
challenge_id: z.string(),
options: z.record(z.string(), z.unknown()),
})
export type WebauthnOptionsResponse = z.infer<
typeof webauthnOptionsResponseSchema
>
export const webauthnVerifyRequestSchema = z.object({
challenge_id: z.string().min(1),
response: z.unknown(),
return_to: z.string().url().optional(),
name: z.string().min(1).max(80).optional(),
})
export type WebauthnVerifyRequest = z.infer<typeof webauthnVerifyRequestSchema>
export const patchPasskeyRequestSchema = z.object({
name: z.string().min(1).max(80),
})
export type PatchPasskeyRequest = z.infer<typeof patchPasskeyRequestSchema>
export const adminSessionSchema = z.object({
id: z.string(),
user_id: z.string(),
email: z.string().email(),
name: z.string(),
ip: z.string().nullable(),
user_agent: z.string().nullable(),
created_at: z.string(),
expires_at: z.string(),
})
export type AdminSession = z.infer<typeof adminSessionSchema>
export const ssoAccessRequestSchema = z.object({
return_to: z.string().url(),
})
export type SsoAccessRequest = z.infer<typeof ssoAccessRequestSchema>
export const createUserRequestSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
password: z.string().min(6),
is_admin: z.boolean().default(false),
apps: z.array(appIdSchema).default([]),
permissions: z.array(permissionKeySchema).default([]),
})
export type CreateUserRequest = z.infer<typeof createUserRequestSchema>
export const patchUserRequestSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).optional(),
password: z.string().min(6).optional(),
is_admin: z.boolean().optional(),
disabled: z.boolean().optional(),
})
export type PatchUserRequest = z.infer<typeof patchUserRequestSchema>
export const putUserAccessRequestSchema = z.object({
apps: z.array(appIdSchema),
permissions: z.array(permissionKeySchema),
})
export type PutUserAccessRequest = z.infer<typeof putUserAccessRequestSchema>
+101
View File
@@ -0,0 +1,101 @@
import { z } from 'zod'
export const OIDC_SCOPES = ['openid', 'profile', 'email', 'groups'] as const
export type OidcScope = (typeof OIDC_SCOPES)[number]
/** Remote groups for Technitium Group Map (and similar RPs). */
export const TECHNITIUM_OIDC_GROUPS = {
admins: 'technitium_admins',
dnsAdmins: 'technitium_dns_admins',
dhcpAdmins: 'technitium_dhcp_admins',
} as const
/**
* Map portal permissions / admin flag → OIDC group names for Technitium.
* Used in id_token + userinfo `groups` and `roles` claims.
*/
export function oidcGroupsForUser(input: {
isAdmin: boolean
permissions: readonly string[]
}): string[] {
const granted = new Set(input.permissions)
const groups: string[] = []
if (input.isAdmin || granted.has('dns:console:admin')) {
groups.push(TECHNITIUM_OIDC_GROUPS.admins)
}
if (
granted.has('dns:dns:write') ||
granted.has('dns:dns:admin') ||
granted.has('dns:console:admin') ||
input.isAdmin
) {
groups.push(TECHNITIUM_OIDC_GROUPS.dnsAdmins)
}
if (
granted.has('dns:dhcp:write') ||
granted.has('dns:dhcp:admin') ||
granted.has('dns:console:admin') ||
input.isAdmin
) {
groups.push(TECHNITIUM_OIDC_GROUPS.dhcpAdmins)
}
return [...new Set(groups)]
}
export const createOidcClientRequestSchema = z.object({
name: z.string().min(1).max(200),
redirect_uris: z.array(z.string().url()).min(1),
scopes: z
.array(z.enum(OIDC_SCOPES))
.min(1)
.default([...OIDC_SCOPES]),
enabled: z.boolean().default(true),
})
export type CreateOidcClientRequest = z.infer<
typeof createOidcClientRequestSchema
>
export const patchOidcClientRequestSchema = z.object({
name: z.string().min(1).max(200).optional(),
redirect_uris: z.array(z.string().url()).min(1).optional(),
scopes: z.array(z.enum(OIDC_SCOPES)).min(1).optional(),
enabled: z.boolean().optional(),
})
export type PatchOidcClientRequest = z.infer<
typeof patchOidcClientRequestSchema
>
export const oidcClientPublicSchema = z.object({
id: z.string(),
client_id: z.string(),
name: z.string(),
redirect_uris: z.array(z.string()),
scopes: z.array(z.string()),
enabled: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
})
export type OidcClientPublic = z.infer<typeof oidcClientPublicSchema>
export const oidcClientCreatedSchema = oidcClientPublicSchema.extend({
client_secret: z.string(),
})
export type OidcClientCreated = z.infer<typeof oidcClientCreatedSchema>
/** True when return_to is portal OIDC authorize (no JWT fragment handoff). */
export function isPortalOidcAuthorizeUrl(
returnTo: string,
issuer: string,
): boolean {
try {
const url = new URL(returnTo)
const iss = new URL(issuer)
if (url.origin !== iss.origin) return false
return (
url.pathname === '/oauth/authorize' ||
url.pathname === '/oauth/authorize/'
)
} catch {
return false
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './contracts/auth.js'
export * from './contracts/app-switcher.js'
export * from './contracts/audit.js'
export * from './contracts/oidc.js'
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
APP_IDS,
allPermissionKeys,
appsMetaFromSwitcher,
buildSsoRedirectUrl,
createOidcClientRequestSchema,
defaultAppSwitcherConfig,
hasPermission,
isJwtExpired,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
loginRequestSchema,
normalizeAppSwitcherConfig,
normalizePermissionKeys,
oidcGroupsForUser,
parseAppSwitcherConfig,
permissionKey,
publicAppSwitcherConfig,
readJwtExp,
readJwtPayload,
tenantsClaimForUser,
} from '../src/index.js'
function jwt(payload: object): string {
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('permissions', () => {
it('иерархия admin ⊃ write ⊃ read в пределах секции', () => {
expect(hasPermission(['dns:dns:admin'], 'dns:dns:write')).toBe(true)
expect(hasPermission(['dns:dns:admin'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:write'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:read'], 'dns:dns:write')).toBe(false)
expect(hasPermission(['dns:dns:admin'], 'dns:dhcp:read')).toBe(false)
expect(hasPermission([], 'broken')).toBe(false)
})
it('permissionKey / allPermissionKeys согласованы', () => {
expect(permissionKey('dns', 'dns', 'read')).toBe('dns:dns:read')
const all = allPermissionKeys()
expect(new Set(all).size).toBe(all.length)
expect(all).toContain('dns:dns:read')
})
it('normalizePermissionKeys: легаси мапятся, неизвестные отбрасываются, результат отсортирован', () => {
const out = normalizePermissionKeys([
'bgp:peers:read',
'bgp:settings:admin',
'nope:x:read',
'dns:dns:read',
'dns:dns:read',
])
expect(out).toEqual([...out].sort())
expect(out).toContain('bgp:network:read')
expect(out).toContain('bgp:tenant_settings:admin')
expect(out).toContain('dns:dns:read')
expect(out).not.toContain('nope:x:read')
expect(out.filter((k) => k === 'dns:dns:read')).toHaveLength(1)
})
})
describe('oidcGroupsForUser', () => {
it('админ получает все группы Technitium', () => {
expect(oidcGroupsForUser({ isAdmin: true, permissions: [] })).toEqual([
'technitium_admins',
'technitium_dns_admins',
'technitium_dhcp_admins',
])
})
it('права dns/dhcp дают соответствующие группы', () => {
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dns:write'] }),
).toEqual(['technitium_dns_admins'])
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dhcp:admin'] }),
).toEqual(['technitium_dhcp_admins'])
expect(oidcGroupsForUser({ isAdmin: false, permissions: [] })).toEqual([])
})
})
describe('isReturnToAllowed', () => {
it.each([
['https://cfdm.shnt.top/x', '.shnt.top', true],
['https://shnt.top', '.shnt.top', true],
['https://evilshnt.top', '.shnt.top', false],
['http://localhost:5173/cb', 'localhost', true],
['http://192.168.1.5/x', 'private', true],
['http://172.32.0.1/x', 'private', false],
['https://8.8.8.8', 'private', false],
['http://localhost:5173/a', 'http://localhost:5173', true],
['http://localhost:5174/a', 'http://localhost:5173', false],
['javascript:alert(1)', 'localhost', false],
['not a url', 'localhost', false],
])('%s vs %s → %s', (url, list, expected) => {
expect(isReturnToAllowed(url, list)).toBe(expected)
})
})
describe('JWT helpers', () => {
it('читает payload и exp, определяет протухание с запасом 30 c', () => {
const exp = Math.floor(Date.now() / 1000) + 3600
const t = jwt({ sub: 'u1', exp })
expect(readJwtPayload(t)).toMatchObject({ sub: 'u1' })
expect(readJwtExp(t)).toBe(exp)
expect(isJwtExpired(t)).toBe(false)
expect(isJwtExpired(jwt({ exp: 1 }))).toBe(true)
const justExpired = jwt({ exp: Math.floor(Date.now() / 1000) - 10 })
expect(isJwtExpired(justExpired)).toBe(false)
expect(isJwtExpired(jwt({ sub: 'no-exp' }))).toBe(true)
expect(readJwtPayload('garbage')).toBeNull()
expect(readJwtExp('a.b')).toBeNull()
})
it('buildSsoRedirectUrl кладёт токен во фрагмент и заменяет старый', () => {
const url = buildSsoRedirectUrl('https://a.shnt.top/p?x=1#old', 'T', 'E')
expect(url.startsWith('https://a.shnt.top/p?x=1#')).toBe(true)
expect(url).toContain('access_token=T')
expect(url).not.toContain('old')
})
})
describe('app switcher', () => {
it('default содержит все приложения', () => {
const d = defaultAppSwitcherConfig()
expect(d.apps.map((a) => a.id).sort()).toEqual([...APP_IDS].sort())
})
it('parseAppSwitcherConfig: мусор → default, неполный конфиг дополняется', () => {
expect(parseAppSwitcherConfig({ nope: 1 })).toEqual(defaultAppSwitcherConfig())
const partial = parseAppSwitcherConfig({
menuLabel: 'Меню',
apps: [
{
id: 'dns',
name: 'DNS!',
url: 'https://dns.example.com',
icon: 'globe',
enabled: false,
tenantId: ' ',
},
],
})
expect(partial.menuLabel).toBe('Меню')
expect(partial.apps).toHaveLength(APP_IDS.length)
const dns = partial.apps.find((a) => a.id === 'dns')!
expect(dns.name).toBe('DNS!')
expect(dns.enabled).toBe(false)
expect(dns.tenantId).toBeUndefined()
})
it('public-конфиг без tenantId; tenants-claim только по доступным приложениям', () => {
const base = defaultAppSwitcherConfig()
const cfg = normalizeAppSwitcherConfig({
...base,
apps: base.apps.map((a) => (a.id === 'bgp' ? { ...a, tenantId: 'tenant-1' } : a)),
})
expect(JSON.stringify(publicAppSwitcherConfig(cfg))).not.toContain('tenant-1')
expect(tenantsClaimForUser(cfg, ['bgp'])).toEqual({ bgp: 'tenant-1' })
expect(tenantsClaimForUser(cfg, ['dns'])).toEqual({})
})
it('appsMetaFromSwitcher скрывает отключённые приложения', () => {
const cfg = defaultAppSwitcherConfig()
cfg.apps = cfg.apps.map((a) => (a.id === 'dns' ? { ...a, enabled: false } : a))
expect(appsMetaFromSwitcher(cfg).some((a) => a.id === 'dns')).toBe(false)
})
})
describe('zod-схемы', () => {
it('login: email и непустой пароль обязательны', () => {
expect(loginRequestSchema.safeParse({ email: 'a@b.co', password: 'x' }).success).toBe(true)
expect(loginRequestSchema.safeParse({ email: 'bad', password: 'x' }).success).toBe(false)
expect(loginRequestSchema.safeParse({ email: 'a@b.co', password: '' }).success).toBe(false)
})
it('createOidcClient: scopes по умолчанию, redirect_uris не пустой', () => {
const ok = createOidcClientRequestSchema.parse({
name: 'c',
redirect_uris: ['https://x.example/cb'],
})
expect(ok.scopes).toContain('openid')
expect(ok.enabled).toBe(true)
expect(
createOidcClientRequestSchema.safeParse({ name: 'c', redirect_uris: [] }).success,
).toBe(false)
})
it('isPortalOidcAuthorizeUrl', () => {
const iss = 'https://auth.shnt.top'
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize?x=1`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize/`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl('https://evil.example/oauth/authorize', iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl(`${iss}/other`, iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl('nope', iss)).toBe(false)
})
})
+14
View File
@@ -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"]
}
+15
View File
@@ -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: 90 },
},
},
})