use std::collections::BTreeMap;
use std::path::PathBuf;
use ikigai_core::Capability;
pub fn clients_path() -> Option<PathBuf> {
std::env::var_os("IKIGAI_CLIENTS")
.map(PathBuf::from)
.or_else(|| crate::config::config_home().map(|dir| dir.join("clients.json")))
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Enrolment {
clients: BTreeMap<String, String>,
default_grant: Option<String>,
}
impl Enrolment {
pub fn len(&self) -> usize {
self.clients.len()
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
pub fn default_grant(&self) -> Option<&str> {
self.default_grant.as_deref()
}
pub fn grant_for(&self, fingerprint: &str) -> Option<&str> {
self.clients
.get(&normalize(fingerprint))
.map(String::as_str)
.or_else(|| self.default_grant())
}
pub fn grant_names(&self) -> Vec<String> {
let mut names: Vec<String> = self
.clients
.values()
.cloned()
.chain(self.default_grant.clone())
.collect();
names.sort();
names.dedup();
names
}
}
pub fn enrolment() -> Result<Option<Enrolment>, String> {
let Some(path) = clients_path() else {
return Ok(None);
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("read {}: {e}", path.display())),
};
parse(&text)
.map(Some)
.map_err(|e| format!("{}: {e}", path.display()))
}
fn parse(text: &str) -> Result<Enrolment, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
let mut clients = BTreeMap::new();
if let Some(map) = v.get("clients") {
let map = map
.as_object()
.ok_or("`clients` must be an object of fingerprint → grant")?;
for (fingerprint, entry) in map {
let grant = match entry {
serde_json::Value::String(name) => name.clone(),
serde_json::Value::Object(_) => entry
.get("grant")
.and_then(|g| g.as_str())
.ok_or_else(|| format!("client `{fingerprint}` has no `grant`"))?
.to_string(),
_ => {
return Err(format!(
"client `{fingerprint}` must be a grant name or an object with one"
))
}
};
clients.insert(normalize(fingerprint), grant);
}
}
let default_grant = match v.get("default") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(name)) => Some(name.clone()),
Some(_) => return Err("`default` must be a grant name".to_string()),
};
Ok(Enrolment {
clients,
default_grant,
})
}
fn normalize(fingerprint: &str) -> String {
fingerprint
.chars()
.filter(|c| !c.is_whitespace() && *c != ':')
.flat_map(char::to_lowercase)
.collect()
}
pub fn authority(fingerprint: &str, ceiling: &Capability) -> Result<(String, Capability), String> {
let enrolment = match enrolment()? {
Some(enrolment) => enrolment,
None => {
return Err(format!(
"no client enrolment file ({})",
clients_path().map_or_else(|| "unset".into(), |p| p.display().to_string())
))
}
};
authority_in(&enrolment, fingerprint, ceiling, crate::grant_scopes)
}
fn authority_in(
enrolment: &Enrolment,
fingerprint: &str,
ceiling: &Capability,
scopes_of: impl Fn(&str) -> Vec<String>,
) -> Result<(String, Capability), String> {
let Some(grant) = enrolment.grant_for(fingerprint) else {
return Err("no grant is configured for this certificate".to_string());
};
let scopes = scopes_of(grant);
if scopes.is_empty() {
return Err(format!(
"grant `{grant}` is unknown or grants no scopes (check grants.json)"
));
}
Ok((
grant.to_string(),
ceiling.clamp(&Capability::scoped(scopes)),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn grants(name: &str) -> Vec<String> {
match name {
"contacts-ro" => vec!["urn:cap:personal:contacts:read".to_string()],
"everything" => vec![
"urn:cap:personal:contacts:read".to_string(),
"urn:cap:personal:calendar:read:detail".to_string(),
],
_ => vec![],
}
}
const FP: &str = "6f1c00000000000000000000000000000000000000000000000000000000abcd";
fn enrolled(fingerprint: &str, grant: &str) -> Enrolment {
parse(&format!(
r#"{{"clients": {{"{fingerprint}": {{"grant": "{grant}", "label": "test"}}}}}}"#
))
.unwrap()
}
#[test]
fn an_openssl_style_fingerprint_names_the_same_client() {
let openssl = "6F:1C:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:AB:CD";
let e = enrolled(openssl, "contacts-ro");
assert_eq!(e.grant_for(FP), Some("contacts-ro"));
assert_eq!(e.grant_for(openssl), Some("contacts-ro"));
}
#[test]
fn both_entry_spellings_mean_the_same_grant() {
let object = enrolled(FP, "contacts-ro");
let bare = parse(&format!(r#"{{"clients": {{"{FP}": "contacts-ro"}}}}"#)).unwrap();
assert_eq!(object, bare);
}
#[test]
fn an_unenrolled_certificate_is_refused_not_given_the_ceiling() {
let enrolment = enrolled(FP, "contacts-ro");
let ceiling = Capability::scoped(["urn:cap:personal:contacts:read".to_string()]);
let stranger = "0000000000000000000000000000000000000000000000000000000000000000";
let refused = authority_in(&enrolment, stranger, &ceiling, grants).unwrap_err();
assert!(
refused.contains("no grant is configured"),
"unexpected reason: {refused}"
);
}
#[test]
fn an_enrolled_certificate_naming_an_empty_grant_is_refused() {
let enrolment = enrolled(FP, "typo-ro");
let refused = authority_in(&enrolment, FP, &Capability::root(), grants).unwrap_err();
assert!(refused.contains("typo-ro"), "unexpected reason: {refused}");
}
#[test]
fn a_shared_default_applies_only_when_explicitly_configured() {
let stranger = "0000000000000000000000000000000000000000000000000000000000000000";
let with = parse(&format!(
r#"{{"clients": {{"{FP}": "everything"}}, "default": "contacts-ro"}}"#
))
.unwrap();
let (grant, capability) =
authority_in(&with, stranger, &Capability::root(), grants).unwrap();
assert_eq!(grant, "contacts-ro");
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert_eq!(with.grant_for(FP), Some("everything"));
let without = parse(&format!(r#"{{"clients": {{"{FP}": "everything"}}}}"#)).unwrap();
assert!(authority_in(&without, stranger, &Capability::root(), grants).is_err());
}
#[test]
fn a_narrower_grant_narrows_below_the_operator_ceiling() {
let ceiling = Capability::scoped([
"urn:cap:personal:contacts:read".to_string(),
"urn:cap:personal:calendar:read:detail".to_string(),
]);
let (_, capability) =
authority_in(&enrolled(FP, "contacts-ro"), FP, &ceiling, grants).unwrap();
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert!(
!capability.allows("urn:cap:personal:calendar:read:detail"),
"a narrow grant must not keep the ceiling's other scopes"
);
}
#[test]
fn a_grant_cannot_widen_past_the_operator_ceiling() {
let ceiling = Capability::scoped(["urn:cap:personal:contacts:read".to_string()]);
let (_, capability) =
authority_in(&enrolled(FP, "everything"), FP, &ceiling, grants).unwrap();
assert!(capability.allows("urn:cap:personal:contacts:read"));
assert!(
!capability.allows("urn:cap:personal:calendar:read:detail"),
"the grant named a scope the ceiling never granted — it must be clamped away"
);
let (_, unbounded) =
authority_in(&enrolled(FP, "everything"), FP, &Capability::root(), grants).unwrap();
assert!(unbounded.allows("urn:cap:personal:calendar:read:detail"));
}
#[test]
fn editing_the_enrolment_changes_the_next_connections_authority() {
let before = enrolled(FP, "everything");
let (grant, capability) = authority_in(&before, FP, &Capability::root(), grants).unwrap();
assert_eq!(grant, "everything");
assert!(capability.allows("urn:cap:personal:calendar:read:detail"));
let after = enrolled(FP, "contacts-ro");
let (grant, capability) = authority_in(&after, FP, &Capability::root(), grants).unwrap();
assert_eq!(grant, "contacts-ro");
assert!(!capability.allows("urn:cap:personal:calendar:read:detail"));
let revoked = parse(r#"{"clients": {}}"#).unwrap();
assert!(authority_in(&revoked, FP, &Capability::root(), grants).is_err());
}
#[test]
fn a_malformed_enrolment_is_an_error_rather_than_an_empty_one() {
assert!(parse("{ not json").is_err());
assert!(parse(r#"{"clients": ["a", "b"]}"#).is_err());
assert!(parse(&format!(r#"{{"clients": {{"{FP}": {{"label": "x"}}}}}}"#)).is_err());
assert!(parse(r#"{"default": 7}"#).is_err());
assert!(parse("{}").unwrap().is_empty());
}
#[test]
fn the_declared_grant_names_include_the_default_and_dedupe() {
let e = parse(&format!(
r#"{{"clients": {{"{FP}": "contacts-ro", "aa": "contacts-ro"}}, "default": "everything"}}"#
))
.unwrap();
assert_eq!(e.grant_names(), vec!["contacts-ro", "everything"]);
assert_eq!(e.len(), 2);
}
}