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


The following commit(s) were added to refs/heads/main by this push:
     new 6d84d7b  auth: boot-and-warn when no users / no LDAP configured; 
surface state on login page
6d84d7b is described below

commit 6d84d7be2461d05988f1ddce594e76a0ae66219f
Author: Wu Sheng <[email protected]>
AuthorDate: Mon May 18 17:15:46 2026 +0800

    auth: boot-and-warn when no users / no LDAP configured; surface state on 
login page
    
    Previously the BFF threw BootstrapError and exited when auth wasn't
    wired (backend:local with empty users, or backend:ldap with missing
    auth.ldap / empty groupMappings). For a first-touch operator doing a
    plain `docker run` this produced a CrashLoopBackOff with the
    explanation only in container logs.
    
    Now the BFF boots in that state, logs a warning, and exposes the
    unconfigured condition via /api/auth/health. The login page reads the
    flag and renders a setup-required banner with the operator-facing
    hint, plus disables the form so users don't waste a typed credential.
    
    bff
    - loader.ts: validateBootstrap downgraded from throw → warn for the
      three auth-unconfigured cases. New isAuthConfigured(cfg) predicate
      used by both the warn path and the health endpoint.
    - auth-health.ts: AuthHealthBody adds `configured: boolean` +
      `setupHint: string`. Hint text never leaks DNs or secrets; it points
      the operator at the specific YAML key to fix.
    - BootstrapError class kept for forward-compat; no current callers
      throw it.
    - loader.test.ts: 5 throw-tests rewritten as 5 isAuthConfigured-truth
      tests + 4 validateBootstrap not-to-throw tests. 4 new tests, 73 →
      77 total, all passing.
    
    rbac route-policy
    - Fastify auto-registers HEAD for every GET (RFC: same data, no body).
      My earlier fail-loud check for /api/* policy gaps caught the
      auto-registered `HEAD /api/auth/me` and threw at boot. Fix: HEAD
      inherits its GET sibling's policy when not enumerated explicitly.
      Same data → same RBAC.
    
    ui
    - LoginView: new 'warn' status pill ("Auth not configured", pulsing
      amber dot). Setup-required banner above the form with the BFF's
      exact hint + a Setup → Auth docs link. Username / password inputs
      disabled and the submit button reads "Sign in disabled" when the
      health endpoint reports configured: false. Field-disabled +
      button-disabled styles share a single rule.
    - AuthHealth type adds `configured` + `setupHint` to match the BFF.
---
 apps/bff/src/config/loader.test.ts      | 72 +++++++++++++++++++-------
 apps/bff/src/config/loader.ts           | 71 ++++++++++++++++++-------
 apps/bff/src/http/auth-health.ts        | 29 +++++++++++
 apps/bff/src/rbac/route-policy.ts       |  9 +++-
 apps/ui/src/api/scopes/admin-auth.ts    |  8 +++
 apps/ui/src/features/auth/LoginView.vue | 92 +++++++++++++++++++++++++++++++--
 6 files changed, 239 insertions(+), 42 deletions(-)

diff --git a/apps/bff/src/config/loader.test.ts 
b/apps/bff/src/config/loader.test.ts
index 1f16ef3..b27245e 100644
--- a/apps/bff/src/config/loader.test.ts
+++ b/apps/bff/src/config/loader.test.ts
@@ -17,7 +17,7 @@
 
 import { describe, expect, it } from 'vitest';
 import { configSchema } from './schema.js';
-import { BootstrapError, interpolateEnv, validateBootstrap } from 
'./loader.js';
+import { interpolateEnv, isAuthConfigured, validateBootstrap } from 
'./loader.js';
 
 describe('interpolateEnv', () => {
   it('substitutes a defined variable', () => {
@@ -53,19 +53,13 @@ describe('interpolateEnv', () => {
   });
 });
 
-describe('validateBootstrap', () => {
-  function cfgWith(overrides: Record<string, unknown>) {
-    return configSchema.parse({
-      auth: { backend: 'local', local: { users: [] }, ...overrides },
-    });
-  }
-
-  it('refuses to start with backend:local and zero users', () => {
-    const cfg = cfgWith({});
-    expect(() => validateBootstrap(cfg)).toThrow(BootstrapError);
+describe('isAuthConfigured', () => {
+  it('false for backend:local with zero users', () => {
+    const cfg = configSchema.parse({ auth: { backend: 'local', local: { users: 
[] } } });
+    expect(isAuthConfigured(cfg)).toBe(false);
   });
 
-  it('accepts backend:local with at least one user', () => {
+  it('true for backend:local with at least one user', () => {
     const cfg = configSchema.parse({
       auth: {
         backend: 'local',
@@ -74,15 +68,15 @@ describe('validateBootstrap', () => {
         },
       },
     });
-    expect(() => validateBootstrap(cfg)).not.toThrow();
+    expect(isAuthConfigured(cfg)).toBe(true);
   });
 
-  it('refuses to start with backend:ldap and no auth.ldap', () => {
+  it('false for backend:ldap with no auth.ldap', () => {
     const cfg = configSchema.parse({ auth: { backend: 'ldap', local: { users: 
[] } } });
-    expect(() => validateBootstrap(cfg)).toThrow(BootstrapError);
+    expect(isAuthConfigured(cfg)).toBe(false);
   });
 
-  it('refuses backend:ldap when ldap.groupMappings is empty', () => {
+  it('false for backend:ldap when ldap.groupMappings is empty', () => {
     const cfg = configSchema.parse({
       auth: {
         backend: 'ldap',
@@ -94,10 +88,10 @@ describe('validateBootstrap', () => {
         },
       },
     });
-    expect(() => validateBootstrap(cfg)).toThrow(BootstrapError);
+    expect(isAuthConfigured(cfg)).toBe(false);
   });
 
-  it('accepts backend:ldap when ldap has at least one group mapping', () => {
+  it('true for backend:ldap when ldap has at least one group mapping', () => {
     const cfg = configSchema.parse({
       auth: {
         backend: 'ldap',
@@ -109,6 +103,48 @@ describe('validateBootstrap', () => {
         },
       },
     });
+    expect(isAuthConfigured(cfg)).toBe(true);
+  });
+});
+
+describe('validateBootstrap', () => {
+  // Auth-unconfigured cases no longer throw — the BFF boots and surfaces
+  // the state via /api/auth/health so the login page can render a
+  // setup-required banner. The validator only logs.
+  it('does not throw with backend:local and zero users', () => {
+    const cfg = configSchema.parse({ auth: { backend: 'local', local: { users: 
[] } } });
+    expect(() => validateBootstrap(cfg)).not.toThrow();
+  });
+
+  it('does not throw with backend:ldap and no auth.ldap', () => {
+    const cfg = configSchema.parse({ auth: { backend: 'ldap', local: { users: 
[] } } });
+    expect(() => validateBootstrap(cfg)).not.toThrow();
+  });
+
+  it('does not throw with backend:ldap and empty groupMappings', () => {
+    const cfg = configSchema.parse({
+      auth: {
+        backend: 'ldap',
+        local: { users: [] },
+        ldap: {
+          url: 'ldap://localhost',
+          userBaseDn: 'ou=people,dc=corp',
+          groupMappings: [],
+        },
+      },
+    });
+    expect(() => validateBootstrap(cfg)).not.toThrow();
+  });
+
+  it('does not throw for a fully configured local backend', () => {
+    const cfg = configSchema.parse({
+      auth: {
+        backend: 'local',
+        local: {
+          users: [{ username: 'a', passwordHash: '$argon2id$x', roles: 
['admin'] }],
+        },
+      },
+    });
     expect(() => validateBootstrap(cfg)).not.toThrow();
   });
 });
diff --git a/apps/bff/src/config/loader.ts b/apps/bff/src/config/loader.ts
index 1b54624..bd1a3ad 100644
--- a/apps/bff/src/config/loader.ts
+++ b/apps/bff/src/config/loader.ts
@@ -20,6 +20,7 @@ import { resolve } from 'node:path';
 import chokidar from 'chokidar';
 import YAML from 'yaml';
 import { configSchema, type HorizonConfig } from './schema.js';
+import { logger } from '../logger.js';
 
 export interface ConfigSource {
   readonly current: HorizonConfig;
@@ -50,7 +51,9 @@ export function interpolateEnv(
 }
 
 /** Raised when the loaded config is structurally valid but operationally
- *  unusable (e.g. no auth backend wired). */
+ *  unusable in a way that cannot be deferred to runtime (reserved — no
+ *  current callers; the auth-unconfigured cases boot and surface the
+ *  problem on the login page instead). */
 export class BootstrapError extends Error {
   constructor(message: string) {
     super(message);
@@ -59,32 +62,62 @@ export class BootstrapError extends Error {
 }
 
 /**
- * Refuse to start when neither auth backend has a usable configuration.
- * The principle is fail-loud: a misconfigured deployment should crash on
- * boot with a clear pointer, not silently accept no logins.
+ * Returns `true` when the loaded config has a usable auth backend wired
+ * (at least one local user, or an LDAP block with a non-empty group
+ * mappings table). Returns `false` when the operator hasn't finished
+ * setting up auth — the BFF still boots, login attempts are rejected
+ * with helpful messages, and the login page shows a setup-required
+ * banner driven by `/api/auth/health`. The frame for this is that an
+ * out-of-the-box `docker run` should reach a visible UI rather than
+ * crash with a log line a beginner won't see.
+ */
+export function isAuthConfigured(cfg: HorizonConfig): boolean {
+  if (cfg.auth.backend === 'local') {
+    return cfg.auth.local.users.length > 0;
+  }
+  if (cfg.auth.backend === 'ldap') {
+    return !!cfg.auth.ldap && cfg.auth.ldap.groupMappings.length > 0;
+  }
+  return false;
+}
+
+/**
+ * Inspect the loaded config and emit a startup warning if auth isn't
+ * wired yet. Kept as a separate function (rather than inlined into
+ * `loadConfig`) so callers can choose to skip it (tests) or run it on
+ * config hot-reload too.
+ *
+ * The historical contract was fail-loud — a misconfigured deployment
+ * crashed on boot. That was inconvenient for first-touch operators:
+ * a clean `docker run` produced a CrashLoopBackOff instead of a UI
+ * with a "set up auth" hint. We now boot, log a warning, and surface
+ * the same information to the login page so the first interaction is
+ * "open browser → see the next step" rather than "watch container
+ * logs → guess what's wrong".
  *
  * Returns the input on success so callers can chain.
  */
 export function validateBootstrap(cfg: HorizonConfig): HorizonConfig {
-  if (cfg.auth.backend === 'local') {
-    if (cfg.auth.local.users.length === 0) {
-      throw new BootstrapError(
-        'auth.backend is "local" but auth.local.users is empty. ' +
-          'Add at least one user (use `pnpm --filter bff cli:hash` for the 
password hash) ' +
-          'or switch to LDAP.',
-      );
-    }
+  if (cfg.auth.backend === 'local' && cfg.auth.local.users.length === 0) {
+    logger.warn(
+      'auth.backend is "local" but auth.local.users is empty. ' +
+        'BFF is booting but no login will succeed until you add at least one 
user ' +
+        '(use `pnpm --filter bff cli:hash` for the password hash) or switch to 
LDAP. ' +
+        'The login page will surface this state to the operator.',
+    );
   } else if (cfg.auth.backend === 'ldap') {
     if (!cfg.auth.ldap) {
-      throw new BootstrapError(
+      logger.warn(
         'auth.backend is "ldap" but auth.ldap is missing. ' +
-          'Configure the directory connection or switch to local users.',
+          'BFF is booting but every login attempt will fail until you 
configure ' +
+          'the directory connection or switch to local users.',
       );
-    }
-    if (cfg.auth.ldap.groupMappings.length === 0) {
-      throw new BootstrapError(
-        'auth.ldap.groupMappings is empty — no LDAP user would be assigned any 
role. ' +
-          'Add at least one mapping (use `group: "*"` to assign a fallback 
role to everyone).',
+    } else if (cfg.auth.ldap.groupMappings.length === 0) {
+      logger.warn(
+        'auth.ldap.groupMappings is empty — no LDAP user would be assigned any 
role, ' +
+          'so every login will fail. Add at least one mapping (use `group: 
"*"` to ' +
+          'assign a fallback role to everyone). BFF is booting; the login page 
will ' +
+          'surface this state.',
       );
     }
   }
diff --git a/apps/bff/src/http/auth-health.ts b/apps/bff/src/http/auth-health.ts
index b8e553c..680da42 100644
--- a/apps/bff/src/http/auth-health.ts
+++ b/apps/bff/src/http/auth-health.ts
@@ -27,6 +27,7 @@
 
 import type { FastifyInstance } from 'fastify';
 import type { ConfigSource } from '../config/loader.js';
+import { isAuthConfigured } from '../config/loader.js';
 import type { LdapHealth } from '../user/ldap-health.js';
 
 export interface AuthHealthRouteDeps {
@@ -36,6 +37,16 @@ export interface AuthHealthRouteDeps {
 
 export interface AuthHealthBody {
   backend: 'local' | 'ldap';
+  /** False when auth isn't wired (local backend with no users, or LDAP
+   *  backend without `auth.ldap` / with empty `groupMappings`). The BFF
+   *  boots in this state; the login page reads this flag to render a
+   *  setup-required banner and disable the form, so first-touch
+   *  operators see the next step in the browser rather than only in
+   *  container logs. */
+  configured: boolean;
+  /** Operator-facing hint when `configured` is false. Empty string
+   *  otherwise. Never leaks DNs or secrets. */
+  setupHint: string;
   ldap: null | {
     reachable: boolean;
     /** Hostname only — port and full DN are admin-only. */
@@ -52,6 +63,21 @@ export interface AuthHealthBody {
   };
 }
 
+function setupHintFor(cfg: import('../config/schema.js').HorizonConfig): 
string {
+  if (cfg.auth.backend === 'local' && cfg.auth.local.users.length === 0) {
+    return 'No users configured. Add at least one entry to auth.local.users in 
horizon.yaml (use `pnpm --filter bff cli:hash` for the password hash) or switch 
to LDAP.';
+  }
+  if (cfg.auth.backend === 'ldap') {
+    if (!cfg.auth.ldap) {
+      return 'LDAP backend selected but auth.ldap block is missing. Configure 
the directory connection in horizon.yaml or switch to local users.';
+    }
+    if (cfg.auth.ldap.groupMappings.length === 0) {
+      return 'LDAP backend has no group → role mappings. Add at least one 
auth.ldap.groupMappings entry (use `group: "*"` to assign a fallback role to 
every authenticated user).';
+    }
+  }
+  return '';
+}
+
 function hostnameOf(url: string): string {
   try {
     return new URL(url).host;
@@ -82,8 +108,11 @@ export function registerAuthHealthRoute(app: 
FastifyInstance, deps: AuthHealthRo
       };
     }
     const breakGlassArmed = !!cfg.auth.breakGlass && (ldap === null ? false : 
!ldap.reachable);
+    const configured = isAuthConfigured(cfg);
     const body: AuthHealthBody = {
       backend: cfg.auth.backend,
+      configured,
+      setupHint: configured ? '' : setupHintFor(cfg),
       ldap,
       breakGlass: { armed: breakGlassArmed },
     };
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 83f3a9d..685e473 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -212,8 +212,13 @@ export function makeRouteAuthHook(deps: AuthDeps) {
     let chosen: RoutePolicy | null = null;
     let chosenKey: string | null = null;
     for (const m of methods) {
-      const key = `${String(m).toUpperCase()} ${route.url}`;
-      const p = ROUTE_POLICY[key];
+      const M = String(m).toUpperCase();
+      const key = `${M} ${route.url}`;
+      // Fastify auto-registers HEAD for every GET (RFC-correct: HEAD
+      // returns the same data with no body). Same data → same RBAC, so
+      // fall back to the GET sibling's policy when HEAD isn't enumerated
+      // explicitly. The policy table only carries GET entries.
+      const p = ROUTE_POLICY[key] ?? (M === 'HEAD' ? ROUTE_POLICY[`GET 
${route.url}`] : undefined);
       if (p === undefined) continue;
       if (chosen !== null && chosen !== p) {
         logger.warn(
diff --git a/apps/ui/src/api/scopes/admin-auth.ts 
b/apps/ui/src/api/scopes/admin-auth.ts
index 7246c8e..beeebd1 100644
--- a/apps/ui/src/api/scopes/admin-auth.ts
+++ b/apps/ui/src/api/scopes/admin-auth.ts
@@ -19,6 +19,14 @@ import type { BffClient } from '../client';
 
 export interface AuthHealth {
   backend: 'local' | 'ldap';
+  /** False when auth isn't wired (local backend with no users, or LDAP
+   *  backend without `auth.ldap` / with empty `groupMappings`). The BFF
+   *  boots in this state; the login page renders a setup-required
+   *  banner and disables the form. */
+  configured: boolean;
+  /** Operator-facing hint when `configured` is false. Empty string
+   *  otherwise. Never leaks DNs or secrets. */
+  setupHint: string;
   ldap: null | {
     reachable: boolean;
     host: string;
diff --git a/apps/ui/src/features/auth/LoginView.vue 
b/apps/ui/src/features/auth/LoginView.vue
index 84b1340..afc018f 100644
--- a/apps/ui/src/features/auth/LoginView.vue
+++ b/apps/ui/src/features/auth/LoginView.vue
@@ -49,14 +49,16 @@ onUnmounted(() => {
   if (pingTimer) clearInterval(pingTimer);
 });
 
-const statusKind = computed<'ok' | 'err' | 'info' | null>(() => {
+const statusKind = computed<'ok' | 'err' | 'info' | 'warn' | null>(() => {
   if (!health.value) return null;
+  if (!health.value.configured) return 'warn';
   if (health.value.backend === 'local') return 'ok';
   if (health.value.backend === 'ldap') return health.value.ldap?.reachable ? 
'ok' : 'err';
   return 'info';
 });
 const statusLabel = computed<string>(() => {
   if (!health.value) return 'Checking auth backend…';
+  if (!health.value.configured) return 'Auth not configured';
   if (health.value.backend === 'local') return 'Local users';
   if (health.value.backend === 'ldap') {
     return health.value.ldap?.reachable ? 'LDAP reachable' : 'LDAP 
unreachable';
@@ -64,6 +66,8 @@ const statusLabel = computed<string>(() => {
   return 'Unknown backend';
 });
 const statusHost = computed<string | null>(() => health.value?.ldap?.host ?? 
null);
+const unconfigured = computed<boolean>(() => health.value !== null && 
!health.value.configured);
+const setupHint = computed<string>(() => health.value?.setupHint ?? '');
 const currentYear = new Date().getFullYear();
 
 async function submit(): Promise<void> {
@@ -116,6 +120,26 @@ async function submit(): Promise<void> {
           <h1>Welcome to SkyWalking</h1>
         </div>
 
+        <!-- First-touch banner: auth isn't wired yet. The BFF still
+             boots in this state; this banner is what the operator sees
+             instead of a crashed container. -->
+        <div v-if="unconfigured" class="setup-banner" role="alert">
+          <div class="setup-banner-head">
+            <span class="setup-banner-icon" aria-hidden="true">⚙︎</span>
+            <b>Auth not configured</b>
+          </div>
+          <p class="setup-banner-body">{{ setupHint }}</p>
+          <p class="setup-banner-foot">
+            See
+            <a
+              
href="https://skywalking.apache.org/docs/skywalking-horizon-ui/next/en/setup/auth/";
+              target="_blank"
+              rel="noreferrer noopener"
+            >Setup → Auth</a>
+            for backend selection, user / role schema, and an LDAP example.
+          </p>
+        </div>
+
         <label class="field">
           <span>Username</span>
           <input
@@ -124,6 +148,7 @@ async function submit(): Promise<void> {
             name="username"
             autocomplete="username"
             autofocus
+            :disabled="unconfigured"
             required
           />
         </label>
@@ -135,14 +160,15 @@ async function submit(): Promise<void> {
             type="password"
             name="password"
             autocomplete="current-password"
+            :disabled="unconfigured"
             required
           />
         </label>
 
         <div v-if="auth.loginError" class="error">{{ auth.loginError }}</div>
 
-        <button class="sign-in" type="submit" :disabled="submitting">
-          {{ submitting ? 'Signing in…' : 'Sign in' }}
+        <button class="sign-in" type="submit" :disabled="submitting || 
unconfigured">
+          {{ unconfigured ? 'Sign in disabled' : (submitting ? 'Signing in…' : 
'Sign in') }}
         </button>
       </form>
     </main>
@@ -300,6 +326,66 @@ async function submit(): Promise<void> {
   background: rgba(56, 189, 248, 0.15);
   border-color: rgba(56, 189, 248, 0.5);
 }
+.pill-warn {
+  color: var(--sw-warn, #f59e0b);
+  background: rgba(245, 158, 11, 0.15);
+  border-color: rgba(245, 158, 11, 0.5);
+}
+.pill-warn .status-dot {
+  box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45);
+  animation: pulse-warn 1.6s ease-out infinite;
+}
+@keyframes pulse-warn {
+  0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); }
+  80% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); }
+  100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
+}
+
+/* First-touch setup-required banner — appears above the form fields
+   when `/api/auth/health` reports `configured: false`. */
+.setup-banner {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 12px 14px;
+  margin: 0 0 4px;
+  border-radius: 8px;
+  background: rgba(245, 158, 11, 0.08);
+  border: 1px solid rgba(245, 158, 11, 0.35);
+  color: var(--sw-fg-0);
+  font-size: 13px;
+  line-height: 1.4;
+}
+.setup-banner-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--sw-warn, #f59e0b);
+}
+.setup-banner-icon {
+  font-size: 14px;
+}
+.setup-banner-body {
+  margin: 0;
+  color: var(--sw-fg-1);
+}
+.setup-banner-foot {
+  margin: 0;
+  color: var(--sw-fg-2);
+  font-size: 12px;
+}
+.setup-banner-foot a {
+  color: var(--sw-accent);
+  text-decoration: underline;
+}
+.field input:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
+.sign-in:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+}
 @keyframes pulse {
   0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.45); }
   80% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }

Reply via email to