settings: admit Operators and the GlobalReader read-only (user decision)

The /settings layout now admits any own-tenant role or platform scope via
hasOwnTenantOrPlatformRole — the same set the sidebar's Configuration
visibility uses, closing the "sidebar shows it, guard bounces it" mismatch
the nav matrix test had ratcheted. Read-only stays enforced by the data
layer: MemberRead serves non-admins redacted config, canEditConfig is
false, and every write path (PUT + field PATCH) is TenantAdminOrGA.
This also realigns the code with the customer docs, which already
documented Operators/Viewers as read-only settings viewers.

The guard-consistency ratchet is now empty and may only stay empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-14 00:04:13 +02:00
parent c4280e823c
commit c60c0bbede
2 changed files with 21 additions and 31 deletions
@@ -4,33 +4,31 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { ProtectedRoute } from "../../components/ProtectedRoute";
import { useAuth } from "../../contexts/AuthContext";
import { hasOwnTenantOrPlatformRole } from "@/lib/tenantScope";
import { TenantConfigProvider } from "./TenantConfigContext";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const router = useRouter();
// Admission mirrors the sidebar's Configuration visibility (user decision 2026-08-13):
// any own-tenant role (Admin, Operator, Viewer) or platform scope (GlobalAdmin,
// read-only GlobalReader) may VIEW settings. Read-only is enforced by the data layer,
// not by this gate — the backend serves non-admins redacted config via the MemberRead
// tier, canEditConfig stays false for them, and every write path (PUT + field PATCH)
// is TenantAdminOrGA server-side. Delegated (MSP) callers without an own-tenant role
// stay excluded: settings operates on the caller's OWN tenant, which they don't have.
const admitted = hasOwnTenantOrPlatformRole(user);
useEffect(() => {
if (!user) return;
// Regular users (no tenant role) → redirect to progress portal. Viewers are admitted
// read-only: the backend serves them redacted config via the MemberRead tier and
// canEditConfig stays false, so the sections render without mutation affordances.
if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator" && user.role !== "Viewer") {
if (!admitted) {
router.replace("/progress");
return;
}
// Operator without bootstrap permission → no settings access
if (user.role === "Operator" && !user.isTenantAdmin && !user.isGlobalAdmin && !user.canManageBootstrapTokens) {
router.replace("/dashboard");
}
}, [user, router]);
}, [user, admitted, router]);
// Don't render until we know user is allowed
if (!user) return null;
if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator" && user.role !== "Viewer") return null;
if (user.role === "Operator" && !user.isTenantAdmin && !user.isGlobalAdmin && !user.canManageBootstrapTokens) return null;
if (!user || !admitted) return null;
return (
<ProtectedRoute>
@@ -234,8 +234,9 @@ describe("nav ↔ route-guard consistency", () => {
* layouts (settings/ops/backups/customs-archive/software) → requireGlobalAdmin,
* - app/sessions/inspector/layout.tsx → requireGlobalAdmin,
* - app/fleet/layout.tsx → requireFleetScope,
* - app/settings/layout.tsx → rule A (admin/GA/Operator/Viewer) minus rule B
* (Operator without admin standing needs canManageBootstrapTokens).
* - app/settings/layout.tsx → any own-tenant role or platform scope
* (hasOwnTenantOrPlatformRole; user decision 2026-08-13 — mirrors the sidebar's
* Configuration visibility, read-only enforced by the data layer).
* Longest prefix wins. Routes without a declared guard have no layout gate.
*/
const ROUTE_GUARDS: Array<{ prefix: string; admits: (input: NavVisibilityInput, flags: NavFlags) => boolean }> = [
@@ -249,25 +250,16 @@ describe("nav ↔ route-guard consistency", () => {
{ prefix: "/fleet", admits: (_i, f) => f.hasFleetScope },
{
prefix: "/settings",
admits: (i, f) => {
const role = i.user?.role ?? null;
const admitted = f.isAdminLike || role === "Operator" || role === "Viewer";
const operatorBounced = role === "Operator" && !f.isAdminLike && !(i.user?.canManageBootstrapTokens ?? false);
return admitted && !operatorBounced;
},
admits: (_i, f) => f.isTenantMember || f.hasGlobalScope,
},
];
/**
* Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches (pre-existing, user
* decision pending — see tasks/todo.md P7.4): a plain Operator and a read-only
* GlobalReader both see Configuration entries whose /settings layout bounces them.
* Fixing a guard requires deleting its entry here; any NEW mismatch fails the test.
* Ratchet of KNOWN sidebar-shows-it/guard-bounces-it mismatches. Empty since the
* 2026-08-13 user decision opened /settings to Operators and the GlobalReader
* (read-only). Any NEW mismatch fails the test; entries may only ever be removed.
*/
const KNOWN_MISMATCHES = new Set([
"Operator × /settings",
"GlobalReader+adminModeOn × /settings",
]);
const KNOWN_MISMATCHES = new Set<string>();
const guardFor = (href: string) =>
ROUTE_GUARDS.filter((g) => href === g.prefix || href.startsWith(g.prefix + "/"))