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.
This commit is contained in:
maziggy
2026-09-07 12:48:31 +02:00
parent 09b4584d5f
commit 93eeb05264
8 changed files with 398 additions and 16 deletions
+19 -9
View File
@@ -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;
};