Unverified Commit 563041ad authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(panel): add fail-closed role-switcher view-mode logic

Pure logic layer for the top-right avatar role-switcher: derive the home
a principal is in and may switch into, mirroring nav.ts/auth.ts so the
decision is unit-tested without a React renderer.

- ViewMode is derived from NavSection["id"], so the three switchable
  homes (User/Admin/SysAdmin) can never drift from the nav sections.
- availableViewModes / effectiveViewMode resolve a requested view against
  the live is_admin flag, failing closed: a non-admin or a demoted admin
  always collapses to the User home.
- restoreViewMode re-gates a persisted (localStorage) choice on every
  read, never trusting the stored value over the live flag, closing the
  one escalation vector a client-side persona could open.
- sectionsForView composes the view ceiling on top of visibleSections, so
  the switcher only ever narrows the sidebar, never widens access.

The avatar dropdown UI that consumes this lands as a separate increment.
parent 346ec68e
Loading
Loading
Loading
Loading
+164 −0
Changes for panel/src/lib/viewmode.test.ts: 164 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { describe, it, expect } from "vitest";
import {
  VIEW_MODES,
  availableViewModes,
  effectiveViewMode,
  parseViewMode,
  restoreViewMode,
  sectionsForView,
  type ViewMode,
} from "./viewmode";
import { visibleSections } from "./nav";

// The role-switcher decides which home a principal is in and which they may switch
// into. One rule carries security weight and the rest is UX focus, so the cases
// below are split accordingly: the bulk pin the fail-closed rule from every angle a
// view can be chosen — a fresh request, a restored localStorage value, a tampered
// value — because the single thing that must never happen is a non-admin (or a
// demoted admin) landing in an Admin- or SysAdmin-Side view. The ceiling/section
// cases pin the navigational focus, which can declutter but never escalate.

describe("availableViewModes", () => {
  it("offers a non-admin exactly the User-Side home", () => {
    expect(availableViewModes(false)).toEqual(["user"]);
  });

  it("treats the fail-closed default (false) exactly like a non-admin", () => {
    // TierProvider passes `false` while /me loads or after it rejects; the switcher
    // must offer nothing but the user home in that window.
    expect(availableViewModes(false)).toEqual(["user"]);
  });

  it("offers an admin every home, ordered least- to most-revealing", () => {
    expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]);
  });

  it("returns a fresh array so a caller cannot mutate the canonical list", () => {
    const a = availableViewModes(true);
    a.push("user");
    expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]);
  });
});

describe("VIEW_MODES", () => {
  it("is the three homes ordered by how much they reveal", () => {
    expect(VIEW_MODES).toEqual(["user", "admin", "ops"]);
  });
});

describe("effectiveViewMode (fail-closed resolution)", () => {
  it("collapses any admin-level request from a non-admin to user", () => {
    expect(effectiveViewMode("admin", false)).toBe("user");
    expect(effectiveViewMode("ops", false)).toBe("user");
  });

  it("honours an admin's request for any home they are entitled to", () => {
    expect(effectiveViewMode("user", true)).toBe("user");
    expect(effectiveViewMode("admin", true)).toBe("admin");
    expect(effectiveViewMode("ops", true)).toBe("ops");
  });

  it("defaults a null/undefined request to the user home for either tier", () => {
    expect(effectiveViewMode(null, true)).toBe("user");
    expect(effectiveViewMode(undefined, true)).toBe("user");
    expect(effectiveViewMode(null, false)).toBe("user");
  });

  it("collapses a value outside the known homes to user, even for an admin", () => {
    // Defends the runtime boundary: a value cast past the type system (a stale enum,
    // a hand-edited store) is not in the allow-list, so it fails to the safe side.
    expect(effectiveViewMode("root" as unknown as ViewMode, true)).toBe("user");
    expect(effectiveViewMode("" as unknown as ViewMode, true)).toBe("user");
  });
});

describe("parseViewMode (shape only, no gating)", () => {
  it("accepts each known home verbatim", () => {
    expect(parseViewMode("user")).toBe("user");
    expect(parseViewMode("admin")).toBe("admin");
    expect(parseViewMode("ops")).toBe("ops");
  });

  it("rejects anything that is not a known home, returning null", () => {
    expect(parseViewMode("operator")).toBeNull();
    expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose
    expect(parseViewMode("")).toBeNull();
    expect(parseViewMode(null)).toBeNull();
    expect(parseViewMode(undefined)).toBeNull();
    expect(parseViewMode(2)).toBeNull();
    expect(parseViewMode({ mode: "ops" })).toBeNull();
  });
});

