diff --git a/Cargo.lock b/Cargo.lock index 9479398..139ca78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5d570ac..413ae07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/README.md b/README.md index 4a218d3..cadf5fe 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,21 @@ 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. +- **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..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 diff --git a/src/access.rs b/src/access.rs new file mode 100644 index 0000000..ea8c2e3 --- /dev/null +++ b/src/access.rs @@ -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 }, + /// 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, +} + +/// 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, + pub text: String, + /// The dynamic pages' pattern ids, so a concrete `/shape/abc` + /// resolves to the policy written for `/shape/[key]`. + patterns: Vec, +} + +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, + aggregates: &HashMap, + ) -> Self { + let mut text = String::new(); + let mut rules = Vec::new(); + let mut ids: HashMap = 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| { + 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::>().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 = Vec::new(); + let principal_uid = match principal { + Principal::User { sub, groups } => { + let parents: HashSet = 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..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>, +) { + 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::(&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)); + } +} diff --git a/src/answers.rs b/src/answers.rs index 8a90c0e..5b57fbe 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -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 = @@ -329,12 +334,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(); diff --git a/src/app.rs b/src/app.rs index 7f8fdcf..5f5d435 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2045,7 +2045,7 @@ pub async fn list_qualifying_questions( chain: Option, ) -> Result, 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::(); @@ -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, ¤t, &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, ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; - use crate::content::is_qualified; use crate::server::AppState; let state = expect_context::(); @@ -2100,11 +2104,16 @@ pub async fn list_announcements() -> Result, 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::(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,46 @@ 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; + } + } // `/shape/{curve.key}`: the next page may be chosen by the answer. let next = match alt.action.as_deref() { Some(action) => Some( diff --git a/src/content/gitea.rs b/src/content/gitea.rs index 9711076..60c99b1 100644 --- a/src/content/gitea.rs +++ b/src/content/gitea.rs @@ -190,6 +190,7 @@ pub async fn watch_for_reload( arc_swap::ArcSwap>, >, site: std::sync::Arc>, + access: std::sync::Arc>, ) { let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { Ok(sub) => sub, @@ -225,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)); diff --git a/src/content/handlers.rs b/src/content/handlers.rs index b006571..569a20f 100644 --- a/src/content/handlers.rs +++ b/src/content/handlers.rs @@ -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 diff --git a/src/content/mod.rs b/src/content/mod.rs index 360aef8..a3e0a59 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -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, + /// 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, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Invite { + pub group: String, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/content/validate.rs b/src/content/validate.rs index c1ab78c..2d17c4c 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -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 @@ -145,6 +153,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) { diff --git a/src/lib.rs b/src/lib.rs index c1e8ff4..0b0526b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,12 @@ pub mod announce; #[cfg(feature = "ssr")] pub mod http; +#[cfg(feature = "ssr")] +pub mod access; + +#[cfg(feature = "ssr")] +pub mod people; + #[cfg(feature = "ssr")] pub mod server; diff --git a/src/main.rs b/src/main.rs index 6bf0fc4..042c458 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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)); @@ -75,7 +76,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 +88,7 @@ async fn main() -> anyhow::Result<()> { questions, aggregates, site, + access, gitea_base, content_raw_base, content_branch: content_branch.clone(), @@ -207,3 +212,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..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 } +} diff --git a/src/people.rs b/src/people.rs new file mode 100644 index 0000000..a02a73b --- /dev/null +++ b/src/people.rs @@ -0,0 +1,164 @@ +//! 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, + pub link_expires_ms: Option, + pub created: bool, +} + +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 { + 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) -> 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 { + let (status, _) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?; + match status { + 200 => Ok(true), + 404 => Ok(false), + other => anyhow::bail!("kanidm: looking up {username}: http {other}"), + } + } + + /// The whole onboarding, in the order that leaves the least behind + /// if a step fails: create, then email, then group, then the link. + pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result { + let username = username_for(email); + let created = if 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, + } + 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"); + } +} diff --git a/src/resource.rs b/src/resource.rs index 8611e75..988b9e3 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -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, ¶ms).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, ¶ms).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, ¶ms).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::(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")] diff --git a/src/server/mod.rs b/src/server/mod.rs index a59a7b7..d83e5d1 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -33,6 +33,10 @@ pub struct AppState { /// `aggregates` on the same reload. Default = the historical uhhm /// look. pub site: Arc>, + /// 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>, /// `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