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";
|
|
|
|
|
|
2026-05-16 14:37:16 +02:00
|
|
|
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,
|
|
|
|
|
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 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() {
|
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);
|
|
|
|
|
}
|
2026-05-16 14:37:16 +02:00
|
|
|
|
|
|
|
|
#[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(),
|
|
|
|
|
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
|
|
|
}
|