describe("restoreViewMode (the persisted-value re-gate — escalation vector)", () => {
  // This is the one place a client-persisted persona could escalate: a value lives
  // in the user's own localStorage, fully under their control, and is read back on
  // every load. The contract is that it is re-gated against the LIVE flag every
  // time and never trusted on its own.

  it("honours a stored admin/ops home only while the principal is still an admin", () => {
    expect(restoreViewMode("ops", true)).toBe("ops");
    expect(restoreViewMode("admin", true)).toBe("admin");
    expect(restoreViewMode("user", true)).toBe("user");
  });

  it("collapses a stored admin/ops home to user for a non-admin (stale or tampered)", () => {
    // A hand-edited localStorage "ops" on a non-admin account must NOT grant the
    // SysAdmin view; and a once-admin who has since been demoted re-reads as user.
    expect(restoreViewMode("ops", false)).toBe("user");
    expect(restoreViewMode("admin", false)).toBe("user");
  });

  it("collapses to user while the admin flag is still fail-closed false (loading)", () => {
    // Until /me resolves, isAdmin is false; a restored "ops" must wait at user, not
    // flash the SysAdmin home and then yank it back.
    expect(restoreViewMode("ops", false)).toBe("user");
  });

  it("collapses a malformed/garbage stored value to user even for an admin", () => {
    expect(restoreViewMode("root", true)).toBe("user");
    expect(restoreViewMode("", true)).toBe("user");
    expect(restoreViewMode(null, true)).toBe("user");
    expect(restoreViewMode(42, true)).toBe("user");
  });
});

describe("sectionsForView (UX ceiling, composed on visibleSections)", () => {
  it("shows a non-admin only the User-Side regardless of the requested view", () => {
    for (const v of ["user", "admin", "ops"] as ViewMode[]) {
      expect(sectionsForView(v, false).map((s) => s.id)).toEqual(["user"]);
    }
  });

  it("foregrounds homes up to the chosen ceiling for an admin", () => {
    expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]);
    expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]);
    expect(sectionsForView("ops", true).map((s) => s.id)).toEqual([
      "user",
      "admin",
      "ops",
    ]);
  });

  it("lets an admin step DOWN to the User-home and see only User-Side", () => {
    // The new capability the switcher adds: an admin can choose to view the app as a
    // plain user. visibleSections alone could never hide their admin nav; this can.
    const ids = sectionsForView("user", true).map((s) => s.id);
    expect(ids).toEqual(["user"]);
    expect(ids).not.toContain("admin");
    expect(ids).not.toContain("ops");
  });

  it("never returns more than visibleSections already permits (subset invariant)", () => {
    // The switcher only ever narrows. For every (view, isAdmin) pair the result must
    // be a subset of visibleSections(isAdmin) — it can never widen access.
    for (const isAdmin of [true, false]) {
      const permitted = new Set(visibleSections(isAdmin).map((s) => s.id));
      for (const v of ["user", "admin", "ops"] as ViewMode[]) {
        for (const s of sectionsForView(v, isAdmin)) {
          expect(permitted.has(s.id)).toBe(true);
        }
      }
    }
  });
});
+113 −0
Changes for panel/src/lib/viewmode.ts: 113 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { visibleSections, type NavSection } from "./nav";

// The avatar role-switcher model, kept as pure logic so the "which home is this
// principal actually in, and what may they switch into" decision is testable
// without rendering React — the same discipline as nav.ts (visibleSections) and
// auth.ts (deriveAuth). DESIGN-WEB-3SIDES / the identity model surface three homes
// by hostname tier: the User-Side home (mc.<root_domain>), the Admin-Side home
// (console.<root_domain>) and the SysAdmin-Side home (op.console.<root_domain>).
// The switcher in the top-right avatar lets a principal move between the homes
// they are entitled to.
//
// ViewMode is derived from NavSection["id"] on purpose: the three switchable homes
// ARE the three nav sections, so the two can never drift — add a section and the
// VIEW_RANK Record below stops compiling until the new home is given a rank.
//
// One invariant is load-bearing and security-relevant; the rest is UX focus:
//   * Fail-closed (security): a non-admin can NEVER end up in an Admin- or
//     SysAdmin-Side view, no matter what is requested, persisted, or tampered with.
//     Admin-ness is the single fail-closed `is_admin === true` flag (tier.tsx); the
//     switcher only ever narrows what an entitled principal sees, never widens it.
//   * Ceiling (UX only): the chosen view is a *ceiling* on which sections are
//     foregrounded. Hiding a section is convenience, NOT access control — every
//     /admin and /ops data call is independently 403-gated server-side (nav.ts).
//     So a wrong ceiling can declutter or clutter the sidebar, but can never grant
//     access; only the fail-closed rule above carries security weight.

