Skip to content

Admin access & authorization ​

What/why: Admin governance — what this screen manages and why it exists.

The NanaSelect /admin area is gated to NanaWall staff by Cloudflare Access (Zero Trust). (On the POC review host the whole site sits behind one site-wide Access app that this same machinery verifies — see POC access.) Identity comes from the Cloudflare Access JWT — no passwords are stored in the app — and a small D1 role table decides what each signed-in person may do. Authorization is always enforced server-side; the UI only reflects it.

How it works ​

request ──► Cloudflare Access (edge)  ──►  Worker
            blocks anonymous users        hooks.server.ts verifies the
            (login required)              Cf-Access-Jwt-Assertion JWT, maps
                                          the email → role, sets locals.admin
                                                    │
                              /admin/+layout.server.ts turns that into
                              401 (no identity) / 403 (no grant) / ok
  1. Edge (Cloudflare Access). In production, Access sits in front of the whole host (one site-wide app — see Production setup) and forces a login before any request reaches the Worker. It injects a signed JWT on every request it lets through.
  2. JWT verification (src/lib/server/auth/jwt.ts). The Worker verifies that JWT with Web Crypto (RS256 against the team's JWKS at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs) and checks the aud, iss, exp, and nbf claims. A forged or tampered token fails here. No JWT library is added — the app keeps its zero-runtime-dependency footprint.
  3. Role lookup (src/lib/server/auth/roles.ts). The verified email is looked up in the admin_users D1 table. No row → forbidden (authenticated at the edge, but not authorized for the admin).
  4. Guard (src/routes/admin/+layout.server.ts). Reads the decision from event.locals.admin (resolved once per request in hooks.server.ts) and returns 401 / 403 / the signed-in identity accordingly.

Roles & capabilities ​

A role maps to capabilities; every server-side check asks "does this role have this capability?", never "is this a specific role?".

Roleedit_data (systems, attributes, media, factors)edit_rules (right-sizing)view_audit (trail, analytics, leads)manage_users (roster)
data_editor✅—✅—
rules_editor—✅✅—
admin✅✅✅✅

An unknown / legacy role grants nothing (fails closed). The matrix was reviewed against the incoming multi-admin duties (systems, attributes, images, rules — #899): they all map onto edit_data / edit_rules, so the three roles stand; manage_users is deliberately admin-only so an editor role can never grant access. Emails are stored and compared lower-cased.

Managing the roster ​

Grants live in the admin_users table (email, role, name) and are managed from /admin/users (the Admins section, manage_users capability): add by email, change roles, remove — every change audit-logged. Two protections are enforced server-side: the roster can never lose its last admin-role user, and removing or demoting yourself requires an explicit extra confirmation. Remember the grant is two-part — the Cloudflare Access policy must also admit the email, or the roster row never takes effect. Local seeds live in seed/sql/010-admin-users.sql.

Managing the knowledge base. Once signed in with the edit_data capability:

  • managing-systems.md — the Systems screen: creating, retiring (soft delete), and editing systems and their typed attribute values.
  • managing-attributes.md — the Attributes screen: the comparison-feature catalog, value types, and safe value-type changes.
  • managing-factors.md — the Selection factors screen: the guided-flow questions, their options, and option → attribute mappings.
  • managing-tracks.md — the Tracks screen: buyer-facing guided-flow variants at /select/<slug>, their factor subsets and pre-seeded answers, and the draft → published → retired lifecycle.
  • managing-corpus.md — the Corpus screen: uploading, replacing, and retiring the product-expertise documents behind retrieval.
  • audit-and-validation.md — how edits are validated before writing, and the append-only Audit trail screen (view_audit).

Local development (dev bypass) ​

Cloudflare Access is not in front of the local dev server, so the app provides a bypass that is compile-time impossible to enable in production: it keys off SvelteKit's dev flag, which is statically false in the built Worker.

  • Running npm run dev (or /dev-up) signs you into /admin automatically as the seeded superuser admin@test.local (role admin) — zero config.

  • To exercise a different role, copy .dev.vars.example to .dev.vars and set ADMIN_DEV_EMAIL to one of the seeded users, then restart the dev server:

    EmailRole
    admin@test.localadmin
    data@test.localdata_editor
    rules@test.localrules_editor

.dev.vars is git-ignored and never deployed.

Production setup (as built — live since 2026-07-14, #898) ​

The edge protection is a Cloudflare Zero Trust configuration in the Paul@iwpi.com's Account account (bb4c9b919e317b42c003591ab2cc4ecd); the dashboard-side pieces were human-provisioned (todos #939 → #1152 → #1324) and the app half is wired in the repo. As built:

  • Access application: one site-wide self-hosted app covering nanaselect.iwpi.com — not a separate /admin* app. One app means one AUD for the whole host (#1152), so the same JWT verifies for the POC review gate and the admin. The *.workers.dev and preview hosts are disabled instead of gated, because Access cannot sit in front of *.workers.dev (#1059). When the app moves to its production .nanawall.com subdomain, the same one-app-one-AUD structure carries over (add the hostname, keep a single app).
  • Login method: One-time PIN (email OTP) — staff enter their email and a code; no IdP and no passwords.
  • Access policy: admits the current NanaWall staff emails (paul@iwpi.com verified end-to-end).
  • Worker vars: CF_ACCESS_TEAM_DOMAIN = iwpi.cloudflareaccess.com and CF_ACCESS_AUD are committed in wrangler.jsonc vars (commit 878dc6e) and deployed (#1324).
  • Roster: production admin_users holds paul@iwpi.com → admin (managed at /admin/users; the Access policy and the roster are both required — see Managing the roster).

Verified live (2026-07-14, record on #560): an anonymous request to /admin gets a 302 to the iwpi.cloudflareaccess.com login; an allowed + rostered email lands in the admin shell with the correct role; the dev bypass still works under vite dev and is compiled out of the built Worker (env.ADMIN_DEV_BYPASS ?? void 0 — no dev-flag path remains, and the explicit-var path additionally requires an unset CF_ACCESS_TEAM_DOMAIN, which production always defines).

Still outstanding: the allowed-but-un-rostered → 403 negative path is code-level verified in src/routes/admin/+layout.server.ts but has not been exercised live — it needs a second Access-admitted identity that is not in the roster (human-owned; tracked as a todo on #898).

Adding a new staff member (repeatable) ​

  1. Add the email to the Access policy in the Zero Trust dashboard (or it never reaches the app).
  2. Add the same email to the roster at /admin/users with the right role.

Session surface (#900). The admin shell shows the signed-in identity + role (from event.locals.admin), a distinct badge for dev-bypass sessions, and a Sign out link to Cloudflare's Access logout endpoint (/cdn-cgi/access/logout) — Access owns the session; the app only hands off. Verified on the gated POC host that the edge answers that path (a sessionless request gets Access's empty 404, not the origin's HTML). The end-to-end check — sign out under a real OTP session and land back at the Access login — is the one #900 AC still open; it rides on the publish todo #1157, since it needs the deployed Worker behind Access rather than the dev-bypass tunnel.