2026-09-30 13:28:28 +02:00
|
|
|
# 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.
|
|
|
|
|
|
2026-09-30 13:40:12 +02:00
|
|
|
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.
|
2026-09-30 13:28:28 +02:00
|
|
|
|
|
|
|
|
## 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.
|