/** ViewMode is the home a principal is currently viewing. It is exactly the set of
 *  nav section ids, so the switcher and the sidebar share one vocabulary. */
export type ViewMode = NavSection["id"];

// VIEW_RANK is the single source of truth for how much each home reveals, lowest
// first. It doubles as the section-visibility ceiling: a section is foregrounded
// in a view iff its own rank is at or below the view's rank. Typed as a total
// Record<ViewMode, ...> so a newly added home fails to compile until ranked —
// there is no silent "unranked → 0" default that could hide or leak a section.
const VIEW_RANK: Record<ViewMode, number> = {
  user: 0,
  admin: 1,
  ops: 2,
};

/** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is
 *  derived from VIEW_RANK so it can never fall out of sync with the rank table. */
export const VIEW_MODES: ViewMode[] = (Object.keys(VIEW_RANK) as ViewMode[]).sort(
  (a, b) => VIEW_RANK[a] - VIEW_RANK[b],
);

/**
 * availableViewModes returns the homes a principal may switch INTO, given the
 * fail-closed admin flag. A non-admin (or the `false` used while /me loads or after
 * it errors) gets exactly `["user"]`; an admin gets every home. This is the list the
 * avatar menu renders, and the allow-list effectiveViewMode resolves against.
 */
export function availableViewModes(isAdmin: boolean): ViewMode[] {
  return isAdmin ? [...VIEW_MODES] : ["user"];
}

/**
 * effectiveViewMode resolves a *requested* view (a menu click, a persisted choice,
 * or null while nothing is chosen) against the LIVE admin flag, failing closed. It
 * is the single authority for which home a principal is actually in, and it never
 * trusts the request over the flag: the request is honoured only if it is in the
 * principal's currently-available set, so a non-admin — or an admin who has just
 * been demoted — always collapses to "user". An absent/null request also lands on
 * "user", the safe default.
 */
export function effectiveViewMode(
  requested: ViewMode | null | undefined,
  isAdmin: boolean,
): ViewMode {
  const allowed = availableViewModes(isAdmin);
  return requested != null && allowed.includes(requested) ? requested : "user";
}

/**
 * parseViewMode narrows an untrusted value (a string read back from localStorage, a
 * query param, anything) to a ViewMode, or null if it is not one of the known homes.
 * It performs NO gating — it validates shape only — so it must always be composed
 * with effectiveViewMode before the result is trusted (see restoreViewMode).
 */
export function parseViewMode(raw: unknown): ViewMode | null {
  return typeof raw === "string" && (VIEW_MODES as string[]).includes(raw)
    ? (raw as ViewMode)
    : null;
}

/**
 * restoreViewMode is the load-bearing restore path for a persisted view choice. It
 * takes whatever was stored (a localStorage string, or any untrusted value) plus the
 * LIVE admin flag, and resolves the home the principal actually gets — re-gating
 * every time. A stored "ops"/"admin" is honoured only while `isAdmin` is true right
 * now; a non-admin reading a stale or hand-edited "ops" out of their own storage
 * collapses to "user". The persisted value is never trusted over the live flag,
 * which is the one escalation vector a client-side persisted persona could open and
 * is closed here. Shape validation (parseViewMode) runs first so a malformed value
 * cannot slip past as a truthy non-ViewMode.
 */
export function restoreViewMode(raw: unknown, isAdmin: boolean): ViewMode {
  return effectiveViewMode(parseViewMode(raw), isAdmin);
}

/**
 * sectionsForView returns the nav sections foregrounded for a given requested view,
 * composed on top of visibleSections so it is fail-closed twice over: visibleSections
 * first drops every admin-gated section for a non-admin, then the view ceiling drops
 * any section ranked above the resolved view. The result is therefore always a subset
 * of what the principal's is_admin flag already permits — the switcher can only ever
 * narrow the sidebar, never widen it past `visibleSections(isAdmin)`.
 */
export function sectionsForView(view: ViewMode, isAdmin: boolean): NavSection[] {
  const ceiling = VIEW_RANK[effectiveViewMode(view, isAdmin)];
  return visibleSections(isAdmin).filter((s) => VIEW_RANK[s.id] <= ceiling);
}