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 - Canonical signed-operation envelopes exist for keychain/auth signature
payloads. The keychain reducer builds an active identity view for admin keys, payloads. The keychain reducer builds an active identity view for admin keys,
users, devices, nodes, agents, and endpoint bindings. 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, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future 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 geth_types::{AuthOpId, Capability, GroupId, PrincipalId, ResourceId, SecretId, UnixMillis};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const AUTH_SIGNATURE_NAMESPACE: &str = "geth.auth-op.v1@geth.local"; 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 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthExplanation { pub struct AuthExplanation {
pub subject: String, pub subject: String,
@ -148,4 +361,163 @@ mod tests {
assert_eq!(signed.namespace(), AUTH_SIGNATURE_NAMESPACE); assert_eq!(signed.namespace(), AUTH_SIGNATURE_NAMESPACE);
assert_eq!(signed.payload(), &op); 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. bindings. Revoked identity subtrees are excluded from that active view.
The authorization plane is `geth-auth`: resource-local signed operation logs, 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 Both keychain and auth operations use `geth-codec` canonical envelopes for
signature payloads. The envelope includes a version, an explicit signature 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. - Revoked keys/devices/nodes are excluded from active views.
- Tests cover add, rename, revoke, and endpoint rotation. - Tests cover add, rename, revoke, and endpoint rotation.
- `[ ]` Resource auth operation reducer. - `[x]` Resource auth operation reducer.
Acceptance criteria: Acceptance criteria:
- Resource create, authority set, grants, revocations, and groups reduce into - Resource create, authority set, grants, revocations, and groups reduce into
a current permission view. a current permission view.