875 lines
29 KiB
Rust
875 lines
29 KiB
Rust
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";
|
|
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)
|
|
}
|
|
|
|
#[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)]
|
|
pub struct AuthOpSignature {
|
|
pub op_id: AuthOpId,
|
|
pub signer: geth_types::KeyId,
|
|
pub signer_public_key: String,
|
|
pub namespace: String,
|
|
pub signature: Vec<u8>,
|
|
pub created_at: UnixMillis,
|
|
}
|
|
|
|
#[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>,
|
|
capabilities: Vec<Capability>,
|
|
expires_at: Option<UnixMillis>,
|
|
},
|
|
BearerAccessRevoke {
|
|
secret: SecretId,
|
|
},
|
|
GroupCreate {
|
|
group: GroupId,
|
|
},
|
|
GroupAddMember {
|
|
group: GroupId,
|
|
principal: PrincipalId,
|
|
},
|
|
GroupRemoveMember {
|
|
group: GroupId,
|
|
principal: PrincipalId,
|
|
},
|
|
}
|
|
|
|
#[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>,
|
|
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,
|
|
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(),
|
|
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_ops_and_view(ops, &view, subject, resource, capability)
|
|
}
|
|
|
|
pub fn explain_from_view(
|
|
view: &AuthView,
|
|
evaluated_ops: usize,
|
|
subject: PrincipalId,
|
|
resource: ResourceId,
|
|
capability: Capability,
|
|
) -> AuthExplanation {
|
|
explain_from_ops_and_view(&[], view, subject, resource, capability)
|
|
.with_evaluated_ops(evaluated_ops)
|
|
}
|
|
|
|
fn explain_from_ops_and_view(
|
|
ops: &[AuthOp],
|
|
view: &AuthView,
|
|
subject: PrincipalId,
|
|
resource: ResourceId,
|
|
capability: Capability,
|
|
) -> AuthExplanation {
|
|
let evaluated_ops = ops.len();
|
|
if let Some(secret) = subject.as_str().strip_prefix("bearer:") {
|
|
let secret = SecretId::new(secret.to_owned());
|
|
if let Some(access) = view.bearer_access.get(&secret) {
|
|
if access.resource == resource {
|
|
if let Some(granted_capability) = access
|
|
.capabilities
|
|
.iter()
|
|
.find(|granted| capability_allows(granted, &capability))
|
|
{
|
|
return AuthExplanation {
|
|
subject: subject.to_string(),
|
|
resource: resource.to_string(),
|
|
capability: capability.to_string(),
|
|
allowed: true,
|
|
reason: format!(
|
|
"active bearer access {} allows resource-scoped capability via {} without granting node identity",
|
|
access.secret, granted_capability
|
|
),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:bearer-secret".to_owned(),
|
|
format!("bearer:active:{}", access.secret),
|
|
format!("capability:matched:{granted_capability}"),
|
|
],
|
|
};
|
|
}
|
|
return AuthExplanation {
|
|
subject: subject.to_string(),
|
|
resource: resource.to_string(),
|
|
capability: capability.to_string(),
|
|
allowed: false,
|
|
reason:
|
|
"active bearer access exists for this resource but lacks the requested capability"
|
|
.to_owned(),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:bearer-secret".to_owned(),
|
|
format!("bearer:active:{secret}"),
|
|
"capability:missing".to_owned(),
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
if ops.iter().any(|op| {
|
|
op.resource == resource
|
|
&& matches!(
|
|
&op.kind,
|
|
AuthOpKind::BearerAccessRevoke { secret: revoked } if revoked == &secret
|
|
)
|
|
}) {
|
|
return AuthExplanation {
|
|
subject: subject.to_string(),
|
|
resource: resource.to_string(),
|
|
capability: capability.to_string(),
|
|
allowed: false,
|
|
reason:
|
|
"bearer access for this resource was revoked; bearer secrets do not grant node identity"
|
|
.to_owned(),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:bearer-secret".to_owned(),
|
|
format!("bearer:revoked:{secret}"),
|
|
],
|
|
};
|
|
}
|
|
|
|
return AuthExplanation {
|
|
subject: subject.to_string(),
|
|
resource: resource.to_string(),
|
|
capability: capability.to_string(),
|
|
allowed: false,
|
|
reason: "no active bearer access exists for this resource and capability".to_owned(),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:bearer-secret".to_owned(),
|
|
"bearer:missing".to_owned(),
|
|
],
|
|
};
|
|
}
|
|
|
|
let group_principals = groups_for_subject(view, &resource, &subject);
|
|
for grant in view.grants.values() {
|
|
let Some(granted_capability) = grant
|
|
.capabilities
|
|
.iter()
|
|
.find(|granted| capability_allows(granted, &capability))
|
|
else {
|
|
continue;
|
|
};
|
|
if grant.resource != resource {
|
|
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 via {}",
|
|
grant.id, granted_capability
|
|
),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:direct-principal".to_owned(),
|
|
format!("grant:matched:{}", grant.id),
|
|
format!("capability:matched:{granted_capability}"),
|
|
],
|
|
};
|
|
}
|
|
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 via {}",
|
|
grant.id, granted_capability
|
|
),
|
|
evaluated_ops,
|
|
diagnostics: vec![
|
|
"subject:group-member".to_owned(),
|
|
format!("grant:matched:{}", grant.id),
|
|
format!("capability:matched:{granted_capability}"),
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
if let Some(grant_id) = revoked_matching_grant_id(ops, &subject, &resource, &capability) {
|
|
return AuthExplanation {
|
|
subject: subject.to_string(),
|
|
resource: resource.to_string(),
|
|
capability: capability.to_string(),
|
|
allowed: false,
|
|
reason: format!(
|
|
"matching grant {grant_id} was revoked; no active direct or group grant currently allows the requested capability"
|
|
),
|
|
evaluated_ops,
|
|
diagnostics: vec![format!("grant:revoked:{grant_id}")],
|
|
};
|
|
}
|
|
|
|
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,
|
|
diagnostics: vec!["grant:missing".to_owned()],
|
|
}
|
|
}
|
|
|
|
fn revoked_matching_grant_id(
|
|
ops: &[AuthOp],
|
|
subject: &PrincipalId,
|
|
resource: &ResourceId,
|
|
capability: &Capability,
|
|
) -> Option<String> {
|
|
let mut matching_grants = BTreeSet::new();
|
|
let mut group_members = BTreeMap::<GroupId, BTreeSet<PrincipalId>>::new();
|
|
for op in ops {
|
|
if &op.resource != resource {
|
|
continue;
|
|
}
|
|
match &op.kind {
|
|
AuthOpKind::GrantCreate {
|
|
grant_id,
|
|
principal,
|
|
capabilities,
|
|
} => {
|
|
let direct = principal == subject;
|
|
let group = group_members
|
|
.get(&GroupId::new(principal.as_str()))
|
|
.is_some_and(|members| members.contains(subject));
|
|
let capability_matches = capabilities
|
|
.iter()
|
|
.any(|granted| capability_allows(granted, capability));
|
|
if (direct || group) && capability_matches {
|
|
matching_grants.insert(grant_id.clone());
|
|
}
|
|
}
|
|
AuthOpKind::GrantRevoke { grant_id } => {
|
|
if matching_grants.remove(grant_id) {
|
|
return Some(grant_id.clone());
|
|
}
|
|
}
|
|
AuthOpKind::GroupAddMember { group, principal } => {
|
|
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);
|
|
}
|
|
}
|
|
AuthOpKind::ResourceCreate
|
|
| AuthOpKind::ResourceAuthoritySet { .. }
|
|
| AuthOpKind::BearerAccessCreate { .. }
|
|
| AuthOpKind::BearerAccessRevoke { .. }
|
|
| AuthOpKind::GroupCreate { .. } => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
pub resource: String,
|
|
pub capability: String,
|
|
pub allowed: bool,
|
|
pub reason: String,
|
|
pub evaluated_ops: usize,
|
|
#[serde(default)]
|
|
pub diagnostics: Vec<String>,
|
|
}
|
|
|
|
impl AuthExplanation {
|
|
#[must_use]
|
|
pub fn with_evaluated_ops(mut self, evaluated_ops: usize) -> Self {
|
|
self.evaluated_ops = evaluated_ops;
|
|
self
|
|
}
|
|
|
|
pub fn add_diagnostic(&mut self, diagnostic: impl Into<String>) {
|
|
let diagnostic = diagnostic.into();
|
|
if !self.diagnostics.contains(&diagnostic) {
|
|
self.diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
diagnostics: vec!["auth-log:empty".to_owned()],
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
diagnostics: vec![
|
|
"subject:discovered-only".to_owned(),
|
|
"trust:missing".to_owned(),
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
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);
|
|
assert!(revoked.reason.contains("grant:revoked"));
|
|
assert!(
|
|
revoked
|
|
.diagnostics
|
|
.contains(&"grant:revoked:grant:revoked".to_owned())
|
|
);
|
|
}
|
|
|
|
#[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()),
|
|
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());
|
|
|
|
let revoked = explain_auth_ops(
|
|
&ops,
|
|
"bearer:secret:invite".into(),
|
|
"resource:notes".into(),
|
|
"pipe.connect".into(),
|
|
);
|
|
assert!(!revoked.allowed);
|
|
assert!(revoked.reason.contains("revoked"));
|
|
assert!(
|
|
revoked
|
|
.diagnostics
|
|
.contains(&"bearer:revoked:secret:invite".to_owned())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_explain_reports_active_bearer_access() {
|
|
let ops = vec![op(
|
|
1,
|
|
AuthOpKind::BearerAccessCreate {
|
|
secret: "secret:invite".into(),
|
|
token_hash: Some("token-hash".to_owned()),
|
|
capabilities: vec!["pipe.connect".into()],
|
|
expires_at: None,
|
|
},
|
|
)];
|
|
|
|
let allowed = explain_auth_ops(
|
|
&ops,
|
|
"bearer:secret:invite".into(),
|
|
"resource:notes".into(),
|
|
"pipe.connect".into(),
|
|
);
|
|
assert!(allowed.allowed);
|
|
assert!(allowed.reason.contains("bearer access"));
|
|
assert!(
|
|
allowed
|
|
.diagnostics
|
|
.contains(&"bearer:active:secret:invite".to_owned())
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|