Route Authentication
Fullfinity provides three authentication modes for API routes, allowing fine-grained control over who can access each endpoint.
User Types
Section titled “User Types”Users are classified by their group membership:
| Group Identifier | Name | Description |
|---|---|---|
core_internal | Internal | Backend users with full system access |
core_portal | Portal | External users with limited access (customers, vendors) |
core_public | Public | Guest/anonymous users |
A user may additionally be a service account (User.service_account) — a login-less
account an integration acts as, holding one or more API keys. This is orthogonal to the groups
above: a service account is classified by its groups exactly like anyone else, so a route
guarded by requires_internal accepts a service account holding core_internal. What the flag
restricts is enforced on the read path: a session token belonging to a service account
authenticates nobody, so it can act only by presenting a key.
Who a User picker offers
Section titled “Who a User picker offers”User.name_search — the search behind every relation picker bound to User — offers only the
accounts a person acts through. Two populations are left out:
| Left out | Why |
|---|---|
Service accounts (service_account) | A credential’s identity. It cannot hold a session, so it is nobody’s salesperson, assignee or approver. |
External logins (core_portal, core_public) | A customer or a visitor, not a colleague. |
So a picker offers exactly the accounts holding Internal — a total question, because User Type is a mandatory category (below): no account is without one. An employee who also holds a portal login on their own customer record is still offered.
Your own User relations inherit this with no filter= of their own — model the customer side
of a document as a Contact, which is where the portal login points anyway (User.contact).
A picker that genuinely wants one of these populations opts back in with the field’s ctx. The
two are independent, so an administration screen listing integrations is not also handed the
customer list:
- type: field name: account properties: widget: DataCombo ctx: include_service_accounts: true # and/or: include_external_users: trueThe scope applies to pickers only — a List of User records still shows every account, so
administration screens are unaffected.
Mandatory group categories
Section titled “Mandatory group categories”Groups are presented by category, and a category whose groups set category_required must
be answered: the access picker offers no “No Access” option for it, and saving a user who holds
no group from it — directly or through the implication chain — is refused with a message naming
the category and its choices.
User Type ships marked. Internal, Portal or Public is therefore a property every account has, which is what lets a picker ask for staff with a single positive test instead of listing the types it does not want.
Mark every group in the category, since a category is a label the groups share rather than a record of its own:
- data_type: Group name: Internal identifier: core_internal category: User Type category_required: trueImplication counts as an answer: granting Settings (which implies Access Management, which implies Internal) satisfies User Type, exactly as the form shows it selected.
Authentication Dependencies
Section titled “Authentication Dependencies”Use these FastAPI dependencies to protect your routes:
requires_internal
Section titled “requires_internal”Restricts access to internal users only. Use for admin panels, settings, and sensitive operations.
from fastapi import Dependsfrom fullfinity.engine.middleware import requires_internal
@router.post("/api/settings/update", dependencies=[Depends(requires_internal)])async def update_settings(request: Request): # Only internal users can access this passBehavior:
- Returns
401if no valid token and none can be renewed from the request’s refresh cookie - Returns
403if user is not incore_internalgroup
requires_user
Section titled “requires_user”Allows any authenticated user (internal or portal). Use for features accessible to all logged-in users.
from fastapi import Dependsfrom fullfinity.engine.middleware import requires_user
@router.get("/api/my-profile", dependencies=[Depends(requires_user)])async def get_profile(request: Request): # Internal and portal users can access this passBehavior:
- Returns
401if no valid token and none can be renewed from the request’s refresh cookie - Returns
403if user only hascore_publicgroup (guest user)
allows_public
Section titled “allows_public”Allows public access with optional authentication. Tries to authenticate if a token is present, otherwise falls back to guest user. An expired session is renewed from the browser’s refresh cookie before that fallback (see A session renews itself), so an aged-out token shows a signed-in visitor their own page, not the guest one.
from fastapi import Dependsfrom fullfinity.engine.middleware import allows_publicfrom fullfinity.engine.context import env_ctx
@router.get("/api/products", dependencies=[Depends(allows_public)])async def list_products(request: Request): # Anyone can access, but authenticated users get personalized results env = env_ctx.get() user = env.user # Either the authenticated user or guest user passBehavior:
- Never returns 401/403
- Always succeeds with either authenticated user or guest user context
- Guest is the fallback only when there is genuinely no session to renew
- Useful for pages that work for everyone but offer personalization when logged in
No Dependency (Fully Public)
Section titled “No Dependency (Fully Public)”For routes that need no authentication at all (no user context):
@router.get("/api/health")async def health_check(): # No authentication, no user context return {"status": "ok"}requires_control_panel
Section titled “requires_control_panel”Guards the database-management plane: creating, deleting, backing up and restoring whole databases. These endpoints cannot use the dependencies above, because they run before there is a database to authenticate a user against — on a brand-new install there are no users yet. They are gated by a server-level key instead.
from fastapi import Dependsfrom fullfinity.engine.control_panel_key import requires_control_panel
@router.post("/my-database-operation", dependencies=[Depends(requires_control_panel)])async def do_something_drastic(): # Only a caller holding the control panel key reaches this passBehavior:
- Returns
401if the request carries neither a valid key nor a valid panel session - Returns
503if the server has no key at all, so the plane is disabled entirely
The key is CONTROL_PANEL_KEY in the server configuration. Set it yourself to choose the
value. If it is unset, the server generates one at startup, prints it to the log, and saves
it, so it stays the same across restarts:
───────────────────────────────────────────────────────────── Control panel key (database management)
kZ8f2Qw…
No CONTROL_PANEL_KEY was configured, so one was generated. Saved to config.local.yaml, so it stays the same across restarts. Edit that file to change it. ─────────────────────────────────────────────────────────────Where it is saved depends on the environment, because the two config files swap roles:
| Saved to | Why | |
|---|---|---|
| Official container image | config.yaml | Bind-mounted from the host, so it survives redeploys. config.local.yaml is regenerated from the environment on every start. |
| Anywhere else | config.local.yaml | Gitignored, so a generated key never lands in a file you might commit. |
The server distinguishes the two by FULLFINITY_CONTAINER, which our entrypoint exports — not
by guessing from the host.
A container config file only persists if it is mounted. docker compose restart keeps the
container and its filesystem, but docker compose up -d or an image update starts from a fresh
container layer — so a config.yaml baked into the image, rather than bind-mounted from the
host, takes the saved key with it. The server checks for this (a bind mount is a separate
filesystem, so its device id differs from the container root) and warns instead of claiming it
saved something durable:
Saved to /app/config.yaml, but that file is NOT mounted from the host: it will be LOST when the container is recreated (docker compose up, an image update), and a new key issued. Mount it, or set CONTROL_PANEL_KEY in your environment to pin a value of your own.The shipped docker-compose.yml mounts it, so this warning means your deployment has diverged
from it.
It is never written to the filestore. Backup tooling archives the filestore off-box, so a key there would travel into object storage and into any environment restored from a backup, where it would still be valid against production. If neither config file is writable (a Kubernetes ConfigMap is always read-only) the key is kept in the process environment instead: it still works and still reaches every worker, but it changes on restart, and the startup banner says so.
There is no default value and no bypass — with no key in force, every request on this plane
is refused. A CHANGE-ME… placeholder counts as no key, so an operator who skips that line in
the shipped config gets a closed plane rather than a published credential. And the key is never
derived from SECRET_KEY, which itself falls back to a published literal when unset.
If you lose it, read it out of the config file it was saved to, or out of the log:
grep CONTROL_PANEL_KEY config.local.yaml # or config.yaml in a containerdocker compose logs fullfinity | grep -A6 "Control panel key"A caller authenticates either by sending the key in an X-Control-Panel-Key header (scripts,
curl) or by exchanging it once at /web/control-panel/authenticate for a short-lived,
HttpOnly session cookie (what the browser control panel does). Rotating the key immediately
invalidates every outstanding session.
Reach for this only for genuine bootstrap operations that cannot have a user context. Anything
an operator does after signing in belongs behind requires_internal.
Comparison Table
Section titled “Comparison Table”| Dependency | Internal | Portal | Public/Guest | Anonymous |
|---|---|---|---|---|
requires_internal | ✅ | ❌ 403 | ❌ 403 | ❌ 401 |
requires_user | ✅ | ✅ | ❌ 403 | ❌ 401 |
allows_public | ✅ | ✅ | ✅ (as guest) | ✅ (as guest) |
requires_control_panel | key/session only — user identity is not consulted | |||
| (none) | ✅ | ✅ | ✅ | ✅ (no user ctx) |
Use Cases
Section titled “Use Cases”Internal Only Routes
Section titled “Internal Only Routes”# Module installation - internal admins only@router.post("/api/modules/install", dependencies=[Depends(requires_internal)])async def install_module(request: Request, module_name: str): pass
# User management@router.get("/api/users", dependencies=[Depends(requires_internal)])async def list_users(request: Request): passPortal + Internal Routes
Section titled “Portal + Internal Routes”# View own orders - any logged-in user@router.get("/api/orders", dependencies=[Depends(requires_user)])async def my_orders(request: Request): env = env_ctx.get() user = env.user # Filter by user's company/permissions pass
# Submit support ticket@router.post("/api/tickets", dependencies=[Depends(requires_user)])async def create_ticket(request: Request): passPublic Routes with Optional Auth
Section titled “Public Routes with Optional Auth”# Product catalog - visible to everyone@router.get("/api/catalog", dependencies=[Depends(allows_public)])async def catalog(request: Request): env = env_ctx.get() user = env.user # Show prices based on user type (guest sees retail, portal sees wholesale) pass
# Website pages@router.get("/{path:path}", dependencies=[Depends(allows_public)])async def web_page(request: Request, path: str): passTwo credentials, told apart by prefix
Section titled “Two credentials, told apart by prefix”Both arrive on Authorization, and the prefix decides how each is resolved:
ff_…— an API key. Resolved from its own table by hash, belongs to one user, and carries that user’s groups, companies and record rules. It is never written to the session store, so it cannot be refreshed and does not expire unless given an expiry date. Revoking or archiving it — or deactivating its account — refuses it on the next call.- anything else — a session token. Resolved from the session store as described below.
The two are disjoint by construction: a key is never a session, and a session token never
matches the key prefix, so neither path can be handed the other’s credential. Routes are
unaffected — the same requires_internal / requires_user dependencies apply, because by the
time they run, both credentials have resolved to an ordinary user.
Sessions are bound to a database
Section titled “Sessions are bound to a database”A login issues an access token (a short-lived session handle) and a refresh token. Both are scoped to the database the user authenticated against:
- The access token is stored in the shared
auth_valkeystore as{"user_id", "db"}, bound to the database it was minted in. - The refresh token (a JWT) carries a
dbclaim and only mints a new access token when that claim matches the request’s current database.
On every request the token is resolved for the request’s current database only
(resolve_auth_session(token, db)). A token minted for one database is never honored
against another. This matters because the same user id can refer to different people in
different databases — without the binding, a session for “user 5 in database A” would
silently authenticate as “user 5 in database B”.
Consequences:
- Dropping (or dropping and recreating) a database invalidates the sessions that were minted against it. The next request resolves to no session and the user is sent to a fresh login — they are never let into a different database on the old token.
- Switching the active database mid-session (a new
dbcookie) requires a fresh login; the previous database’s token does not carry over. - A stale
dbcookie pointing at a dropped database is detected separately by the middleware and surfaces as a409 DATABASE_NOT_FOUND(XHR) or, for a navigation, a redirect — to the database selector, or to/whenDEFAULT_DBnames a database to fall back to. Either way it is distinct from a401 TOKEN_INVALID_OR_EXPIRED, so the client routes the user to a live database instead of prompting an impossible in-place re-login.
A session renews itself, and outlives the session store
Section titled “A session renews itself, and outlives the session store”The access token is short-lived and registered in auth_valkey; the refresh token is
long-lived, held only by the browser as an HttpOnly cookie, and signed rather than stored.
That difference is what makes renewal work without the user seeing anything, and it happens
on the request that finds the session dead — every route, whatever its auth mode:
- A request arrives with an access token the store no longer has: it aged out, or the store was restarted.
- Before answering, the framework reads the request’s own
refresh_tokencookie, verifies its signature anddbclaim, mints a new pair, and registers the new access token. The request then proceeds as that user — arequires_userroute serves its page instead of redirecting to login, and anallows_publicroute serves the visitor’s personalised page instead of the guest one. - The response carries the new HttpOnly cookies, so the browser is signed in again for the next request too.
This matters most for pages, and that is why it is not left to the client. An HTTP client can
meet a 401, post to /auth/refresh, and replay its request; a browser navigating to a
website or portal page has nothing to replay, because the page it asked for is already being
answered. Renewing in the request path is what makes an expiry survivable for both.
Requests racing the same expiry — the several a page load fires at once, all carrying the same dead token — share a single renewal, so the browser is not handed one cookie while other freshly registered sessions are stranded behind it.
/auth/refresh remains available for callers that manage their own tokens, and as the client’s
fallback. It takes the refresh token from the request body or the cookie, and returns the new
pair both in the body and as cookies — a browser acts on the cookies and cannot read an
HttpOnly one to put it in a body; a non-browser caller reads the body.
An API key is never renewed this way. It is a durable credential of its own with no session behind it, so a request presenting one is answered on the key alone and never as whoever the browser’s cookies belong to.
Because renewal needs nothing from the store to succeed, losing the store does not sign anyone
out — the next request rebuilds its session from the signed refresh token and re-registers it.
So auth_valkey needs no persistence, and the reference docker-compose.yml deliberately
runs Valkey with snapshotting off: nothing it holds is a source of truth, and restoring a stale
copy of a cache is worse than starting from an empty one.
What does end a session: a refresh token past its own expiry
(REFRESH_TOKEN_EXPIRE_DAYS), a db claim that no longer
matches the request’s database, and a deleted or archived user. Logging out clears the
browser’s cookies, which ends the session for that browser.
Logging a user in from your own route
Section titled “Logging a user in from your own route”Custom auth code — an SSO callback, a signed magic link, a route that provisions and enters an
environment — needs to establish a session without going through the password endpoint. Two
helpers in fullfinity.engine.api.auth do it:
from fastapi.responses import RedirectResponsefrom fullfinity.engine.api.auth import establish_session, mint_session
# Attach a session to any response — the browser lands already signed in.response = RedirectResponse(url="/", status_code=303)establish_session(response, user_id)return responsemint_session(user_id, db=None) mints and registers the token pair and returns the payload;
establish_session(response, user_id, db=None) is the common case — mint, then set the HttpOnly
cookies on a response you are returning. Use establish_session when you are navigating the
browser, and mint_session when you are answering an XHR and want the tokens in the body.
Pass db when minting for a database other than the one your code is running in. Sessions are
database-bound, so a token minted under the wrong name authenticates nowhere — and this is
silent, since the token is perfectly valid, just not for the database the next request resolves.
To resolve sessions, or to work with the store directly, use the other helpers in the same
module — store_auth_session(token, user_id, db, ttl_seconds), resolve_auth_session(token, db)
and current_session_db(request) — rather than reading or writing auth_valkey yourself, so the
database binding is always applied.
Combining with Model Access
Section titled “Combining with Model Access”Route authentication works alongside model-level permissions:
- Route level: Controls who can call the endpoint (
requires_internal, etc.) - Model level: Controls CRUD operations per group (see Model Access)
- Record level: Filters visible records per group (see Record Rules)
@router.get("/api/invoices", dependencies=[Depends(requires_user)])async def list_invoices(request: Request): # Route allows portal + internal # But Invoice model access rules determine what each user can read invoices = await Invoice.filter().all() # Automatically filtered by record rules return invoicesNext Steps
Section titled “Next Steps”- Groups and Users - Managing user groups
- Model Access - CRUD permissions per model
- Record Rules - Row-level security