use geth_types::{AuthOpId, Capability, GroupId, PrincipalId, ResourceId, SecretId, UnixMillis}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; pub const AUTH_SIGNATURE_NAMESPACE: &str = "geth.auth-op.v1@geth.local"; pub const RESOURCE_GRANT_SIGNATURE_NAMESPACE: &str = "geth.resource-grant.v1@geth.local"; pub const REVOCATION_SIGNATURE_NAMESPACE: &str = "geth.revocation.v1@geth.local"; pub type SignedAuthOp = geth_codec::SignedEnvelope; pub fn auth_signing_payload(op: &AuthOp) -> Result, geth_codec::CodecError> { geth_codec::signing_payload(AUTH_SIGNATURE_NAMESPACE, op) } pub fn auth_signing_payload_hash( op: &AuthOp, ) -> Result { geth_codec::signing_payload_hash(AUTH_SIGNATURE_NAMESPACE, op) } #[must_use] pub fn signed_auth_op(op: AuthOp, signer: PrincipalId, signature: Vec) -> SignedAuthOp { geth_codec::SignedEnvelope::new(AUTH_SIGNATURE_NAMESPACE, op, signer, signature) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthOp { pub id: AuthOpId, pub resource: ResourceId, pub created_at: UnixMillis, pub kind: AuthOpKind, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum AuthOpKind { ResourceCreate, ResourceAuthoritySet { authority: ResourceId, }, GrantCreate { grant_id: String, principal: PrincipalId, capabilities: Vec, }, GrantRevoke { grant_id: String, }, BearerAccessCreate { secret: SecretId, capabilities: Vec, expires_at: Option, }, BearerAccessRevoke { secret: SecretId, }, GroupCreate { group: GroupId, }, GroupAddMember { group: GroupId, principal: PrincipalId, }, GroupRemoveMember { group: GroupId, principal: PrincipalId, }, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthView { pub resources: BTreeMap, pub grants: BTreeMap, pub groups: BTreeMap, pub bearer_access: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceAuthRecord { pub id: ResourceId, pub authority: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GrantRecord { pub id: String, pub resource: ResourceId, pub principal: PrincipalId, pub capabilities: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct GroupRecord { pub id: GroupId, pub resource: ResourceId, pub members: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BearerAccessRecord { pub secret: SecretId, pub resource: ResourceId, pub capabilities: Vec, pub expires_at: Option, } pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView { let mut view = AuthView::default(); let mut group_members = BTreeMap::>::new(); for op in ops { match &op.kind { AuthOpKind::ResourceCreate => { view.resources .entry(op.resource.clone()) .or_insert_with(|| ResourceAuthRecord { id: op.resource.clone(), authority: None, }); } AuthOpKind::ResourceAuthoritySet { authority } => { view.resources .entry(op.resource.clone()) .and_modify(|record| record.authority = Some(authority.clone())) .or_insert_with(|| ResourceAuthRecord { id: op.resource.clone(), authority: Some(authority.clone()), }); } AuthOpKind::GrantCreate { grant_id, principal, capabilities, } => { let mut capabilities = capabilities.clone(); capabilities.sort(); capabilities.dedup(); view.grants.insert( grant_id.clone(), GrantRecord { id: grant_id.clone(), resource: op.resource.clone(), principal: principal.clone(), capabilities, }, ); } AuthOpKind::GrantRevoke { grant_id } => { view.grants.remove(grant_id); } AuthOpKind::BearerAccessCreate { secret, capabilities, expires_at, } => { let mut capabilities = capabilities.clone(); capabilities.sort(); capabilities.dedup(); view.bearer_access.insert( secret.clone(), BearerAccessRecord { secret: secret.clone(), resource: op.resource.clone(), capabilities, expires_at: *expires_at, }, ); } AuthOpKind::BearerAccessRevoke { secret } => { view.bearer_access.remove(secret); } AuthOpKind::GroupCreate { group } => { view.groups .entry(group.clone()) .or_insert_with(|| GroupRecord { id: group.clone(), resource: op.resource.clone(), members: Vec::new(), }); group_members.entry(group.clone()).or_default(); } AuthOpKind::GroupAddMember { group, principal } => { view.groups .entry(group.clone()) .or_insert_with(|| GroupRecord { id: group.clone(), resource: op.resource.clone(), members: Vec::new(), }); group_members .entry(group.clone()) .or_default() .insert(principal.clone()); } AuthOpKind::GroupRemoveMember { group, principal } => { if let Some(members) = group_members.get_mut(group) { members.remove(principal); } } } } for (group, members) in group_members { if let Some(record) = view.groups.get_mut(&group) { record.members = members.into_iter().collect(); } } view } pub fn group_principal_id(group: &GroupId) -> PrincipalId { PrincipalId::new(group.as_str()) } pub fn explain_auth_ops( ops: &[AuthOp], subject: PrincipalId, resource: ResourceId, capability: Capability, ) -> AuthExplanation { let view = reduce_auth_ops(ops); explain_from_view(&view, ops.len(), subject, resource, capability) } pub fn explain_from_view( view: &AuthView, evaluated_ops: usize, subject: PrincipalId, resource: ResourceId, capability: Capability, ) -> AuthExplanation { let group_principals = groups_for_subject(view, &resource, &subject); for grant in view.grants.values() { let Some(granted_capability) = grant .capabilities .iter() .find(|granted| capability_allows(granted, &capability)) else { continue; }; if grant.resource != resource { continue; } if grant.principal == subject { return AuthExplanation { subject: subject.to_string(), resource: resource.to_string(), capability: capability.to_string(), allowed: true, reason: format!( "direct grant {} allows capability via {}", grant.id, granted_capability ), evaluated_ops, }; } if group_principals.contains(&grant.principal) { return AuthExplanation { subject: subject.to_string(), resource: resource.to_string(), capability: capability.to_string(), allowed: true, reason: format!( "group grant {} allows capability via {}", grant.id, granted_capability ), evaluated_ops, }; } } AuthExplanation { subject: subject.to_string(), resource: resource.to_string(), capability: capability.to_string(), allowed: false, reason: "no active direct or group grant contains the requested capability".to_owned(), evaluated_ops, } } pub fn capability_allows(granted: &Capability, requested: &Capability) -> bool { if granted == requested { return true; } match (granted.as_str(), requested.as_str()) { ("kv.write", requested) if requested == "kv.write" || requested.starts_with("kv.write_key:") || requested.starts_with("kv.write_prefix:") => { true } (granted, requested) => { let Some(granted_prefix) = granted.strip_prefix("kv.write_prefix:") else { return false; }; if let Some(requested_key) = requested.strip_prefix("kv.write_key:") { return requested_key.starts_with(granted_prefix); } if let Some(requested_prefix) = requested.strip_prefix("kv.write_prefix:") { return requested_prefix.starts_with(granted_prefix); } false } } } fn groups_for_subject( view: &AuthView, resource: &ResourceId, subject: &PrincipalId, ) -> BTreeSet { view.groups .values() .filter(|group| &group.resource == resource && group.members.contains(subject)) .map(|group| group_principal_id(&group.id)) .collect() } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AuthExplanation { pub subject: String, pub resource: String, pub capability: String, pub allowed: bool, pub reason: String, pub evaluated_ops: usize, } impl AuthExplanation { #[must_use] pub fn stub(subject: String, resource: String, capability: String) -> Self { Self { subject, resource, capability, allowed: false, reason: "authorization logs are scaffolded; no grant reducer is active yet".to_owned(), evaluated_ops: 0, } } #[must_use] pub fn discovered_candidate(subject: String, resource: String, capability: String) -> Self { Self { subject, resource, capability, allowed: false, reason: "subject is a discovered peer candidate only; discovery does not grant trust or authorization".to_owned(), evaluated_ops: 0, } } } #[cfg(test)] mod tests { use super::*; #[test] fn auth_structs_roundtrip() { let op = AuthOp { id: "op:auth:1".into(), resource: "resource:notes".into(), created_at: UnixMillis(10), kind: AuthOpKind::GrantCreate { grant_id: "grant:1".to_owned(), principal: "node:laptop".into(), capabilities: vec!["kv.read".into(), "kv.write_prefix:apps/foo/".into()], }, }; let json = serde_json::to_string(&op).expect("json"); let decoded: AuthOp = serde_json::from_str(&json).expect("decode"); assert_eq!(decoded, op); } #[test] fn auth_signing_payload_is_canonical_and_namespaced() { let op = AuthOp { id: "op:auth:1".into(), resource: "resource:notes".into(), created_at: UnixMillis(10), kind: AuthOpKind::GrantCreate { grant_id: "grant:1".to_owned(), principal: "node:laptop".into(), capabilities: vec!["kv.read".into(), "kv.write_prefix:apps/foo/".into()], }, }; assert_eq!( auth_signing_payload(&op).expect("payload"), auth_signing_payload(&op).expect("payload again") ); assert_ne!( auth_signing_payload_hash(&op).expect("hash"), geth_codec::hash_canonical(&op).expect("raw op hash") ); let signed = signed_auth_op(op.clone(), "node:laptop".into(), vec![1, 2, 3]); assert_eq!(signed.namespace(), AUTH_SIGNATURE_NAMESPACE); assert_eq!(signed.payload(), &op); } fn op(sequence: i64, kind: AuthOpKind) -> AuthOp { AuthOp { id: format!("op:auth:{sequence}").into(), resource: "resource:notes".into(), created_at: UnixMillis(sequence), kind, } } #[test] fn auth_reducer_tracks_grants_groups_and_revocations() { let ops = vec![ op(1, AuthOpKind::ResourceCreate), op( 2, AuthOpKind::ResourceAuthoritySet { authority: "resource:notes-auth".into(), }, ), op( 3, AuthOpKind::GrantCreate { grant_id: "grant:direct".to_owned(), principal: "node:laptop".into(), capabilities: vec!["kv.write_prefix:apps/foo/".into(), "kv.read".into()], }, ), op( 4, AuthOpKind::GrantCreate { grant_id: "grant:revoked".to_owned(), principal: "node:laptop".into(), capabilities: vec!["kv.admin".into()], }, ), op( 5, AuthOpKind::GrantRevoke { grant_id: "grant:revoked".to_owned(), }, ), op( 6, AuthOpKind::GroupCreate { group: "group:editors".into(), }, ), op( 7, AuthOpKind::GroupAddMember { group: "group:editors".into(), principal: "node:tablet".into(), }, ), op( 8, AuthOpKind::GrantCreate { grant_id: "grant:group".to_owned(), principal: "group:editors".into(), capabilities: vec!["document.write".into()], }, ), ]; let view = reduce_auth_ops(&ops); assert_eq!( view.resources .get(&ResourceId::from("resource:notes")) .and_then(|record| record.authority.clone()), Some(ResourceId::from("resource:notes-auth")) ); assert!(view.grants.contains_key("grant:direct")); assert!(!view.grants.contains_key("grant:revoked")); assert_eq!( view.groups .get(&GroupId::from("group:editors")) .map(|group| group.members.clone()), Some(vec![PrincipalId::from("node:tablet")]) ); let direct = explain_auth_ops( &ops, "node:laptop".into(), "resource:notes".into(), "kv.write_prefix:apps/foo/".into(), ); assert!(direct.allowed); assert_eq!(direct.evaluated_ops, ops.len()); assert!(direct.reason.contains("direct grant")); let group = explain_auth_ops( &ops, "node:tablet".into(), "resource:notes".into(), "document.write".into(), ); assert!(group.allowed); assert!(group.reason.contains("group grant")); let revoked = explain_auth_ops( &ops, "node:laptop".into(), "resource:notes".into(), "kv.admin".into(), ); assert!(!revoked.allowed); } #[test] fn auth_reducer_removes_group_members_and_bearer_access() { let ops = vec![ op( 1, AuthOpKind::GroupCreate { group: "group:editors".into(), }, ), op( 2, AuthOpKind::GroupAddMember { group: "group:editors".into(), principal: "node:tablet".into(), }, ), op( 3, AuthOpKind::GroupRemoveMember { group: "group:editors".into(), principal: "node:tablet".into(), }, ), op( 4, AuthOpKind::BearerAccessCreate { secret: "secret:invite".into(), capabilities: vec!["pipe.connect".into()], expires_at: Some(UnixMillis(100)), }, ), op( 5, AuthOpKind::BearerAccessRevoke { secret: "secret:invite".into(), }, ), ]; let view = reduce_auth_ops(&ops); assert_eq!( view.groups .get(&GroupId::from("group:editors")) .map(|group| group.members.clone()), Some(Vec::new()) ); assert!(view.bearer_access.is_empty()); } #[test] fn prefix_scoped_kv_capabilities_allow_only_matching_keys() { assert!(capability_allows( &Capability::from("kv.write_prefix:apps/foo/"), &Capability::from("kv.write_key:apps/foo/config"), )); assert!(capability_allows( &Capability::from("kv.write_prefix:apps/foo/"), &Capability::from("kv.write_prefix:apps/foo/nested/"), )); assert!(!capability_allows( &Capability::from("kv.write_prefix:apps/foo/"), &Capability::from("kv.write_key:apps/bar/config"), )); assert!(!capability_allows( &Capability::from("kv.write_prefix:apps/foo/"), &Capability::from("kv.read"), )); assert!(capability_allows( &Capability::from("kv.write"), &Capability::from("kv.write_key:apps/bar/config"), )); } #[test] fn auth_explain_uses_prefix_scoped_kv_capabilities() { let ops = vec![op( 1, AuthOpKind::GrantCreate { grant_id: "grant:kv-prefix".to_owned(), principal: "node:laptop".into(), capabilities: vec!["kv.write_prefix:apps/foo/".into()], }, )]; let allowed = explain_auth_ops( &ops, "node:laptop".into(), "resource:notes".into(), "kv.write_key:apps/foo/config".into(), ); assert!(allowed.allowed); assert!(allowed.reason.contains("kv.write_prefix:apps/foo/")); let denied = explain_auth_ops( &ops, "node:laptop".into(), "resource:notes".into(), "kv.write_key:apps/bar/config".into(), ); assert!(!denied.allowed); } }