1 Roles and Access
build-crew edited this page 2026-08-25 19:25:13 +00:00

Roles and access

Every request to this app is asked one question first: who is asking? The answer is one of three tiers, decided at the auth layer (Forgejo, through the platform edge) — never by a list of names in app code.

Tier Who How they get it
anon No verified identity. Only reaches the app when it is declared public in the org's ground (apps/<app>/app.yaml, public: true) — so link crawlers can fetch share cards — or when the app runs without the edge (local dev).
user A verified Forgejo identity. On a private app: anyone Forgejo lets read the backing repo (a collaborator, a team, an org member). On a public app: anyone who signs in.
admin A Forgejo owner. Org owners, on every repo the org owns; a repo's own admins, on that repo. The edge collapses owner/admin to X-Plat-Perm: admin. To make an org team admins of this app, set PLAT_ADMIN_TEAMS=<team>,<team> (self-serve env) — membership is then managed in Forgejo, like everything else.

Between user and admin there is one capability, not a tier: manage (write+ on the repo) — the contributor. Keep it for surfaces where "can push to this repo" is genuinely the question. Anything that administers the app belongs to admins.

Guards

Every data route is private by default. Put the guard at preValidation (never preHandler — Fastify validates the body before preHandler, so a stranger would get a 400 describing the route's shape before a 401):

app.get("/api/things",  { preValidation: requireUser },  handler);   // user or admin
app.post("/api/settings", { preValidation: requireAdmin }, handler);  // admin only
app.post("/api/notes",  { preValidation: requireManage }, handler);  // write+ on the repo
app.get("/api/ward",    { preValidation: requireTeam("ward-staff") }, handler); // an org team (admins pass)

req.viewer is the verified viewer inside the handler: viewer.role ("user" | "admin"), viewer.user, viewer.perm, viewer.manage, viewer.teams. The SPA gets the same shape from GET /api/me (useViewer(), isAdmin(viewer), canManage(viewer)).

The door

Anonymous requests never reach a private route: src/security.ts refuses every /api/*, /openapi* and /.well-known/plat/* request with 401 before the body parser, the validator or the 404 handler can say anything more informative. To let a stranger read something (a menu, a schedule, a public directory), write the exact path into PUBLIC_READS — reads only, whole paths only — and let the handler answer an anonymous viewer with less than it answers a user. src/anon-surface.test.ts walks every registered route as a stranger and fails the build on anything open that is not written down.

Signing in on a public app goes through the edge's own entry points on this host: /_plat/login?rd=<path> and /_plat/logout?rd=<path> (src/contracts/edge-auth.ts; the SPA's signIn()/signOut() use them). rd is a path on this host, never a URL.

The other locks

  • Headers on every response — a Content-Security-Policy the app satisfies (inline scripts pinned by hash, no unsafe-eval), X-Frame-Options: DENY, nosniff, a referrer policy, a permissions policy, COOP. Need another origin (a tile server, a media host)? CSP_EXTRA_SOURCES="https://tiles.example" adds it to connect/img/font; scripts stay 'self'.
  • Same-origin mutations — a POST/PUT/PATCH/DELETE to /api must come from this origin (Sec-Fetch-Site or Origin) and carry JSON or no body; anything else is 403 and audited.
  • Anonymous rate limit — ANON_RATE_LIMIT requests a minute per IP (default 120) across /api/*, the share cards and the door; signed-in viewers are never counted.

Making an app public

  1. In the org's ground repo, add apps/<app>/app.yaml with public: true (the platform bot reads it; the edge starts letting anonymous readers through within a minute).
  2. Decide what a stranger may read (PUBLIC_READS) and what the door page shows them.
  3. Run the tests: the anonymous walk is the review.

Private is the default. Nothing here loosens by accident.