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:
co-authored by
Claude Fable 5.1
parent
7408fa972a
commit
eeeb89b217
+28
-14
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user