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.comadmin (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.