Files
portal/docs/getting-started.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 ef4789f9b2
Test / test (pull_request) Successful in 56s
Invites and suggestions are cases; the responsible follow the content
Five things the first cut of accounts-from-content left open.

The first person and the responsible people are set up side by side,
and both try again a minute apart: a seed that waited twenty minutes on
Kanidm used to hold everyone else up, and a responsible person who
failed waited for the next restart.

The responsible are gone over again whenever the content changes.
watch_for_reload publishes portal.content.reloaded once new content is
in place, and seed.rs listens.

Being responsible ends when the content stops saying so: someone no
page names any more leaves RESPONSIBLE_GROUP in the same pass, if
portal is who put them there (Invited.already_member). The account
stays.

An invite keeps itself current. portal_invites has an expired state;
signing in closes a person's invites, a sweep moves the ones whose link
ran out, and a desk's move from expired back to open makes a fresh link
and mails it. Invites made at start are on file with the desks'.

A suggested edit is a case in the built-in portal_edit_suggestions
bucket, the person who made it is mailed at each move, and the card
lists what they sent and where it stands.

Found on the way, against Kanidm 1.11.2: a link's end comes as seconds
since the epoch, which portal did not read, so invite mails said
"until" and nothing; and a mail search answered with a 5xx was taken
for a refusal.

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

201 lines
9.1 KiB
Markdown

