Every person a page names as responsible gets a Kanidm account at start if they have none, and a mail saying they are listed as responsible for asking that question, with the link to sign in; RESPONSIBLE_GROUP puts them in one group. SEED_ADMIN_EMAIL invites the first person into the most privileged group - the one whose desk may invite into the most - once. Signed in, a responsible person may suggest a change to their own pages; checked on the server, kept, published, and mailed to the site's inbox. Fixes an invite of someone new on a Kanidm with only the onboarding account: the person is created with their address in one call, since idm_people_on_boarding may create but not modify (it answered 403). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
6.1 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 |
KANIDM_API_TOKEN |
invites and grants:; unset, neither happens |
SEED_ADMIN_EMAIL, 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; they get an account at start 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.