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);
 }

Reply via email to