geth/crates/geth-auth/src/lib.rs

625 lines
20 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use geth_types::{AuthOpId, Capability, GroupId, PrincipalId, ResourceId, SecretId, UnixMillis};
use serde::{Deserialize, Serialize};
2026-05-16 14:41:23 +02:00
use std::collections::{BTreeMap, BTreeSet};
2026-05-15 15:08:20 +02:00
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<AuthOp, PrincipalId>;
pub fn auth_signing_payload(op: &AuthOp) -> Result<Vec<u8>, geth_codec::CodecError> {
geth_codec::signing_payload(AUTH_SIGNATURE_NAMESPACE, op)
}
pub fn auth_signing_payload_hash(
op: &AuthOp,
) -> Result<geth_types::BlobHash, geth_codec::CodecError> {
geth_codec::signing_payload_hash(AUTH_SIGNATURE_NAMESPACE, op)
}
#[must_use]
pub fn signed_auth_op(op: AuthOp, signer: PrincipalId, signature: Vec<u8>) -> SignedAuthOp {
geth_codec::SignedEnvelope::new(AUTH_SIGNATURE_NAMESPACE, op, signer, signature)
}
2026-05-15 15:08:20 +02:00
#[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<Capability>,
},
GrantRevoke {
grant_id: String,
},
BearerAccessCreate {
secret: SecretId,
token_hash: Option<String>,
2026-05-15 15:08:20 +02:00
capabilities: Vec<Capability>,
expires_at: Option<UnixMillis>,
},
BearerAccessRevoke {
secret: SecretId,
},
GroupCreate {
group: GroupId,
},
GroupAddMember {
group: GroupId,
principal: PrincipalId,
},
GroupRemoveMember {
group: GroupId,
principal: PrincipalId,
},
}
2026-05-16 14:41:23 +02:00
#[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 token_hash: Option<String>,
2026-05-16 14:41:23 +02:00
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,
token_hash,
2026-05-16 14:41:23 +02:00
capabilities,
expires_at,
} => {
let mut capabilities = capabilities.clone();
capabilities.sort();
capabilities.dedup();
view.bearer_access.insert(
secret.clone(),
BearerAccessRecord {
secret: secret.clone(),
token_hash: token_hash.clone(),
2026-05-16 14:41:23 +02:00
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() {
2026-05-17 18:26:30 +02:00
let Some(granted_capability) = grant
.capabilities
.iter()
.find(|granted| capability_allows(granted, &capability))
else {
continue;
};
if grant.resource != resource {
2026-05-16 14:41:23 +02:00
continue;
}
if grant.principal == subject {
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: true,
2026-05-17 18:26:30 +02:00
reason: format!(
"direct grant {} allows capability via {}",
grant.id, granted_capability
),
2026-05-16 14:41:23 +02:00
evaluated_ops,
};
}
if group_principals.contains(&grant.principal) {
return AuthExplanation {
subject: subject.to_string(),
resource: resource.to_string(),
capability: capability.to_string(),
allowed: true,
2026-05-17 18:26:30 +02:00
reason: format!(
"group grant {} allows capability via {}",
grant.id, granted_capability
),
2026-05-16 14:41:23 +02:00
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,
}
}
2026-05-17 18:26:30 +02:00
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
}
}
}
2026-05-16 14:41:23 +02:00
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()
}
2026-05-15 15:08:20 +02:00
#[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,
}
}
2026-05-16 14:24:21 +02:00
#[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,
}
}
2026-05-15 15:08:20 +02:00
}
#[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);
}
2026-05-16 14:41:23 +02:00
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(),
token_hash: Some("token-hash".to_owned()),
2026-05-16 14:41:23 +02:00
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());
}
2026-05-17 18:26:30 +02:00
#[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);
}
2026-05-15 15:08:20 +02:00
}