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
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 11:44:39 +02:00
co-authored by Claude Fable 5.1
parent 7408fa972a
commit eeeb89b217
15 changed files with 1416 additions and 42 deletions
+61 -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,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(