Implement resource auth reducer

This commit is contained in:
Eric Wendland 2026-05-16 14:41:23 +02:00
commit 187b99eb8d
4 changed files with 379 additions and 2 deletions

View file

@ -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

View file

@ -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<ResourceId, ResourceAuthRecord>,
pub grants: BTreeMap<String, GrantRecord>,
pub groups: BTreeMap<GroupId, GroupRecord>,
pub bearer_access: BTreeMap<SecretId, BearerAccessRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceAuthRecord {
pub id: ResourceId,
pub authority: Option<ResourceId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantRecord {
pub id: String,
pub resource: ResourceId,
pub principal: PrincipalId,
pub capabilities: Vec<Capability>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupRecord {
pub id: GroupId,
pub resource: ResourceId,
pub members: Vec<PrincipalId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccessRecord {
pub secret: SecretId,
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>,
}
pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView {
let mut view = AuthView::default();
let mut group_members = BTreeMap::<GroupId, BTreeSet<PrincipalId>>::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<PrincipalId> {
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());
}
}

View file

@ -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

View file

@ -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.