Files
portal/docs/operations.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a
Test / test (pull_request) Successful in 58s
Memberships: groups kept by portal itself, a person by phone number first
A membership is a case in the built-in portal_memberships bucket:
invited by a desk, a grants, the seed or the responsible pass; active
from the person's first sign-in, when the provider's subject, phone
number or address matches the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:28:28 +02:00

6.5 KiB

Operations

Portal does not deploy itself. It publishes versions; each site decides when to take one.

Cut a release

cargo release patch        # or minor / major

That bumps Cargo.toml, tags v<version> and pushes. CI (.gitea/workflows/publish.yml) reacts to the tag, builds once, and attaches portal-v<version>.tar.gz (portal, iris, hash.txt, site/) to the Gitea release. Plain pushes to main only run the tests: nothing reaches production from this repo.

The same job re-pins, tests and tags iris to match, so a portal change to anything the lint reads fails the release rather than the next content push.

Before cutting, move the Unreleased lines in CHANGELOG.md under a heading for the new version. That section becomes the release notes, and the table at the end of the file records which version each site runs.

Pkg files are content-hashed (hash-files = true, instances run with LEPTOS_HASH_FILES=true), so a freshly served page can never pair a stale cached bundle with new wasm.

Roll it out

In the content repo, one line of .gitea/workflows/deploy.yml:

env:
  PORTAL_RELEASE: v0.4.0

Its CI downloads the pinned artifact, ships /srv/app/<instance>/releases/<tag>, flips current, rewrites the instance env from that repo's own Actions variables and secrets, restarts app@<instance> and refreshes the Caddy route. Every rollout is a commit, so reverting a bad version is git revert and a push.

Sites upgrade independently and may pin different versions.

Content changes never come through any of this: they hot-reload live from the content repos over NATS.

Add a site

A new content repo with a copy of an existing deploy.yml (change INSTANCE, the port and the domains), Actions variables (KANIDM_URL, OAUTH2_CLIENT_ID, PUBLIC_URL, SITE_NAME) and secrets (NATS_URL, OAUTH2_CLIENT_SECRET, PORTAL_GITEA_API_TOKEN — Gitea reserves the GITEA_ prefix for secret names), a Kanidm OAuth2 client, and DNS. site.yaml handles all branding; no portal change is needed.

Environment

Runtime configuration is environment variables; src/main.rs is the authority and each content repo's deploy.yml holds the production values.

NATS_URL events, projections, sessions, content reload, access questions
CONTENT_REPO, CONTENT_BRANCH the repo that is the site
KANIDM_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET sign-in
PEOPLE_BACKEND kanidm or memberships (see Memberships); unset, Kanidm when KANIDM_API_TOKEN is set, memberships otherwise
KANIDM_API_TOKEN invites and grants: through Kanidm
PHONE_COUNTRY the calling code a bare phone number gets, +47 unless set
SEED_ADMIN_EMAIL or SEED_ADMIN_PHONE, SEED_ADMIN_NAME, SEED_GROUP the first person in, invited once at start into the most privileged group (or SEED_GROUP)
RESPONSIBLE_GROUP the group every person a page names as responsible joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way
PUBLIC_URL, SITE_NAME what the site calls itself in links and mail
GITEA_API_TOKEN authenticated resource pulls
AUTOMATION_READ_TOKEN the automation KV read endpoint
GARAGE_* uploads
MAPBOX_TOKEN map tiles, proxied; unset, maps are off
GARAGE_* (4) attachments; see below. Unset, upload fields fail closed
PORTAL_RATE_BURST, PORTAL_RATE_SECONDS the rate limit; see below

Mail is delivered by gdo, a separate unit on the same host reading the same NATS.

Rate limiting

Every request passes a per-caller-IP limit before a session is loaded or a handler is entered: a burst of 30, refilled one every two seconds. A visitor filling in a form never reaches it (a page load fetches several resources at once); a script does.

PORTAL_RATE_BURST how many requests at once, default 30
PORTAL_RATE_SECONDS seconds per refilled request, default 2

Either set to 0 turns it off, for an instance behind something that already does this. The caller is read from X-Forwarded-For or X-Real-IP, falling back to the socket — all of these instances are behind Caddy.

This is a fence, not an accounting system. What keeps uploads honest is the policy check on the route itself.

Attachments

A type: file field stores an object key in the record. The file comes back out at:

GET /attachment/{bucket}/{record}/{field}

which reads the key out of that record rather than taking one from the caller, so a file can only be fetched through a record somebody is allowed to see, and a leaked key is a link to nothing. Reading it needs the same policy permission as reading the bucket on a desk.

Uploads need a bucket and a key in the Garage on the host:

garage bucket create <site>-attachments
garage key create <site>-portal-uploads
garage bucket allow --read --write <site>-attachments --key <site>-portal-uploads

then GARAGE_S3_ENDPOINT, GARAGE_ACCESS_KEY, GARAGE_SECRET_KEY and GARAGE_UPLOADS_BUCKET in that instance's env. Unset, an upload field fails closed rather than appearing to work and storing nowhere.

Who deploys what

Both hosts deploy from CI now, and a rollout is the same commit either way: bump PORTAL_RELEASE in the site's own content repo.

runner how it is allowed to
ergo runs-on: bare the runner is privileged and writes /srv/app and /etc/app itself
kasse runs-on: fish the runner is not privileged. It may run one root script, /usr/local/bin/deploy-portal-instance, which checks the instance against a fixed list and the tag against a release-tag shape before touching anything

The kasse grant is a single sudoers line (/etc/sudoers.d/30-portal-deploy). It exists so a workflow can pick which of three known instances gets which published portal tag, and nothing else.

Both paths health-check the instance afterwards without a forwarded header. A check that sends the header Caddy would send cannot tell you the site is broken for everything that is not Caddy, which is how v0.5.1 reached a host.

IRIS_RELEASE in a repo's lint workflow should equal its PORTAL_RELEASE. Bump it only once iris's own publish job has attached the binary for that tag - portal's release tags iris, but iris builds and uploads on its own run, and a pin bumped in between fails the lint on a download that is not there yet.