Просмотр исходного кода

fix(auth): let the sidebar read install flags without settings:read (issue #3023)

cost_centers:read_own exists so a non-admin can see their own wallet, balance
and cost-centre spend, and the Finance page honoured it -- typing the URL
worked and rendered their balance. The sidebar never offered the entry.

It decides whether to show Finance by reading billing_enabled from
GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so
the value arrived undefined, `undefined !== true` held, and the entry was
hidden from precisely the users the permission was written for. The permission
map and the route guard were both already right; only discovery was broken.

Three more fields came from that same 403, and one of them failed the other way
up. The Notifications gate tests `=== false`, which undefined never satisfies,
so an administrator who switched user notifications off still left the entry
showing to the non-admins it governs. Nobody reported that one, and no
administrator could have reproduced either: administrators can read /settings.
The remaining two were quieter -- the sponsor prompt fell back to EUR whatever
the install uses, and the update check ran where it had been turned off.

SETTINGS_READ cannot be the price of knowing whether billing is on. It also
grants sight of the SMTP, LDAP and MQTT credentials, which is the reason
/settings/ui-preferences exists at all.

So: a second endpoint, GET /settings/ui-flags, carrying those four fields and
asking only that the caller be signed in, via the existing
require_auth_if_enabled. Layout drops its /settings query altogether, which
closes the class rather than the two instances that happened to be visible.

Deliberately not four more fields on /ui-preferences. That endpoint is served
to anyone at all on the recorded grounds that its contents are "public defaults
that ship with the app" (test_route_auth_coverage.py), and its field set is
pinned by a test written to make anyone adding to it stop and think. These
fields are not defaults -- they say how this deployment is configured -- so
they get their own endpoint at their own trust level instead of stretching that
charter to fit them. require_auth_if_enabled also keeps the auth-disabled case
that /ui-preferences was ungated for: "works when there is no auth" and
"readable by anyone" are different statements, and conflating them is what put
a settings read in front of a permission that never needed one.

Twelve tests. Backend pins that the operator can read the flags, that the same
operator still gets 403 from /settings, that an anonymous caller is refused
when auth is on, that it answers when auth is off, the exact field set, that no
credential ever appears, and that the public endpoint did not quietly gain
these fields. Frontend pins Finance visible for cost_centers:read_own with
/settings returning 403, and Notifications hidden when the flag is off -- each
waiting on a positive signal before asserting an absence, so the negative cases
cannot pass before the query resolves.

Reported by @lonix, who traced it to the queryKey and the route gate.
maziggy 11 часов назад
Родитель
Сommit
93eeb05264

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 57 - 1
backend/app/api/routes/settings.py

@@ -11,7 +11,12 @@ from pydantic import BaseModel, Field
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    caller_is_api_key,
+    require_auth_if_enabled,
+    require_energy_cost_update,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -490,6 +495,57 @@ async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
     return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped}
 
 
