use super::canonical::{CanonicalAuthError, role_hash, validate_scope_label};
use crate::{
dto::auth::{DelegatedRoleGrant, DelegationAudience},
ids::{CanisterRole, FleetKey},
};
use thiserror::Error;
pub const MAX_DELEGATED_ROLE_GRANTS: usize = 16;
pub const MAX_SCOPES_PER_ROLE_GRANT: usize = 32;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AudienceAcceptanceContext {
pub local_fleet: FleetKey,
}
#[derive(Debug, Eq, Error, PartialEq)]
pub enum AudienceError {
#[error("delegated auth role grants must not be empty")]
GrantsEmpty,
#[error("delegated auth role grants exceed max {max}: {found}")]
TooManyGrants { found: usize, max: usize },
#[error("delegated auth role grant for {role} has no scopes")]
EmptyGrantScopes { role: CanisterRole },
#[error("delegated auth role grant for {role} exceeds max scopes {max}: {found}")]
TooManyGrantScopes {
role: CanisterRole,
found: usize,
max: usize,
},
#[error("delegated auth role grants must be strictly sorted and unique")]
NonCanonicalGrants,
#[error("delegated auth grant scope rejected: {scope}")]
GrantScopeRejected { scope: String },
#[error(transparent)]
Canonical(#[from] CanonicalAuthError),
}
pub fn validate_role_grants(grants: &[DelegatedRoleGrant]) -> Result<(), AudienceError> {
if grants.is_empty() {
return Err(AudienceError::GrantsEmpty);
}
if grants.len() > MAX_DELEGATED_ROLE_GRANTS {
return Err(AudienceError::TooManyGrants {
found: grants.len(),
max: MAX_DELEGATED_ROLE_GRANTS,
});
}
let mut previous = None;
for grant in grants {
role_hash(&grant.target)?;
let current = grant.target.as_str().as_bytes();
if previous.is_some_and(|previous| previous >= current) {
return Err(AudienceError::NonCanonicalGrants);
}
previous = Some(current);
if grant.scopes.is_empty() {
return Err(AudienceError::EmptyGrantScopes {
role: grant.target.clone(),
});
}
if grant.scopes.len() > MAX_SCOPES_PER_ROLE_GRANT {
return Err(AudienceError::TooManyGrantScopes {
role: grant.target.clone(),
found: grant.scopes.len(),
max: MAX_SCOPES_PER_ROLE_GRANT,
});
}
validate_grant_scopes(&grant.scopes)?;
}
Ok(())
}
pub fn audience_subset(child: &DelegationAudience, parent: &DelegationAudience) -> bool {
child == parent
}
pub fn audience_accepted(ctx: AudienceAcceptanceContext, audience: &DelegationAudience) -> bool {
matches!(audience, DelegationAudience::Fleet(fleet) if *fleet == ctx.local_fleet)
}
pub fn role_grants_subset(child: &[DelegatedRoleGrant], parent: &[DelegatedRoleGrant]) -> bool {
child.iter().all(|child_grant| {
parent
.iter()
.find(|parent_grant| parent_grant.target == child_grant.target)
.is_some_and(|parent_grant| scopes_subset(&child_grant.scopes, &parent_grant.scopes))
})
}
pub fn scopes_for_role(
grants: &[DelegatedRoleGrant],
local_role: &CanisterRole,
) -> Option<Vec<String>> {
grants
.iter()
.find(|grant| &grant.target == local_role)
.map(|grant| grant.scopes.clone())
}
fn validate_grant_scopes(scopes: &[String]) -> Result<(), AudienceError> {
let mut previous = None;
for scope in scopes {
validate_scope_label(scope).map_err(|err| match err {
CanonicalAuthError::InvalidScope { scope } => {
AudienceError::GrantScopeRejected { scope }
}
other => AudienceError::Canonical(other),
})?;
let current = scope.as_bytes();
if previous.is_some_and(|previous| previous >= current) {
return Err(AudienceError::Canonical(
CanonicalAuthError::NonCanonicalScopes,
));
}
previous = Some(current);
}
Ok(())
}
fn scopes_subset(child: &[String], parent: &[String]) -> bool {
child.iter().all(|scope| parent.contains(scope))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::support::fleet_key;
fn grant(role: &str, scopes: &[&str]) -> DelegatedRoleGrant {
DelegatedRoleGrant {
target: CanisterRole::owned(role.to_string()),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
}
}
#[test]
fn audience_subset_requires_matching_fleet() {
assert!(audience_subset(
&DelegationAudience::Fleet(fleet_key(1)),
&DelegationAudience::Fleet(fleet_key(1))
));
assert!(!audience_subset(
&DelegationAudience::Fleet(fleet_key(1)),
&DelegationAudience::Fleet(fleet_key(2))
));
}
#[test]
fn audience_acceptance_requires_the_protected_local_fleet() {
let ctx = AudienceAcceptanceContext {
local_fleet: fleet_key(1),
};
assert!(audience_accepted(
ctx,
&DelegationAudience::Fleet(fleet_key(1))
));
assert!(!audience_accepted(
ctx,
&DelegationAudience::Fleet(fleet_key(2))
));
}
#[test]
fn role_grants_require_canonical_order() {
assert_eq!(
validate_role_grants(&[
grant("project_instance", &["upload"]),
grant("project_hub", &["upload"])
]),
Err(AudienceError::NonCanonicalGrants)
);
}
#[test]
fn role_grants_subset_checks_scopes_per_role() {
let parent = [
grant("project_hub", &["session", "upload"]),
grant("project_instance", &["upload"]),
];
let child = [
grant("project_hub", &["upload"]),
grant("project_instance", &["upload"]),
];
assert!(role_grants_subset(&child, &parent));
assert!(!role_grants_subset(
&[grant("project_instance", &["admin"])],
&parent
));
}
}