First Commit
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './contracts/auth.js'
|
||||
Reference in New Issue
Block a user