Files
portal/docs/memberships.md
T
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a
Test / test (pull_request) Successful in 58s
Memberships: groups kept by portal itself, a person by phone number first
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
2026-09-30 13:28:28 +02:00

4.5 KiB

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:

        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:

{ "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.