Compare commits

..
9 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug cb31995299 Release 0.5.10
docs / deploy (push) Successful in 4s
Test / test (push) Failing after 45s
Publish release / publish (push) Successful in 3m35s
2026-09-30 14:10:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 579ad6f50d The lint lets an invite form ask for a phone number instead of an address
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:09:49 +02:00
Bendik Aagaard Lynghaug 89d419ff7b Release 0.5.9
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 58s
Publish release / publish (push) Successful in 3m36s
2026-09-30 14:06:58 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 00571f65bd Changelog: 0.5.9
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:56 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c4e2de5efa Memberships beside Kanidm: the invite makes the account too
Kanidm makes people, portal makes members. A site can switch to
memberships before its sign-in provider is one everyone already has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 14:06:55 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 5bc021de77 Docs: vel.klingenbergbygg.no runs v0.5.8
docs / deploy (push) Failing after 2s
Test / test (push) Failing after 25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:55:49 +02:00
Bendik Aagaard Lynghaug f4ed98322f Release 0.5.8
docs / deploy (push) Failing after 3s
Test / test (push) Failing after 59s
Publish release / publish (push) Successful in 3m22s
2026-09-30 13:40:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 bce9ad13e4 Changelog: 0.5.8
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:40:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6c86dc47a2 The phone scope is asked for only when told to: Kanidm denies a scope the person lacks
0.5.7 asked every provider for phoneNumber, so a Kanidm site could
not sign anyone in. OIDC_EXTRA_SCOPES=phoneNumber asks for it (Vipps).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 13:40:12 +02:00
8 changed files with 58 additions and 19 deletions
+13 -1
View File
@@ -4,6 +4,18 @@ 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.10 (2026-09-30)
- The lint lets an invite form collect a required `phone` in place of a required `email`: memberships know a person by number first.
## 0.5.9 (2026-09-30)
- With memberships as the people backend and a Kanidm token still set, an invite makes the Kanidm account as well (no group, the one-time link in the mail): Kanidm makes people, portal makes members, and a site can switch to memberships before its sign-in provider is one everyone already has.
## 0.5.8 (2026-09-30)
- Fixed: 0.5.7 asked every provider for the `phoneNumber` scope, and Kanidm denies a sign-in that asks for a scope the person does not hold, so nobody could sign in. The scope is asked for only with `OIDC_EXTRA_SCOPES=phoneNumber` (Vipps). 0.5.7 must not be deployed on a Kanidm site.
## 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.
@@ -388,4 +400,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.6 |
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.8 |
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.5.7"
version = "0.5.10"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.5.7"
version = "0.5.10"
edition = "2021"
[lib]
+9 -7
View File
@@ -64,10 +64,11 @@ 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.
site, and your place is there". The provider makes people, portal
makes members: with Vipps Login everyone can already sign in. A
Kanidm still configured beside memberships (`KANIDM_API_TOKEN`)
makes the account too, with no group and the one-time link in the
mail, so a site can switch to memberships before its provider does.
- `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.
@@ -76,9 +77,10 @@ memberships. With memberships:
- 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.
With `OIDC_EXTRA_SCOPES=phoneNumber` the sign-in asks the provider for
the number and reads `phone_number` from userinfo. Set it for Vipps;
not for Kanidm, which denies a sign-in that asks for a scope the
person does not hold. Without it, matching falls back to the address.
## Asking who belongs
+1
View File
@@ -72,6 +72,7 @@ values.
| `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 |
| `OIDC_EXTRA_SCOPES` | scopes to ask the provider for besides `openid profile email`: `phoneNumber` for Vipps. Not for Kanidm, which denies a sign-in that asks for a scope the person does not hold |
| `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 |
+5 -2
View File
@@ -278,9 +278,12 @@ pub fn validate_questions(
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && !r.optional)
};
if !has("name") || !has("email") {
// A person is known by a phone number or an address;
// either will do, since memberships key on the number
// first and Kanidm on the address.
if !has("name") || !(has("email") || has("phone")) {
anyhow::bail!(
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
"question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field",
question.id, alternative.name
);
}
+19 -1
View File
@@ -61,7 +61,25 @@ impl People {
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,
People::Memberships => {
let mut invited = crate::memberships::grant(state, group, name, who, by).await?;
// A Kanidm beside memberships makes the person, not the
// member: an account with no group, and the one-time
// link, so someone invited by address can sign in
// before the provider is one everyone already has.
if let (Some(kanidm), Some(email)) = (Kanidm::from_env(), who.email.as_deref()) {
match kanidm.account(name, email).await {
Ok(account) => {
invited.username = account.username;
invited.reset_link = account.reset_link;
invited.link_expires_ms = account.link_expires_ms;
invited.created = account.created;
}
Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"),
}
}
Ok(invited)
}
}
}
+9 -6
View File
@@ -149,7 +149,7 @@ pub async fn login(
let oidc = state.oidc.configured()?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = oidc
let mut request = oidc
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
@@ -159,11 +159,14 @@ 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();
.set_pkce_challenge(pkce_challenge);
// What else to ask the provider for: `phoneNumber` for one that
// verifies numbers (Vipps). Only when asked, because Kanidm denies
// a sign-in that requests a scope the person does not hold.
for scope in std::env::var("OIDC_EXTRA_SCOPES").unwrap_or_default().split_whitespace() {
request = request.add_scope(Scope::new(scope.to_string()));
}
let (auth_url, csrf_state, nonce) = request.url();
session
.insert(PKCE_KEY, pkce_verifier.secret())