Implement resource auth reducer
This commit is contained in:
parent
2a31cc3168
commit
187b99eb8d
4 changed files with 379 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue