Conventions
This file is the point of the skeleton. The code exists mostly to prove these rules are real; if you follow one thing here, follow this file.
The layering rule
app/endpoints/ façade - authenticate, deserialise, serialise. Nothing else.
app/core/ logic - every decision about what may be read or written.
db/ models - SQLAlchemy declarations and nothing else.
Two invariants hold this together. Both are easy to break by accident, and both are what make the core testable without a running web server:
-
Nothing in
app/coreimportsdb_session. Every core function takes its session as the first argument. That is what letstests/test_auth.pycall core functions directly against an in-memory SQLite with no Flask anywhere. -
Nothing in
app/coreraises an HTTP error. The core raisesInvalid,Forbidden,NotFound,Conflict(seeapp/core/context.py). The façade maps them to status codes through theSTATUStable inapp/endpoints/crud.py. Add a new error class there and add its status to that table in the same edit.
A core function's signature is (session, principal, ...), it calls
principal.require(Scope.X) before it does anything, and it returns model
objects rather than JSON. app/core/orgs.py is the worked example — read it
before writing your first core module.
Multi-tenancy
Anything a customer owns carries org_id, and queries for it go through
scope_to_org(query, Model, principal). That function is the single place the
tenancy rule is written down.
Principal.__post_init__ refuses to build a customer-facing principal with no
organisation, so the leak fails at construction rather than being relied on to be
filtered downstream. Only system callers span organisations, and that is
declared in SPANNING_SURFACES rather than implied.
When a caller asks for another tenant's row, raise NotFound, never Forbidden
— "that exists but is not yours" confirms a competitor is a customer.
Adding a route
- Write the logic in
app/core/<thing>.py, taking(session, principal, ...). - Write the façade in
app/endpoints/<thing>.py, decorated with@endpoint. - Register it in
app/routes.py.
It is protected automatically — require_session is a before_request guard
built as an allowlist. To make a route reachable signed-out, add its path to
PUBLIC_PATHS in app/endpoints/crud.py and write a comment saying what
defends it instead (a single-use token, a signature header, enumeration
safety). Every existing entry has one; that comment is the review.
Machine-to-machine surfaces (API tokens, provider webhooks) authenticate themselves inside the endpoint and return early from the guard before the CSRF check — they hold no cookie, so there is no ambient credential for a forged request to ride.
Sign-in paths
Every one of them ends at the same two functions: auth.principal_for_user
resolves (user, org, role) → Principal, and session._begin_session starts the
cookie. _begin_session clears the session first — a pre-existing session id
must not survive a sign-in, or a fixed cookie becomes an authenticated one.
app/endpoints/oauth.py (Google) is the worked example for adding another. Two
rules it follows and a new one must too:
- The redirect leg cannot use our CSRF token — no session exists yet. Its
forgery defence is the OAuth
stateparameter, single-use, popped rather than read. - Only ever link a provider identity to an existing email when the provider says
it verified that address (
email_verified). Without that check, linking is a way to seize somebody's account.
Configuration
app.yaml a host, an id, a public address, a flag -> committed
env_secrets.yaml anything that authenticates -> never committed
env_secrets.yaml is gitignored and rendered by CI from repository secrets.
app.yaml pulls it in with includes:.
An unset config variable means the feature is off, never a crash. The app
must run end to end — locally and in CI — with no account registered at any third
party. SMTP_HOST in settings.py is the worked example: unset, mail is logged
instead of sent. Copy that stance for every integration you add, and .strip()
every secret so a trailing newline from a piped value is not baked into a
comparison.
Never interpolate a secret into a shell string. The CI render step writes them
through a quoted heredoc with json.dumps for a reason — an unquoted one let
the shell expand a password containing $, which arrived three characters short
and silently broke outbound mail for three days.
Database
- Add a model to
db/db_declarations.py, thenalembic revision --autogenerate -m "...". Review the generated file; it is a draft, not an answer. alembic checkruns in CI between the migration and the deploy. It is the only thing comparing models to schema — tests need no database, andupgrade headonly applies what already exists.- Connection budget:
(pool_size + max_overflow) × WEB_CONCURRENCY × max_instancesmust fit inside the Cloud SQL instance'smax_connections, minus whatever else shares it. Those numbers live inutils/db_wrapper.py,gunicorn.pyandapp.yamland are one decision — changing one alone is what exhausts the database for every app on the instance.
Tests
The suite runs on in-memory SQLite and needs no Postgres. Two fixtures:
session for core-unit tests, app_client for façade tests. Mark anything that
genuinely needs a live database @pytest.mark.integration.
Prefer a core-unit test: it is faster, and it only passes if the layering rule above is still intact.
Environments
main deploys to int automatically on merge; a v* tag promotes an
int-tested commit to production. The tag must be an ancestor of main — CI
enforces it. Int is a separate App Engine service in the same project, with its
own database and its own secret file.
Comments
Comments here say why, not what — what the surprising constraint was, what broke last time, what the alternative was and why it lost. A comment restating the code is noise; a comment recording a decision is why this file is short.