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
+17
@@ -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.<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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user