Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s
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:
co-authored by
Claude Fable 5.1
parent
b9761cec63
commit
f047e21c4a
@@ -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. |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user