This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit e02e89960cf30473ae30b885861e4d9c6a8079c0 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 10:07:38 2026 +0800 bff: rbac verb gating and audit jsonl --- apps/bff/src/audit/logger.ts | 60 ++++++++++++++++++++++++++++++++++++ apps/bff/src/auth/routes.ts | 22 ++++++++++++-- apps/bff/src/rbac/policy.ts | 57 +++++++++++++++++++++++++++++++++++ apps/bff/src/rbac/verbs.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++ apps/bff/src/server.ts | 6 +++- 5 files changed, 213 insertions(+), 4 deletions(-) diff --git a/apps/bff/src/audit/logger.ts b/apps/bff/src/audit/logger.ts new file mode 100644 index 0000000..c10dc28 --- /dev/null +++ b/apps/bff/src/audit/logger.ts @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createWriteStream, type WriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { logger } from '../logger.js'; + +export interface AuditEvent { + ts: string; // ISO-8601 + actor: string; + action: string; // e.g. "rule.addOrUpdate", "auth.login", "role.update" + target?: string; + outcome: 'success' | 'failure'; + details?: Record<string, unknown>; +} + +export class AuditLogger { + private stream: WriteStream | null = null; + private readonly absPath: string; + + constructor(filePath: string) { + this.absPath = resolve(filePath); + } + + async open(): Promise<void> { + await mkdir(dirname(this.absPath), { recursive: true }); + this.stream = createWriteStream(this.absPath, { flags: 'a' }); + this.stream.on('error', (err) => logger.error({ err }, 'audit stream error')); + } + + record(evt: Omit<AuditEvent, 'ts'>): void { + const line: AuditEvent = { ts: new Date().toISOString(), ...evt }; + if (!this.stream) { + logger.warn({ evt: line }, 'audit logged before open()'); + return; + } + this.stream.write(JSON.stringify(line) + '\n'); + } + + async close(): Promise<void> { + if (!this.stream) return; + await new Promise<void>((resolveDone) => this.stream!.end(() => resolveDone())); + this.stream = null; + } +} diff --git a/apps/bff/src/auth/routes.ts b/apps/bff/src/auth/routes.ts index 363f449..2fdf0ff 100644 --- a/apps/bff/src/auth/routes.ts +++ b/apps/bff/src/auth/routes.ts @@ -17,8 +17,10 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; +import type { AuditLogger } from '../audit/logger.js'; import { badRequest, unauthorized } from '../errors.js'; import type { ConfigSource } from '../config/loader.js'; +import { resolveVerbsForRoles } from '../rbac/verbs.js'; import { verifyLocalCredentials } from './local.js'; import type { SessionStore } from './sessions.js'; @@ -31,6 +33,7 @@ export function registerAuthRoutes( app: FastifyInstance, source: ConfigSource, sessions: SessionStore, + audit: AuditLogger, ): void { const cookieName = () => source.current.session.cookieName; const cookieSecure = () => source.current.session.cookieSecure; @@ -45,9 +48,13 @@ export function registerAuthRoutes( parsed.data.username, parsed.data.password, ); - if (!verified) throw unauthorized('invalid credentials'); + if (!verified) { + audit.record({ actor: parsed.data.username, action: 'auth.login', outcome: 'failure' }); + throw unauthorized('invalid credentials'); + } const session = sessions.create(verified.username, verified.roles); + audit.record({ actor: session.username, action: 'auth.login', outcome: 'success' }); reply.setCookie(cookieName(), session.sid, { httpOnly: true, sameSite: 'strict', @@ -60,7 +67,11 @@ export function registerAuthRoutes( app.post('/api/auth/logout', async (req, reply) => { const sid = req.cookies[cookieName()]; - if (sid) sessions.destroy(sid); + if (sid) { + const session = sessions.touch(sid); + if (session) audit.record({ actor: session.username, action: 'auth.logout', outcome: 'success' }); + sessions.destroy(sid); + } reply.clearCookie(cookieName(), { path: '/' }); return { status: 'ok' }; }); @@ -70,6 +81,11 @@ export function registerAuthRoutes( if (!sid) throw unauthorized(); const session = sessions.touch(sid); if (!session) throw unauthorized(); - return { username: session.username, roles: session.roles }; + const verbs = resolveVerbsForRoles( + source.current.rbac.roles, + session.roles, + source.current.rbac.enabled, + ); + return { username: session.username, roles: session.roles, verbs }; }); } diff --git a/apps/bff/src/rbac/policy.ts b/apps/bff/src/rbac/policy.ts new file mode 100644 index 0000000..d016173 --- /dev/null +++ b/apps/bff/src/rbac/policy.ts @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { forbidden, unauthorized } from '../errors.js'; +import type { Session } from '../auth/sessions.js'; +import type { SessionStore } from '../auth/sessions.js'; +import type { ConfigSource } from '../config/loader.js'; +import { hasVerb, resolveVerbsForRoles, type Verb } from './verbs.js'; + +// Augment Fastify's request type so handlers can `req.session` without casts. +declare module 'fastify' { + interface FastifyRequest { + session?: Session; + verbs?: Verb[]; + } +} + +export function makeRequireAuth(source: ConfigSource, sessions: SessionStore) { + return async function requireAuth(req: FastifyRequest, _reply: FastifyReply): Promise<void> { + const cookieName = source.current.session.cookieName; + const sid = req.cookies[cookieName]; + if (!sid) throw unauthorized(); + const session = sessions.touch(sid); + if (!session) throw unauthorized(); + req.session = session; + req.verbs = resolveVerbsForRoles( + source.current.rbac.roles, + session.roles, + source.current.rbac.enabled, + ); + }; +} + +export function makeRequireVerb(source: ConfigSource, sessions: SessionStore, verb: Verb) { + const requireAuth = makeRequireAuth(source, sessions); + return async function requireVerb(req: FastifyRequest, reply: FastifyReply): Promise<void> { + await requireAuth(req, reply); + if (!hasVerb(req.verbs ?? [], verb)) { + throw forbidden(`missing verb: ${verb}`); + } + }; +} diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts new file mode 100644 index 0000000..f4db0ea --- /dev/null +++ b/apps/bff/src/rbac/verbs.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Verbs are dot-namespaced. Special grants: +// `*` → grants everything +// `<area>:*` → grants every action in an area (e.g. `rule:*`) +// `*:read` → grants read in every area +export type Verb = string; + +export const VERBS = { + ruleRead: 'rule:read', + ruleWrite: 'rule:write', + ruleWriteStructural: 'rule:write:structural', + ruleDelete: 'rule:delete', + ruleDebug: 'rule:debug', + clusterRead: 'cluster:read', + inspectRead: 'inspect:read', + dashboardRead: 'dashboard:read', + dashboardWrite: 'dashboard:write', + userRead: 'user:read', + userWrite: 'user:write', + roleRead: 'role:read', + roleWrite: 'role:write', + auditRead: 'audit:read', + alarmRuleRead: 'alarm-rule:read', + alarmRuleWrite: 'alarm-rule:write', + admin: 'admin', +} as const; + +export type KnownVerb = (typeof VERBS)[keyof typeof VERBS]; + +function matchOne(grant: Verb, required: Verb): boolean { + if (grant === '*' || grant === 'admin') return true; + if (grant === required) return true; + // `area:*` matches any verb in that area + const [grantArea, grantAction] = grant.split(':', 2); + const [reqArea, reqAction] = required.split(':', 2); + if (grantAction === '*' && grantArea === reqArea) return true; + // `*:action` matches any area for that action + if (grantArea === '*' && grantAction === reqAction) return true; + return false; +} + +export function hasVerb(grantedVerbs: readonly Verb[], required: Verb): boolean { + for (const g of grantedVerbs) if (matchOne(g, required)) return true; + return false; +} + +export function resolveVerbsForRoles( + rolePolicy: Record<string, string[]>, + userRoles: readonly string[], + rbacEnabled: boolean, +): Verb[] { + if (!rbacEnabled) return ['*']; + const set = new Set<Verb>(); + for (const r of userRoles) for (const v of rolePolicy[r] ?? []) set.add(v); + return [...set]; +} diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts index 75468b1..f302175 100644 --- a/apps/bff/src/server.ts +++ b/apps/bff/src/server.ts @@ -17,6 +17,7 @@ import Fastify from 'fastify'; import cookie from '@fastify/cookie'; +import { AuditLogger } from './audit/logger.js'; import { registerAuthRoutes } from './auth/routes.js'; import { SessionStore } from './auth/sessions.js'; import { loadConfig, type ConfigSource } from './config/loader.js'; @@ -41,10 +42,12 @@ app.setErrorHandler((err, _req, reply) => { }); const sessions = new SessionStore({ ttlMinutes: source.current.session.ttlMinutes }); +const audit = new AuditLogger(source.current.audit.file); +await audit.open(); await app.register(cookie); -registerAuthRoutes(app, source, sessions); +registerAuthRoutes(app, source, sessions, audit); app.get('/api/health', async () => ({ status: 'ok', @@ -65,6 +68,7 @@ async function shutdown(signal: string) { logger.info({ signal }, 'shutting down'); await app.close(); await sessions.close(); + await audit.close(); await source.close(); process.exit(0); }
