|
|
|
@@ -0,0 +1,300 @@
|
|
|
|
|
import { z } from 'zod'
|
|
|
|
|
|
|
|
|
|
export const APP_IDS = ['cfdm', 'vps', 'bgp'] as const
|
|
|
|
|
export type AppId = (typeof APP_IDS)[number]
|
|
|
|
|
export const appIdSchema = z.enum(APP_IDS)
|
|
|
|
|
|
|
|
|
|
export const PERMISSION_ACTIONS = ['read', 'write', 'admin'] as const
|
|
|
|
|
export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]
|
|
|
|
|
export const permissionActionSchema = z.enum(PERMISSION_ACTIONS)
|
|
|
|
|
|
|
|
|
|
export type AppMeta = {
|
|
|
|
|
id: AppId
|
|
|
|
|
title: string
|
|
|
|
|
description: string
|
|
|
|
|
url: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const APPS: AppMeta[] = [
|
|
|
|
|
{
|
|
|
|
|
id: 'cfdm',
|
|
|
|
|
title: 'Cloudflare Domain Manager',
|
|
|
|
|
description: 'Домены, DNS, сертификаты, группы и сервисы',
|
|
|
|
|
url: 'https://cfdm.shnt.top',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'vps',
|
|
|
|
|
title: 'VPS Tracker',
|
|
|
|
|
description: 'Серверы, аккаунты, платежи и синхронизация',
|
|
|
|
|
url: 'https://vps.shnt.top',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'bgp',
|
|
|
|
|
title: 'EvoBGP',
|
|
|
|
|
description: 'Модули, peers, сеть и apply',
|
|
|
|
|
url: 'https://bgp.shnt.top',
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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('settings', 'Настройки', 'Настройки приложения', ['admin']),
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
appId: 'bgp',
|
|
|
|
|
title: 'EvoBGP',
|
|
|
|
|
sections: [
|
|
|
|
|
section('modules', 'Модули', 'Модули префиксов'),
|
|
|
|
|
section('peers', 'Peers', 'BGP peers'),
|
|
|
|
|
section('network', 'Сеть', 'Спикеры и сеть'),
|
|
|
|
|
section('apply', 'Apply', 'Apply / rollback', ['write']),
|
|
|
|
|
section('access', 'Доступ', 'API-ключи', ['admin']),
|
|
|
|
|
section('settings', 'Настройки', 'Настройки tenant', ['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`, or full origins `http://localhost:5173`.
|
|
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const permissionKeySchema = z
|
|
|
|
|
.string()
|
|
|
|
|
.regex(/^[a-z]+:[a-z0-9_]+:(read|write|admin)$/)
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
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()),
|
|
|
|
|
created_at: z.string(),
|
|
|
|
|
updated_at: z.string(),
|
|
|
|
|
})
|
|
|
|
|
export type AdminUser = z.infer<typeof adminUserSchema>
|
|
|
|
|
|
|
|
|
|
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>
|