Files
portal/docs/memberships.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 6c86dc47a2 The phone scope is asked for only when told to: Kanidm denies a scope the person lacks
0.5.7 asked every provider for phoneNumber, so a Kanidm site could
not sign anyone in. OIDC_EXTRA_SCOPES=phoneNumber asks for it (Vipps).

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

108 lines
4.6 KiB
Markdown

# Memberships
Who belongs to which group, kept by portal itself.
A site's groups are named in its content: `qualifies` on a page,
`requires_group` on a list, `invite: { group }` on a desk, `grants` on
a state. Until now the groups themselves lived in Kanidm, and portal
read them from the sign-in token. With memberships they live in portal,
as records in the built-in `portal_memberships` bucket, and the
identity provider only has to say who someone is. Cedar sees no
difference: a person's active memberships are the parents of their
`User` entity, exactly as a `groups` claim was.
## A membership is a case
One record per group and person:
| state | means | how it gets there |
|---|---|---|
| `invited` | a place is held for them | a desk invite, a state that `grants`, the first person, someone the content names as responsible |
| `active` | they are in | they signed in, and the provider's subject, phone number or address matched the record |
| `removed` | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record |
The record carries `name`, `group`, `by` (who or what put them there:
a username, `seed`, `responsible`, or the case as `bucket:id`), and
what is known of the person: `phone`, `email`, `sub`. At sign-in
everything the provider verified is written onto every record that
matched, so a record made from an address learns the phone number,
and the next sign-in matches on the subject before anything else.
Because it is a case, a desk lists it like any other bucket:
```yaml
resource:
source: { kind: kv, bucket: portal_memberships }
requires_group: styret
transitions:
- { from: invited, to: removed, label: Withdraw }
- { from: active, to: removed, label: Take out }
- { from: removed, to: invited, label: Invite again }
```
## A person is their phone number first
A provider that verifies phone numbers (Vipps) makes the number the
surest thing a record can hold. So a person is known by, in order:
their phone number, their email address, the provider's subject. A
form that collects `phone`, `telefon`, `tel` or `mobil` gives the
first; `email` or `epost` the second. Numbers are kept as `+4791234567`
whatever way they were typed; a bare national number gets the site's
country (`PHONE_COUNTRY`, `+47` unless set). Addresses are lowercased.
A record matches a person on any one of the three, so a desk that
invited by address and a decision that granted by number are talking
about one person, and there is one record.
## Switching a site to memberships
```
PEOPLE_BACKEND=memberships
```
Unset, a `KANIDM_API_TOKEN` means Kanidm and no token means
memberships. With memberships:
- A desk invite writes an `invited` record and mails "log in at the
site, and your place is there". No account is made: the provider
makes people, portal makes members. So the provider has to be one
everyone can already sign in to, which is what Vipps Login is for a
Norwegian association, and what Kanidm is not.
- `grants` writes a record for the person the case is about, from the
form's name and phone number or address.
- `SEED_ADMIN_PHONE` or `SEED_ADMIN_EMAIL` writes the first person's.
- The people the content names as responsible get theirs in
`RESPONSIBLE_GROUP`, and lose it when the content stops naming them.
- The `groups` claim, if the provider sends one, still counts: a
person's groups are the union.
With `OIDC_EXTRA_SCOPES=phoneNumber` the sign-in asks the provider for
the number and reads `phone_number` from userinfo. Set it for Vipps;
not for Kanidm, which denies a sign-in that asks for a scope the
person does not hold. Without it, matching falls back to the address.
## Asking who belongs
Other applications ask portal the same access question they always
could, over NATS on `portal.access.<site>.may`. A principal that names
a person and lists no groups is looked up:
```json
{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" },
"action": "transition",
"resource": { "kind": "bucket", "name": "hazards" },
"context": { "from": "open", "to": "fixed" } }
```
The answer is Cedar's, with the rule ids that granted it, for a person
who may never have opened the site. That is the whole authorization
engine: the provider says who, memberships say what they belong to,
the policy compiled from content says what they may do.
## What Kanidm still does
For a site on Kanidm nothing changes: invites make accounts and add to
Kanidm groups, and `portal_invites` tracks the one-time links. The two
backends can be swapped by the one variable; the content and the
policy are the same either way.