Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d419ff7b | ||
|
|
00571f65bd | ||
|
|
c4e2de5efa | ||
|
|
5bc021de77 | ||
|
|
f4ed98322f | ||
|
|
bce9ad13e4 | ||
|
|
6c86dc47a2 | ||
|
|
cc26a232ec | ||
|
|
77f2880e83 | ||
|
|
23f98add51 | ||
|
|
a9b5a81ebd | ||
|
|
f047e21c4a | ||
|
|
b9761cec63 |
@@ -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,2 +1,3 @@
|
||||
/target
|
||||
.claude/
|
||||
docs/changelog.md
|
||||
|
||||
+17
-2
@@ -4,6 +4,21 @@ 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.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.
|
||||
- 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.
|
||||
@@ -344,7 +359,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
|
||||
@@ -381,4 +396,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.5 |
|
||||
| vel.klingenbergbygg.no | kasse | `tomtervel-portal-1` (podman, tomtervel/infrastructure) | v0.5.8 |
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.5.6"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.5.6"
|
||||
version = "0.5.9"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,108 @@
|
||||
# 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". 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.
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
+13
-2
@@ -69,8 +69,11 @@ 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 |
|
||||
| `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 |
|
||||
| `GITEA_API_TOKEN` | authenticated resource pulls |
|
||||
@@ -127,6 +130,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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
+21
-10
@@ -2652,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,
|
||||
@@ -2743,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;
|
||||
}
|
||||
@@ -2908,7 +2919,7 @@ mod responsible_tests {
|
||||
use crate::content::Responsible;
|
||||
|
||||
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(), email: email.map(String::from) }
|
||||
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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+1
-1
@@ -1865,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,8 @@ pub mod mail;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod seed;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod memberships;
|
||||
|
||||
pub mod maps;
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
+94
-20
@@ -23,6 +23,79 @@
|
||||
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 => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +588,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 +628,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 +735,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)));
|
||||
|
||||
+29
-13
@@ -208,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;
|
||||
};
|
||||
@@ -222,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;
|
||||
@@ -236,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) => {
|
||||
@@ -277,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;
|
||||
@@ -290,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())
|
||||
@@ -316,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}");
|
||||
|
||||
+38
-6
@@ -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 -
|
||||
@@ -143,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,
|
||||
@@ -153,8 +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()))
|
||||
.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())
|
||||
@@ -242,13 +254,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)?;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user