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
244 lines
10 KiB
Rust
244 lines
10 KiB
Rust
//! Loading a content repo from Gitea, and the NATS-signalled reload
|
|
//! that hot-swaps it (last-good content is kept on any failure). Every
|
|
//! fetch goes through `crate::http`: bounded in time and size.
|
|
|
|
use super::*;
|
|
|
|
/// Fetches and parses `site.yaml` from the content repo root. A fetch
|
|
/// failure (typically 404 - the file is optional) yields the default
|
|
/// config; a file that exists but doesn't parse or validate is a real
|
|
/// error, surfaced at boot rather than papered over.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn load_site_from_gitea(repo_url: &str, branch: &str) -> anyhow::Result<SiteConfig> {
|
|
let (owner, repo) = parse_owner_repo(repo_url)?;
|
|
let api_base = gitea_api_base(repo_url)?;
|
|
let raw = match fetch_gitea_file(&api_base, &owner, &repo, branch, "site.yaml").await {
|
|
Ok(raw) => raw,
|
|
Err(e) => {
|
|
tracing::info!("no site.yaml in content repo ({e}), using default branding");
|
|
return Ok(SiteConfig::default());
|
|
}
|
|
};
|
|
let site: SiteConfig =
|
|
serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing site.yaml: {e}"))?;
|
|
site.validate()?;
|
|
Ok(site)
|
|
}
|
|
|
|
/// Extracts `scheme://host` from a repo's normal browser URL - the
|
|
/// Gitea API base every helper in this module builds requests against.
|
|
#[cfg(feature = "ssr")]
|
|
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
|
|
/// `site_asset_handler` proxies content-shipped files from.
|
|
#[cfg(feature = "ssr")]
|
|
pub fn gitea_raw_base(repo_url: &str) -> anyhow::Result<String> {
|
|
let (owner, repo) = parse_owner_repo(repo_url)?;
|
|
Ok(format!("{}/api/v1/repos/{owner}/{repo}/raw", gitea_api_base(repo_url)?))
|
|
}
|
|
|
|
#[cfg(feature = "ssr")]
|
|
pub fn gitea_api_base(repo_url: &str) -> anyhow::Result<String> {
|
|
let parsed = url::Url::parse(repo_url)
|
|
.map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?;
|
|
Ok(format!(
|
|
"{}://{}",
|
|
parsed.scheme(),
|
|
parsed
|
|
.host_str()
|
|
.ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))?
|
|
))
|
|
}
|
|
|
|
/// Splits a repo's normal browser URL into `(owner, repo)` - shared by
|
|
/// every loader in this module that needs to build a Gitea contents API
|
|
/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`).
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> {
|
|
let parsed = url::Url::parse(repo_url)
|
|
.map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?;
|
|
let mut segments = parsed
|
|
.path_segments()
|
|
.ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?;
|
|
let owner = segments
|
|
.next()
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))?
|
|
.to_string();
|
|
let repo = segments
|
|
.next()
|
|
.filter(|s| !s.is_empty())
|
|
.ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))?
|
|
.to_string();
|
|
Ok((owner, repo))
|
|
}
|
|
|
|
/// Fetches one file from a Gitea repo's contents API and returns its raw
|
|
/// text - the single-file counterpart to `load_questions_from_gitea`'s
|
|
/// directory-listing loop, used by `load_aggregates_from_gitea` for the
|
|
/// one `aggregates.yaml` file at the repo root.
|
|
#[cfg(feature = "ssr")]
|
|
pub(crate) async fn fetch_gitea_file(
|
|
api_base: &str,
|
|
owner: &str,
|
|
repo: &str,
|
|
branch: &str,
|
|
path: &str,
|
|
) -> anyhow::Result<String> {
|
|
let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}");
|
|
let meta_text = crate::http::get_text(&meta_url, crate::http::GITEA_CAP)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))?;
|
|
let meta: serde_json::Value = serde_json::from_str(&meta_text)
|
|
.map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?;
|
|
let download_url = meta
|
|
.get("download_url")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?;
|
|
crate::http::get_text(download_url, crate::http::GITEA_CAP)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))
|
|
}
|
|
|
|
/// Fetches and parses `aggregates.yaml` from the repo root (sibling to
|
|
/// the pages `subdir`, so the "every yaml under subdir is a page"
|
|
/// convention needs no exclusion). A malformed file fails here, before
|
|
/// ever reaching `AppState`.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn load_aggregates_from_gitea(
|
|
repo_url: &str,
|
|
branch: &str,
|
|
) -> anyhow::Result<std::collections::HashMap<String, crate::aggregates::AggregateSchema>> {
|
|
let (owner, repo) = parse_owner_repo(repo_url)?;
|
|
let api_base = gitea_api_base(repo_url)?;
|
|
let raw = fetch_gitea_file(&api_base, &owner, &repo, branch, "aggregates.yaml").await?;
|
|
crate::aggregates::parse_aggregates_yaml(&raw)
|
|
}
|
|
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn load_questions_from_gitea(
|
|
repo_url: &str,
|
|
branch: &str,
|
|
subdir: &str,
|
|
) -> anyhow::Result<std::collections::HashMap<String, Question>> {
|
|
let (owner, repo) = parse_owner_repo(repo_url)?;
|
|
let api_base = gitea_api_base(repo_url)?;
|
|
|
|
// One recursive git-trees call for the whole repo instead of a
|
|
// contents listing per directory - the tree IS the router now, so
|
|
// nested files matter.
|
|
let tree_url =
|
|
format!("{api_base}/api/v1/repos/{owner}/{repo}/git/trees/{branch}?recursive=true");
|
|
let listing = crate::http::get_text(&tree_url, crate::http::GITEA_CAP)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("listing {tree_url}: {e}"))?;
|
|
let tree: serde_json::Value = serde_json::from_str(&listing)
|
|
.map_err(|e| anyhow::anyhow!("parsing tree from {tree_url}: {e}"))?;
|
|
if tree.get("truncated").and_then(|v| v.as_bool()) == Some(true) {
|
|
anyhow::bail!("git tree listing for {owner}/{repo} was truncated - repo too large");
|
|
}
|
|
let prefix = format!("{subdir}/");
|
|
|
|
let mut files = Vec::new();
|
|
for entry in tree
|
|
.get("tree")
|
|
.and_then(|v| v.as_array())
|
|
.map(|v| v.as_slice())
|
|
.unwrap_or_default()
|
|
{
|
|
let path = entry.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
if entry.get("type").and_then(|v| v.as_str()) != Some("blob")
|
|
|| !path.starts_with(&prefix)
|
|
|| !path.ends_with(".yaml")
|
|
{
|
|
continue;
|
|
}
|
|
// Brackets (dynamic pages like `[record].yaml`) must be
|
|
// percent-encoded in the raw URL's path.
|
|
let encoded = path.replace('[', "%5B").replace(']', "%5D");
|
|
let raw_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/raw/{encoded}?ref={branch}");
|
|
let raw = crate::http::get_text(&raw_url, crate::http::GITEA_CAP)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))?;
|
|
files.push((path[prefix.len()..].to_string(), raw));
|
|
}
|
|
build_questions(&files)
|
|
}
|
|
|
|
/// Published by the content repo's own CI (after it lints a push) to
|
|
/// tell every running instance to pick up the change - a plain fire
|
|
/// and forget NATS publish, no payload, matching `events.rs`'s
|
|
/// `ANSWERS_SUBJECT` pattern.
|
|
#[cfg(feature = "ssr")]
|
|
pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload";
|
|
|
|
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
|
|
/// atomically swaps both `questions` and `aggregates` on every
|
|
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
|
|
/// after both have loaded and validated successfully, so a reader never
|
|
/// sees pages that reference a half-updated state graph. A fetch/parse
|
|
/// failure logs and keeps serving the last-good content rather than
|
|
/// clearing it - a bad push to the content repo (which should already
|
|
/// have been caught by its own lint step) doesn't take the site down.
|
|
#[cfg(feature = "ssr")]
|
|
pub async fn watch_for_reload(
|
|
nats: async_nats::Client,
|
|
repo_url: String,
|
|
branch: String,
|
|
subdir: String,
|
|
questions: std::sync::Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Question>>>,
|
|
aggregates: std::sync::Arc<
|
|
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,
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "failed to subscribe to content reload subject");
|
|
return;
|
|
}
|
|
};
|
|
use futures::StreamExt;
|
|
while sub.next().await.is_some() {
|
|
let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await {
|
|
Ok(loaded) => with_builtin_aggregates(loaded),
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content");
|
|
continue;
|
|
}
|
|
};
|
|
// Branding swaps with the same all-or-nothing rule: a
|
|
// present-but-broken site.yaml keeps last-good everything
|
|
// (load_site distinguishes "absent" - a normal default - from
|
|
// "present but invalid").
|
|
let loaded_site = match load_site_from_gitea(&repo_url, &branch).await {
|
|
Ok(loaded) => loaded,
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "site.yaml reload failed, keeping last-good content");
|
|
continue;
|
|
}
|
|
};
|
|
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
|
|
Ok(loaded) => {
|
|
if let Err(e) = validate_questions(&loaded, &loaded_aggregates) {
|
|
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
|
|
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));
|
|
tracing::info!(count, "reloaded content");
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "content reload failed, keeping last-good content");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|