Skip to Content
The production modelOwnership and access

Ownership and access

Applies to The Fabrica v0.1.2. Documentation source: 1ee1bc9cb661, including post-release documentation corrections.

The factory’s admin gate is User.email in project_config.admin_emails. That’s it. No roles table. No is_admin column. No RBAC framework.

What competitors ship

class User: is_admin: bool is_superuser: bool roles: Many[Role] # → Permission → Endpoint matrix

Then a route guard like:

@require_role("admin", "billing.read") def get_stats(): ...

Looks scalable. Reads like enterprise software.

Where it breaks for founder-scale SaaS

  1. Bootstrap chicken-egg. Who creates the first admin row? You either ship a migration that hard-codes the founder’s email (defeats the abstraction) or a CLI that requires a DB connection to a server you haven’t deployed yet.
  2. Promotion ceremony. “I want my cofounder to be admin” → DB query + UPDATE → CI deploy → coffee getting cold. Email allowlist: add string to ADMIN_EMAILS env var, redeploy. 30 seconds.
  3. Recovery from a compromised account. RBAC table compromised → attacker grants themselves admin → you can’t even revoke their row because they revoked yours first. Email allowlist lives in env vars → not user-mutable from inside the app.
  4. YAGNI on permissions. Every admin endpoint the factory ships needs exactly the same gate — the admin router (users, stats, metrics overview, audit log), the broadcast composer, the feedback viewer and the email preview. RBAC’s value-add is “different admins can do different things”, which is irrelevant while the answer for every endpoint is the same allowlist.

What we ship

# config/project.py admin_emails: list[str] = [] # parametric per-buyer # api/deps.py async def require_admin(user: CurrentUserDep) -> User: allowlist = {e.strip().lower() for e in project_config.admin_emails} if user.email.lower() not in allowlist: raise ForbiddenError(...) return user AdminDep = Annotated[User, Depends(require_admin)]

Wired with:

ADMIN_EMAILS="founder@acme.com,cofounder@acme.com"

Promoting a new admin = add the env var, redeploy. Demoting = remove it, redeploy. The env var is the source of truth.

When this stops working

When you need to give different admins different permissions (e.g. “billing admin can refund but not delete users”). At that point you have the data — RBAC migration is straightforward because User is your join target. Until then, you save the migration + the admin UI for the role table + the bug surface from out-of-sync permission matrices.

Audit log integration

The admin gate writes nothing on its own; the endpoints write AuditLog rows for sensitive actions. So you get the trail (“admin@acme.com deleted user_xyz at 2026-05-12T18:00:00Z”) without the RBAC scaffolding. The audit-log destination is the same regardless of whether the actor is a free-tier user, a paid user, or an admin — single table, single shape.

Receipts

  • backend/src/api/deps.py:require_admin
  • backend/src/config/project.py:admin_emails
  • backend/src/api/routers/admin.py (every endpoint gated by AdminDep in its function signature); the same gate is applied in broadcast.py, feedback.py and email.py

Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).

Extracted from docs/recipes/admin-allowlist-vs-roles.md.

That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.

Last updated on