Compare commits

...
11 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug cc26a232ec Release 0.5.7
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m21s
2026-09-30 13:33:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 77f2880e83 Changelog: 0.5.7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:33:12 +02:00
bl 23f98add51 Merge pull request 'Memberships: groups kept by portal itself, a person by phone number first' (#6) from memberships into main
docs / deploy (push) Failing after 3s
Test / test (push) Successful in 58s
2026-09-30 11:33:12 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 a9b5a81ebd Docs as a site: portal.uhhm.no from docs/ with mdBook, put to git-pages
Test / test (pull_request) Successful in 58s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:31:23 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f047e21c4a 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
2026-09-30 13:28:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b9761cec63 Docs: vel.klingenbergbygg.no runs v0.5.6
Test / test (push) Failing after 40s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:28:05 +02:00
Bendik Aagaard Lynghaug b9327a170f Release 0.5.6
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m9s
2026-09-30 08:18:41 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 789cbf7977 Changelog: 0.5.6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:18:39 +02:00
bl 6acb7e6a30 Merge pull request 'A group may hold a page's responsibility' (#5) from group-responsible into main
Test / test (push) Successful in 55s
2026-09-30 06:18:38 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 014594283a A group may hold a page's responsibility
Test / test (pull_request) Successful in 56s
responsible: { name, contact, group } says the address is a group's
inbox, not a person: no account or mail is made for it, and any
signed-in member of the group gets the suggest-a-change card. A
board's shared address was onboarded as a person before this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:17:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 de65392917 Docs: vel.klingenbergbygg.no runs v0.5.5
Test / test (push) Failing after 56s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 08:04:35 +02:00
27 changed files with 1015 additions and 86 deletions
+45
View File
@@ -0,0 +1,45 @@
name: docs
# docs/ as a site at portal.uhhm.no: built with mdBook and put to
# git-pages on every push to main that touches it (the uhhm pages
# pattern, uhhm/infrastructure PAGES.md). The changelog rides along as
# the book's last page. Authorized by the _git-pages-forge-allowlist
# TXT record for portal.uhhm.no plus a token the forge confirms may
# push to this repo (the GITEA_TOKEN secret).
on:
push:
branches: [main]
paths: ["docs/**", "book.toml", "CHANGELOG.md", ".gitea/workflows/docs.yml"]
workflow_dispatch:
env:
MDBOOK_VERSION: v0.5.4
jobs:
deploy:
runs-on: bare
steps:
- uses: actions/checkout@v4
- name: build the book
run: |
set -eu
tool=/var/local/cargo-target/mdbook-$MDBOOK_VERSION
if [ ! -x "$tool/mdbook" ]; then
mkdir -p "$tool"
curl -sSfL "https://github.com/rust-lang/mdBook/releases/download/$MDBOOK_VERSION/mdbook-$MDBOOK_VERSION-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$tool"
fi
cp CHANGELOG.md docs/changelog.md
"$tool/mdbook" build
test -f target/book/index.html
test -f target/book/memberships.html
- name: deploy to portal.uhhm.no
run: |
tar -czf site.tar.gz -C target/book .
curl --fail -sS -X PUT "https://portal.uhhm.no/" \
-H "Content-Type: application/x-tar+gzip" \
-H "Forge-Authorization: token ${{ secrets.GITEA_TOKEN }}" \
--data-binary @site.tar.gz \
-w "deployed: %{http_code}\n"
+1
View File
@@ -1,2 +1,3 @@
/target
.claude/
docs/changelog.md
+13 -2
View File
@@ -4,6 +4,17 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.5.7 (2026-09-30)
- Memberships: groups kept by portal itself (`src/memberships.rs`, `docs/memberships.md`). 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; `removed` by a desk. A person is their phone number first (`+4791234567`, `PHONE_COUNTRY` for bare numbers), their address second, the provider's subject third, and one record per group and person however it was keyed. At sign-in the active memberships join the `groups` claim, so Cedar sees the same parents as before. `PEOPLE_BACKEND=memberships` switches a site; unset, a `KANIDM_API_TOKEN` means Kanidm and no token means memberships. `SEED_ADMIN_PHONE` seeds by number. The sign-in asks for the `phoneNumber` scope and reads `phone_number` from userinfo.
- The NATS access service answers for a person named by `sub`, `phone` or `email` with no groups listed: their memberships are looked up first.
- `who_for` (the person a `grants` is about) takes `navn` as well as `name`, and a phone number in place of an address.
- The invite mail for someone who needs no link says where to sign in.
## 0.5.6 (2026-09-30)
- A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person.
## 0.5.5 (2026-09-30)
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
@@ -340,7 +351,7 @@ content repos pin the tag they run (see the end of this file).
- README: what the engine provides
- Feature icons: masked span painted by currentColor, not <img>
- Responsible note: one line, same hue and size as the hero description
- Responsible note as <small>: meta information, dimmer than the description
- Responsible note as `<small>`: meta information, dimmer than the description
- Responsible note back to the footer, above the question nav
- Bound inputs: a field that loads its value from a sibling-parameterized resource
- add some more width to our alternatives
@@ -377,4 +388,4 @@ hosts now, since kasse's runner deploys its own instances.
| redoal.com | ergo | `app@redoal-portal` | v0.5.2 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.5.2 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.5.2 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.3 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.6 |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.5.5"
version = "0.5.7"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.5.5"
version = "0.5.7"
edition = "2021"
[lib]
+23
View File
@@ -0,0 +1,23 @@
# The docs as a site: portal.uhhm.no, built by .gitea/workflows/docs.yml
# from docs/ with mdBook and put to git-pages (see uhhm/infrastructure
# PAGES.md). `mdbook serve` here previews it.
[book]
title = "portal"
description = "A Git repo of YAML becomes a running site: pages, desks, cases, people."
authors = ["uhhm"]
language = "en"
src = "docs"
[build]
build-dir = "target/book"
create-missing = false
[output.html]
default-theme = "light"
preferred-dark-theme = "navy"
git-repository-url = "https://project.uhhm.no/uhhm/portal"
site-url = "/"
no-section-label = true
[output.html.search]
limit-results = 20
+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. |
+21
View File
@@ -0,0 +1,21 @@
# Summary
[portal](README.md)
# Using it
- [Getting started](getting-started.md)
- [Content reference](content-reference.md)
- [State machines](state-machines.md)
- [Access](access.md)
- [Memberships](memberships.md)
- [Operations](operations.md)
# How it is made
- [Architecture](architecture.md)
- [Filesystem routes](design/filesystem-routes.md)
---
[Changelog](changelog.md)
+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:
+4 -1
View File
@@ -49,7 +49,10 @@ description: What we need from you.
qualifies: case_officers # a Kanidm group may open this page
requires_chain: /start # or: an answer they must already have given
followup: true # keep it out of the nav until earned
responsible: { name: Solveig, email: post@vestmark.example }
responsible: { name: Solveig, contact: post@vestmark.example }
# or a group's: no account is made for the inbox, and any signed-in
# member of the group may suggest a change to the page
responsible: { name: The board, contact: board@vestmark.example, group: board }
event: # this page announces an occasion
starts: 2026-10-14T18:00
duration: 2h
+6 -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.
@@ -27,6 +31,7 @@ Three things in the content decide who needs an account:
| content | who | what they get |
|---|---|---|
| `responsible: { name, contact }` on a page | the person answering for that question | an account, and a mail saying they are listed as responsible for asking it |
| `responsible: { name, contact, group }` | a group answering for it | nothing: the inbox is not a person; the group's members have the desk, and each of them may suggest changes to the page |
| `qualifies: styret` on a page or `_section.yaml` | members of that group | the pages and desks under it |
| `invite: { group: kasserer }` on a desk page | the desk's own group | the right to bring people into `kasserer` |
+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.
+12 -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 |
@@ -127,6 +129,14 @@ then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
## The docs site
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
with mdBook (`book.toml`, `docs/SUMMARY.md`) by `.gitea/workflows/docs.yml`
on every push to main that touches them, and put to git-pages the way
every static site on uhhm.no is (uhhm/infrastructure `PAGES.md`). The
changelog is the book's last page. `mdbook serve` previews it locally.
## Who deploys what
Both hosts deploy from CI now, and a rollout is the same commit either
+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
+32 -6
View File
@@ -51,7 +51,17 @@ action invite appliesTo { principal: [User], resource: [Group] };
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Principal {
/// A signed-in person, or nobody (`groups` empty, `sub` "anonymous").
User { sub: String, groups: Vec<String> },
/// Over NATS, a question may name the person by phone number or
/// address instead of listing their groups, and portal looks their
/// memberships up (see `serve`).
User {
sub: String,
groups: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
phone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
email: Option<String>,
},
/// Whoever holds one record's own link and knows the email on it.
Submitter { record: String, email: String },
/// A machine with a shared token.
@@ -60,12 +70,12 @@ pub enum Principal {
impl Principal {
pub fn anonymous() -> Self {
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None }
}
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
match user {
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() },
None => Principal::anonymous(),
}
}
@@ -311,7 +321,7 @@ impl Policy {
};
let mut entities: Vec<Entity> = Vec::new();
let principal_uid = match principal {
Principal::User { sub, groups } => {
Principal::User { sub, groups, .. } => {
let parents: HashSet<EntityUid> = groups.iter().map(|g| uid("Group", g)).collect();
for g in &parents {
entities.push(Entity::new_no_attrs(g.clone(), HashSet::new()));
@@ -444,6 +454,7 @@ pub async fn serve(
nats: async_nats::Client,
site: String,
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
state: Option<crate::server::AppState>,
) {
use futures::StreamExt;
let subject = subject_for(&site);
@@ -458,7 +469,22 @@ pub async fn serve(
while let Some(msg) = sub.next().await {
let Some(reply) = msg.reply else { continue };
let decision = match serde_json::from_slice::<Query>(&msg.payload) {
Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context),
Ok(mut q) => {
// A question that names a person and lists no groups is
// asking portal what they belong to: their memberships,
// matched on subject, phone number or address.
if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) {
if groups.is_empty() && sub != "anonymous" {
let who = crate::memberships::Identity {
sub: Some(sub.clone()),
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
email: email.as_deref().map(|e| e.trim().to_lowercase()),
};
*groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who);
}
}
decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context)
}
Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] },
};
let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec());
@@ -487,7 +513,7 @@ mod tests {
}
fn user(groups: &[&str]) -> Principal {
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None }
}
#[test]
+55 -25
View File
@@ -461,7 +461,7 @@ fn QuestionView(
{question
.responsible
.as_ref()
.filter(|r| is_responsible(user.as_ref(), &r.contact))
.filter(|r| is_responsible(user.as_ref(), r))
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
{question
.responsible
@@ -611,12 +611,16 @@ fn Announcements(current_id: String) -> impl IntoView {
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
/// Whether the signed-in person is the one a page names as responsible:
/// their Kanidm address equals the page's `responsible.contact`
/// (`mailto:` off, case ignored).
pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
let contact = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
/// Whether the signed-in person answers for a page: their Kanidm
/// address equals the page's `responsible.contact` (`mailto:` off,
/// case ignored), or they are in the group that holds it.
pub fn is_responsible(user: Option<&User>, responsible: &Responsible) -> bool {
let Some(user) = user else { return false };
if responsible.group.as_deref().is_some_and(|g| !g.is_empty() && user.groups.iter().any(|mine| mine == g)) {
return true;
}
let contact = responsible.contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
user.email.as_deref().is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
}
/// One suggestion as the person who made it sees it: what they wrote
@@ -2557,8 +2561,8 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
}
let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
if !is_responsible(user.as_ref(), &responsible.contact) {
return Err(ServerFnError::new("only the person responsible for this page may suggest changes to it here"));
if !is_responsible(user.as_ref(), &responsible) {
return Err(ServerFnError::new("only whoever is responsible for this page may suggest changes to it here"));
}
let user = user.expect("is_responsible needs a user");
let email = user.email.clone().unwrap_or_default();
@@ -2648,6 +2652,16 @@ pub async fn my_suggestions(question_id: String) -> Result<Vec<MySuggestion>, Se
Ok(mine)
}
/// Who a principal is, for a record: the username, or the service.
#[cfg(feature = "ssr")]
fn principal_name(principal: &crate::access::Principal) -> String {
match principal {
crate::access::Principal::User { sub, .. } => sub.clone(),
crate::access::Principal::Submitter { email, .. } => email.clone(),
crate::access::Principal::Service { name } => name.clone(),
}
}
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
@@ -2739,21 +2753,22 @@ pub async fn submit_answer(
if !may.allow {
return Err(ServerFnError::new("not authorized to invite into this group"));
}
let kanidm = crate::people::Kanidm::from_env()
.ok_or_else(|| ServerFnError::new("invites are not configured on this instance (KANIDM_API_TOKEN)"))?;
let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(String::from);
let (name, email) = match (field("name"), field("email")) {
(Some(n), Some(e)) => (n, e),
_ => return Err(ServerFnError::new("an invite needs a name and an email")),
let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?;
let Some((name, who)) = crate::people::who_for(&responses) else {
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
};
let invited = kanidm
.invite(&name, &email, &invite.group)
let invited = people
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
.await
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
if let Some(map) = responses.as_object_mut() {
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
}
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
// Through Kanidm the invite is its own case, with a link that
// runs out; a membership is the case already.
if people.is_kanidm() {
record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string());
}
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
}
@@ -2901,17 +2916,32 @@ mod down_the_tree_tests {
mod responsible_tests {
use super::is_responsible;
use crate::auth::User;
use crate::content::Responsible;
fn user(email: Option<&str>) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], email: email.map(String::from) }
fn user(email: Option<&str>, groups: &[&str]) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) }
}
fn named(contact: &str, group: Option<&str>) -> Responsible {
Responsible { name: "R".into(), contact: contact.into(), group: group.map(String::from) }
}
#[test]
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
assert!(is_responsible(Some(&user(Some("kari@vel.no"))), "mailto:Kari@Vel.no"));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"))), "kari@vel.no"));
assert!(!is_responsible(Some(&user(None)), "kari@vel.no"));
assert!(!is_responsible(None, "kari@vel.no"));
assert!(!is_responsible(Some(&user(Some(""))), ""));
assert!(is_responsible(Some(&user(Some("kari@vel.no"), &[])), &named("mailto:Kari@Vel.no", None)));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(None, &[])), &named("kari@vel.no", None)));
assert!(!is_responsible(None, &named("kari@vel.no", None)));
assert!(!is_responsible(Some(&user(Some(""), &[])), &named("", None)));
}
#[test]
fn a_page_a_group_answers_for_is_every_members() {
let board = named("post@vel.no", Some("styret"));
assert!(is_responsible(Some(&user(Some("ola@vel.no"), &["styret"])), &board));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"), &["trafikk"])), &board));
// The inbox itself, signed in, still counts.
assert!(is_responsible(Some(&user(Some("post@vel.no"), &[])), &board));
assert!(!is_responsible(None, &board));
}
}
+4
View File
@@ -14,6 +14,10 @@ pub struct User {
/// live, so a group change only takes effect on the next login.
#[serde(default)]
pub groups: Vec<String>,
/// The phone number the provider verified (`phone_number`, from
/// userinfo), when it gives one: what a membership is keyed by first.
#[serde(default)]
pub phone: Option<String>,
/// The address Kanidm holds for them (the `email` claim), so a page
/// can know it is talking to the person it names as responsible.
#[serde(default)]
+8 -1
View File
@@ -296,6 +296,13 @@ pub struct Responsible {
/// A mailto address or plain email - deliberately just a string,
/// same as everywhere else content keeps contact info simple.
pub contact: String,
/// The group that holds the responsibility, when it is a group's
/// and not one person's: the board's inbox, not the board's
/// secretary. Then the address gets no account of its own (see
/// `seed.rs`), and any signed-in member of the group may suggest a
/// change to the page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
}
/// Whether to render `question`'s alternatives or its sign-in gate for
@@ -1858,7 +1865,7 @@ alternatives:
fn a_signed_in_person_is_not_asked_who_they_are() {
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
let q: Question = serde_yaml::from_str(yaml).unwrap();
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], email: Some("kari@vel.no".into()) };
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) };
let mut signed_in = q.clone();
fill_from_user(&mut signed_in, Some(&user));
let reqs = &signed_in.alternatives[0].features[0].requirements;
+11
View File
@@ -64,6 +64,17 @@ aggregates:
transitions:
open: [done, expired]
expired: [open, done]
- bucket: portal_memberships
attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in
initial: invited
states:
invited: { event: invited }
active: { event: active }
removed: { event: removed }
transitions:
invited: [active, removed]
active: [removed]
removed: [invited, active]
- bucket: portal_edit_suggestions
attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too
initial: open
+2
View File
@@ -33,6 +33,8 @@ pub mod mail;
#[cfg(feature = "ssr")]
pub mod seed;
#[cfg(feature = "ssr")]
pub mod memberships;
pub mod maps;
+2 -2
View File
@@ -565,7 +565,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
),
(None, false, true) => (
"Du har fått plass i {group} på {site_name}".to_string(),
"Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".to_string(),
"Hei {name},\n\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".to_string(),
),
(None, true, false) => (
"You are invited into {group} at {site_name}".to_string(),
@@ -573,7 +573,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
),
(None, false, false) => (
"You now have a place in {group} at {site_name}".to_string(),
"Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(),
"Hello {name},\n\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".to_string(),
),
};
let now_ms = chrono::Utc::now().timestamp_millis();
+3 -3
View File
@@ -86,9 +86,6 @@ async fn main() -> anyhow::Result<()> {
site.clone(),
access.clone(),
));
// The same decisions, answerable by other apps over NATS.
tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone()));
let state = AppState {
leptos_options: leptos_options.clone(),
nats,
@@ -104,6 +101,9 @@ async fn main() -> anyhow::Result<()> {
garage,
};
// The same decisions, answerable by other apps over NATS, for a
// person named by phone number or address as well as by groups.
tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone())));
// Announced pages: keep their desk records current, close ended
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
+399
View File
@@ -0,0 +1,399 @@
//! Who belongs to which group, kept by portal itself.
//!
//! A membership is a case: one record per (group, person) in the
//! built-in `portal_memberships` bucket, `invited` until the person is
//! seen, `active` from then on, `removed` when a desk takes them out.
//! A desk invite writes one, a state that `grants` a group writes one,
//! the first person and the people the content names as responsible
//! get theirs at start. Nothing here talks to an identity provider:
//! the provider says who someone is, and this says what they belong
//! to. At sign-in, the person's active memberships become their groups,
//! and the access policy (Cedar, `access.rs`) sees them as the parents
//! of the `User` entity, exactly as it saw a `groups` claim before.
//!
//! **A person is their phone number first, their email second.** A
//! provider that verifies phone numbers (Vipps) makes the number the
//! surest thing a record can hold; a form that collected an address
//! still matches on it. A record's key is the group and whichever of
//! the two it has, so a second invite of the same person into the same
//! group finds the first.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
use crate::answers::Answer;
pub const BUCKET: &str = "portal_memberships";
pub const INVITED: &str = "invited";
pub const ACTIVE: &str = "active";
pub const REMOVED: &str = "removed";
/// Stamped on moves nobody at a desk made.
pub const BY: &str = "portal";
/// The country a phone number without one is in, as a calling code.
fn default_country() -> String {
std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string())
}
/// Who a person is, as far as a record or a session knows.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Identity {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
/// A phone number as one string for everyone: digits with a leading
/// `+` and a country code, `+4791234567`. Spaces, dots and dashes go;
/// `00` becomes `+`; a bare national number gets the site's country.
/// `None` for anything that is not a number.
pub fn normalize_phone(raw: &str, country: &str) -> Option<String> {
let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect();
let trimmed = raw.trim();
if digits.is_empty() || digits.len() < 5 || digits.len() > 15 {
return None;
}
if trimmed.starts_with('+') {
return Some(format!("+{digits}"));
}
if let Some(rest) = digits.strip_prefix("00") {
return Some(format!("+{rest}"));
}
if trimmed.starts_with('(') && !trimmed.contains('+') {
digits = digits.trim_start_matches('0').to_string();
}
Some(format!("{}{digits}", country.trim()))
}
fn normalize_email(raw: &str) -> Option<String> {
let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase();
(e.contains('@') && !e.contains(char::is_whitespace)).then_some(e)
}
impl Identity {
/// From what a form collected: the first phone-like field and the
/// first email-like field, by the names forms use for them.
pub fn from_responses(responses: &serde_json::Value) -> Self {
let field = |names: &[&str]| {
names
.iter()
.find_map(|n| responses.get(*n).and_then(|v| v.as_str()))
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
};
let country = default_country();
Identity {
sub: None,
phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)),
email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)),
}
}
pub fn from_user(user: &crate::auth::User) -> Self {
let country = default_country();
Identity {
sub: Some(user.sub.clone()),
phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)),
email: user.email.as_deref().and_then(normalize_email),
}
}
/// One person given as one string: a phone number if it reads as
/// one, else an address.
pub fn from_contact(contact: &str) -> Self {
let country = default_country();
match normalize_email(contact) {
Some(email) => Identity { sub: None, phone: None, email: Some(email) },
None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None },
}
}
/// What a membership record is keyed by: the phone number, else the
/// address, else the provider's subject. `None` when there is
/// nothing to know the person by.
pub fn key(&self) -> Option<String> {
self.phone
.as_deref()
.or(self.email.as_deref())
.or(self.sub.as_deref())
.map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect())
}
/// Whether a record about `other` is about this person: the same
/// subject, phone number or address, any one of them.
pub fn matches(&self, other: &Identity) -> bool {
let same = |a: &Option<String>, b: &Option<String>| a.is_some() && a == b;
same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email)
}
/// What is worth writing on a record from a login: everything the
/// provider verified that the record did not have.
fn merged_from(&self, seen: &Identity) -> Identity {
Identity {
sub: self.sub.clone().or_else(|| seen.sub.clone()),
phone: self.phone.clone().or_else(|| seen.phone.clone()),
email: self.email.clone().or_else(|| seen.email.clone()),
}
}
}
/// One record's id: the group and the person's key.
pub fn record_id(group: &str, key: &str) -> String {
format!("{}.{key}", group.trim())
}
/// The identity a record holds.
pub fn identity_of(answer: &Answer) -> Identity {
Identity {
sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
}
}
pub fn group_of(answer: &Answer) -> String {
answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string()
}
/// The groups `who` is an active member of, from every record.
pub fn groups_of(records: &[Answer], who: &Identity) -> Vec<String> {
let mut groups: Vec<String> = records
.iter()
.filter(|a| a.state == ACTIVE && identity_of(a).matches(who))
.map(group_of)
.filter(|g| !g.is_empty())
.collect();
groups.sort();
groups.dedup();
groups
}
async fn store(state: &crate::server::AppState) -> anyhow::Result<async_nats::jetstream::kv::Store> {
match state.jetstream.get_key_value(BUCKET).await {
Ok(store) => Ok(store),
Err(_) => Ok(state
.jetstream
.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() })
.await?),
}
}
/// Every membership record.
pub async fn all(state: &crate::server::AppState) -> Vec<Answer> {
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() };
let keys: Vec<String> = {
use futures::TryStreamExt;
match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => Vec::new(),
}
};
let mut out = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) {
out.push(answer);
}
}
}
out
}
/// Moves one record the way a desk button would: through the event
/// log, onto the projection, out on the answers subject.
async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result<Answer> {
let aggregates = state.aggregates.load();
let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by });
crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
answer.state = target.to_string();
answer.decided_ms = Some(now_ms);
answer.decided_by = Some(by.to_string());
store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?;
let parent_hashes = vec![answer.id.clone()];
let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms);
let event = crate::events::AnswerSubmitted {
chain_hash,
parent_hashes,
question_id: answer.question_id.clone(),
alternative: target.to_string(),
responses: payload,
timestamp_ms: now_ms,
};
let _ = crate::events::emit_answer_submitted(&state.nats, &event).await;
Ok(answer)
}
/// Puts a person in a group, or finds them there. A new record starts
/// `invited`; a removed one is invited again; an invited or active one
/// is left as it is, so a second grant costs nothing. `created` in
/// what comes back says whether the person had a place already.
pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result<crate::people::Invited> {
let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?;
let store = store(state).await?;
// The person's record in this group, however it was keyed: a desk
// that invited by address and a decision that granted by phone
// number are talking about one person once a sign-in has joined
// the two, and must not make two.
let existing: Option<Answer> = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who));
let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key));
let now_ms = chrono::Utc::now().timestamp_millis();
let invited = |created: bool| crate::people::Invited {
username: key.clone(),
display_name: name.to_string(),
email: who.email.clone().unwrap_or_default(),
group: group.to_string(),
reset_link: None,
link_expires_ms: None,
created,
already_member: !created,
};
match existing {
Some(answer) if answer.state == REMOVED => {
move_to(state, &store, answer, INVITED, by).await?;
Ok(invited(true))
}
Some(_) => Ok(invited(false)),
None => {
let responses = serde_json::json!({
"name": name, "group": group, "by": by,
"phone": who.phone, "email": who.email, "sub": who.sub,
});
crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms).await?;
Ok(invited(true))
}
}
}
/// Takes a person out of a group: the record moves to `removed` and
/// stays, so the desk can see who was in and put them back.
pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result<bool> {
let Some(key) = who.key() else { return Ok(false) };
let store = store(state).await?;
let id = record_id(group, &key);
let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::<Answer>(&b).ok()) else {
return Ok(false);
};
if answer.state == REMOVED {
return Ok(false);
}
move_to(state, &store, answer, REMOVED, by).await?;
Ok(true)
}
/// Someone signed in: every invitation that was for them is now a
/// membership, with what the provider verified written on the record,
/// and their active groups come back to be their groups for the
/// session. Their `sub` is remembered, so the next login matches on
/// it before anything else.
pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec<String> {
let who = Identity::from_user(user);
let records = all(state).await;
let Ok(store) = store(state).await else { return groups_of(&records, &who) };
let mut settled = Vec::with_capacity(records.len());
for answer in records {
let held = identity_of(&answer);
if !held.matches(&who) {
settled.push(answer);
continue;
}
let merged = held.merged_from(&who);
let mut answer = answer;
if merged != held {
if let Some(map) = answer.responses.as_object_mut() {
map.insert("sub".into(), serde_json::json!(merged.sub));
map.insert("phone".into(), serde_json::json!(merged.phone));
map.insert("email".into(), serde_json::json!(merged.email));
}
if let Ok(bytes) = serde_json::to_vec(&answer) {
let _ = store.put(&answer.id, bytes.into()).await;
}
}
if answer.state == INVITED {
match move_to(state, &store, answer.clone(), ACTIVE, BY).await {
Ok(active) => {
tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership");
settled.push(active);
}
Err(e) => {
tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}");
settled.push(answer);
}
}
} else {
settled.push(answer);
}
}
groups_of(&settled, &who)
}
#[cfg(test)]
mod tests {
use super::*;
fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer {
Answer {
id: record_id(group, "k"),
question_id: "/".into(),
alternative: "Membership".into(),
responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }),
state: state.into(),
submitted_ms: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
}
}
#[test]
fn a_phone_number_is_one_string_for_everyone() {
assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into()));
assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into()));
assert_eq!(normalize_phone("kari@vel.no", "+47"), None);
assert_eq!(normalize_phone("123", "+47"), None);
}
#[test]
fn a_person_is_their_phone_first_and_their_address_second() {
let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" }));
assert_eq!(both.key().as_deref(), Some("_4791234567"));
assert_eq!(both.email.as_deref(), Some("kari@vel.no"));
let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" }));
assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no"));
assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no"));
assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567"));
assert!(Identity::default().key().is_none());
}
#[test]
fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() {
let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari));
assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari));
assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari));
assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari));
assert!(!Identity::default().matches(&kari));
}
#[test]
fn only_active_memberships_are_groups() {
let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) };
let records = vec![
record("styret", ACTIVE, Some("+4791234567"), None, None),
record("kasserer", INVITED, None, Some("kari@vel.no"), None),
record("trafikk", REMOVED, Some("+4791234567"), None, None),
record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None),
record("styret", ACTIVE, Some("+4799999999"), None, None),
];
assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]);
}
}
+76 -20
View File
@@ -23,6 +23,61 @@
use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites";
/// Where people are kept. Kanidm makes accounts and holds groups; with
/// memberships (`memberships.rs`) portal holds the groups itself and
/// the identity provider only says who someone is. `PEOPLE_BACKEND`
/// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence
/// means memberships, so a site with no Kanidm token can still invite.
pub enum People {
Kanidm(Kanidm),
Memberships,
}
impl People {
/// `None` only when Kanidm is asked for and not configured.
pub fn from_env() -> Option<Self> {
match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() {
Some("memberships") => Some(People::Memberships),
Some("kanidm") => Kanidm::from_env().map(People::Kanidm),
Some(other) => {
tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships");
Some(People::Memberships)
}
None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)),
}
}
/// What is missing when `from_env` says no.
pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)";
/// Puts a person in a group, making them known if they were not:
/// through Kanidm an account and a one-time link, through
/// memberships a record that becomes theirs when they sign in.
/// `by` is who asked, for the record.
pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result<Invited> {
match self {
People::Kanidm(kanidm) => {
let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?;
kanidm.invite(name, email, group).await
}
People::Memberships => crate::memberships::grant(state, group, name, who, by).await,
}
}
/// Takes a person out of a group. Through Kanidm only what portal
/// put there (see `Invited.already_member`); memberships remember.
pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> {
match self {
People::Kanidm(kanidm) => kanidm.remove_member(group, username).await,
People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()),
}
}
pub fn is_kanidm(&self) -> bool {
matches!(self, People::Kanidm(_))
}
}
pub const INVITED_SUBJECT: &str = "portal.people.invited";
/// The states an invite moves between, besides the schema's initial
/// `open` (see `BUILTIN_AGGREGATES_YAML`).
@@ -515,21 +570,21 @@ pub fn username_for(email: &str) -> String {
}
}
/// The person a record is about, for a state that grants a group.
/// Both are required by the lint of any form that fills such a bucket
/// (see `validate_questions`), so a record missing either is one that
/// predates the rule, and is reported rather than guessed at.
pub fn who_for(responses: &serde_json::Value) -> Option<(String, String)> {
let field = |name: &str| {
responses
.get(name)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
};
let email = field("email").filter(|e| e.contains('@'))?;
Some((field("name")?, email))
/// The person a record is about, for a state that grants a group: their
/// name, and a phone number or an address to know them by. Required by
/// the lint of any form that fills such a bucket (see
/// `validate_questions`), so a record missing them is one that predates
/// the rule, and is reported rather than guessed at.
pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> {
let name = responses
.get("name")
.or_else(|| responses.get("navn"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)?;
let who = crate::memberships::Identity::from_responses(responses);
who.key().is_some().then_some((name, who))
}
/// A record entered `state`: if that state grants a group, put the
@@ -555,15 +610,15 @@ pub async fn on_state(
else {
return;
};
let Some(kanidm) = Kanidm::from_env() else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but invites are not configured on this instance (KANIDM_API_TOKEN) - nobody was added");
let Some(people) = People::from_env() else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED);
return;
};
let Some((name, email)) = who_for(responses) else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and email - nobody was added");
let Some((name, who)) = who_for(responses) else {
tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added");
return;
};
let invited = match kanidm.invite(&name, &email, &group).await {
let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await {
Ok(invited) => invited,
Err(e) => {
tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}");
@@ -662,6 +717,7 @@ mod tests {
username: username.into(),
display_name: String::new(),
groups: vec![],
phone: None,
email: email.map(String::from),
};
assert!(is_for(&invited, &user("kari", None)));
+43 -15
View File
@@ -20,7 +20,9 @@
//!
//! **Everyone the content names as responsible.** A page's
//! `responsible: { name, contact }` is a person answering for that
//! question, so they get an account: at start, and again whenever the
//! question - unless `group` says it is a group's, in which case the
//! address is an inbox and nobody is made for it. A person gets an
//! account: at start, and again whenever the
//! content changes, each responsible address Kanidm does not already
//! hold is given one, and mailed that they are listed as responsible
//! for asking those questions, with the link to sign in. Signed in,
@@ -77,7 +79,9 @@ pub fn seed_key(email: &str, group: &str) -> String {
/// Everyone the content names as responsible, by address (lowercased,
/// `mailto:` taken off): their name as the first page gives it, and the
/// questions they answer for, in page order.
/// questions they answer for, in page order. A page a group answers
/// for (`responsible.group`) names nobody: the board's inbox is not a
/// person, and its members have their accounts through the desk.
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
let mut ids: Vec<&String> = questions.keys().collect();
ids.sort();
@@ -85,6 +89,9 @@ pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<Str
for id in ids {
let q = &questions[id];
let Some(r) = &q.responsible else { continue };
if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) {
continue;
}
let email = contact_email(&r.contact);
let Some(email) = email else { continue };
let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new()));
@@ -201,9 +208,9 @@ async fn responsible(state: &crate::server::AppState) -> usize {
if people.is_empty() && group.is_none() {
return 0;
}
let Some(kanidm) = crate::people::Kanidm::from_env() else {
let Some(people_backend) = crate::people::People::from_env() else {
if !people.is_empty() {
tracing::info!(people = people.len(), "the content names responsible people, but invites are not configured (KANIDM_API_TOKEN) - no accounts made");
tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED);
}
return 0;
};
@@ -215,13 +222,15 @@ async fn responsible(state: &crate::server::AppState) -> usize {
continue;
}
let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() };
let who = crate::memberships::Identity::from_contact(email);
// With a group, one call makes the account if there is none and
// adds them either way; without one, only a missing account is
// made. Either way a person is mailed only when an account was
// made for them: someone who had one already has their way in.
let made = match &group {
Some(group) => kanidm.invite(&name, email, group).await,
None => match kanidm.has_person_with_mail(email).await {
// made (Kanidm; a membership is nothing without a group). Either
// way a person is mailed only when an account was made for
// them: someone who had one already has their way in.
let made = match (&group, &people_backend) {
(Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await,
(None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await {
Ok(true) => {
let _ = store.put(&key, "existing".into()).await;
continue;
@@ -229,6 +238,7 @@ async fn responsible(state: &crate::server::AppState) -> usize {
Ok(false) => kanidm.account(&name, email).await,
Err(e) => Err(e),
},
(None, crate::people::People::Memberships) => continue,
};
match made {
Ok(invited) => {
@@ -270,7 +280,8 @@ async fn responsible(state: &crate::server::AppState) -> usize {
}
for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) {
if !invited.already_member {
if let Err(e) = kanidm.remove_member(&group, &invited.username).await {
let who = crate::memberships::Identity::from_contact(&invited.email);
if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await {
left += 1;
tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}");
continue;
@@ -283,11 +294,23 @@ async fn responsible(state: &crate::server::AppState) -> usize {
left
}
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one.
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one -
/// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by
/// their number.
async fn seed_admin(state: &crate::server::AppState) {
let Some(email) = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')) else {
let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty());
let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@'));
let (Some(contact), who) = (
phone.clone().or_else(|| email.clone()),
crate::memberships::Identity {
sub: None,
phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))),
email: email.as_deref().map(|e| e.to_lowercase()),
},
) else {
return;
};
let email = contact;
let name = std::env::var("SEED_ADMIN_NAME")
.ok()
.map(|n| n.trim().to_string())
@@ -309,15 +332,15 @@ async fn seed_admin(state: &crate::server::AppState) {
tracing::info!(group, "the first person was already invited into this group; not again");
return;
}
let Some(kanidm) = crate::people::Kanidm::from_env() else {
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but invites are not configured on this instance (KANIDM_URL, KANIDM_API_TOKEN) - nobody was seeded");
let Some(people) = crate::people::People::from_env() else {
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED);
return;
};
let mut attempt = 0;
let invited = loop {
attempt += 1;
match kanidm.invite(&name, &email, &group).await {
match people.invite(state, &name, &who, &group, "seed").await {
Ok(invited) => break invited,
Err(e) if attempt < ATTEMPTS => {
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
@@ -415,6 +438,11 @@ mod tests {
assert_eq!(people.keys().collect::<Vec<_>>(), ["styret@vel.no", "trafikk@vel.no"]);
assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]);
assert_eq!(contact_email("+47 900 00 000"), None);
// A group's inbox is nobody: its members have the desk.
let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap();
held.id = "/d".into();
qs.insert("/d".into(), held);
assert!(!responsible_people(&qs).contains_key("post@vel.no"));
}
#[test]
+32 -3
View File
@@ -11,10 +11,10 @@ use axum::{
response::Redirect,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse,
OAuth2TokenResponse, TokenResponse,
};
use serde::Deserialize;
use tower_sessions::Session;
@@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce";
const REDIRECT_KEY: &str = "oidc_post_login_redirect";
impl Oidc {
/// Sign-in disabled: what a content-only instance has, and what a
/// test builds an `AppState` with.
pub fn disabled() -> Self {
Self { inner: None }
}
/// Discovers the provider and builds the client from environment:
/// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
/// With `KANIDM_URL` unset, sign-in is disabled instead of fatal -
@@ -153,6 +159,9 @@ pub async fn login(
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
// A provider that verifies phone numbers (Vipps) gives one for
// this scope; Kanidm ignores a scope it does not know.
.add_scope(Scope::new("phoneNumber".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
@@ -242,13 +251,33 @@ pub async fn callback(
let groups = extract_groups_claim(&id_token);
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
let user = User {
// The verified phone number lives at userinfo, not in the ID token,
// for providers that give one (Vipps). A provider that has none, or
// no userinfo endpoint, leaves it empty; nothing depends on it.
let phone = match oidc.client.user_info(token_response.access_token().clone(), None) {
Ok(request) => request
.request_async(&oidc.http)
.await
.ok()
.and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())),
Err(_) => None,
};
let mut user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
groups,
phone,
email,
};
// What they belong to, as portal keeps it: every invitation for
// them becomes a membership now, and their active memberships join
// whatever groups the provider itself asserted.
for group in crate::memberships::on_signed_in(&state, &user).await {
if !user.groups.contains(&group) {
user.groups.push(group);
}
}
// Rotate the session id on privilege change, then store the user.
session.cycle_id().await.map_err(internal)?;
+95
View File
@@ -153,3 +153,98 @@ async fn a_session_survives_the_process_that_made_it() {
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
next_boot.delete(&collide.id).await.ok();
}
/// A membership is a case portal keeps itself: invited by a desk or a
/// decision, active from the person's first sign-in, removed by a desk,
/// and invited again after that. What the person belongs to is read
/// back from the same records, matched on phone number or address.
#[tokio::test]
async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back() {
use portal::memberships::{self, Identity, ACTIVE, INVITED, REMOVED};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let nats = async_nats::connect(&url).await.expect("connect");
let js = async_nats::jetstream::new(nats.clone());
ensure_stream(&js).await.expect("event stream");
// A clean bucket per run.
let _ = js.delete_key_value(memberships::BUCKET).await;
let aggregates = portal::content::with_builtin_aggregates(Default::default());
let questions = std::collections::HashMap::new();
let access = portal::access::Policy::from_content(&questions, &aggregates);
let state = portal::server::AppState {
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
nats,
jetstream: js,
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(questions)),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(Default::default())),
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
gitea_base: String::new(),
content_raw_base: String::new(),
content_branch: "main".into(),
oidc: std::sync::Arc::new(portal::server::oidc::Oidc::disabled()),
garage: None,
};
// Groups of this run's own: the event log outlives the bucket, and
// a record id seen before would replay its old history.
let run = chrono::Utc::now().timestamp_millis();
let styret = format!("styret{run}");
let medlemmer = format!("medlemmer{run}");
let (styret, medlemmer) = (styret.as_str(), medlemmer.as_str());
// A desk invites Kari by phone number; a decision grants her a
// second group by address. Both are invitations until she is seen.
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
assert!(first.created && first.reset_link.is_none());
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
assert!(!again.created, "a second invite into the same group finds the first");
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
let records = memberships::all(&state).await;
assert_eq!(records.len(), 2);
assert!(records.iter().all(|r| r.state == INVITED));
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
// She signs in: the provider knows her subject, phone and address.
let kari = portal::auth::User {
sub: "vipps-abc".into(),
username: "kari".into(),
display_name: "Kari".into(),
groups: vec![],
phone: Some("912 34 567".into()),
email: Some("Kari@Vel.no".into()),
};
let mut groups = memberships::on_signed_in(&state, &kari).await;
groups.sort();
assert_eq!(groups, vec![medlemmer, styret]);
let records = memberships::all(&state).await;
assert!(records.iter().all(|r| r.state == ACTIVE));
// Both records now know her by everything the provider verified.
for r in &records {
let held = memberships::identity_of(r);
assert_eq!(held.sub.as_deref(), Some("vipps-abc"));
assert_eq!(held.phone.as_deref(), Some("+4791234567"));
assert_eq!(held.email.as_deref(), Some("kari@vel.no"));
}
// A desk takes her out of styret; the record stays, as removed.
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
let records = memberships::all(&state).await;
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
assert_eq!(styret_record.state, REMOVED);
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
// Invited again: the same record, invited, and hers at the next sign-in.
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
assert!(back.created);
let records = memberships::all(&state).await;
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string()));
}