use std::future::Future;
use crate::AuthBindingRef;
use super::{ActingOnBehalfOf, AuthGrant, GrantAction, GrantScope, PrincipalRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthBindingUseRequest {
principal: PrincipalRef,
durable_target: PrincipalRef,
auth_binding: AuthBindingRef,
}
impl AuthBindingUseRequest {
#[must_use]
pub fn new(
principal: PrincipalRef,
durable_target: PrincipalRef,
auth_binding: AuthBindingRef,
) -> Self {
Self {
principal,
durable_target,
auth_binding,
}
}
#[must_use]
pub fn principal(&self) -> &PrincipalRef {
&self.principal
}
#[must_use]
pub fn durable_target(&self) -> &PrincipalRef {
&self.durable_target
}
#[must_use]
pub fn auth_binding(&self) -> &AuthBindingRef {
&self.auth_binding
}
fn grant_scope(&self) -> GrantScope {
GrantScope::AuthBinding {
realm_id: self.auth_binding.realm.clone(),
binding_id: self.auth_binding.binding.clone(),
profile_id: self.auth_binding.profile.clone(),
}
}
fn delegation(&self) -> ActingOnBehalfOf {
ActingOnBehalfOf::new(self.principal.clone(), self.durable_target.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AuthBindingUseDenial {
#[error("binding-use authorization requires an explicit configured auth binding")]
ExplicitConfiguredBindingRequired,
#[error("principal is not authorized to use the requested auth binding for the durable target")]
MissingExactGrant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthBindingUseWitness {
principal: PrincipalRef,
durable_target: PrincipalRef,
auth_binding: AuthBindingRef,
}
impl AuthBindingUseWitness {
#[must_use]
pub fn authorizes(&self, request: &AuthBindingUseRequest) -> bool {
self.principal == request.principal
&& self.durable_target == request.durable_target
&& self.auth_binding == request.auth_binding
}
#[must_use]
pub fn principal(&self) -> &PrincipalRef {
&self.principal
}
#[must_use]
pub fn durable_target(&self) -> &PrincipalRef {
&self.durable_target
}
#[must_use]
pub fn auth_binding(&self) -> &AuthBindingRef {
&self.auth_binding
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthBindingUseDecision {
Allowed(AuthBindingUseWitness),
Denied(AuthBindingUseDenial),
}
impl AuthBindingUseDecision {
pub fn into_result(self) -> Result<AuthBindingUseWitness, AuthBindingUseDenial> {
match self {
Self::Allowed(witness) => Ok(witness),
Self::Denied(denial) => Err(denial),
}
}
}
#[must_use]
pub fn authorize_explicit_auth_binding_use(
request: &AuthBindingUseRequest,
grants: &[AuthGrant],
) -> AuthBindingUseDecision {
if request.auth_binding.is_env_default() {
return AuthBindingUseDecision::Denied(
AuthBindingUseDenial::ExplicitConfiguredBindingRequired,
);
}
let scope = request.grant_scope();
let delegation = request.delegation();
let allowed = grants.iter().any(|grant| {
grant.allows(
&request.principal,
GrantAction::UseAuthBinding,
&scope,
Some(&delegation),
)
});
if allowed {
AuthBindingUseDecision::Allowed(AuthBindingUseWitness {
principal: request.principal.clone(),
durable_target: request.durable_target.clone(),
auth_binding: request.auth_binding.clone(),
})
} else {
AuthBindingUseDecision::Denied(AuthBindingUseDenial::MissingExactGrant)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum AuthBindingUseGateError<E> {
Authorization(AuthBindingUseDenial),
Materialization(E),
}
pub async fn authorize_then_materialize_auth_binding<T, E, F, Fut>(
request: &AuthBindingUseRequest,
grants: &[AuthGrant],
materialize: F,
) -> Result<T, AuthBindingUseGateError<E>>
where
F: FnOnce(AuthBindingUseWitness) -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let witness = authorize_explicit_auth_binding_use(request, grants)
.into_result()
.map_err(AuthBindingUseGateError::Authorization)?;
materialize(witness)
.await
.map_err(AuthBindingUseGateError::Materialization)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::{BindingId, BindingOrigin, ProfileId, RealmId};
use super::*;
use crate::auth::PrincipalKind;
fn principal(kind: PrincipalKind, id: &str) -> PrincipalRef {
PrincipalRef::new(kind, id).expect("valid principal")
}
fn configured_binding(realm: &str, binding: &str) -> AuthBindingRef {
AuthBindingRef {
realm: RealmId::parse(realm).expect("valid realm"),
binding: BindingId::parse(binding).expect("valid binding"),
profile: None,
origin: BindingOrigin::Configured,
}
}
fn exact_grant(request: &AuthBindingUseRequest) -> AuthGrant {
AuthGrant {
principal: request.principal.clone(),
scope: request.grant_scope(),
actions: BTreeSet::from([GrantAction::UseAuthBinding]),
acting_on_behalf_of: Some(request.delegation()),
}
}
#[test]
fn binding_existence_is_insufficient_without_an_exact_grant() {
let request = AuthBindingUseRequest::new(
principal(PrincipalKind::Human, "alice"),
principal(PrincipalKind::PersonalAgent, "personal-agent"),
configured_binding("home", "chatgpt"),
);
assert_eq!(
authorize_explicit_auth_binding_use(&request, &[]),
AuthBindingUseDecision::Denied(AuthBindingUseDenial::MissingExactGrant)
);
}
#[test]
fn wrong_durable_identity_and_wrong_realm_are_denied() {
let allowed = AuthBindingUseRequest::new(
principal(PrincipalKind::Human, "alice"),
principal(PrincipalKind::PersonalAgent, "personal-agent"),
configured_binding("home", "chatgpt"),
);
let grants = [exact_grant(&allowed)];
let wrong_identity = AuthBindingUseRequest::new(
allowed.principal.clone(),
principal(PrincipalKind::PersonalAgent, "another-agent"),
allowed.auth_binding.clone(),
);
assert_eq!(
authorize_explicit_auth_binding_use(&wrong_identity, &grants),
AuthBindingUseDecision::Denied(AuthBindingUseDenial::MissingExactGrant)
);
let wrong_realm = AuthBindingUseRequest::new(
allowed.principal.clone(),
allowed.durable_target,
configured_binding("work", "chatgpt"),
);
assert_eq!(
authorize_explicit_auth_binding_use(&wrong_realm, &grants),
AuthBindingUseDecision::Denied(AuthBindingUseDenial::MissingExactGrant)
);
}
#[test]
fn binding_override_profile_requires_an_exact_profile_grant() {
let base = AuthBindingUseRequest::new(
principal(PrincipalKind::Human, "alice"),
principal(PrincipalKind::PersonalAgent, "personal-agent"),
configured_binding("home", "chatgpt"),
);
let grants = [exact_grant(&base)];
let mut profile_binding = base.auth_binding.clone();
profile_binding.profile = Some(ProfileId::parse("secondary").expect("valid profile"));
let profile_request = AuthBindingUseRequest::new(
base.principal.clone(),
base.durable_target,
profile_binding,
);
assert_eq!(
authorize_explicit_auth_binding_use(&profile_request, &grants),
AuthBindingUseDecision::Denied(AuthBindingUseDenial::MissingExactGrant)
);
}
#[tokio::test]
async fn denied_request_has_zero_resolver_and_factory_side_effects() {
let resolver_calls = AtomicUsize::new(0);
let factory_calls = AtomicUsize::new(0);
let request = AuthBindingUseRequest::new(
principal(PrincipalKind::Human, "mallory"),
principal(PrincipalKind::PersonalAgent, "personal-agent"),
configured_binding("home", "chatgpt"),
);
let result = authorize_then_materialize_auth_binding(&request, &[], |_witness| async {
resolver_calls.fetch_add(1, Ordering::SeqCst);
factory_calls.fetch_add(1, Ordering::SeqCst);
Ok::<_, ()>(())
})
.await;
assert_eq!(
result,
Err(AuthBindingUseGateError::Authorization(
AuthBindingUseDenial::MissingExactGrant
))
);
assert_eq!(resolver_calls.load(Ordering::SeqCst), 0);
assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn allowed_request_materializes_once_with_an_exact_witness() {
let calls = AtomicUsize::new(0);
let request = AuthBindingUseRequest::new(
principal(PrincipalKind::Human, "alice"),
principal(PrincipalKind::PersonalAgent, "personal-agent"),
configured_binding("home", "chatgpt"),
);
let grants = [exact_grant(&request)];
let expected_request = request.clone();
let calls_for_materialize = &calls;
let value =
authorize_then_materialize_auth_binding(&request, &grants, |witness| async move {
assert!(witness.authorizes(&expected_request));
calls_for_materialize.fetch_add(1, Ordering::SeqCst);
Ok::<_, ()>(42)
})
.await
.expect("authorized materialization");
assert_eq!(value, 42);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}