# Getting started: bringing your people in
A site is run by a handful of people: whoever answers for each question
it asks, and whoever works each desk. This is how they get in - from
an empty Kanidm to everyone signed in at their own desk - without
anyone handing out passwords or running an admin tool per person.
The idea in one line: **the content says who is involved, and portal
makes their accounts.** A page names who is responsible for it; a
directory is gated on a group; a desk may invite into groups. Portal
reads that and does the rest at start.
## What you need
- A content repo, and a portal instance running it
([Operations](operations.md) has adding a site).
- A Kanidm of the site's own. Group names below are exactly the ones
the content uses, with no prefix; that is only safe when the Kanidm
serves this site alone.
- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
it accounts are still made, but nobody hears about them.
## 1. Say who is involved, in the content
Three things in the content decide who needs an account:
| content | who | what they get |
|---|---|---|
| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it |
| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` |
The desk whose group may invite into the most groups is the site's most
privileged one; on a vel that is the board. Portal works that out from
the content (see step 4).
## 2. Set up Kanidm for portal
Portal needs four things from Kanidm. The
[tomtervel/infrastructure](https://prosjekt.klingenbergbygg.no/tomtervel/infrastructure)
repo's `kanidm-setup.sh` does all of them and is a good starting point;
by hand, as `idm_admin`:
```sh
K="kanidm -D idm_admin -H https://id.example.no"
# An onboarding account: may create people and issue a first password
# link, nothing more. pii_read lets an invite find someone who already
# has an account by their email instead of making a second one.
$K service-account create portal-onboarding "Portal onboarding" idm_admin
$K group add-members idm_people_on_boarding portal-onboarding
$K group add-members idm_people_pii_read portal-onboarding
# One group per `qualifies:` in the content, managed by that account:
# adding a member to a group takes the right to manage it.
for g in styret kasserer trafikk; do
$K group create "$g" portal-onboarding
done
# The sign-in client. Everyone in this Kanidm may sign in; what they
# see comes from their groups, in the `groups` claim.
$K system oauth2 create portal "My site" https://site.example.no
$K system oauth2 add-redirect-url portal https://site.example.no/auth/callback
$K system oauth2 update-scope-map portal idm_all_persons openid profile email
for g in styret kasserer trafikk; do
$K system oauth2 update-claim-map portal groups "$g" "$g"
done
$K system oauth2 update-claim-map-join portal groups array
# The onboarding token, read-write, shown once: into portal's env.
$K service-account api-token generate portal-onboarding portal --readwrite
```
Then portal's environment:
| variable | value |
|---|---|
| `KANIDM_URL` | `https://id.example.no` |
| `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | the client above (`show-basic-secret`) |
| `KANIDM_API_TOKEN` | the onboarding token |
## 3. Name the first person
Someone has to be first: every later person is invited from a desk,
and on an empty Kanidm there is no desk anyone can open yet.
| variable | value |
|---|---|
| `SEED_ADMIN_EMAIL` | the first person's address |
| `SEED_ADMIN_NAME` | their name, as it should appear (optional) |
| `SEED_GROUP` | a group to put them in, if not the most privileged one (optional) |
| `RESPONSIBLE_GROUP` | a group every responsible person joins, e.g. `redaktor` (optional) |
## 4. Start portal
At start, portal:
1. **Invites the first person** into `SEED_GROUP`, or the most
privileged group, and mails them the one-time link. Once: it is
remembered in the `portal_seed` bucket, so restarts and upgrades
never send a second link.
2. **Makes an account for everyone named responsible** who has none,
and mails them that they are listed as responsible for asking those
questions, with their link. With `RESPONSIBLE_GROUP` they all join
it; someone who already had an account is added without a mail.
The two run side by side. Kanidm may still be starting beside portal,
so each is tried again a minute apart, up to twenty times. The log says
what happened, per person.
The second happens again **whenever the content changes**, so a new
page naming a new person sets them up without a restart. And it works
both ways: someone the content has stopped naming leaves
`RESPONSIBLE_GROUP` in the same pass, and with it whatever else that
group opens. Only if portal is who put them there - a person who was in
the group before is left in it - and their account stays, since it is
theirs.
## 5. The rest come in from the desks
From here nobody touches Kanidm:
- **At a desk**, whoever may invite fills in a name and an address.
Portal makes the account, adds them to the group, and mails the link.
- **A decision can be the invitation.** A state with `grants: group`
in `aggregates.yaml` puts the person a case is about into that group
when the case arrives there - an approved committee application that
*is* the membership.
- **A new page with a new responsible person** gets them an account
when the page goes live.
### An invite that nobody used
A link lives a day. Every invite - a desk's, and the ones portal made
at start - is a case in `portal_invites`, and keeps itself current:
| state | means | how it gets there |
|---|---|---|
| `open` | the link is waiting | the invite was sent, or sent again |
| `done` | the person is in | they signed in to the site; or a desk says so |
| `expired` | the link ran out unused | a sweep, every ten minutes |
A desk that lists the bucket offers the way back with a transition from
`expired` to `open`. Pressing it makes a fresh link and mails it:
```yaml
resource:
source: { kind: kv, bucket: portal_invites }
requires_group: styret
transitions:
- { from: open, to: done, label: Is in }
- { from: expired, to: open, label: Send again }
- { from: expired, to: done, label: Is in }
```
## 6. What the people who ask the questions can do
Signed in, a person a page names as responsible sees a card at the
foot of their own pages to suggest a change. The server checks it is
really them, and the suggestion is mailed to the site's inbox with them
as reply-to, so answering it answers them.
It is also a case, in the built-in `portal_edit_suggestions` bucket, so
it does not depend on somebody reading that inbox. Give a desk the
list, and whoever sits there takes it up and settles it:
```yaml
resource:
source: { kind: kv, bucket: portal_edit_suggestions }
requires_group: styret
transitions:
- { from: open, to: discussed, label: Looking at it }
- { from: open, to: done, label: Changed }
- { from: open, to: declined, label: Will not change }
- { from: discussed, to: done, label: Changed }
- { from: discussed, to: declined, label: Will not change }
```
The person who suggested it is mailed at each move, in the site's
language, and the card on their page lists what they have sent and
where each one stands. A content repo that declares the bucket itself
in `aggregates.yaml`, with a `mail:` on a state, sends that instead.
Their group can also be the way into other tools. The vel's
`redaktor` group signs in to the project Gitea with the same account:
a second OAuth2 client scoped to that group alone.
## When it does not work
| what you see | why |
|---|---|
| an invite fails with `403` setting `mail` | a portal before 0.5.4 set the address in a second call; the onboarding account may create but not modify |
| an invite fails with `404` adding to a group | the group is not managed by `portal-onboarding`: `group set-entry-manager <group> portal-onboarding` |
| `Item not found` setting the login page's name or logo | those are system settings only `admin` may change, not `idm_admin` - or the CLI and server versions differ |
| accounts are made but nobody is mailed | `site.yaml` has no `mail.from`, or gdo is not running on the host |
| someone responsible never got a mail | they already had an account (they are only added to `RESPONSIBLE_GROUP`), or their `contact` is not an email address |
| an invite stays `open` though the person has set a passkey | Kanidm does not show a passkey to the onboarding account; it becomes `done` when they sign in to the site |
| "send again" is not on the desk | the list has no `{ from: expired, to: open }` transition, or the content declares `portal_invites` itself without an `expired` state |
| someone left a page and is still in `RESPONSIBLE_GROUP` | they were in the group before portal added them, so it is not portal's to undo |
| the first person was invited twice | it cannot be, per address and group; changing `SEED_ADMIN_EMAIL` or `SEED_GROUP` invites the new pair |