Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
@@ -15,19 +15,21 @@
|
||||
"@cfdm/db": "workspace:*",
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/jwt": "^10.2.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"fastify": "^5.6.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cors": "^2.8.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"express": "^4.21.1",
|
||||
"cors": "^2.8.5",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"sql.js": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { notificationsRoutes } from './routes/notifications.js'
|
||||
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
|
||||
import { appSwitcherRoutes } from './routes/app-switcher.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -44,6 +45,9 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
await app.register(authPlugin)
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
await app.register(dataRoutes)
|
||||
await app.register(vpsRoutes)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
} from '../lib/permissions.js'
|
||||
|
||||
describe('hasPermission hierarchy', () => {
|
||||
it('grants read via write/admin', () => {
|
||||
expect(hasPermission(['vps:vps:write'], 'vps:vps:read')).toBe(true)
|
||||
expect(hasPermission(['vps:vps:admin'], 'vps:vps:read')).toBe(true)
|
||||
expect(hasPermission(['vps:vps:admin'], 'vps:vps:write')).toBe(true)
|
||||
})
|
||||
|
||||
it('denies missing section', () => {
|
||||
expect(hasPermission(['vps:vps:read'], 'vps:settings:admin')).toBe(false)
|
||||
expect(hasPermission(['vps:vps:read'], 'vps:vps:write')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('permissionForRequest', () => {
|
||||
it('maps vps CRUD', () => {
|
||||
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
|
||||
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
|
||||
})
|
||||
|
||||
it('maps sync and settings', () => {
|
||||
expect(permissionForRequest('POST', '/api/sync/acc-1')).toBe('vps:sync:write')
|
||||
expect(permissionForRequest('GET', '/api/settings')).toBe('vps:settings:admin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: vps:<section>:<read|write|admin>
|
||||
*/
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
methods: string[]
|
||||
match: (path: string) => boolean
|
||||
permission: string
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) => p.startsWith('/api/dashboard'),
|
||||
permission: 'vps:dashboard:read',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p === '/api/vps' ||
|
||||
p.startsWith('/api/vps/') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/data'),
|
||||
permission: 'vps:vps:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/vps') || p.startsWith('/api/projects'),
|
||||
permission: 'vps:vps:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/providers') ||
|
||||
p.startsWith('/api/provider-accounts'),
|
||||
permission: 'vps:accounts:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/providers') ||
|
||||
p.startsWith('/api/provider-accounts'),
|
||||
permission: 'vps:accounts:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/payments') ||
|
||||
p.startsWith('/api/balance-ledger') ||
|
||||
p.startsWith('/api/rates'),
|
||||
permission: 'vps:payments:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/payments') ||
|
||||
p.startsWith('/api/balance-ledger'),
|
||||
permission: 'vps:payments:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST'],
|
||||
match: (p) => p.startsWith('/api/sync'),
|
||||
permission: 'vps:sync:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/settings') ||
|
||||
p.startsWith('/api/backup') ||
|
||||
p.startsWith('/api/audit') ||
|
||||
p.startsWith('/api/migrate') ||
|
||||
p.startsWith('/api/notifications') ||
|
||||
p.startsWith('/api/app-switcher'),
|
||||
permission: 'vps:settings:admin',
|
||||
},
|
||||
]
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
export function permissionForRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
const m = method.toUpperCase()
|
||||
const pathname = path.split('?')[0] ?? path
|
||||
for (const rule of RULES) {
|
||||
if (!rule.methods.includes(m)) continue
|
||||
if (rule.match(pathname)) return rule.permission
|
||||
}
|
||||
// Default: any authenticated vps user for unmatched /api/*
|
||||
if (pathname.startsWith('/api/')) return 'vps:dashboard:read'
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
|
||||
import Fastify from 'fastify'
|
||||
import { authPlugin, loadAuthConfig } from '../plugins/auth.js'
|
||||
|
||||
describe('auth plugin (AUTH_REQUIRED)', () => {
|
||||
const secret = 'test-secret-at-least-8'
|
||||
const issuer = 'https://auth.shnt.top'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AUTH_REQUIRED = 'true'
|
||||
process.env.AUTH_JWT_SECRET = secret
|
||||
process.env.AUTH_ISSUER = issuer
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.AUTH_REQUIRED
|
||||
delete process.env.AUTH_JWT_SECRET
|
||||
delete process.env.AUTH_ISSUER
|
||||
})
|
||||
|
||||
it('loadAuthConfig reads env', () => {
|
||||
const cfg = loadAuthConfig({
|
||||
AUTH_REQUIRED: 'true',
|
||||
AUTH_JWT_SECRET: secret,
|
||||
AUTH_ISSUER: issuer,
|
||||
})
|
||||
expect(cfg.required).toBe(true)
|
||||
expect(cfg.jwtSecret).toBe(secret)
|
||||
})
|
||||
|
||||
it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => {
|
||||
const app = Fastify()
|
||||
await app.register(authPlugin)
|
||||
app.get('/api/vps', async () => [{ id: '1' }])
|
||||
app.post('/api/vps', async () => ({ ok: true }))
|
||||
await app.ready()
|
||||
|
||||
const noAuth = await app.inject({ method: 'GET', url: '/api/vps' })
|
||||
expect(noAuth.statusCode).toBe(401)
|
||||
|
||||
const tokenNoApp = app.jwt.sign(
|
||||
{
|
||||
sub: 'u1',
|
||||
email: '[email protected]',
|
||||
name: 'A',
|
||||
apps: ['cfdm'],
|
||||
permissions: ['vps:vps:read'],
|
||||
iss: issuer,
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
const forbiddenApp = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/vps',
|
||||
headers: { authorization: `Bearer ${tokenNoApp}` },
|
||||
})
|
||||
expect(forbiddenApp.statusCode).toBe(403)
|
||||
|
||||
const readOnly = app.jwt.sign(
|
||||
{
|
||||
sub: 'u2',
|
||||
email: '[email protected]',
|
||||
name: 'R',
|
||||
apps: ['vps'],
|
||||
permissions: ['vps:vps:read'],
|
||||
iss: issuer,
|
||||
},
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
const okRead = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/vps',
|
||||
headers: { authorization: `Bearer ${readOnly}` },
|
||||
})
|
||||
expect(okRead.statusCode).toBe(200)
|
||||
|
||||
const denyWrite = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/vps',
|
||||
headers: { authorization: `Bearer ${readOnly}` },
|
||||
payload: {},
|
||||
})
|
||||
expect(denyWrite.statusCode).toBe(403)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import fp from 'fastify-plugin'
|
||||
import fjwt from '@fastify/jwt'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
hasPermission,
|
||||
permissionForRequest,
|
||||
type AuthUser,
|
||||
} from '../lib/permissions.js'
|
||||
|
||||
export type AuthConfig = {
|
||||
required: boolean
|
||||
jwtSecret: string
|
||||
issuer: string
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authConfig: AuthConfig
|
||||
}
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
}
|
||||
user: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps?: string[]
|
||||
permissions?: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === '') return fallback
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
export function loadAuthConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AuthConfig {
|
||||
const isProd = env.NODE_ENV === 'production'
|
||||
return {
|
||||
required: boolEnv(env.AUTH_REQUIRED, false),
|
||||
jwtSecret:
|
||||
env.AUTH_JWT_SECRET ??
|
||||
env.JWT_SECRET ??
|
||||
(isProd ? '' : 'dev-secret-change-me'),
|
||||
issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top',
|
||||
}
|
||||
}
|
||||
|
||||
function isPublicPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
if (path === '/health' || path === '/ready') return true
|
||||
if (path.startsWith('/api/integrations/cfdm')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const authPlugin = fp(async (app) => {
|
||||
const config = loadAuthConfig()
|
||||
app.decorate('authConfig', config)
|
||||
|
||||
if (!config.required) {
|
||||
app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled')
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.jwtSecret || config.jwtSecret.length < 8) {
|
||||
throw new Error('AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true')
|
||||
}
|
||||
|
||||
await app.register(fjwt, {
|
||||
secret: config.jwtSecret,
|
||||
verify: {
|
||||
allowedIss: [config.issuer],
|
||||
},
|
||||
})
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (isPublicPath(request.url)) return
|
||||
if (!request.url.startsWith('/api/')) return
|
||||
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
|
||||
const payload = request.user
|
||||
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
|
||||
const permissions = Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: []
|
||||
|
||||
if (!apps.includes('vps')) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Нет доступа к приложению VPS Tracker',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
request.authUser = {
|
||||
id: String(payload.sub),
|
||||
email: String(payload.email ?? ''),
|
||||
name: String(payload.name ?? ''),
|
||||
apps,
|
||||
permissions,
|
||||
isAdmin: Boolean(payload.is_admin),
|
||||
}
|
||||
|
||||
const required = permissionForRequest(request.method, request.url)
|
||||
if (required && !hasPermission(permissions, required)) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: `Недостаточно прав: ${required}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export async function requirePermission(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
permission: string,
|
||||
): Promise<void> {
|
||||
const user = request.authUser
|
||||
if (!user) {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
if (!hasPermission(user.permissions, permission)) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: `Недостаточно прав: ${permission}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user