+# Install configuration the app shell reads before it can render correctly.
+#
+# Deliberately a second list rather than more entries in _UI_PREFERENCE_FIELDS.
+# That one is served to anyone at all, on the recorded grounds that its contents
+# are "public defaults that ship with the app" (test_route_auth_coverage.py), and
+# its field set is pinned by a test written to make anyone adding to it stop and
+# think. These fields are not defaults -- they are facts about how this
+# particular deployment is configured -- so they get their own endpoint at their
+# own trust level instead of stretching that charter to fit them.
+_UI_FLAG_FIELDS: tuple[str, ...] = (
+    # The sidebar hides Finance unless billing is on. Layout read this from
+    # GET /settings, which requires SETTINGS_READ, so for a non-admin the query
+    # 403'd, the value arrived undefined, `undefined !== true` held, and the
+    # entry was hidden from exactly the users cost_centers:read_own exists to
+    # serve. The page itself was reachable by URL the whole time (#3023).
+    "billing_enabled",
+    # Same 403, opposite outcome. That gate tests `=== false`, which undefined
+    # never satisfies, so an administrator who turned user notifications off
+    # still left the entry showing -- to precisely the non-admins it governs.
+    "user_notifications_enabled",
+    # Not gates, but read by the shell and equally undefined for a non-admin:
+    # the sponsor prompt fell back to EUR whatever the install uses, and the
+    # update check ran even where it had been switched off.
+    "currency",
+    "check_updates",
+)
+
+
+@router.get("/ui-flags")
+async def get_ui_flags(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Install configuration the app shell needs, for any signed-in user.
+
+    Gated on being authenticated rather than on ``SETTINGS_READ``. The sidebar
+    has to know whether billing is enabled before it can decide whether to offer
+    Finance, and ``SETTINGS_READ`` cannot be the price of knowing that -- it also
+    grants sight of the SMTP, LDAP and MQTT credentials.
+
+    ``require_auth_if_enabled`` returns ``None`` when auth is switched off
+    entirely, which is the case /ui-preferences was left ungated for. That is the
+    distinction the two endpoints draw: "works when there is no auth" is not the
+    same statement as "readable by anyone", and conflating them is what put a
+    settings read in front of a permission that was never meant to require one.
+    """
+    full = await _build_settings_response(db, is_api_key=False)
+    dumped = full.model_dump()
+    return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+
+
 @router.get("/check-ffmpeg")
 async def check_ffmpeg(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),

+ 169 - 0
backend/tests/integration/test_settings_ui_flags_3023.py

@@ -0,0 +1,169 @@
+"""The app shell can read install configuration without settings:read (#3023).
+
+Reporter @lonix: a user holding `cost_centers:read_own` never saw the Finance
+entry in the sidebar. The permission map was right and the route guard was
+right -- navigating to /finance directly worked and showed their balance. What
+hid it was an extra condition, `billing_enabled !== true`, read from
+GET /settings, which requires SETTINGS_READ. A non-admin gets 403 there, so the
+value arrived undefined and the entry was hidden from exactly the users the
+permission exists to serve.
+
+SETTINGS_READ cannot be the price of knowing whether billing is on: it also
+grants sight of the SMTP, LDAP and MQTT credentials. Hence /settings/ui-flags,
+which asks only that the caller be signed in.
+
+It is deliberately not more fields on /settings/ui-preferences. That endpoint is
+served to anyone at all, on the recorded grounds that its contents are "public
+defaults that ship with the app" (test_route_auth_coverage.py), and its field
+set is pinned by a test written to stop exactly this kind of addition. These
+fields are not defaults -- they say how this deployment is configured -- so the
+last test here pins that they did not leak into it.
+"""
+
+import secrets
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.settings import Settings
+
+FLAGS_URL = "/api/v1/settings/ui-flags"
+_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12)  # pragma: allowlist secret
+
+
+async def _setup_admin(async_client: AsyncClient, username: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _create_operator(
+    async_client: AsyncClient,
+    admin_token: str,
+    *,
+    username: str,
+    permissions: list[str],
+) -> str:
+    """A non-admin holding exactly `permissions` -- never settings:read."""
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    grp = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": f"ui_flags_test_{username}", "permissions": permissions},
+    )
+    assert grp.status_code == 201, grp.text
+    user = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={
+            "username": username,
+            "password": _FIXTURE_PW,
+            "role": "user",
+            "group_ids": [grp.json()["id"]],
+        },
+    )
+    assert user.status_code == 201, user.text
+    assert user.json()["is_admin"] is False
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+@pytest.mark.integration
+class TestTheUserTheEndpointExistsFor:
+    """A non-admin with cost_centers:read_own and nothing else."""
+
+    @pytest.mark.asyncio
+    async def test_they_can_read_the_flags(self, async_client: AsyncClient):
+        admin = await _setup_admin(async_client, "flagadmin1")
+        op = await _create_operator(async_client, admin, username="flagop1", permissions=["cost_centers:read_own"])
+
+        resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
+        assert resp.status_code == 200, resp.text
+        assert "billing_enabled" in resp.json()
+
+    @pytest.mark.asyncio
+    async def test_they_still_cannot_read_settings(self, async_client: AsyncClient):
+        """The fix must not have widened SETTINGS_READ to get there."""
+        admin = await _setup_admin(async_client, "flagadmin2")
+        op = await _create_operator(async_client, admin, username="flagop2", permissions=["cost_centers:read_own"])
+
+        resp = await async_client.get("/api/v1/settings/", headers={"Authorization": f"Bearer {op}"})
+        assert resp.status_code == 403, resp.text
+
+    @pytest.mark.asyncio
+    async def test_billing_enabled_carries_the_configured_value(self, async_client: AsyncClient, db_session):
+        """The whole point: the sidebar tests this for `true`, so it has to be
+        the real value and a real bool, not a truthy string."""
+        admin = await _setup_admin(async_client, "flagadmin3")
+        op = await _create_operator(async_client, admin, username="flagop3", permissions=["cost_centers:read_own"])
+        db_session.add(Settings(key="billing_enabled", value="true"))
+        await db_session.commit()
+
+        resp = await async_client.get(FLAGS_URL, headers={"Authorization": f"Bearer {op}"})
+        assert resp.json()["billing_enabled"] is True
+
+
+@pytest.mark.integration
+class TestTheBoundaryItDraws:
+    """Signed in is required; settings:read is not."""
+
+    @pytest.mark.asyncio
+    async def test_an_anonymous_caller_is_refused_when_auth_is_on(self, async_client: AsyncClient):
+        """This is the reason it is a separate endpoint rather than four more
+        fields on the public one."""
+        await _setup_admin(async_client, "flagadmin4")
+
+        resp = await async_client.get(FLAGS_URL)
+        assert resp.status_code in (401, 403), resp.text
+
+    @pytest.mark.asyncio
+    async def test_it_answers_when_auth_is_switched_off(self, async_client: AsyncClient):
+        """An install with no auth has no user to authenticate, and the shell
+        still has to render. require_auth_if_enabled returns None there."""
+        resp = await async_client.get(FLAGS_URL)
+        assert resp.status_code == 200, resp.text
+
+
+@pytest.mark.integration
+class TestWhatItExposes:
+    @pytest.mark.asyncio
+    async def test_the_field_set_is_exactly_these_four(self, async_client: AsyncClient):
+        """Pinned like the /ui-preferences set: anything added here is readable
+        by every signed-in user, so adding one should require editing this."""
+        resp = await async_client.get(FLAGS_URL)
+        assert set(resp.json().keys()) == {
+            "billing_enabled",
+            "user_notifications_enabled",
+            "currency",
+            "check_updates",
+        }
+
+    @pytest.mark.asyncio
+    async def test_no_credential_ever_appears(self, async_client: AsyncClient, db_session):
+        for i, key in enumerate(
+            ("smtp_password", "ldap_bind_password", "mqtt_password", "ha_token", "prometheus_token")
+        ):
+            db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
+        await db_session.commit()
+
+        body = (await async_client.get(FLAGS_URL)).text
+        assert "DO_NOT_LEAK" not in body
+
+    @pytest.mark.asyncio
+    async def test_the_public_endpoint_did_not_gain_them(self, async_client: AsyncClient):
+        """These describe the deployment, not app defaults, so they must not
+        have been added to the endpoint that serves anyone at all."""
+        public = (await async_client.get("/api/v1/settings/ui-preferences")).json()
+        assert "billing_enabled" not in public
+        assert "user_notifications_enabled" not in public

+ 136 - 4
frontend/src/__tests__/components/Layout.test.tsx

@@ -2,10 +2,11 @@
  * Tests for the Layout component.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { Layout } from '../../components/Layout';
+import { getAuthToken, setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout';
@@ -39,6 +40,16 @@ describe('Layout', () => {
           auto_archive: true,
         });
       }),
+      // What the sidebar actually gates on. Layout used to read these from
+      // /settings/, which a non-admin cannot fetch (#3023).
+      http.get('/api/v1/settings/ui-flags', () => {
+        return HttpResponse.json({
+          check_updates: false,
+          billing_enabled: false,
+          user_notifications_enabled: true,
+          currency: 'EUR',
+        });
+      }),
       http.get('/api/v1/external-links/', () => {
         return HttpResponse.json([]);
       }),
@@ -173,12 +184,12 @@ describe('Layout', () => {
 
     it('appears between Statistics and Settings once billing is on', async () => {
       server.use(
-        http.get('/api/v1/settings/', () =>
+        http.get('/api/v1/settings/ui-flags', () =>
           HttpResponse.json({
             check_updates: false,
-            check_printer_firmware: false,
-            auto_archive: true,
             billing_enabled: true,
+            user_notifications_enabled: true,
+            currency: 'EUR',
           }),
         ),
       );
@@ -196,6 +207,127 @@ describe('Layout', () => {
     });
   });
 
+  describe('Sidebar gates survive a user who cannot read /settings (#3023)', () => {
+    // Every gate below used to be fed by GET /settings, which requires
+    // settings:read. A non-admin gets 403 there, so the value arrived
+    // undefined and each gate silently took its fallback -- in opposite
+    // directions, which is why only one of the two was ever reported.
+    let priorToken: string | null = null;
+
+    const asNonAdmin = (permissions: string[]) => {
+      server.use(
+        http.get('/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 2,
+            username: 'operator',
+            role: 'user',
+            is_active: true,
+            is_admin: false,
+            groups: [{ id: 2, name: 'Operators' }],
+            permissions,
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+        // The 403 that started it. Layout must not need this call at all.
+        http.get('/api/v1/settings/', () =>
+          HttpResponse.json({ detail: 'Not enough permissions' }, { status: 403 }),
+        ),
+      );
+      // localStorage is a no-op mock in setup.ts, so writing the key there
+      // authenticates nobody. Set the client's token directly.
+      priorToken = getAuthToken();
+      setAuthToken('test-token', 'session');
+    };
+
+    afterEach(() => {
+      setAuthToken(priorToken, 'session');
+      priorToken = null;
+    });
+
+    it('shows Finance to a user with cost_centers:read_own and no settings:read', async () => {
+      asNonAdmin(['cost_centers:read_own']);
+      server.use(
+        http.get('/api/v1/settings/ui-flags', () =>
+          HttpResponse.json({ billing_enabled: true, user_notifications_enabled: true }),
+        ),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
+      });
+    });
+
+    it('still hides Finance from that user when billing is off', async () => {
+      // Waits on Notifications appearing rather than on the sidebar existing.
+      // Asserting absence the moment <aside> renders passes before the flags
+      // query has even resolved, which makes the assertion prove nothing.
+      asNonAdmin(['cost_centers:read_own', 'notifications:user_email']);
+      server.use(
+        http.get('/api/v1/auth/advanced-auth/status', () =>
+          HttpResponse.json({ advanced_auth_enabled: true }),
+        ),
+        http.get('/api/v1/settings/ui-flags', () =>
+          HttpResponse.json({ billing_enabled: false, user_notifications_enabled: true }),
+        ),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside a[href="/notifications"]')).toBeInTheDocument();
+      });
+      expect(document.querySelector('aside a[href="/finance"]')).toBeNull();
+    });
+
+    it('hides Notifications from that user when user notifications are off', async () => {
+      // The same 403, landing the other way up: this gate tests `=== false`,
+      // which undefined never satisfies, so an administrator who switched user
+      // notifications off still left the entry showing to the non-admins it
+      // governs. Unreported, and invisible to an admin testing it.
+      asNonAdmin(['notifications:user_email', 'cost_centers:read_own']);
+      server.use(
+        http.get('/api/v1/auth/advanced-auth/status', () =>
+          HttpResponse.json({ advanced_auth_enabled: true }),
+        ),
+        http.get('/api/v1/settings/ui-flags', () =>
+          HttpResponse.json({ billing_enabled: true, user_notifications_enabled: false }),
+        ),
+      );
+
+      render(<Layout />);
+
+      // Finance appearing is the proof that the flags arrived; only then does
+      // the absence of Notifications mean anything.
+      await waitFor(() => {
+        expect(document.querySelector('aside a[href="/finance"]')).toBeInTheDocument();
+      });
+      expect(document.querySelector('aside a[href="/notifications"]')).toBeNull();
+    });
+
+    it('shows Notifications to that user when they are on', async () => {
+      asNonAdmin(['notifications:user_email']);
+      server.use(
+        http.get('/api/v1/auth/advanced-auth/status', () =>
+          HttpResponse.json({ advanced_auth_enabled: true }),
+        ),
+        http.get('/api/v1/settings/ui-flags', () =>
+          HttpResponse.json({ billing_enabled: false, user_notifications_enabled: true }),
+        ),
+      );
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside a[href="/notifications"]')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('version display', () => {
     it('shows version info', async () => {
       render(<Layout />);

+ 14 - 0
frontend/src/api/client.ts

@@ -5664,6 +5664,20 @@ export const api = {
       chamber_temp_presets?: string;
       fan_speed_presets?: string;
     }>('/settings/ui-preferences'),
+  // Install configuration the app shell needs, for any signed-in user. Separate
+  // from getUiPreferences: that endpoint is public because its fields are
+  // defaults shipped with the app, while these describe how this deployment is
+  // configured. Neither requires settings:read -- which is the point, since a
+  // non-admin reading GET /settings gets a 403 and every gate that consults it
+  // silently takes its fallback (#3023). Keep in sync with _UI_FLAG_FIELDS in
+  // backend/app/api/routes/settings.py.
+  getUiFlags: () =>
+    request<{
+      billing_enabled?: boolean;
+      user_notifications_enabled?: boolean;
+      currency?: string;
+      check_updates?: boolean;
+    }>('/settings/ui-flags'),
   updateSettings: (data: AppSettingsUpdate) =>
     request<AppSettings>('/settings/', {
       method: 'PUT',

+ 19 - 9
frontend/src/components/Layout.tsx

@@ -132,14 +132,21 @@ export function Layout() {
     staleTime: Infinity,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
+  // GET /settings requires settings:read, so for every non-admin this query
+  // 403'd and each of the four gates below silently took its fallback: Finance
+  // vanished from the sidebar for the users cost_centers:read_own exists for,
+  // a disabled user_notifications setting stopped applying to them, the sponsor
+  // prompt showed EUR whatever the install uses, and the update check ran where
+  // it had been switched off. Two of those were invisible to an administrator
+  // testing it, because an administrator can read /settings (#3023).
+  const { data: uiFlags } = useQuery({
+    queryKey: ['ui-flags'],
+    queryFn: api.getUiFlags,
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
   // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
-  useSponsorPrompt(settings?.currency ?? 'EUR');
+  useSponsorPrompt(uiFlags?.currency ?? 'EUR');
 
   // Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a
   // tag with no inventory match (only when `auto_add_unknown_rfid` is off).
@@ -196,7 +203,7 @@ export function Layout() {
   const { data: updateCheck } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
-    enabled: settings?.check_updates !== false,
+    enabled: uiFlags?.check_updates !== false,
     staleTime: 60 * 60 * 1000, // 1 hour
     refetchInterval: 60 * 60 * 1000, // Check every hour
   });
@@ -341,12 +348,15 @@ export function Layout() {
         if (!granted) return true;
       }
       // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
-      if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
+      if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (uiFlags?.user_notifications_enabled === false))) return true;
       // Finance is off by default and the page is meaningless without it, so it
       // stays hidden until billing is explicitly on. Tested for `true` rather
-      // than `!== false` on purpose: settings are undefined on the first render,
-      // and a nav entry that appears and then vanishes reads as a glitch.
-      if (id === 'finance' && settings?.billing_enabled !== true) return true;
+      // than `!== false` on purpose: the flags are undefined on the first
+      // render, and a nav entry that appears and then vanishes reads as a
+      // glitch. That polarity is also why reading this from /settings hid the
+      // entry outright for anyone without settings:read, rather than failing
+      // open the way the notifications gate two lines up did (#3023).
+      if (id === 'finance' && uiFlags?.billing_enabled !== true) return true;
       return false;
     };
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CRqoMZy0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CXiMYrIe.js"></script>
+    <script type="module" crossorigin src="/assets/index-CRqoMZy0.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов