Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s

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
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 13:28:28 +02:00
co-authored by Claude Fable 5.1
parent b9761cec63
commit f047e21c4a
20 changed files with 849 additions and 64 deletions
+1
View File
@@ -11,6 +11,7 @@ engine does with it.
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
+13
View File
@@ -4,6 +4,14 @@ Who may do what to what is compiled from content on every load into
one Cedar policy set, and every server entry point asks that one
policy. Identity stays Kanidm's; portal only decides.
## Where the groups come from
The policy names groups; it does not hold them. A `User` entity's
parents are the person's groups, and those come from the sign-in: the
`groups` claim the provider sent, joined with the person's active
[memberships](memberships.md) when portal keeps them itself. Cedar
sees one list either way.
## What content compiles to
| content | rule |
@@ -42,6 +50,11 @@ Other applications ask the same question over NATS request-reply on
and context the server uses internally — so an automation acting as a
group gets exactly the answer a person in that group would.
A principal may also name a person by `sub`, `phone` or `email` and
list no groups; portal then looks their [memberships](memberships.md)
up before deciding. That is what makes the policy usable for someone
who has never opened the site.
## The submitter
A person who sent a form has no account. Their credential is the link:
+5 -1
View File
@@ -16,7 +16,11 @@ reads that and does the rest at start.
([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.
serves this site alone. (Or no Kanidm at all: with
`PEOPLE_BACKEND=memberships` portal keeps the groups itself and any
OIDC provider that can say who someone is will do; see
[Memberships](memberships.md). The rest of this page is the Kanidm
way.)
- Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without
it accounts are still made, but nobody hears about them.
+106
View File
@@ -0,0 +1,106 @@
# 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.
The sign-in flow asks for the `phoneNumber` scope and reads
`phone_number` from userinfo; a provider without either gives nothing,
and 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.
+4 -2
View File
@@ -69,8 +69,10 @@ 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`) |
| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); 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 |
+6 -3
View File
@@ -87,9 +87,12 @@ also need somebody to remember to invite them afterwards.
The lint requires every form filling that bucket to collect a required
`name` and a required `email`, because by the time a desk presses
approve there is nobody left to ask. An instance with no Kanidm
configured grants nothing and says so in the log; the transition
itself always stands.
approve there is nobody left to ask. With memberships as the people
backend a phone number does as well as an address, and the grant is a
record in `portal_memberships` rather than a Kanidm call (see
[Memberships](memberships.md)). An instance with neither configured
grants nothing and says so in the log; the transition itself always
stands.
### after