From 187b99eb8db24f4d1ea660b601a4e475fb3a70d3 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 14:41:23 +0200 Subject: [PATCH] Implement resource auth reducer --- AGENTS.md | 2 + crates/geth-auth/src/lib.rs | 372 ++++++++++++++++++++++++++++++++++++ docs/architecture.md | 5 +- docs/roadmap.md | 2 +- 4 files changed, 379 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 119a050..3753cdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ Roadmap items should be actionable and checkable: - Canonical signed-operation envelopes exist for keychain/auth signature payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. +- The auth reducer builds a current permission view for resources, grants, + groups, and bearer access. Daemon-side enforcement is still roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index 8ff27ac..592b3e6 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -1,5 +1,6 @@ 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"; @@ -66,6 +67,218 @@ pub enum AuthOpKind { }, } +#[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() { + if grant.resource != resource || !grant.capabilities.contains(&capability) { + 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", grant.id), + 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", grant.id), + 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, + } +} + +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, @@ -148,4 +361,163 @@ mod tests { 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()); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 54d5bdc..468af4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,10 @@ admin keys, users, devices, node records, agent bindings, and endpoint-to-node bindings. Revoked identity subtrees are excluded from that active view. The authorization plane is `geth-auth`: resource-local signed operation logs, -grants, revocations, groups, and `auth explain`. +grants, revocations, groups, and `auth explain`. Auth operations reduce into a +current permission view for resources, grants, groups, and bearer access. The +library can explain direct and group grants, while daemon-side enforcement and +durable auth-log storage are still future work. Both keychain and auth operations use `geth-codec` canonical envelopes for signature payloads. The envelope includes a version, an explicit signature diff --git a/docs/roadmap.md b/docs/roadmap.md index e02182a..fedf2dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -151,7 +151,7 @@ resource-scoped capability decisions. - Revoked keys/devices/nodes are excluded from active views. - Tests cover add, rename, revoke, and endpoint rotation. -- `[ ]` Resource auth operation reducer. +- `[x]` Resource auth operation reducer. Acceptance criteria: - Resource create, authority set, grants, revocations, and groups reduce into a current permission view.