Compare commits

..
22 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 9ed8194484 Release 0.3.47
Test / test (push) Failing after 55s
Publish release / publish (push) Successful in 3m48s
iris learns stage_words; portal unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 05:17:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2ac34b6167 Release 0.3.46
Test / test (push) Successful in 1m7s
Publish release / publish (push) Successful in 3m45s
An invite finds an existing account by its email and only adds it to
the group.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 22:18:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 3c68814030 Release 0.3.45
Test / test (push) Successful in 1m3s
Publish release / publish (push) Successful in 3m18s
A mail stream that cannot be created at boot is a warning, not a
failed start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:24:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 0c68396d5b Release 0.3.44
Test / test (push) Failing after 1m3s
Publish release / publish (push) Successful in 3m6s
The mail stream is `mail`, not shouted; gdo 0.1.2 matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:15:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1ca3ac6807 Release 0.3.43
Test / test (push) Successful in 49s
Publish release / publish (push) Successful in 2m53s
Mail as content, delivered by uhhm/gdo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:50 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 19c8f541de Mail is content: a state's mail: and the invite mail, published for gdo
`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:45:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6d60f8b9a3 Release 0.3.42
Test / test (push) Successful in 52s
Publish release / publish (push) Successful in 2m57s
people: the Kanidm person lookup reads the body (200 + null is a
missing person in Kanidm 1.11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f36d88a69c people: a missing person is 200 + null in Kanidm 1.11, not 404
The existence check went by status alone, so every invitee looked like
an existing account: creation was skipped and the group step failed.
Read the body; `null` means nobody there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 ef46b463e4 CHANGELOG: kasse sites run v0.3.41
Test / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 12:57:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 4818c5a26f CHANGELOG: the sections for 0.3.39 to 0.3.41; publish refuses a tag without one
Test / test (push) Failing after 28s
Three releases went out with no section because the edit that moves
Unreleased under a version heading was a no-op when no Unreleased
section existed, and nothing checked. Now the publish job fails when
the tag has no section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:54:28 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7106543089 Release 0.3.41
Test / test (push) Failing after 51s
Publish release / publish (push) Successful in 2m50s
publish: the iris step retries on a fresh clone when its push loses a
race against a push to iris main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:53:00 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 44d5ef3683 Release 0.3.40
Test / test (push) Successful in 48s
Publish release / publish (push) Failing after 5m46s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:44:45 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 eeeb89b217 One access policy from content; onboarding into Kanidm as a form
Who may do what to what was five checks in five files: `qualifies` on
a page, `requires_group`/`public` on a resource, a desk's transition
allow-list, a self_transition's email check, the automation token.
They are now compiled from content on every load into one Cedar
policy set (src/access.rs), and every server entry point asks it: open
and submit a page, read a resource, move a record, fire a self
transition, read a bucket as the automation service, extend an
invite. Each decision names the rules that granted it; refusals and
every mutation are published on portal.access.decided, and
portal.access.<site>.may answers the same question for other apps
over request-reply (proven live against uhhm's content). The policy
validates against a fixed schema, so a content shape the compiler
cannot express fails instead of granting nothing. Identity is still
Kanidm's; the browser keeps is_qualified as a rendering hint only.

`invite: {group}` on an alternative of a gated page onboards a person:
src/people.rs creates the account, records the email, adds the group
and mints a one-time credential link through Kanidm's REST API with a
service-account token (KANIDM_API_TOKEN, in idm_people_on_boarding).
The result is recorded with the answer into the runtime's
portal_invites bucket and announced on portal.people.invited. Validated:
an invite needs a gated page and required name and email fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 11:44:39 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7408fa972a CHANGELOG: vel.klingenbergbygg.no runs on kasse too
Test / test (push) Failing after 9s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:08:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d45dbe672a CHANGELOG: all four sites run v0.3.36
Test / test (push) Failing after 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:06:48 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 7f23ef2de0 Release 0.3.39
Publish release / publish (push) Successful in 2m39s
Test / test (push) Failing after 21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 06:00:17 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 e02c18acab publish: release the matching iris by proxy
Test / test (push) Failing after 24s
iris is versioned as portal. On a tag push this job clones uhhm/iris,
re-pins its portal dependency to the tag, sets its version to match,
builds and tests it, writes the changelog line, and pushes the commit
and tag - which iris's own CI publishes. The tarball ships the binary
just built. A portal change to something the lint reads fails the
release at this step. Needs the IRIS_PUSH_USER variable and
IRIS_PUSH_TOKEN secret on this repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 22:05:31 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 dcc67ff2cd Release 0.3.38
Publish release / publish (push) Successful in 1m17s
Test / test (push) Failing after 11s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:54:08 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9879e1f5cf publish: writable cargo home for the iris build step
Test / test (push) Failing after 52s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:50:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 050c2350f4 Release 0.3.37
Test / test (push) Successful in 41s
Publish release / publish (push) Failing after 1m7s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:48:30 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1ebd800fda The lint moves out to uhhm/iris
question_lint, needs_replay, the needs module and the local-checkout
loaders now live in uhhm/iris, which depends on this crate as a
library pinned to a release tag. The tarball ships the matching iris
binary (IRIS_RELEASE in publish.yml) in place of question_lint, so a
site that pins nothing extra keeps a lint at current/iris. Portal
loses about 1,100 lines and keeps only what runs at request time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:29:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 623399f25d CHANGELOG: ergo sites run v0.3.36
Test / test (push) Successful in 1m3s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-22 17:03:09 +02:00
23 changed files with 2039 additions and 1505 deletions
+64 -6
View File
@@ -56,10 +56,67 @@ jobs:
- name: Build
run: SITE_NAME=${{ vars.PORTAL_SITE_NAME }} cargo leptos build --release
# cargo-leptos only builds the leptos bin-target ("portal") - the
# question-lint utility binary needs its own plain cargo build.
- name: Build question-lint
run: cargo build --release --bin question_lint --features ssr
# The lint (uhhm/iris) is versioned as portal: this step re-pins
# iris to the tag being released, builds and tests it against it,
# and pushes the matching iris tag, which iris's own CI publishes.
# A portal change to something the lint reads fails the release
# right here. Needs IRIS_PUSH_USER (a variable) and IRIS_PUSH_TOKEN
# (a secret) that may push to uhhm/iris; the workflow's own token
# is scoped to this repo. A re-run of a tag whose iris tag already
# exists just builds that.
- name: Release iris to match
env:
# The shared cargo home is read-only for the runner, and a git
# dependency has to be cloned into one (same as uhhm/iris's CI).
CARGO_HOME: /var/local/cargo-target/iris-cargo-home
RUSTC_WRAPPER: /usr/bin/sccache
IRIS_PUSH_USER: ${{ vars.IRIS_PUSH_USER }}
IRIS_PUSH_TOKEN: ${{ secrets.IRIS_PUSH_TOKEN }}
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
ver="${tag#v}"
host="${{ github.server_url }}"
remote="https://${IRIS_PUSH_USER}:${IRIS_PUSH_TOKEN}@${host#https://}/uhhm/iris.git"
# Re-pin, version, changelog: idempotent, so a retry on a fresh
# clone redoes exactly the same edits.
match() {
sed -i -E "s|^(portal = \{ git = \"[^\"]+\", tag = \")[^\"]+(\")|\1$tag\2|" Cargo.toml
sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"$ver\"/" Cargo.toml
cargo update -q -p portal
awk -v h="## $ver ($(date -u +%F)) - portal $tag" -v l="- Matches portal $tag (released by portal's publish job)." \
'done==0 && /^## /{print h; print ""; print l; print ""; done=1} {print}' CHANGELOG.md > CHANGELOG.new
mv CHANGELOG.new CHANGELOG.md
git config user.name "portal release"
git config user.email "portal@uhhm.no"
git commit -qam "Match portal $tag"
git tag "$tag"
}
# Someone may push to iris main while this job builds; a push
# then fails as non-fast-forward, and the answer is to start
# over on a fresh clone, once.
for attempt in 1 2; do
cd "$GITHUB_WORKSPACE"
rm -rf iris-src
git clone -q "$host/uhhm/iris.git" iris-src
cd iris-src
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
echo "iris $tag already exists, building it"
git checkout -q "$tag"
cargo build --release
cargo test
break
fi
match
cargo build --release
cargo test
if git push -q "$remote" main "$tag"; then
echo "pushed iris $tag"
break
fi
echo "push rejected on attempt $attempt (iris main moved), retrying on a fresh clone"
[ "$attempt" = 2 ] && exit 1
done
- name: Package
run: |
@@ -67,7 +124,7 @@ jobs:
tag="${{ github.ref_name }}"
stage=$(mktemp -d)
cp "$CARGO_TARGET_DIR/release/portal" "$stage/portal"
cp "$CARGO_TARGET_DIR/release/question_lint" "$stage/question_lint"
cp "$CARGO_TARGET_DIR/release/iris" "$stage/iris"
# hash-files = true: leptos resolves hash.txt next to the running
# binary, so it ships beside portal in the release dir.
cp "$CARGO_TARGET_DIR/release/hash.txt" "$stage/hash.txt"
@@ -75,7 +132,7 @@ jobs:
# not affected by CARGO_TARGET_DIR - only the plain `cargo build`
# outputs (release/, front/) move with that override.
cp -r target/site "$stage/site"
tar -C "$stage" -czf "portal-$tag.tar.gz" portal question_lint hash.txt site
tar -C "$stage" -czf "portal-$tag.tar.gz" portal iris hash.txt site
rm -rf "$stage"
# The run's own ephemeral token has write access to this repo -
@@ -90,6 +147,7 @@ jobs:
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
# The release notes are this version's section of CHANGELOG.md.
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $tag - write it and re-tag"; exit 1; }
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
id=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$body" "$api/releases" | jq .id) \
+52 -5
View File
@@ -4,6 +4,52 @@ 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.3.47 (2026-09-24)
- iris: needs.yaml takes `stage_words`, each state in the site's own words, carried into the simulation model for the trainer's desk score. No change to portal itself.
## 0.3.46 (2026-09-23)
- An invite first looks the email up in Kanidm (`/v1/raw/search` on `mail`, when the service account may read people: `idm_people_pii_read`) and only adds an existing account, under whatever username, to the group; no second account, no reset link.
## 0.3.45 (2026-09-23)
- A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename.
## 0.3.44 (2026-09-23)
- The mail stream is named `mail` (was `WORMHOLE`); gdo 0.1.2 matches. Delete the old stream on each NATS after upgrading both.
## 0.3.43 (2026-09-23)
- Mail is content: `mail: { to, subject, body }` on a state in aggregates.yaml sends when a record enters that state (`to: submitter` or one address; `{field}`, `{site}`, `{chain}`, `{email}` in the templates), and an invite mails its one-time link built in; `site.yaml` gains `mail: { from, reply_to, invite }`. Portal renders and publishes on `portal.mail.send` in the `WORMHOLE` stream; uhhm/gdo delivers. The lint requires a required `email` field on every form recording into a bucket that mails the submitter.
## 0.3.42 (2026-09-23)
- people: the Kanidm person lookup reads the body, since Kanidm 1.11 answers a missing person with 200 and `null` rather than 404; before this every invite skipped account creation and failed on the group step
## 0.3.41 (2026-09-23)
- publish: the iris release step retries on a fresh clone when its push loses a race against a push to iris main (the v0.3.40 tag never published)
- iris: `check --access` prints the access matrix, and every check validates the compiled policy against the schema
## 0.3.40 (2026-09-23)
- One access policy, compiled from content into Cedar on every load (`src/access.rs`): pages, resource reads, desk transitions, self transitions and the automation endpoint all decide through it; decisions carry rule ids, refusals and mutations publish on `portal.access.decided`, and `portal.access.<site>.may` answers the same question for other apps over NATS
- `invite: {group}` on an alternative onboards a person into Kanidm from a desk (`src/people.rs`, `KANIDM_API_TOKEN`), recording the one-time credential link in `portal_invites` and on `portal.people.invited`
## 0.3.39 (2026-09-23)
- publish: releases the matching uhhm/iris by proxy - re-pins, builds, tests and tags it as this version (needs the `IRIS_PUSH_USER` variable and `IRIS_PUSH_TOKEN` secret); iris carries portal's version number from here on
## 0.3.38 (2026-09-22)
- publish: the iris build step gets a writable cargo home; the v0.3.37 tag never published
## 0.3.37 (2026-09-22)
- The lint moves to its own repo, uhhm/iris: `question_lint` becomes `iris check`, `needs_replay` becomes `iris replay`; the tarball ships `iris` (pinned in publish.yml) instead of `question_lint`; the needs module and local loaders leave portal
## 0.3.36 (2026-09-22)
- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered
@@ -271,13 +317,14 @@ content repos pin the tag they run (see the end of this file).
## What each site runs
Read from the hosts on 2026-09-22; a rollout is a pin bump in the
Read from the hosts on 2026-09-23; a rollout is a pin bump in the
site's content repo (`PORTAL_RELEASE` in its `deploy.yml`), or a manual
tarball deploy on kasse.
| Site | Host | Instance | Version |
|---|---|---|---|
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.35 |
| redoal.com | ergo | `app@redoal-portal` | v0.3.35 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.35 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.35 |
| uhhm.no | ergo | `app@uhhm-portal` | v0.3.36 |
| redoal.com | ergo | `app@redoal-portal` | v0.3.36 |
| westra.klingenbergbygg.no | kasse | `app@westra-portal` | v0.3.41 |
| portal.klingenbergbygg.no | kasse | `app@klingenberg-portal` | v0.3.41 |
| vel.klingenbergbygg.no | kasse | `app@tomtervel-portal` | v0.3.41 |
Generated
+450 -1
View File
@@ -44,6 +44,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -53,6 +62,21 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "ascii-canvas"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
dependencies = [
"term",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -661,6 +685,16 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"bytes",
"cfg_aliases",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -724,6 +758,70 @@ dependencies = [
"shlex",
]
[[package]]
name = "cedar-policy"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e4f5e46c425491b7133eecb3030d82d27301b84bfa08533c59a6902cca7eb80"
dependencies = [
"cedar-policy-core",
"cedar-policy-formatter",
"itertools 0.15.0",
"linked-hash-map",
"linked_hash_set",
"miette",
"ref-cast",
"semver",
"serde",
"serde_json",
"serde_with",
"smol_str",
"thiserror 2.0.19",
]
[[package]]
name = "cedar-policy-core"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a30d11033d59b262bbb380d8bb5dc5c3dd2dd2281675c558ef4fd8a161ce669"
dependencies = [
"chrono",
"educe",
"either",
"itertools 0.15.0",
"lalrpop",
"lalrpop-util",
"linked-hash-map",
"linked_hash_set",
"miette",
"nonempty",
"ref-cast",
"regex",
"rustc-literal-escaper",
"serde",
"serde_json",
"serde_with",
"smol_str",
"stacker",
"thiserror 2.0.19",
"unicode-security",
]
[[package]]
name = "cedar-policy-formatter"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a2c8dc671e62597c85caa11694365b76050abcd9f2177f0520ccac0580f9a41"
dependencies = [
"cedar-policy-core",
"itertools 0.15.0",
"logos",
"miette",
"pretty",
"regex",
"smol_str",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -1229,6 +1327,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "educe"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd"
dependencies = [
"enum-ordinalize",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "either"
version = "1.17.0"
@@ -1266,6 +1376,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1275,6 +1394,26 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "enum-ordinalize"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677"
dependencies = [
"enum-ordinalize-derive",
]
[[package]]
name = "enum-ordinalize-derive"
version = "4.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1345,6 +1484,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2066,6 +2211,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2193,6 +2347,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -2208,6 +2371,38 @@ version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "lalrpop"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
dependencies = [
"ascii-canvas",
"bit-set",
"ena",
"itertools 0.14.0",
"lalrpop-util",
"petgraph",
"pico-args",
"regex",
"regex-syntax",
"sha3",
"string_cache",
"term",
"unicode-xid",
"walkdir",
]
[[package]]
name = "lalrpop-util"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
dependencies = [
"regex-automata",
"rustversion",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2451,6 +2646,24 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
dependencies = [
"serde",
]
[[package]]
name = "linked_hash_set"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "984fb35d06508d1e69fc91050cceba9c0b748f983e6739fa2c7a9237154c52c8"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2479,6 +2692,38 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "logos"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
dependencies = [
"logos-derive",
]
[[package]]
name = "logos-codegen"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
dependencies = [
"fnv",
"proc-macro2",
"quote",
"regex-automata",
"regex-syntax",
"syn 2.0.119",
]
[[package]]
name = "logos-derive"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
dependencies = [
"logos-codegen",
]
[[package]]
name = "lru"
version = "0.16.4"
@@ -2548,6 +2793,29 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miette"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
dependencies = [
"cfg-if",
"miette-derive",
"serde",
"unicode-width 0.1.14",
]
[[package]]
name = "miette-derive"
version = "7.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2592,6 +2860,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "next_tuple"
version = "0.1.0"
@@ -2613,6 +2887,15 @@ dependencies = [
"signatory",
]
[[package]]
name = "nonempty"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
dependencies = [
"serde",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2712,6 +2995,15 @@ dependencies = [
"url",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "oco_ref"
version = "0.2.1"
@@ -2872,6 +3164,31 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2948,7 +3265,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.3.36"
version = "0.3.47"
dependencies = [
"anyhow",
"arc-swap",
@@ -2956,6 +3273,7 @@ dependencies = [
"aws-sdk-s3",
"axum",
"base64 0.22.1",
"cedar-policy",
"chrono",
"console_error_panic_hook",
"dotenvy",
@@ -3012,6 +3330,23 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156"
dependencies = [
"arrayvec",
"typed-arena",
"unicode-width 0.2.2",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -3105,6 +3440,16 @@ dependencies = [
"unarray",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@@ -3549,6 +3894,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc-literal-escaper"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe6f213fb658c8fb95baabd5420393438cf5a98d707f5dd701d9197c705f71e"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3874,6 +4225,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -4107,6 +4459,16 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest 0.10.7",
"keccak",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -4154,6 +4516,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@@ -4175,6 +4543,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smol_str"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
dependencies = [
"borsh",
"serde_core",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4223,6 +4601,31 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4334,6 +4737,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "term"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4838,12 +5250,49 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]]
name = "unicode-security"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50"
dependencies = [
"unicode-normalization",
"unicode-script",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
+3 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.3.36"
version = "0.3.47"
edition = "2021"
[lib]
@@ -38,6 +38,7 @@ uuid = { version = "1", features = ["v4", "serde"], optional = true }
dotenvy = { version = "0.15", optional = true }
anyhow = { version = "1", optional = true }
futures = { version = "0.3", optional = true }
cedar-policy = { version = "4", optional = true }
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# jq-filter evaluation for ResourceSource's optional `jq` field
@@ -96,6 +97,7 @@ ssr = [
"dep:dotenvy",
"dep:anyhow",
"dep:futures",
"dep:cedar-policy",
"dep:tracing",
"dep:tracing-subscriber",
"dep:jaq-core",
@@ -110,16 +112,6 @@ proptest = "1"
name = "jetstream"
required-features = ["ssr"]
[[bin]]
name = "question_lint"
path = "src/bin/question_lint.rs"
required-features = ["ssr"]
[[bin]]
name = "needs_replay"
path = "src/bin/needs_replay.rs"
required-features = ["ssr"]
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
+32 -19
View File
@@ -30,7 +30,7 @@ stream.
## What the engine provides
- **Content-driven pages** (`src/content.rs`, `src/app.rs`): YAML
- **Content-driven pages** (`src/content/`, `src/app.rs`): YAML
loaded from Gitea at boot and hot-swapped on a NATS reload signal;
a bad push keeps the last-good content serving. The `questions/`
tree IS the router — file paths become URLs, `_section.yaml`
@@ -75,17 +75,27 @@ stream.
it - the chain verifiably answered the alternative that leads there,
or it hangs off an earned page by a link that records nothing. A
page behind an answer they have not given is never offered.
- **Business needs as a checkable spec** (`src/needs.rs`,
`question_lint`): a content repo may ship a `needs.yaml` saying who
wants what, which bucket the answer must land in, and which group
has to carry it to a finished state. The lint proves the structure
(a path of submissions within an effort budget, a desk held by that
group, every finished state reachable, no record stranding; a
`planned` need warns instead of failing). Its personas - visitors in
their own words - become typed `choice` tasks for any external
answering engine (`--needs-tasks`, `--skim` for headings only) whose
picks are graded with `--needs-score`: a misroute is a wording
defect. The runtime never reads the file.
- **One access policy** (`src/access.rs`): who may do what to what is
compiled from content on every load into a Cedar policy set - `qualifies`
on a page, `requires_group`/`public` on a resource, a desk's transitions,
a `self_transition` by link and email, an `invite` - and every server
entry point asks that one policy. Decisions carry the rule ids that
granted them; refusals and every mutation are published on
`portal.access.decided`, and other apps ask the same question over
request-reply on `portal.access.<site>.may`. Identity stays Kanidm's.
- **Onboarding as a form** (`src/people.rs`): an alternative with
`invite: {group}` on a gated page creates the person in Kanidm, records
their email, adds them to the group and mints a one-time link to set
their own credentials, through a service account in
`idm_people_on_boarding` (`KANIDM_API_TOKEN`). The result lands in the
`portal_invites` bucket for the inviter's desk and on
`portal.people.invited` for whatever sends the mail.
- **Business needs as a checkable spec** (in
[uhhm/iris](https://project.uhhm.no/uhhm/iris)): a content repo may
ship a `needs.yaml` saying who wants what, which bucket the answer
must land in, and which group has to carry it to a finished state.
`iris check` proves the site meets it. The runtime never reads the
file; portal only provides the content types iris reads.
- **Live resources** (`src/resource.rs`): content can pull a KV
bucket, Gitea starred/org repos/releases, or any public JSON URL
(SSRF fail-closed), reshaped by a content-declared `jq` filter.
@@ -102,17 +112,20 @@ stream.
## Binaries
- `portal` — the server (Leptos SSR + hydrate, Axum underneath).
- `question_lint` — headless content validation, shipped inside every
release artifact so each content repo's CI lints with the exact
portal version its instance runs; also works offline:
`question_lint --path <dir>`.
- `iris` — the lint, from [uhhm/iris](https://project.uhhm.no/uhhm/iris),
which depends on this crate as a library pinned to a release tag and
carries the same version: the publish job here re-pins, tests and tags
iris for every portal release.
Every release tarball ships that `iris` binary
too, so each content repo's CI can
lint with the exact portal version its instance runs:
`iris check --path <dir>`.
## Development
```sh
cargo leptos build # full app (server + wasm)
cargo test --features ssr # engine tests
cargo build --features ssr --bin question_lint
cargo test --features ssr # engine tests; tests/jetstream.rs needs PORTAL_TEST_NATS_URL
```
Runtime configuration is env vars (see `src/main.rs`, and each
@@ -136,7 +149,7 @@ cargo release patch # or minor / major
That bumps `Cargo.toml`, tags `v<version>`, and pushes; CI
(`.gitea/workflows/publish.yml`) reacts to the tag, builds once, and
attaches `portal-v<version>.tar.gz` (`portal` + `question_lint` +
attaches `portal-v<version>.tar.gz` (`portal` + `iris` +
`hash.txt` + `site/`) to the Gitea release. Pkg files are
content-hashed (`hash-files = true`; instances run with
`LEPTOS_HASH_FILES=true`), so a freshly served page can never pair a
+574
View File
@@ -0,0 +1,574 @@
//! The one place a request is allowed or refused.
//!
//! Content already says who may do what: `qualifies` on a page,
//! `requires_group`/`public` on a resource, the transitions a desk
//! offers, a `self_transition` a submitter may fire with their own
//! link, an `invite` a group may extend. Until now those were five
//! checks in five files. Here they are compiled, on every content
//! load, into one Cedar policy set, and every server entry point asks
//! the same question: may this principal take this action on this
//! resource? The answer carries the ids of the rules that granted it,
//! and every decision that matters is published on NATS.
//!
//! The policy text is derived, never hand-written: content is the
//! source, this module is the compiler, iris prints the result as a
//! matrix and validates it against the schema.
#![cfg(feature = "ssr")]
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
RestrictedExpression, Schema, ValidationMode, Validator,
};
use serde::{Deserialize, Serialize};
use crate::aggregates::AggregateSchema;
use crate::content::{path_matches, Question};
/// What every entity and action may look like. The generated policies
/// are validated against this, so a content shape the compiler does
/// not know how to express fails loudly instead of granting nothing.
pub const SCHEMA: &str = r#"
entity Group;
entity User in [Group];
entity Submitter { email: String };
entity Service;
entity Page;
entity Bucket;
entity Feature;
action open, submit appliesTo { principal: [User], resource: [Page] };
action read appliesTo { principal: [User], resource: [Feature] };
action transition appliesTo { principal: [User], resource: [Bucket], context: { from: String, to: String } };
action self_transition appliesTo { principal: [Submitter], resource: [Bucket], context: { from: String, to: String, stored_email: String } };
action automation_read appliesTo { principal: [Service], resource: [Bucket] };
action invite appliesTo { principal: [User], resource: [Group] };
"#;
/// Who is asking.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[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> },
/// Whoever holds one record's own link and knows the email on it.
Submitter { record: String, email: String },
/// A machine with a shared token.
Service { name: String },
}
impl Principal {
pub fn anonymous() -> Self {
Principal::User { sub: "anonymous".into(), groups: Vec::new() }
}
pub fn from_user(user: Option<&crate::auth::User>) -> Self {
match user {
Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() },
None => Principal::anonymous(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Open,
Submit,
Read,
Transition,
SelfTransition,
AutomationRead,
Invite,
}
impl Action {
fn name(self) -> &'static str {
match self {
Action::Open => "open",
Action::Submit => "submit",
Action::Read => "read",
Action::Transition => "transition",
Action::SelfTransition => "self_transition",
Action::AutomationRead => "automation_read",
Action::Invite => "invite",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Resource {
Page { id: String },
Bucket { name: String },
/// One resource block on one alternative of one page. `feature` is
/// the feature name, or `feature|requirement` (`|bind` appended)
/// for a requirement's own resource.
Feature { page: String, alternative: String, feature: String },
Group { name: String },
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Decision {
pub allow: bool,
/// The ids of the rules that granted it (empty on a refusal).
pub reasons: Vec<String>,
}
/// One row of the matrix iris prints: who may do what to what.
#[derive(Clone, Debug, Serialize)]
pub struct Rule {
pub id: String,
pub who: String,
pub action: String,
pub resource: String,
pub when: String,
}
pub struct Policy {
policies: PolicySet,
schema: Schema,
pub rules: Vec<Rule>,
pub text: String,
/// The dynamic pages' pattern ids, so a concrete `/shape/abc`
/// resolves to the policy written for `/shape/[key]`.
patterns: Vec<String>,
}
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
impl Policy {
/// Compiles content into policy. Infallible on valid content: every
/// rule below is a shape the schema declares; the validation in
/// `validate` is what iris runs to prove that stays true.
pub fn from_content(
questions: &HashMap<String, Question>,
aggregates: &HashMap<String, AggregateSchema>,
) -> Self {
let mut text = String::new();
let mut rules = Vec::new();
let mut ids: HashMap<String, usize> = HashMap::new();
// Each rule is parsed on its own under its own id, so a
// decision's reasons name rules, not `policy17`.
let mut sources: Vec<(String, String)> = Vec::new();
let mut emit = |id_base: String, who: String, action: &str, resource: String, when: String, policy: String, text: &mut String, rules: &mut Vec<Rule>| {
let n = ids.entry(id_base.clone()).or_insert(0);
*n += 1;
let id = if *n == 1 { id_base } else { format!("{id_base}#{n}") };
text.push_str(&format!("@id({})\n{policy}\n\n", quote(&id)));
rules.push(Rule { id: id.clone(), who, action: action.to_string(), resource, when });
sources.push((id, policy));
};
let mut pages: Vec<&Question> = questions.values().collect();
pages.sort_by(|a, b| a.id.cmp(&b.id));
for q in &pages {
let page = quote(&q.id);
let (who, principal) = match &q.qualifies {
None => ("anyone".to_string(), "principal".to_string()),
Some(g) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
};
emit(
format!("open:{}", q.id), who, "open, submit", format!("page {}", q.id), String::new(),
format!("permit({principal}, action in [Action::\"open\", Action::\"submit\"], resource == Page::{page});"),
&mut text, &mut rules,
);
for alt in &q.alternatives {
// A feature's own resource, and the resources its
// requirements read (select options, bound fields):
// each is a Feature entity of its own.
let mut specs: Vec<(String, &crate::content::ResourceSpec)> = Vec::new();
for feature in &alt.features {
if let Some(spec) = &feature.resource {
specs.push((format!("{}|{}|{}", q.id, alt.name, feature.name), spec));
}
for req in &feature.requirements {
if let Some(spec) = &req.resource {
specs.push((format!("{}|{}|{}|{}", q.id, alt.name, feature.name, req.name), spec));
}
if let Some(bind) = &req.bind {
specs.push((format!("{}|{}|{}|{}|bind", q.id, alt.name, feature.name, req.name), &bind.resource));
}
}
}
for (fid, spec) in specs {
let (who, principal) = match (&spec.requires_group, spec.public) {
(Some(g), _) => (format!("group {g}"), format!("principal in Group::{}", quote(g))),
(None, true) => ("anyone".to_string(), "principal".to_string()),
// Neither: unreachable by design (fail closed).
(None, false) => continue,
};
emit(
format!("read:{fid}"), who.clone(), "read", format!("feature {fid}"), String::new(),
format!("permit({principal}, action == Action::\"read\", resource == Feature::{});", quote(&fid)),
&mut text, &mut rules,
);
if let (Some(bucket), Some(g)) = (spec.bucket(), &spec.requires_group) {
for t in &spec.transitions {
emit(
format!("move:{bucket}:{}>{}", t.from, t.to), format!("group {g}"), "transition",
format!("bucket {bucket}"), format!("{} -> {}", t.from, t.to),
format!(
"permit(principal in Group::{}, action == Action::\"transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} }};",
quote(g), quote(bucket), quote(&t.from), quote(&t.to)
),
&mut text, &mut rules,
);
}
}
}
if let Some(st) = &alt.self_transition {
let from = crate::answers::OPEN_STATE;
emit(
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
format!(
"permit(principal is Submitter, action == Action::\"self_transition\", resource == Bucket::{}) when {{ context.from == {} && context.to == {} && principal.email == context.stored_email }};",
quote(&st.bucket), quote(from), quote(&st.to)
),
&mut text, &mut rules,
);
}
if let (Some(invite), Some(g)) = (&alt.invite, &q.qualifies) {
emit(
format!("invite:{}", invite.group), format!("group {g}"), "invite", format!("group {}", invite.group), String::new(),
format!("permit(principal in Group::{}, action == Action::\"invite\", resource == Group::{});", quote(g), quote(&invite.group)),
&mut text, &mut rules,
);
}
}
}
// The one machine principal: the automation read endpoint, which
// authenticates by shared token and may read any bucket.
let mut buckets: Vec<&String> = aggregates.keys().collect();
buckets.sort();
emit(
"automation".to_string(), "service automation".to_string(), "automation_read", "any bucket".to_string(), String::new(),
"permit(principal == Service::\"automation\", action == Action::\"automation_read\", resource);".to_string(),
&mut text, &mut rules,
);
let _ = buckets;
let mut policies = PolicySet::new();
for (id, source) in &sources {
let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), source)
.expect("generated policy text always parses");
policies.add(policy).expect("rule ids are unique");
}
let schema = Schema::from_cedarschema_str(SCHEMA).map(|(s, _)| s).expect("the schema is a constant");
let patterns = questions.values().filter(|q| q.is_dynamic()).map(|q| q.id.clone()).collect();
Policy { policies, schema, rules, text, patterns }
}
/// Every generated rule type-checks against the schema. A rule the
/// schema cannot express is a compiler bug, and this is where it
/// shows.
pub fn validate(&self) -> Result<(), String> {
let result = Validator::new(self.schema.clone()).validate(&self.policies, ValidationMode::Strict);
if result.validation_passed() {
Ok(())
} else {
Err(result.validation_errors().map(|e| e.to_string()).collect::<Vec<_>>().join("; "))
}
}
fn page_entity(&self, id: &str) -> String {
if self.patterns.iter().any(|p| p == id) {
return id.to_string();
}
self.patterns
.iter()
.find(|p| path_matches(p, id).is_some())
.cloned()
.unwrap_or_else(|| id.to_string())
}
/// The decision. `context` holds what the action needs: `from`/`to`
/// for a transition, plus `stored_email` for a self transition.
pub fn decide(
&self,
principal: &Principal,
action: Action,
resource: &Resource,
context: serde_json::Value,
) -> Decision {
let uid = |ty: &str, id: &str| {
EntityUid::from_type_name_and_id(EntityTypeName::from_str(ty).expect("static type"), EntityId::new(id))
};
let mut entities: Vec<Entity> = Vec::new();
let principal_uid = match principal {
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()));
}
let me = uid("User", sub);
entities.push(Entity::new_no_attrs(me.clone(), parents));
me
}
Principal::Submitter { record, email } => {
let me = uid("Submitter", record);
let attrs = HashMap::from([("email".to_string(), RestrictedExpression::new_string(email.clone()))]);
entities.push(Entity::new(me.clone(), attrs, HashSet::new()).expect("a string attribute always evaluates"));
me
}
Principal::Service { name } => {
let me = uid("Service", name);
entities.push(Entity::new_no_attrs(me.clone(), HashSet::new()));
me
}
};
let resource_uid = match resource {
Resource::Page { id } => uid("Page", &self.page_entity(id)),
Resource::Bucket { name } => uid("Bucket", name),
Resource::Feature { page, alternative, feature } => {
uid("Feature", &format!("{}|{alternative}|{feature}", self.page_entity(page)))
}
Resource::Group { name } => uid("Group", name),
};
entities.push(Entity::new_no_attrs(resource_uid.clone(), HashSet::new()));
let action_uid = uid("Action", action.name());
let Ok(entities) = Entities::from_entities(entities, None) else {
return Decision::default();
};
// An absent context is an empty one, not a malformed one.
let context = if context.is_object() { context } else { serde_json::json!({}) };
let Ok(context) = Context::from_json_value(context, None) else {
return Decision::default();
};
let Ok(request) = Request::new(principal_uid, action_uid, resource_uid, context, None) else {
return Decision::default();
};
let response = Authorizer::new().is_authorized(&request, &self.policies, &entities);
Decision {
allow: response.decision() == cedar_policy::Decision::Allow,
reasons: response.diagnostics().reason().map(|id| id.to_string()).collect(),
}
}
pub fn allows(&self, principal: &Principal, action: Action, resource: &Resource) -> bool {
self.decide(principal, action, resource, serde_json::json!({})).allow
}
}
/// What gets published on `portal.access.decided`: the question and
/// the answer. Page opens and reads are published only when refused;
/// every mutation is published either way.
#[derive(Clone, Debug, Serialize)]
pub struct Decided<'a> {
pub principal: &'a Principal,
pub action: Action,
pub resource: &'a Resource,
pub context: &'a serde_json::Value,
pub allow: bool,
pub reasons: &'a [String],
pub at_ms: i64,
}
pub const DECIDED_SUBJECT: &str = "portal.access.decided";
pub fn worth_publishing(action: Action, allow: bool) -> bool {
!allow || !matches!(action, Action::Open | Action::Read)
}
/// Decides and, when it matters, publishes. Fire and forget: a NATS
/// hiccup never changes an answer.
pub fn decide_and_publish(
nats: &async_nats::Client,
policy: &Policy,
principal: &Principal,
action: Action,
resource: &Resource,
context: serde_json::Value,
) -> Decision {
let decision = policy.decide(principal, action, resource, context.clone());
if worth_publishing(action, decision.allow) {
let event = Decided {
principal,
action,
resource,
context: &context,
allow: decision.allow,
reasons: &decision.reasons,
at_ms: chrono::Utc::now().timestamp_millis(),
};
if let Ok(bytes) = serde_json::to_vec(&event) {
let nats = nats.clone();
tokio::spawn(async move {
let _ = nats.publish(DECIDED_SUBJECT, bytes.into()).await;
});
}
}
decision
}
/// The request-reply face for other apps: a JSON question in, a
/// decision out. Same policy, same answer as the site gives itself.
#[derive(Debug, Deserialize)]
pub struct Query {
pub principal: Principal,
pub action: Action,
pub resource: Resource,
#[serde(default)]
pub context: serde_json::Value,
}
pub fn subject_for(site: &str) -> String {
format!("portal.access.{site}.may")
}
pub fn policy_subject_for(site: &str) -> String {
format!("portal.access.{site}.policy")
}
/// Answers `portal.access.<site>.may` for as long as the process runs,
/// with the policy as it is at the moment of each question (it is
/// swapped on content reload). A malformed question gets a refusal
/// with the parse error as its reason, never silence.
pub async fn serve(
nats: async_nats::Client,
site: String,
access: std::sync::Arc<arc_swap::ArcSwap<Policy>>,
) {
use futures::StreamExt;
let subject = subject_for(&site);
let mut sub = match nats.subscribe(subject.clone()).await {
Ok(sub) => sub,
Err(e) => {
tracing::error!(error = %e, %subject, "cannot serve access decisions");
return;
}
};
tracing::info!(%subject, "answering access questions");
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),
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());
let _ = nats.publish(reply, bytes.into()).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregates::parse_aggregates_yaml;
use crate::content::build_questions;
use serde_json::json;
fn site() -> Policy {
let files = vec![
("index.yaml".to_string(), "name: Home\nalternatives:\n - name: Apply\n action: /applied\n record_as: applicants\n - name: Unsubscribe\n self_transition: { bucket: subscribers, to: unsubscribed, label: Go }\n".to_string()),
("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()),
("shape/[key].yaml".to_string(), "name: Shape\n".to_string()),
("review/_section.yaml".to_string(), "qualifies: owners\n".to_string()),
("review/index.yaml".to_string(), "name: Review\nalternatives:\n - name: Applicants\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: applicants }\n requires_group: owners\n transitions:\n - { to: invited, label: Invite }\n - { from: invited, to: active, label: Onboard }\n - name: Releases\n resource:\n public: true\n source: { kind: gitea_releases, owner: o, repo: r }\n - name: Invite a colleague\n invite: { group: owners }\n features:\n - name: \"\"\n requirements:\n - { name: name }\n - { name: email, type: email }\n".to_string()),
];
let questions = build_questions(&files).unwrap();
let aggregates = parse_aggregates_yaml("aggregates:\n - bucket: applicants\n initial: open\n states:\n open: { event: a }\n invited: { event: b }\n active: { event: c }\n transitions:\n open: [invited]\n invited: [active]\n").unwrap();
Policy::from_content(&questions, &aggregates)
}
fn user(groups: &[&str]) -> Principal {
Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() }
}
#[test]
fn the_generated_policy_validates_against_the_schema() {
let p = site();
assert_eq!(p.validate(), Ok(()), "{}", p.text);
assert!(p.rules.iter().any(|r| r.id == "move:applicants:open>invited"));
}
#[test]
fn pages_open_to_anyone_unless_qualified() {
let p = site();
let page = |id: &str| Resource::Page { id: id.into() };
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/")));
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/review")));
assert!(!p.allows(&user(&["staff"]), Action::Submit, &page("/review")));
let d = p.decide(&user(&["owners"]), Action::Submit, &page("/review"), json!({}));
assert!(d.allow);
assert_eq!(d.reasons, vec!["open:/review"]);
// A concrete dynamic path resolves to its pattern's rule.
assert!(p.allows(&Principal::anonymous(), Action::Open, &page("/shape/abc")));
assert!(!p.allows(&Principal::anonymous(), Action::Open, &page("/nowhere")));
}
#[test]
fn reads_follow_requires_group_or_public() {
let p = site();
let desk = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "".into() };
let releases = Resource::Feature { page: "/review".into(), alternative: "Applicants".into(), feature: "Releases".into() };
assert!(p.allows(&user(&["owners"]), Action::Read, &desk));
assert!(!p.allows(&user(&["staff"]), Action::Read, &desk));
assert!(p.allows(&Principal::anonymous(), Action::Read, &releases));
}
#[test]
fn transitions_are_exactly_the_desks_buttons() {
let p = site();
let bucket = Resource::Bucket { name: "applicants".into() };
let ctx = |from: &str, to: &str| json!({ "from": from, "to": to });
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
assert!(p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("invited", "active")).allow);
// Right target, wrong starting state: no rule.
assert!(!p.decide(&user(&["owners"]), Action::Transition, &bucket, ctx("open", "active")).allow);
assert!(!p.decide(&user(&["staff"]), Action::Transition, &bucket, ctx("open", "invited")).allow);
}
#[test]
fn a_submitter_moves_their_own_record_only_with_the_matching_email() {
let p = site();
let bucket = Resource::Bucket { name: "subscribers".into() };
let me = Principal::Submitter { record: "r1".into(), email: "a@b.c".into() };
let ctx = |stored: &str| json!({ "from": "open", "to": "unsubscribed", "stored_email": stored });
assert!(p.decide(&me, Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, ctx("x@y.z")).allow);
assert!(!p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "open", "to": "somewhere", "stored_email": "a@b.c" })).allow);
// A signed-in owner has no such rule: the link is the credential.
assert!(!p.decide(&user(&["owners"]), Action::SelfTransition, &bucket, ctx("a@b.c")).allow);
}
#[test]
fn invites_and_automation() {
let p = site();
let owners = Resource::Group { name: "owners".into() };
assert!(p.allows(&user(&["owners"]), Action::Invite, &owners));
assert!(!p.allows(&user(&["staff"]), Action::Invite, &owners));
let bucket = Resource::Bucket { name: "applicants".into() };
assert!(p.allows(&Principal::Service { name: "automation".into() }, Action::AutomationRead, &bucket));
assert!(!p.allows(&Principal::Service { name: "someone".into() }, Action::AutomationRead, &bucket));
assert!(!p.allows(&user(&["owners"]), Action::AutomationRead, &bucket));
}
#[test]
fn a_question_over_nats_is_the_same_question() {
let p = site();
let q: Query = serde_json::from_str(
r#"{"principal":{"kind":"user","sub":"n8n-as-owner","groups":["owners"]},"action":"transition","resource":{"kind":"bucket","name":"applicants"},"context":{"from":"open","to":"invited"}}"#,
)
.unwrap();
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
let q: Query = serde_json::from_str(
r#"{"principal":{"kind":"service","name":"automation"},"action":"automation_read","resource":{"kind":"bucket","name":"applicants"}}"#,
)
.unwrap();
assert!(p.decide(&q.principal, q.action, &q.resource, q.context).allow);
assert_eq!(subject_for("uhhm"), "portal.access.uhhm.may");
}
#[test]
fn only_refusals_and_mutations_are_worth_publishing() {
assert!(!worth_publishing(Action::Open, true));
assert!(worth_publishing(Action::Open, false));
assert!(worth_publishing(Action::Transition, true));
assert!(!worth_publishing(Action::Read, true));
}
}
+80
View File
@@ -36,6 +36,32 @@ pub struct AggregateSchema {
/// rule has an explicit, auditable opt-out instead of a silent
/// one, and so the next reader knows where the answers go.
pub attended_by: Option<String>,
/// The mail a record's arrival in a state sends (see `mail.rs`):
/// declared on the state in aggregates.yaml, next to the event
/// that enters it, so content decides when a person hears
/// something and the mailer never has to know a site.
pub mail_for_state: HashMap<String, MailSpec>,
}
/// A mail to send when a record enters a state. `to` is `submitter`
/// (the record's `email` field, which the lint then requires the
/// forms to collect) or one fixed address. Subject and body are
/// templates: `{field}` for any field of the record, plus `{site}`
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
/// as plain text with an HTML alternative.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailSpec {
pub to: String,
pub subject: String,
pub body: String,
}
impl MailSpec {
pub fn to_submitter(&self) -> bool {
self.to == "submitter"
}
}
impl AggregateSchema {
@@ -43,6 +69,10 @@ impl AggregateSchema {
self.event_for_state.contains_key(state)
}
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
self.mail_for_state.get(state)
}
pub fn allowed(&self, state: &str) -> &[String] {
self.transitions
.get(state)
@@ -54,6 +84,8 @@ impl AggregateSchema {
#[derive(Debug, serde::Deserialize)]
struct RawState {
event: String,
#[serde(default)]
mail: Option<MailSpec>,
}
#[derive(Debug, serde::Deserialize)]
@@ -123,6 +155,22 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
}
}
let mut mail_for_state = HashMap::new();
for (state, raw_state) in raw_schema.states {
if let Some(mail) = raw_state.mail {
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
}
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
mail.to
);
}
mail_for_state.insert(state, mail);
}
}
out.insert(
bucket.clone(),
AggregateSchema {
@@ -132,6 +180,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
state_for_event,
transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
mail_for_state,
},
);
}
@@ -349,6 +398,37 @@ aggregates:
.unwrap()
}
#[test]
fn a_state_may_carry_the_mail_its_arrival_sends() {
let schemas = parse_aggregates_yaml(
r#"
aggregates:
- bucket: reports
initial: open
states:
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
dropped: { event: dropped }
transitions:
open: [fixed, dropped]
"#,
)
.unwrap();
let s = &schemas["reports"];
assert!(s.mail_for("open").unwrap().to_submitter());
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
assert!(s.mail_for("dropped").is_none());
for bad in [
"mail: { to: submitter, subject: \"\", body: b }",
"mail: { to: \"the board\", subject: s, body: b }",
"mail: { to: submitter, subject: s, body: b, cc: x }",
] {
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
}
}
fn event(seq: u64, event_type: &str) -> Event {
Event {
id: uuid::Uuid::new_v4(),
+30 -14
View File
@@ -195,14 +195,6 @@ async fn apply_transition(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
let bucket = resource
.bucket()
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
@@ -232,6 +224,19 @@ async fn apply_transition(
))
})?
.clone();
// The policy holds exactly the buttons this desk offers to this
// group, keyed on (from, to); it is the one check.
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::from_user(Some(user)),
crate::access::Action::Transition,
&crate::access::Resource::Bucket { name: bucket.to_string() },
serde_json::json!({ "from": answer.state, "to": item.to }),
);
if !decision.allow {
return Err(ServerFnError::new("not authorized"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
@@ -263,6 +268,7 @@ async fn apply_transition(
.put(&item.item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
@@ -329,12 +335,21 @@ pub async fn self_transition_answer(
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
let stored_email = answer.responses.get("email").and_then(|v| v.as_str());
if stored_email != Some(email.as_str()) {
return Err(ServerFnError::new("not found"));
}
if answer.state != OPEN_STATE {
return Err(ServerFnError::new("already processed"));
// The link is the credential and the email its second factor; the
// policy compares them and knows the one move a submitter may make.
// Refusal reads "not found" so the endpoint cannot be used to probe
// which records or emails exist.
let stored_email = answer.responses.get("email").and_then(|v| v.as_str()).unwrap_or_default();
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::Submitter { record: item_id.clone(), email: email.clone() },
crate::access::Action::SelfTransition,
&crate::access::Resource::Bucket { name: self_transition.bucket.clone() },
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
);
if !decision.allow {
return Err(ServerFnError::new(if answer.state == OPEN_STATE { "not found" } else { "already processed" }));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
@@ -364,6 +379,7 @@ pub async fn self_transition_answer(
.put(&item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(&state, &self_transition.bucket, &item_id, &self_transition.to, &answer.responses).await;
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
+67 -8
View File
@@ -2045,7 +2045,7 @@ pub async fn list_qualifying_questions(
chain: Option<String>,
) -> Result<Vec<(String, String)>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::{earned_followups, is_qualified, unlocked_by, Question};
use crate::content::{earned_followups, unlocked_by, Question};
use crate::server::AppState;
let state = expect_context::<AppState>();
@@ -2070,9 +2070,14 @@ pub async fn list_qualifying_questions(
// An ended event page is a followup from then on.
let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms);
let earned = earned_followups(&questions, &current, &seeds, &is_outcome);
let access = state.access.load();
let principal = crate::access::Principal::from_user(user.as_ref());
let may_open = |q: &Question| {
access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() })
};
let mut out: Vec<(String, String)> = questions
.values()
.filter(|q| is_qualified(user.as_ref(), q))
.filter(|q| may_open(q))
.filter(|q| !is_outcome(q) || earned.contains(&q.id))
// A live event is announced in the header instead.
.filter(|q| !q.event_live_at(now_ms))
@@ -2089,7 +2094,6 @@ pub async fn list_qualifying_questions(
#[server(endpoint = "list_announcements")]
pub async fn list_announcements() -> Result<Vec<Announcement>, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::is_qualified;
use crate::server::AppState;
let state = expect_context::<AppState>();
@@ -2100,11 +2104,16 @@ pub async fn list_announcements() -> Result<Vec<Announcement>, ServerFnError> {
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let access = state.access.load();
let principal = crate::access::Principal::from_user(user.as_ref());
let mut live: Vec<(i64, Announcement)> = state
.questions
.load()
.values()
.filter(|q| is_qualified(user.as_ref(), q) && q.event_live_at(now_ms))
.filter(|q| {
access.allows(&principal, crate::access::Action::Open, &crate::access::Resource::Page { id: q.id.clone() })
&& q.event_live_at(now_ms)
})
.filter_map(|q| {
let event = q.event.as_ref()?;
let starts = chrono::DateTime::parse_from_rfc3339(&event.starts).ok()?;
@@ -2161,7 +2170,6 @@ pub async fn submit_answer(
use crate::answers::store_answer;
use crate::auth::{User, SESSION_USER_KEY};
use crate::chain::hash_node;
use crate::content::is_qualified;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
use crate::server::AppState;
@@ -2177,7 +2185,16 @@ pub async fn submit_answer(
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if !is_qualified(user.as_ref(), &question) {
let principal = crate::access::Principal::from_user(user.as_ref());
let submit = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&principal,
crate::access::Action::Submit,
&crate::access::Resource::Page { id: question_id.clone() },
serde_json::json!({ "alternative": alternative }),
);
if !submit.allow {
return Err(ServerFnError::new("not authorized for this question"));
}
@@ -2189,10 +2206,47 @@ pub async fn submit_answer(
if alt.disabled {
return Err(ServerFnError::new("this alternative isn't open yet"));
}
let record_as = alt.record_as.clone();
let mut record_as = alt.record_as.clone();
let responses: serde_json::Value = serde_json::from_str(&responses_json)
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// An invite: the policy says whether this group may extend one,
// Kanidm does the onboarding, and what came back (the account, the
// one-time link) is recorded with the answer for the inviter's desk
// and announced for whatever sends the mail.
if let Some(invite) = &alt.invite {
let may = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&principal,
crate::access::Action::Invite,
&crate::access::Resource::Group { name: invite.group.clone() },
serde_json::json!({}),
);
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 invited = kanidm
.invite(&name, &email, &invite.group)
.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());
if let Ok(bytes) = serde_json::to_vec(&invited) {
let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await;
}
crate::mail::on_invite(&state, &invited).await;
}
// `/shape/{curve.key}`: the next page may be chosen by the answer.
let next = match alt.action.as_deref() {
Some(action) => Some(
@@ -2245,6 +2299,11 @@ pub async fn submit_answer(
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
}
}
Ok(SubmitResult { next, chain_hash })
-107
View File
@@ -1,107 +0,0 @@
//! Makes a simulation's cases real: every case becomes an aggregate in
//! its bucket, created and moved by the same code the running site
//! uses - `answers::store_answer` for the submission,
//! `aggregates::transition` for each desk move, against a real
//! JetStream with its event log and its refusal of undeclared moves.
//! Then the buckets are read back by replaying each record's events,
//! and reported per need against `done_when` and `success`.
//!
//! The report half runs on its own (`--report-only`) against any
//! JetStream, which is how the same scorecard is taken from a live
//! site's buckets.
//!
//! Point it at a throwaway NATS, never a site's own: it writes.
#![cfg(feature = "ssr")]
use std::collections::{BTreeMap, HashMap};
use futures::StreamExt;
use portal::events::store::{ensure_stream, load_events};
use portal::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir};
use portal::{aggregates, answers, content, needs};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: needs_replay --path <questions dir> --nats <url> (--cases <cases.jsonl> | --report-only)";
let dir = value("--path").ok_or_else(|| anyhow::anyhow!(usage))?;
let nats_url = value("--nats").ok_or_else(|| anyhow::anyhow!(usage))?;
let cases_path = value("--cases");
if cases_path.is_none() && !argv.iter().any(|a| a == "--report-only") {
anyhow::bail!(usage);
}
let questions = load_from_dir(&dir)?;
let schemas = content::with_builtin_aggregates(load_aggregates_from_dir(&dir)?);
let file = needs::parse(&load_needs_from_dir(&dir)?.ok_or_else(|| anyhow::anyhow!("the repo has no needs.yaml"))?)?;
let js = async_nats::jetstream::new(async_nats::connect(&nats_url).await?);
// The same call the site makes at boot.
ensure_stream(&js).await?;
if let Some(path) = cases_path {
let raw = std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let (mut created, mut moved, mut refused) = (0, 0, 0);
for (n, line) in raw.lines().filter(|l| !l.trim().is_empty()).enumerate() {
let case: serde_json::Value = serde_json::from_str(line)?;
let Some(need) = file.needs.iter().find(|need| case["need"] == need.id.as_str()) else { continue };
let Some(schema) = schemas.get(&need.lands_in) else { continue };
// Only a case that got as far as submitting leaves a record.
let states: Vec<&str> = case["states"].as_array().into_iter().flatten().filter_map(|s| s.as_str()).collect();
if states.is_empty() {
continue;
}
let step = needs::path_for(&questions, need).and_then(|p| p.last().cloned());
let (page, alternative) = step.map(|s| (s.page, s.alternative)).unwrap_or_default();
let id = format!("sim-{}-{}-{n}", case["persona"].as_str().unwrap_or("x"), case["run"]);
let now = chrono::Utc::now().timestamp_millis();
answers::store_answer(&js, &schemas, &need.lands_in, id.clone(), &page, &alternative, &case["record"], now).await?;
created += 1;
for target in &states[1..] {
match aggregates::transition(&js, schema, &id, target, serde_json::json!({"by": "simulation"}), now).await {
Ok(_) => moved += 1,
Err(e) => {
refused += 1;
eprintln!("REFUSED by the engine: {id} -> {target}: {e:?}");
break;
}
}
}
}
println!("{created} aggregate(s) created, {moved} transition(s) accepted, {refused} refused by the engine\n");
}
// Read every bucket back the way the engine knows it: by replaying
// each record's own event log.
let mut by_bucket: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
for bucket in file.needs.iter().map(|n| n.lands_in.clone()).collect::<std::collections::BTreeSet<_>>() {
let (Some(schema), Ok(store)) = (schemas.get(&bucket), js.get_key_value(&bucket).await) else { continue };
let mut keys = store.keys().await?;
while let Some(Ok(id)) = keys.next().await {
let events = load_events(&js, &bucket, &id).await?;
if let Some(record) = aggregates::replay(schema, &id, &events) {
*by_bucket.entry(bucket.clone()).or_default().entry(record.state).or_default() += 1;
}
}
}
println!("{:<18} {:>8} {:>9} {:>9} {}", "bucket", "records", "finished", "success", "by state");
let mut buckets: Vec<_> = by_bucket.iter().collect();
buckets.sort();
for (bucket, states) in buckets {
let needs_here: Vec<&needs::Need> = file.needs.iter().filter(|n| &n.lands_in == bucket).collect();
let is = |pick: fn(&needs::Need) -> &Vec<String>, state: &String| needs_here.iter().any(|n| pick(n).contains(state));
let total: usize = states.values().sum();
let finished: usize = states.iter().filter(|(s, _)| is(|n| &n.done_when, s)).map(|(_, c)| c).sum();
let success: usize = states.iter().filter(|(s, _)| is(|n| &n.success, s)).map(|(_, c)| c).sum();
let pct = |n: usize| if total == 0 { "-".to_string() } else { format!("{:.0}%", n as f64 * 100.0 / total as f64) };
println!(
"{:<18} {:>8} {:>9} {:>9} {}",
bucket,
total,
pct(finished),
pct(success),
states.iter().map(|(s, c)| format!("{s} {c}")).collect::<Vec<_>>().join(", ")
);
}
Ok(())
}
-226
View File
@@ -1,226 +0,0 @@
//! Headless schema-check binary - loads content and `aggregates.yaml`
//! (from a Gitea repo URL or a local directory) and validates them
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
//! do, with no NATS, OIDC, web server, or JetStream connection
//! involved. Built once by portal's own deploy workflow and run
//! directly by `questions`' own CI (same bare-metal runner/host,
//! published to a stable path - no artifact download needed), rather
//! than compiled there - keeps that repo's CI coupling to "run a
//! static binary," not "build a Rust workspace."
#![cfg(feature = "ssr")]
use portal::local::{load_aggregates_from_dir, load_from_dir, load_needs_from_dir, load_site_from_dir};
use portal::{aggregates, content, needs};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let mut repo: Option<String> = None;
let mut branch = "main".to_string();
let mut subdir = "questions".to_string();
let mut path: Option<String> = None;
let mut tasks_out: Option<String> = None;
let mut answers_in: Option<String> = None;
let mut min_accuracy: f64 = 0.0;
let mut skim = false;
let mut sim_out: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--repo" => repo = args.next(),
"--branch" => branch = args.next().unwrap_or(branch),
"--subdir" => subdir = args.next().unwrap_or(subdir),
"--path" => path = args.next(),
"--needs-tasks" => tasks_out = args.next(),
"--skim" => skim = true,
"--needs-sim" => sim_out = args.next(),
"--needs-score" => answers_in = args.next(),
"--min-accuracy" => {
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
}
other => {
eprintln!("unknown argument: {other}");
std::process::exit(2);
}
}
}
let (questions, aggregates_map, site, needs_raw) = if let Some(dir) = path {
(
load_from_dir(&dir)?,
load_aggregates_from_dir(&dir)?,
load_site_from_dir(&dir)?,
load_needs_from_dir(&dir)?,
)
} else if let Some(repo_url) = repo {
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
// load_site_from_gitea already validates; a missing file is
// the default config, same as at portal boot.
let site = content::load_site_from_gitea(&repo_url, &branch).await?;
let needs_raw = content::load_needs_from_gitea(&repo_url, &branch).await?;
(questions, aggregates_map, site, needs_raw)
} else {
eprintln!(
"usage: question-lint (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
);
std::process::exit(2);
};
if let Err(e) = site.validate() {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
match content::validate_questions(&questions, &aggregates_map) {
Ok(()) => {
// The runtime's own event desk: a repo announcing pages
// (Question.event) should read portal_events somewhere, or
// "post what happened" tasks pile up unseen. Warn, don't
// fail - the runtime creates the records either way.
if questions.values().any(|q| q.event.is_some())
&& !content::bucket_is_read(&questions, content::EVENTS_BUCKET)
{
eprintln!(
"WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted",
content::EVENTS_BUCKET
);
}
println!(
"OK: {} question(s), {} aggregate(s) valid",
questions.len(),
aggregates_map.len()
);
check_needs(
needs_raw.as_deref(),
&questions,
&aggregates_map,
tasks_out.as_deref(),
skim,
sim_out.as_deref(),
answers_in.as_deref(),
min_accuracy,
)
}
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
}
}
/// The business-needs pass (see `portal::needs`): structure always,
/// then the wording tasks written out and/or an engine's answers
/// scored, when asked. A repo without `needs.yaml` skips all of it.
fn check_needs(
raw: Option<&str>,
questions: &std::collections::HashMap<String, content::Question>,
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
tasks_out: Option<&str>,
skim: bool,
sim_out: Option<&str>,
answers_in: Option<&str>,
min_accuracy: f64,
) -> anyhow::Result<()> {
let Some(raw) = raw else {
if tasks_out.is_some() || answers_in.is_some() {
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
std::process::exit(1);
}
return Ok(());
};
let file = match needs::parse(raw) {
Ok(file) => file,
Err(e) => {
eprintln!("FAIL: {e}");
std::process::exit(1);
}
};
let findings = needs::check(&file, questions, aggregates_map);
let mut failed = 0;
for finding in &findings {
let level = match finding.level {
needs::Level::Fail => {
failed += 1;
"FAIL"
}
needs::Level::Warn => "WARN",
};
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
}
for need in &file.needs {
if let Some(path) = needs::path_for(questions, need) {
let steps: Vec<String> = path
.iter()
.map(|s| format!("{} [{}]", s.page, s.alternative))
.collect();
println!("need {:?}: {} -> {:?}", need.id, steps.join(" -> "), need.lands_in);
}
}
if failed == 0 {
let planned = file.needs.iter().filter(|n| n.planned).count();
println!(
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
file.needs.len() - planned,
planned,
file.personas.len()
);
}
// The wording pass still runs for the needs that do have a path -
// a closed flow elsewhere is no reason to stop grading the rest.
if let Some(out) = sim_out {
let model = needs::sim_model(&file, questions, aggregates_map);
std::fs::write(out, serde_json::to_string_pretty(&model)?)
.map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
println!("wrote the simulation model to {out}");
}
let tasks = needs::tasks(&file, questions, skim);
if let Some(out) = tasks_out {
let mut lines = String::new();
for task in &tasks {
lines.push_str(&serde_json::to_string(task)?);
lines.push('\n');
}
std::fs::write(out, lines).map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?;
println!("wrote {} wording task(s) to {out}", tasks.len());
}
if let Some(path) = answers_in {
let raw = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
let mut answers = Vec::new();
for (n, line) in raw.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
answers.push(
serde_json::from_str::<needs::Answer>(line)
.map_err(|e| anyhow::anyhow!("{path}:{}: {e}", n + 1))?,
);
}
let score = needs::score(&tasks, &answers, 0.6);
for (id, picked, expected, confidence) in &score.misroutes {
eprintln!(
"MISROUTE: {id}: picked {picked:?}{}, the need is met by {expected:?}",
confidence.map(|c| format!(" ({c:.2})")).unwrap_or_default()
);
}
for (id, confidence) in &score.hesitant {
eprintln!("HESITANT: {id}: right, at {confidence:.2}");
}
for id in &score.unanswered {
eprintln!("UNANSWERED: {id}");
}
println!(
"wording: {}/{} routed right ({:.0}%)",
score.correct,
score.total,
score.accuracy() * 100.0
);
if score.accuracy() < min_accuracy {
eprintln!("FAIL: below --min-accuracy {min_accuracy}");
std::process::exit(1);
}
}
if failed > 0 {
eprintln!("FAIL: {failed} unmet business need finding(s)");
std::process::exit(1);
}
Ok(())
}
+4 -13
View File
@@ -4,19 +4,6 @@
use super::*;
/// Fetches `needs.yaml` from the content repo root - the business's
/// needs spec `question_lint` checks the pages against (see
/// `crate::needs`). Optional: a fetch failure means the repo declares
/// none. The runtime never reads it.
#[cfg(feature = "ssr")]
pub async fn load_needs_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<Option<String>> {
let (owner, repo) = parse_owner_repo(repo_url)?;
let api_base = gitea_api_base(repo_url)?;
Ok(fetch_gitea_file(&api_base, &owner, &repo, branch, "needs.yaml")
.await
.ok())
}
/// Fetches and parses `site.yaml` from the content repo root. A fetch
/// failure (typically 404 - the file is optional) yields the default
/// config; a file that exists but doesn't parse or validate is a real
@@ -203,6 +190,7 @@ pub async fn watch_for_reload(
arc_swap::ArcSwap<std::collections::HashMap<String, crate::aggregates::AggregateSchema>>,
>,
site: std::sync::Arc<arc_swap::ArcSwap<SiteConfig>>,
access: std::sync::Arc<arc_swap::ArcSwap<crate::access::Policy>>,
) {
let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await {
Ok(sub) => sub,
@@ -238,6 +226,9 @@ pub async fn watch_for_reload(
continue;
}
let count = loaded.len();
// The policy is compiled from the content it gates, so
// it swaps in the same breath.
access.store(std::sync::Arc::new(crate::access::Policy::from_content(&loaded, &loaded_aggregates)));
questions.store(std::sync::Arc::new(loaded));
aggregates.store(std::sync::Arc::new(loaded_aggregates));
site.store(std::sync::Arc::new(loaded_site));
+13
View File
@@ -165,6 +165,19 @@ pub async fn automation_kv_handler(
if expected.is_empty() || presented != expected {
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
}
// The token authenticates the automation service; what it may read
// is the policy's to say, like every other principal.
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::Service { name: "automation".to_string() },
crate::access::Action::AutomationRead,
&crate::access::Resource::Bucket { name: bucket.clone() },
serde_json::json!({}),
);
if !decision.allow {
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
}
let store = state
.jetstream
+46 -4
View File
@@ -224,10 +224,12 @@ pub struct Responsible {
pub contact: String,
}
/// Whether `user` may view/submit `question`. `true` when the question
/// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room`
/// in cnats - same synchronous, I/O-free shape, same staleness tradeoff
/// (group membership is fixed at login, not re-checked live).
/// Whether to render `question`'s alternatives or its sign-in gate for
/// `user`. A rendering hint for the browser, which has no policy: the
/// server decides every submit, read, move and listing through
/// `access::Policy`, and this must agree with the `open` rule there
/// (a page is open to anyone unless `qualifies` names a group).
/// Group membership is fixed at login, not re-checked live.
pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool {
match &question.qualifies {
None => true,
@@ -276,6 +278,20 @@ pub struct Alternative {
/// checked against the stored item, not the lookup key.
#[serde(default)]
pub self_transition: Option<SelfTransition>,
/// Submitting this alternative invites a person into a Kanidm
/// group: the form's `name` and `email` become an account, the
/// group membership, and a one-time link to set their own
/// credentials (see `people.rs`). Only on a page gated by
/// `qualifies`: the inviting group is that page's, the invited
/// group is this. The link lands in the runtime's `portal_invites`
/// bucket for the inviter's desk, and on `portal.people.invited`.
#[serde(default)]
pub invite: Option<Invite>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invite {
pub group: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -527,6 +543,32 @@ pub struct SiteConfig {
pub favicon: Option<String>,
#[serde(default)]
pub hero: HeroConfig,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
#[serde(default)]
pub mail: Option<MailConfig>,
}
/// The sender identity, and the one built-in mail's wording.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MailConfig {
/// `Name <address>` or a bare address, on a domain the host's
/// mail server may send for.
pub from: String,
#[serde(default)]
pub reply_to: Option<String>,
/// Rewords the invite mail; the built-in text is in the site's
/// `lang`. `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{site_name}`, `{expires}`.
#[serde(default)]
pub invite: Option<MailTemplate>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MailTemplate {
pub subject: String,
pub body: String,
}
/// What the landing page's hero is. `plain` is just the copy; `module`
+52
View File
@@ -48,6 +48,14 @@ aggregates:
transitions:
announced: [awaiting_summary, summarized]
awaiting_summary: [summarized]
- bucket: portal_invites
attended_by: the inviting group's own desk, or the link in the invite event
initial: open
states:
open: { event: invited }
done: { event: done }
transitions:
open: [done]
";
/// Adds the runtime's own state graphs to a content repo's. Content
@@ -84,6 +92,29 @@ pub fn validate_questions(
questions: &std::collections::HashMap<String, Question>,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
) -> anyhow::Result<()> {
// A mail to the submitter needs an address to go to: every form
// that records into that bucket has to collect a required email.
for (bucket, schema) in aggregates {
let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect();
if submitter_states.is_empty() {
continue;
}
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let collects_email = alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == "email" && r.kind == "email" && !r.optional);
if !collects_email {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email",
question.id, alternative.name, submitter_states
);
}
}
}
}
for question in questions.values() {
if let Some(event) = &question.event {
if question.is_dynamic() {
@@ -145,6 +176,27 @@ pub fn validate_questions(
Some(_) => {}
}
}
if let Some(invite) = &alternative.invite {
if question.qualifies.is_none() {
anyhow::bail!(
"question {:?} alternative {:?}: an invite into {:?} must sit on a page gated by `qualifies` - that group is who may extend it",
question.id, alternative.name, invite.group
);
}
let has = |name: &str| {
alternative
.features
.iter()
.flat_map(|f| &f.requirements)
.any(|r| r.name == name && !r.optional)
};
if !has("name") || !has("email") {
anyhow::bail!(
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
question.id, alternative.name
);
}
}
if let Some(st) = &alternative.self_transition {
if let Some(schema) = aggregates.get(&st.bucket) {
if !schema.has_state(&st.to) {
+9 -6
View File
@@ -13,15 +13,18 @@ pub mod aggregates;
#[cfg(feature = "ssr")]
pub mod announce;
#[cfg(feature = "ssr")]
pub mod needs;
#[cfg(feature = "ssr")]
pub mod local;
#[cfg(feature = "ssr")]
pub mod http;
#[cfg(feature = "ssr")]
pub mod access;
#[cfg(feature = "ssr")]
pub mod people;
#[cfg(feature = "ssr")]
pub mod mail;
#[cfg(feature = "ssr")]
pub mod server;
-100
View File
@@ -1,100 +0,0 @@
//! Loading a content repo from a local checkout - the offline
//! counterparts to the Gitea loaders in `content`, for tools that lint
//! or replay a branch that has not been pushed (question_lint,
//! needs_replay). `dir` is always the pages directory (`questions/`);
//! `site.yaml`, `aggregates.yaml` and `needs.yaml` sit one level up.
#![cfg(feature = "ssr")]
use crate::{aggregates, content};
/// The offline counterpart to `content::load_site_from_gitea` -
/// `site.yaml` lives at the repo root like `aggregates.yaml`, and is
/// just as optional locally: missing means default branding.
pub fn load_site_from_dir(dir: &str) -> anyhow::Result<content::SiteConfig> {
let site_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("site.yaml");
if !site_path.exists() {
return Ok(content::SiteConfig::default());
}
let raw = std::fs::read_to_string(&site_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?;
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display()))
}
/// The offline counterpart to `content::load_aggregates_from_gitea` -
/// `aggregates.yaml` lives at the repo root, one level up from the
/// pages directory `--path` names, so `dir`'s parent is where it's
/// looked for. Missing entirely is not an error here (unlike
/// `--repo` mode, where `aggregates.yaml` is required) - a local
/// checkout being linted may not have one, and every declared
/// transition still gets checked against whatever *is* found; an
/// empty map just means nothing is checked.
pub fn load_aggregates_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
let aggregates_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("aggregates.yaml");
if !aggregates_path.exists() {
return Ok(std::collections::HashMap::new());
}
let raw = std::fs::read_to_string(&aggregates_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
aggregates::parse_aggregates_yaml(&raw)
}
/// The offline counterpart to `content::load_questions_from_gitea` -
/// the same recursive tree walk and `content::build_questions`
/// pipeline (derived ids, relative refs, sections, followup
/// inference), just reading a local checkout instead of Gitea's API,
/// for linting a branch that hasn't been pushed yet.
pub fn load_from_dir(
dir: &str,
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
let base = std::path::Path::new(dir);
let mut files = Vec::new();
collect_yaml(base, base, &mut files)?;
content::build_questions(&files)
}
fn collect_yaml(
base: &std::path::Path,
dir: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_yaml(base, &path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("yaml") {
let rel = path
.strip_prefix(base)
.expect("walked paths sit under their base")
.to_string_lossy()
.replace('\\', "/");
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
out.push((rel, raw));
}
}
Ok(())
}
/// The offline counterpart to `content::load_needs_from_gitea` -
/// `needs.yaml` sits at the repo root beside `aggregates.yaml`.
pub fn load_needs_from_dir(dir: &str) -> anyhow::Result<Option<String>> {
let needs_path = std::path::Path::new(dir)
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("needs.yaml");
if !needs_path.exists() {
return Ok(None);
}
std::fs::read_to_string(&needs_path)
.map(Some)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", needs_path.display()))
}
+284
View File
@@ -0,0 +1,284 @@
//! Portal's side of the mail gate. Content decides when a person
//! hears something (`mail:` on a state in aggregates.yaml, and the
//! built-in invite mail); portal renders the message the moment a
//! record enters that state and publishes it on one JetStream subject.
//! gdo (uhhm/gdo) holds a durable consumer on that subject and hands
//! each message to the host's SMTP. Portal never talks SMTP, gdo never
//! reads content, and a message survives either of them being down.
//!
//! The message is the contract between the two, `Outgoing` below,
//! versioned by `v`. Its id is the JetStream message id, so a
//! transition that fires twice (a retry, a reseed) sends once.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// The stream both sides create on boot, with the same config, so
/// whichever starts first owns nothing the other lacks.
pub const STREAM: &str = "mail";
pub const SUBJECTS: &str = "portal.mail.>";
pub const SUBJECT: &str = "portal.mail.send";
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
/// know rather than guessing.
pub const CONTRACT: u8 = 1;
/// One mail, fully rendered: everything gdo needs and nothing it has
/// to look up.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Outgoing {
pub v: u8,
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
/// the dedup key.
pub id: String,
pub site: String,
pub from: String,
#[serde(default)]
pub reply_to: Option<String>,
pub to: String,
pub subject: String,
pub text: String,
pub html: String,
}
/// Idempotent, called by portal at boot and by gdo. A week of
/// retention: a mail nobody delivered in a week is stale, not owed.
/// The duplicate window has to fit inside it.
pub async fn ensure_stream(js: &async_nats::jetstream::Context) -> anyhow::Result<async_nats::jetstream::stream::Stream> {
Ok(js
.get_or_create_stream(async_nats::jetstream::stream::Config {
name: STREAM.to_string(),
subjects: vec![SUBJECTS.to_string()],
max_age: Duration::from_secs(7 * 24 * 3600),
duplicate_window: Duration::from_secs(24 * 3600),
..Default::default()
})
.await?)
}
/// `{key}` in a template becomes the variable's value, an unknown key
/// becomes nothing, and a brace that opens no key stays a brace.
pub fn render(template: &str, vars: &serde_json::Map<String, serde_json::Value>) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
let key_len = after
.char_indices()
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if key_len > 0 && after[key_len..].starts_with('}') {
let key = &after[..key_len];
match vars.get(key) {
Some(serde_json::Value::String(s)) => out.push_str(s),
Some(serde_json::Value::Null) | None => {}
Some(other) => out.push_str(&other.to_string()),
}
rest = &after[key_len + 1..];
} else {
out.push('{');
rest = after;
}
}
out.push_str(rest);
out
}
/// The HTML alternative: the markdown body as a single column with
/// inline styles, which is as much as mail clients agree on. Raw HTML
/// in the body is dropped, the same rule as page descriptions.
pub fn to_html(markdown: &str) -> String {
use pulldown_cmark::{html, Event, Options, Parser};
let parser = Parser::new_ext(markdown, Options::empty()).filter(|e| !matches!(e, Event::Html(_) | Event::InlineHtml(_)));
let mut body = String::new();
html::push_html(&mut body, parser);
format!(
"<!doctype html><html><body style=\"margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif\"><div style=\"max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em\">{body}</div></body></html>"
)
}
fn public_url() -> String {
std::env::var("PUBLIC_URL").unwrap_or_default().trim_end_matches('/').to_string()
}
fn site_name(site: &crate::content::SiteConfig) -> String {
site.title.clone().unwrap_or_else(|| option_env!("SITE_NAME").unwrap_or("portal").to_string())
}
/// The variables a state mail may use: every field of the record,
/// then the runtime's own.
pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
let mut vars = serde_json::Map::new();
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
vars.insert(k.clone(), match v {
serde_json::Value::String(s) => serde_json::Value::String(s.clone()),
serde_json::Value::Null => serde_json::Value::String(String::new()),
other => serde_json::Value::String(other.to_string()),
});
}
}
let email = responses.get("email").and_then(|v| v.as_str()).unwrap_or_default().trim().to_string();
vars.insert("email".into(), email.into());
vars.insert("site".into(), public_url().into());
vars.insert("chain".into(), id.into());
vars.insert("bucket".into(), bucket.into());
vars.insert("state".into(), state.into());
vars
}
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-Msg-Id", mail.id.as_str());
let bytes = serde_json::to_vec(mail)?;
js.publish_with_headers(SUBJECT, headers, bytes.into()).await?.await?;
Ok(())
}
/// A record entered `state` in `bucket`: send what the schema says,
/// if anything. Best-effort by design - the transition is already
/// committed, and a mail that cannot be rendered is logged, never a
/// failed request.
pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value) {
let Some(spec) = state.aggregates.load().get(bucket).and_then(|s| s.mail_for(to_state)).cloned() else {
return;
};
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return;
};
let vars = vars_for(bucket, id, to_state, responses);
let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
Some(e) => e.to_string(),
None => {
tracing::warn!(bucket, id, to_state, "mail to the submitter, but the record carries no email - not sent");
return;
}
}
} else {
spec.to.clone()
};
let text = render(&spec.body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("{bucket}:{id}:{to_state}"),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to,
subject: render(&spec.subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(bucket, id, to_state, "publishing the mail failed: {e}");
}
}
/// The invite mail: the one-time link to the person just onboarded.
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{expires}`.
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(user = %invited.username, "invite made, but site.yaml has no `mail.from` - the link is on the desk only");
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
(None, true, true) => (
"Du er invitert inn i {group} på {site_name}".to_string(),
"Hei {name},\n\nDu har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}.".to_string(),
),
(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(),
),
(None, true, false) => (
"You are invited into {group} at {site_name}".to_string(),
"Hello {name},\n\nYou have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}.".to_string(),
),
(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(),
),
};
let now_ms = chrono::Utc::now().timestamp_millis();
let expires = invited
.link_expires_ms
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default();
let mut vars = serde_json::Map::new();
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("email".into(), invited.email.clone().into());
vars.insert("group".into(), invited.group.clone().into());
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
vars.insert("site".into(), public_url().into());
vars.insert("site_name".into(), site_name(&site).into());
vars.insert("expires".into(), expires.into());
let text = render(&body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("invite:{}:{}:{now_ms}", invited.username, invited.group),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to: invited.email.clone(),
subject: render(&subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(user = %invited.username, "publishing the invite mail failed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
let vars = vars_for("reports", "abc", "fixed", &responses);
assert_eq!(render("Hi {name}, {count} at {site}/decide?chain={chain}&email={email}", &vars), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no");
assert_eq!(render("{unknown} {hva} {not a key} {", &vars), " {not a key} {");
assert_eq!(vars["bucket"], "reports");
assert_eq!(vars["state"], "fixed");
}
#[test]
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
assert!(html.contains("<strong>there</strong>"));
assert!(html.contains("<a href=\"https://x.no\">link</a>"));
assert!(!html.contains("<script>"));
assert!(html.starts_with("<!doctype html>"));
}
#[test]
fn the_contract_round_trips() {
let mail = Outgoing {
v: CONTRACT,
id: "reports:abc:fixed".into(),
site: "Tomter Vel".into(),
from: "Tomter Vel <vel@example.no>".into(),
reply_to: None,
to: "kari@x.no".into(),
subject: "Fixed".into(),
text: "Done.".into(),
html: to_html("Done."),
};
let back: Outgoing = serde_json::from_slice(&serde_json::to_vec(&mail).unwrap()).unwrap();
assert_eq!(back, mail);
}
}
+25
View File
@@ -37,6 +37,7 @@ async fn main() -> anyhow::Result<()> {
content::validate_questions(&questions, &aggregates)?;
let site = content::load_site_from_gitea(&content_repo, &content_branch).await?;
tracing::info!(count = questions.len(), repo = %content_repo, branch = %content_branch, "loaded content");
let access = Arc::new(arc_swap::ArcSwap::from_pointee(portal::access::Policy::from_content(&questions, &aggregates)));
let questions = Arc::new(arc_swap::ArcSwap::from_pointee(questions));
let aggregates = Arc::new(arc_swap::ArcSwap::from_pointee(aggregates));
let site = Arc::new(arc_swap::ArcSwap::from_pointee(site));
@@ -59,6 +60,14 @@ async fn main() -> anyhow::Result<()> {
// into, and `answers::store_answer` creates it on first use.
let jetstream = async_nats::jetstream::new(nats.clone());
portal::events::store::ensure_stream(&jetstream).await?;
// Outgoing mail waits here for gdo; a mail is never lost to gdo
// being down, and never sent by portal itself.
// Best-effort: a stream that already owns these subjects under an
// older name (a rename mid-upgrade) must not keep a site from
// booting; mail waits until the old stream is removed.
if let Err(e) = portal::mail::ensure_stream(&jetstream).await {
tracing::warn!("the mail stream could not be created: {e} - outgoing mail is off until a stream owns portal.mail.>");
}
let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
@@ -75,7 +84,10 @@ async fn main() -> anyhow::Result<()> {
questions.clone(),
aggregates.clone(),
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(),
@@ -84,6 +96,7 @@ async fn main() -> anyhow::Result<()> {
questions,
aggregates,
site,
access,
gitea_base,
content_raw_base,
content_branch: content_branch.clone(),
@@ -207,3 +220,15 @@ fn main() {
// The browser build is a cdylib; this stub only exists so `cargo check`
// without features still succeeds.
}
#[cfg(feature = "ssr")]
/// The site's name in NATS subjects (`portal.access.<site>.may`):
/// `SITE_NAME`, folded to what a subject token allows.
fn site_name_for_subjects() -> String {
let raw = std::env::var("SITE_NAME").unwrap_or_else(|_| "portal".to_string());
let folded: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c.to_ascii_lowercase() } else { '_' })
.collect();
if folded.is_empty() { "portal".to_string() } else { folded }
}
-969
View File
@@ -1,969 +0,0 @@
//! A business's needs, stated apart from how its pages word them.
//!
//! A content repo may ship a `needs.yaml` at its root: each *need* says
//! who wants what, where they enter, which bucket the answer must land
//! in, and which group has to be able to carry it to a finished state.
//! Two things are checked against it:
//!
//! - **Structure** (`check`): deterministic. A path of submissions
//! leads from the entry page to the bucket, within an effort budget;
//! the handling group has a desk over that bucket; every finished
//! state is reachable through the moves that desk offers, and no
//! record strands on the way.
//! - **Wording** (`tasks` / `score`): each *persona* is a visitor in
//! their own words. Every page on their path becomes one typed
//! `choice` question for an external answering engine - the page's
//! question, its alternatives as the options - and the engine's picks
//! are scored against the alternatives that actually meet the need.
//! A misroute is a wording defect: the page said one thing and the
//! visitor heard another.
//!
//! The engine is deliberately outside this crate: tasks go out as
//! JSONL, answers come back as JSONL, so any classifier or LLM can sit
//! in between and be compared on the same tasks.
#![cfg(feature = "ssr")]
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use serde::{Deserialize, Serialize};
use crate::aggregates::AggregateSchema;
use crate::content::{action_target_id, Alternative, Question};
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeedsFile {
#[serde(default)]
pub needs: Vec<Need>,
#[serde(default)]
pub personas: Vec<Persona>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Need {
pub id: String,
/// The kind of visitor - prose, for the reader of the report.
pub who: String,
pub wants: String,
/// The page they arrive on.
#[serde(default = "root")]
pub enters: String,
/// The group they hold when they arrive; unset means anonymous.
#[serde(default)]
pub enters_as: Option<String>,
/// The alternative on the entry page that serves this need, when
/// several record into the same bucket.
#[serde(default)]
pub via: Option<String>,
/// The bucket their answer must be recorded into.
pub lands_in: String,
/// The group that must be able to see the record and move it.
pub handled_by: String,
/// The states that count as handled.
pub done_when: Vec<String>,
/// Submissions the visitor may be asked for before the record
/// exists.
#[serde(default = "default_max_steps")]
pub max_steps: usize,
/// Required fields across the whole path - the onboarding effort
/// budget.
#[serde(default)]
pub max_fields: Option<usize>,
/// Declared, and knowingly not open yet: what is missing reports
/// as a warning, so the gap stays visible without failing CI.
#[serde(default)]
pub planned: bool,
/// The finished states that are a good outcome for the business -
/// a subset of `done_when`. "lost" is finished; "won" is success.
/// What a simulation or a live bucket report counts toward.
#[serde(default)]
pub success: Vec<String>,
/// Every stage the business said a case goes through, in its own
/// order, first to last. Written from how the business describes
/// its work, before any state machine exists, so that a machine
/// which quietly skips a stage ("we go and look, then we quote"
/// becoming open -> quoted) fails instead of passing.
#[serde(default)]
pub stages: Vec<String>,
/// Other groups whose desks carry some of this need's moves: a
/// site manager closing what a project manager opened. Their
/// buttons count toward reaching `done_when` exactly as
/// `handled_by`'s do. `handled_by` must still hold a desk of its
/// own - someone has to be answerable for the record.
#[serde(default)]
pub also_moved_by: Vec<String>,
}
fn root() -> String {
"/".to_string()
}
fn default_max_steps() -> usize {
2
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Persona {
pub id: String,
pub need: String,
/// The visitor's situation in their own words - never the page's.
pub text: String,
}
pub fn parse(raw: &str) -> anyhow::Result<NeedsFile> {
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing needs.yaml: {e}"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Fail,
Warn,
}
#[derive(Debug)]
pub struct Finding {
pub need: String,
pub level: Level,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Step {
pub page: String,
pub alternative: String,
}
/// One alternative on a page that still meets the need, with the
/// cheapest way on from it.
struct Viable {
alternative: String,
path: Vec<Step>,
fields: usize,
records_with_action: Option<bool>,
}
type Questions = HashMap<String, Question>;
fn visible(q: &Question, as_group: Option<&str>) -> bool {
match &q.qualifies {
None => true,
Some(group) => as_group == Some(group.as_str()),
}
}
fn required_fields(alt: &Alternative) -> usize {
alt.features
.iter()
.flat_map(|f| &f.requirements)
.filter(|r| !r.optional)
.count()
}
/// The alternatives on `page` from which the need is still met within
/// `remaining` submissions.
fn viable(
questions: &Questions,
need: &Need,
page: &Question,
remaining: usize,
entry: bool,
include_disabled: bool,
trail: &mut Vec<String>,
) -> Vec<Viable> {
let mut out = Vec::new();
if remaining == 0 {
return out;
}
for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) {
if entry && need.via.as_deref().is_some_and(|via| via != alt.name) {
continue;
}
let step = Step {
page: page.id.clone(),
alternative: alt.name.clone(),
};
let fields = required_fields(alt);
if alt.record_as.as_deref() == Some(need.lands_in.as_str()) {
out.push(Viable {
alternative: alt.name.clone(),
path: vec![step],
fields,
records_with_action: Some(alt.action.is_some()),
});
continue;
}
let Some(target) = alt
.action
.as_deref()
.and_then(|a| action_target_id(questions, a))
.and_then(|id| questions.get(&id))
else {
continue;
};
if trail.contains(&target.id) || !visible(target, need.enters_as.as_deref()) {
continue;
}
trail.push(target.id.clone());
let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail);
trail.pop();
if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) {
let mut path = vec![step];
path.extend(best.path);
out.push(Viable {
alternative: alt.name.clone(),
path,
fields: fields + best.fields,
records_with_action: best.records_with_action,
});
}
}
out
}
fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option<Vec<Viable>> {
let page = questions.get(&need.enters)?;
let mut trail = vec![page.id.clone()];
Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail))
}
/// The cheapest path that meets the need, if any.
pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
entry_viable(questions, need, false)?
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
}
struct Desk {
page: String,
group: Option<String>,
/// (from, to, button label)
moves: Vec<(String, String, String)>,
}
fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
let mut desks = Vec::new();
for q in questions.values() {
for resource in q
.alternatives
.iter()
.flat_map(|a| &a.features)
.filter_map(|f| f.resource.as_ref())
.filter(|r| r.bucket() == Some(bucket))
{
desks.push(Desk {
page: q.id.clone(),
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
moves: resource
.transitions
.iter()
.map(|t| (t.from.clone(), t.to.clone(), t.label.clone()))
.collect(),
});
}
}
desks.sort_by(|a, b| a.page.cmp(&b.page));
desks
}
/// Self-service moves into `bucket` - a visitor holding their own
/// record's link, no group involved (unsubscribe).
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> {
questions
.values()
.flat_map(|q| &q.alternatives)
.filter_map(|a| a.self_transition.as_ref())
.filter(|s| s.bucket == bucket)
.map(|s| s.to.clone())
.collect()
}
/// Structural check of every need. `Fail` findings mean the business
/// need is not met by the content as written.
pub fn check(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> Vec<Finding> {
let mut findings = Vec::new();
let mut seen = HashSet::new();
for need in &file.needs {
let mut report = |level: Level, message: String| {
findings.push(Finding {
need: need.id.clone(),
level: if need.planned { Level::Warn } else { level },
message: if need.planned && level == Level::Fail {
format!("planned, not met yet: {message}")
} else {
message
},
})
};
if !seen.insert(need.id.as_str()) {
report(Level::Fail, "declared more than once".to_string());
continue;
}
// The visitor's side: can they get their answer recorded?
match questions.get(&need.enters) {
None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)),
Some(page) if !visible(page, need.enters_as.as_deref()) => report(
Level::Fail,
format!(
"entry page {:?} is gated on {:?}, which this visitor does not hold",
need.enters, page.qualifies
),
),
Some(page) => {
if page.responsible.is_none() {
report(
Level::Warn,
format!("entry page {:?} names no one responsible to turn to", need.enters),
);
}
let best = entry_viable(questions, need, false)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields));
match best {
None if entry_viable(questions, need, true).is_some_and(|v| !v.is_empty()) => {
let blocked = entry_viable(questions, need, true)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
.unwrap_or_default();
let at = blocked
.iter()
.find(|s| {
questions[&s.page]
.alternatives
.iter()
.any(|a| a.name == s.alternative && a.disabled)
})
.map(|s| format!("{:?} on {}", s.alternative, s.page))
.unwrap_or_default();
report(
Level::Fail,
format!(
"the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused"
),
)
}
None => report(
Level::Fail,
format!(
"no path from {:?}{} records into {:?} within {} submission(s)",
need.enters,
need.via
.as_ref()
.map(|v| format!(" via {v:?}"))
.unwrap_or_default(),
need.lands_in,
need.max_steps
),
),
Some(best) => {
if let Some(budget) = need.max_fields {
if best.fields > budget {
report(
Level::Fail,
format!(
"the path asks for {} required field(s), over the budget of {budget}",
best.fields
),
);
}
}
if best.records_with_action == Some(false) {
report(
Level::Warn,
"the recording alternative has no `action` - the visitor is never told what happens next".to_string(),
);
}
}
}
}
}
// The business's side: can the handling group finish it?
let Some(schema) = aggregates.get(&need.lands_in) else {
report(
Level::Fail,
format!("aggregates.yaml declares no state machine for {:?}", need.lands_in),
);
continue;
};
let desks = desks_over(questions, &need.lands_in);
let own: Vec<&Desk> = desks
.iter()
.filter(|d| {
d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g))
})
.collect();
if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) {
let others: Vec<String> = desks
.iter()
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
.collect();
report(
Level::Fail,
format!(
"no desk gated on {:?} reads {:?}{}",
need.handled_by,
need.lands_in,
if others.is_empty() {
String::new()
} else {
format!(" - it is read by {}", others.join(", "))
}
),
);
continue;
}
let selfs = self_moves(questions, &need.lands_in);
let exits = |state: &str| -> Vec<String> {
let allowed = schema.allowed(state);
let mut to: Vec<String> = own
.iter()
.flat_map(|d| &d.moves)
.filter(|(from, to, _)| from == state && allowed.contains(to))
.map(|(_, to, _)| to.clone())
.collect();
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
to
};
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
// Reachability walks the whole machine (a finished state may
// lead on to another: subscribed, then unsubscribed). Stranding
// only counts on the way to the first finished state - what
// happens to a handled record afterwards is not this need.
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
let mut stranded = Vec::new();
while let Some((state, past_done)) = queue.pop_front() {
let past_done = past_done || done.contains(state.as_str());
let next = exits(&state);
if next.is_empty() && !past_done {
stranded.push(state.clone());
}
for to in next {
if reached.insert(to.clone()) {
queue.push_back((to, past_done));
}
}
}
for state in &need.done_when {
if !schema.has_state(state) {
report(
Level::Fail,
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
);
} else if !reached.contains(state) {
report(
Level::Fail,
format!(
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves",
need.handled_by
),
);
}
}
for stage in &need.stages {
if !schema.has_state(stage) {
report(
Level::Fail,
format!(
"the business describes a stage {stage:?} that {:?} has no state for",
need.lands_in
),
);
} else if !reached.contains(stage) {
report(
Level::Fail,
format!(
"stage {stage:?} exists but {:?} is offered no moves that reach it",
need.handled_by
),
);
}
}
for state in &need.success {
if !need.done_when.contains(state) {
report(
Level::Fail,
format!("success names {state:?}, which is not one of done_when"),
);
}
}
stranded.sort();
for state in stranded {
report(
Level::Fail,
format!(
"records strand in {state:?}: {:?} is offered no move out of it",
need.handled_by
),
);
}
}
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
for persona in &file.personas {
if !need_ids.contains(persona.need.as_str()) {
findings.push(Finding {
need: persona.need.clone(),
level: Level::Fail,
message: format!("persona {:?} points at a need that is not declared", persona.id),
});
}
}
findings
}
/// Everything a simulation of the business needs, resolved: each
/// need's path with the choice on every page and the form that gets
/// filled, each bucket's state machine with the buttons every desk
/// offers, and the personas. Portal resolves it because only portal
/// loads content the way the runtime does (derived ids, relative
/// refs, sections); whoever simulates reads plain JSON.
pub fn sim_model(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> serde_json::Value {
use serde_json::json;
let mut needs = Vec::new();
let mut buckets = serde_json::Map::new();
for need in &file.needs {
let Some(path) = path_for(questions, need) else {
continue;
};
let steps: Vec<serde_json::Value> = path
.iter()
.enumerate()
.map(|(i, step)| {
let page = &questions[&step.page];
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> =
viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let fields: Vec<serde_json::Value> = page
.alternatives
.iter()
.find(|a| a.name == step.alternative)
.into_iter()
.flat_map(|a| &a.features)
.flat_map(|f| &f.requirements)
.map(|r| {
json!({
"name": r.name,
"label": r.label.clone().unwrap_or_else(|| r.name.clone()),
"type": r.kind,
"optional": r.optional,
"options": r.options,
})
})
.collect();
json!({
"page": step.page,
"question": page.name,
"options": page.alternatives.iter().filter(|a| !a.disabled).map(|a| json!({
"name": a.name,
"description": plain(&a.description),
})).collect::<Vec<_>>(),
"expect": expect,
"alternative": step.alternative,
"fields": fields,
})
})
.collect();
needs.push(json!({
"id": need.id,
"who": need.who,
"wants": need.wants,
"enters_as": need.enters_as,
"lands_in": need.lands_in,
"handled_by": need.handled_by,
"done_when": need.done_when,
"success": need.success,
"steps": steps,
}));
if let Some(schema) = aggregates.get(&need.lands_in) {
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
json!({
"initial": schema.initial,
"transitions": schema.transitions,
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
"page": d.page,
"group": d.group,
"moves": d.moves.iter().map(|(from, to, label)| json!({
"from": from, "to": to, "label": label,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"self_moves": self_moves(questions, &need.lands_in),
})
});
}
}
json!({
"needs": needs,
"buckets": buckets,
"personas": file.personas.iter().map(|p| json!({
"id": p.id, "need": p.need, "text": plain(&p.text),
})).collect::<Vec<_>>(),
})
}
/// One decision for the answering engine - the shape is laya's
/// `predict(state, questions)` input, so an adapter is a loop and
/// nothing more; any other engine reads the same fields.
#[derive(Debug, Serialize)]
pub struct Task {
pub id: String,
pub persona: String,
pub need: String,
pub page: String,
pub state: BTreeMap<&'static str, String>,
pub questions: BTreeMap<&'static str, ChoiceQuestion>,
/// Every alternative that still meets the need from here. The
/// engine never sees this.
pub expect: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ChoiceQuestion {
#[serde(rename = "type")]
pub kind: &'static str,
pub instructions: String,
pub criteria: BTreeMap<String, String>,
}
fn plain(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Every (persona, page-on-their-path) pair with a real choice on it.
/// `skim` shows the engine only each alternative's heading - what a
/// visitor who never reads the description has to go on.
pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
let mut out = Vec::new();
for persona in &file.personas {
let Some(need) = file.needs.iter().find(|n| n.id == persona.need) else {
continue;
};
let Some(path) = path_for(questions, need) else {
continue;
};
for (i, step) in path.iter().enumerate() {
let page = &questions[&step.page];
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled).collect();
if open.len() < 2 {
continue;
}
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let criteria = open
.iter()
.map(|a| {
let description = plain(&a.description);
(
a.name.clone(),
if skim || description.is_empty() { a.name.clone() } else { description },
)
})
.collect();
out.push(Task {
id: format!("{}@{}", persona.id, step.page),
persona: persona.id.clone(),
need: need.id.clone(),
page: step.page.clone(),
state: BTreeMap::from([("body", plain(&persona.text))]),
questions: BTreeMap::from([(
"pick",
ChoiceQuestion {
kind: "choice",
instructions: format!(
"A visitor reads the page question {:?}. Which alternative do they choose?",
page.name
),
criteria,
},
)]),
expect,
});
}
}
out
}
#[derive(Debug, Deserialize)]
pub struct Answer {
pub id: String,
pub choice: String,
#[serde(default)]
pub confidence: Option<f64>,
}
#[derive(Debug, Default)]
pub struct Score {
pub total: usize,
pub correct: usize,
/// (task id, picked, expected, confidence)
pub misroutes: Vec<(String, String, Vec<String>, Option<f64>)>,
/// Right, but the engine was under `hesitant_below` - wording that
/// works and is still close to a coin flip.
pub hesitant: Vec<(String, f64)>,
pub unanswered: Vec<String>,
}
impl Score {
pub fn accuracy(&self) -> f64 {
if self.total == 0 {
1.0
} else {
self.correct as f64 / self.total as f64
}
}
}
pub fn score(tasks: &[Task], answers: &[Answer], hesitant_below: f64) -> Score {
let by_id: HashMap<&str, &Answer> = answers.iter().map(|a| (a.id.as_str(), a)).collect();
let mut score = Score {
total: tasks.len(),
..Score::default()
};
for task in tasks {
match by_id.get(task.id.as_str()) {
None => score.unanswered.push(task.id.clone()),
Some(answer) if task.expect.contains(&answer.choice) => {
score.correct += 1;
if let Some(c) = answer.confidence.filter(|c| *c < hesitant_below) {
score.hesitant.push((task.id.clone(), c));
}
}
Some(answer) => score.misroutes.push((
task.id.clone(),
answer.choice.clone(),
task.expect.clone(),
answer.confidence,
)),
}
}
score
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregates::parse_aggregates_yaml;
use crate::content::build_questions;
fn site(desk_moves: &str) -> (Questions, HashMap<String, AggregateSchema>) {
let files = vec![
(
"index.yaml".to_string(),
"name: What are you here for?\nresponsible: { name: A, contact: a@b.c }\nalternatives:\n - name: Hire us\n description: Bring a problem.\n action: /thanks\n record_as: projects\n features:\n - name: Brief\n requirements:\n - name: email\n type: email\n - name: brief\n type: textarea\n - name: budget\n optional: true\n - name: Follow along\n description: Notes by mail.\n action: /thanks\n record_as: subscribers\n".to_string(),
),
("thanks.yaml".to_string(), "name: Thanks\nfollowup: true\n".to_string()),
(
"desk/index.yaml".to_string(),
format!("name: Desk\nqualifies: owners\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: {{ kind: kv, bucket: projects }}\n requires_group: owners\n transitions:\n{desk_moves}"),
),
];
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: projects\n initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: { event: subscribed }\n transitions: {}\n",
)
.unwrap();
(build_questions(&files).unwrap(), aggregates)
}
const FULL_DESK: &str = " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n";
fn needs(extra: &str) -> NeedsFile {
parse(&format!(
"needs:\n - id: hire\n who: a client\n wants: work done\n lands_in: projects\n handled_by: owners\n done_when: [won, lost]\n{extra}personas:\n - id: founder\n need: hire\n text: I need someone to build our booking tool.\n"
))
.unwrap()
}
fn fails(findings: &[Finding]) -> Vec<&str> {
findings
.iter()
.filter(|f| f.level == Level::Fail)
.map(|f| f.message.as_str())
.collect()
}
#[test]
fn a_met_need_is_silent() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn effort_budget_counts_required_fields_only() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates);
assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]);
}
#[test]
fn a_desk_missing_a_move_strands_records_and_cannot_finish() {
// The desk can quote, but never close a quoted job.
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec![
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves",
"records strand in \"quoted\": \"owners\" is offered no move out of it",
]
);
}
#[test]
fn a_finished_state_may_lead_on_to_another() {
// quoted counts as handled here, yet won must still be
// reachable past it; lost-after-quoted is not this need's
// business, so nothing strands.
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].done_when = vec!["quoted".to_string(), "won".to_string(), "lost".to_string()];
let findings = check(&file, &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn a_disabled_alternative_is_named_as_the_reason() {
let (mut questions, aggregates) = site(FULL_DESK);
questions.get_mut("/").unwrap().alternatives[0].disabled = true;
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["the path exists but is closed: \"Hire us\" on / is `disabled`, so the answer is advertised and refused"]
);
}
#[test]
fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs(" success: [won]\n");
assert!(check(&file, &questions, &aggregates).is_empty());
let model = sim_model(&file, &questions, &aggregates);
let need = &model["needs"][0];
assert_eq!(need["success"][0], "won");
let step = &need["steps"][0];
assert_eq!(step["question"], "What are you here for?");
assert_eq!(step["expect"][0], "Hire us");
assert_eq!(step["options"].as_array().unwrap().len(), 2);
// The form the visitor fills, required and optional alike.
let fields = step["fields"].as_array().unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[2]["optional"], true);
// The desk's buttons, labelled, per state.
let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap();
assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
assert_eq!(model["buckets"]["projects"]["initial"], "open");
assert_eq!(model["personas"][0]["id"], "founder");
// A stage the business named must exist as a state.
file.needs[0].stages = vec!["open".into(), "surveyed".into(), "quoted".into()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["the business describes a stage \"surveyed\" that \"projects\" has no state for"]
);
file.needs[0].stages = vec!["open".into(), "quoted".into(), "won".into()];
assert!(check(&file, &questions, &aggregates).is_empty());
file.needs[0].success = vec!["quoted".to_string()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["success names \"quoted\", which is not one of done_when"]
);
}
#[test]
fn a_need_can_be_carried_by_more_than_one_groups_desk() {
// owners may only quote or lose; closing a quoted job is the
// site managers' button, on a desk of their own.
let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let more = build_questions(&[(
"site/index.yaml".to_string(),
"name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(),
)])
.unwrap();
questions.extend(more);
let mut file = needs("");
// Alone, owners cannot finish it...
assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2);
// ...with the site managers' buttons counted, they can.
file.needs[0].also_moved_by = vec!["site_managers".to_string()];
assert!(check(&file, &questions, &aggregates).is_empty());
// But someone has to be answerable: handled_by needs its own desk.
file.needs[0].handled_by = "nobody".to_string();
assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\""));
}
#[test]
fn the_wrong_group_holding_the_desk_is_a_failure() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].handled_by = "sales".to_string();
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no desk gated on \"sales\" reads \"projects\" - it is read by /desk (owners)"]
);
}
#[test]
fn via_pins_the_entry_alternative() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].via = Some("Follow along".to_string());
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no path from \"/\" via \"Follow along\" records into \"projects\" within 2 submission(s)"]
);
}
#[test]
fn tasks_carry_the_page_as_a_choice_and_score_separates_misroutes() {
let (questions, _) = site(FULL_DESK);
let file = needs("");
let tasks = tasks(&file, &questions, false);
let skimmed = super::tasks(&file, &questions, true);
assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along");
assert_eq!(tasks.len(), 1);
let task = &tasks[0];
assert_eq!(task.id, "founder@/");
assert_eq!(task.expect, vec!["Hire us"]);
let q = &task.questions["pick"];
assert_eq!(q.kind, "choice");
assert_eq!(q.criteria["Follow along"], "Notes by mail.");
assert!(q.instructions.contains("What are you here for?"));
let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }];
let s = score(&tasks, &right, 0.6);
assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1));
let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }];
let s = score(&tasks, &wrong, 0.6);
assert_eq!((s.correct, s.misroutes.len()), (0, 1));
assert_eq!(s.accuracy(), 0.0);
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Onboarding a person into Kanidm from a desk, without an admin
//! session: create the account, record their email, add them to the
//! group, and mint a one-time link where they set their own password
//! or passkey. Kanidm has no self-registration by design; a member of
//! the business vouching for each person is the intended shape, and
//! this is that, as a form.
//!
//! Talks to Kanidm's REST API with a service-account token
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
//! service account belongs to `idm_people_on_boarding`, which may
//! create persons and issue first-credential resets and nothing else,
//! so a leaked token cannot touch existing credentials or admins.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites";
pub const INVITED_SUBJECT: &str = "portal.people.invited";
/// What an invite produced: the account, and the link that sets it up.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invited {
pub username: String,
pub display_name: String,
pub email: String,
pub group: String,
/// Absent when the person already had an account: they were added
/// to the group and their credentials were left alone.
pub reset_link: Option<String>,
pub link_expires_ms: Option<i64>,
pub created: bool,
}
/// Whether a `GET /v1/person/<name>` answer means the person exists.
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
/// rather than 404, so the status alone would report everyone as
/// existing and no account would ever get created. `None` for an
/// answer that is neither.
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
match status {
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
404 => Some(false),
_ => None,
}
}
/// The first entry's `name` in a `/v1/raw/search` answer, which is
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
fn name_from_search(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string)
}
pub struct Kanidm {
base: String,
token: String,
}
impl Kanidm {
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
/// a clear message instead of a half-made account.
pub fn from_env() -> Option<Self> {
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
Some(Kanidm { base, token })
}
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
let mut req = crate::http::client()
.request(method, format!("{}{path}", self.base))
.bearer_auth(&self.token)
.header("content-type", "application/json");
if let Some(body) = body {
req = req.body(serde_json::to_vec(&body)?);
}
let response = req.send().await?;
let status = response.status().as_u16();
let text = crate::http::text(response, crate::http::GITEA_CAP).await?;
Ok((status, text))
}
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
}
/// The account that already carries this email, if the token may
/// see it (the service account in `idm_people_pii_read`). None
/// when nobody has it, or when the search is not permitted: then
/// the username derived from the email decides, as before.
async fn find_by_mail(&self, email: &str) -> anyhow::Result<Option<String>> {
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/raw/search",
Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })),
)
.await?;
if !(200..300).contains(&status) {
tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead");
return Ok(None);
}
Ok(name_from_search(&text))
}
/// The whole onboarding, in the order that leaves the least behind
/// if a step fails: find or create, then email, then group, then
/// the link. A person who already has an account, under whatever
/// username, is only added to the group; their credentials are
/// left alone and no link is made.
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
let existing = self.find_by_mail(email).await?;
let username = existing.clone().unwrap_or_else(|| username_for(email));
let created = if existing.is_some() || self.person_exists(&username).await? {
false
} else {
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/person",
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
}
let (status, text) = self
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
}
true
};
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
}
let (reset_link, link_expires_ms) = if created {
// 24 hours: the longest Kanidm allows, since the link travels
// by email and is read when the person gets to it.
let (status, text) = self
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
}
#[derive(Deserialize)]
struct Intent {
token: String,
#[serde(default)]
expiry_time: Option<serde_json::Value>,
}
let intent: Intent = serde_json::from_str(&text)?;
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
} else {
(None, None)
};
Ok(Invited {
username,
display_name: name.to_string(),
email: email.to_string(),
group: group.to_string(),
reset_link,
link_expires_ms,
created,
})
}
}
/// A Kanidm account name from an email: the local part, lowercased,
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
pub fn username_for(email: &str) -> String {
let local = email.split('@').next().unwrap_or(email).to_lowercase();
let mut out: String = local
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
.collect();
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
out.remove(0);
}
if out.is_empty() {
"person".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usernames_are_the_email_local_part_made_safe() {
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
assert_eq!(username_for("42@x.no"), "person");
assert_eq!(username_for("Ærlig@x.no"), "rlig");
}
#[test]
fn a_search_by_mail_yields_the_accounts_own_name() {
assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into()));
assert_eq!(name_from_search(r#"{"entries":[]}"#), None);
assert_eq!(name_from_search("null"), None);
assert_eq!(name_from_search("not json"), None);
}
#[test]
fn a_null_person_answer_means_nobody_there() {
assert_eq!(person_lookup_found(200, "null"), Some(false));
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
assert_eq!(person_lookup_found(404, ""), Some(false));
assert_eq!(person_lookup_found(401, "unauthorized"), None);
}
}
+30 -16
View File
@@ -38,7 +38,7 @@ pub async fn get_resource(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
authorize_resource(resource).await?;
authorize_resource(&state, &question_id, &alternative, &feature_name).await?;
fetch_resource_value(&state, resource, &params).await
}
@@ -82,7 +82,7 @@ pub async fn get_requirement_options(
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no resource"))?;
authorize_resource(resource).await?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}")).await?;
fetch_resource_value(&state, resource, &params).await
}
@@ -113,7 +113,7 @@ pub async fn get_requirement_binding(
.bind
.as_ref()
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
authorize_resource(&bind.resource).await?;
authorize_resource(&state, &question_id, &alternative, &format!("{feature_name}|{requirement_name}|bind")).await?;
fetch_resource_value(&state, &bind.resource, &params).await
}
@@ -152,26 +152,40 @@ fn find_feature(
}
#[cfg(feature = "ssr")]
async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> {
async fn authorize_resource(
state: &crate::server::AppState,
question_id: &str,
alternative: &str,
feature: &str,
) -> Result<(), ServerFnError> {
use crate::access::{decide_and_publish, Action, Principal, Resource};
use crate::auth::{User, SESSION_USER_KEY};
if resource.public {
return Ok(());
}
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
.map_err(|e| ServerFnError::new(e.to_string()))?;
let resource = Resource::Feature {
page: question_id.to_string(),
alternative: alternative.to_string(),
feature: feature.to_string(),
};
let decision = decide_and_publish(
&state.nats,
&state.access.load(),
&Principal::from_user(user.as_ref()),
Action::Read,
&resource,
serde_json::json!({}),
);
if decision.allow {
Ok(())
} else if user.is_none() {
Err(ServerFnError::new("not signed in"))
} else {
Err(ServerFnError::new("not authorized"))
}
Ok(())
}
#[cfg(feature = "ssr")]
+4
View File
@@ -33,6 +33,10 @@ pub struct AppState {
/// `aggregates` on the same reload. Default = the historical uhhm
/// look.
pub site: Arc<ArcSwap<crate::content::SiteConfig>>,
/// The access policy compiled from the content above (see
/// `access.rs`), swapped with it on every reload. Every server entry
/// point decides through this and nothing else.
pub access: Arc<ArcSwap<crate::access::Policy>>,
/// `scheme://host` of the Gitea instance content is loaded from
/// (see `content::gitea_api_base`) - kept alongside `questions`
/// rather than re-derived per call, since `resolve_gitea_repo` needs