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
+4
View File
@@ -190,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,
@@ -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));
+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
+20 -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)]
+29
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
@@ -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) {