canic_core/access/
mod.rs

1//! Access rule composition and evaluation.
2//!
3//! This module defines generic rule combinators used to gate access.
4//! It does not define authorization, topology, or environment logic.
5
6/// Access-layer errors returned by user-defined auth, guard, and rule hooks.
7///
8/// These errors are framework-agnostic and are converted into InternalError
9/// immediately at the framework boundary.
10pub mod auth;
11pub mod env;
12pub mod guard;
13pub mod metrics;
14pub mod rule;
15pub mod topology;
16
17use crate::{
18    cdk::{api::msg_caller, types::Principal},
19    ids::{BuildNetwork, CanisterRole},
20    log,
21    log::Topic,
22};
23use std::pin::Pin;
24use thiserror::Error as ThisError;
25
26///
27/// AccessRuleError
28///
29/// Each variant captures the principal that failed a rule (where relevant),
30/// making it easy to emit actionable diagnostics in logs.
31///
32
33#[derive(Debug, ThisError)]
34pub enum AccessRuleError {
35    #[error("access dependency unavailable: {0}")]
36    DependencyUnavailable(String),
37
38    /// Guardrail for unreachable states (should not be observed in practice).
39    #[error("invalid error state - this should never happen")]
40    InvalidState,
41
42    /// No rules were provided to an authorization check.
43    #[error("one or more rules must be defined")]
44    NoRulesDefined,
45
46    #[error("caller '{0}' does not match the app directory's canister role '{1}'")]
47    NotAppDirectoryType(Principal, CanisterRole),
48
49    #[error("caller '{0}' does not match the subnet directory's canister role '{1}'")]
50    NotSubnetDirectoryType(Principal, CanisterRole),
51
52    #[error("caller '{0}' is not a child of this canister")]
53    NotChild(Principal),
54
55    #[error("caller '{0}' is not a controller of this canister")]
56    NotController(Principal),
57
58    #[error("caller '{0}' is not the parent of this canister")]
59    NotParent(Principal),
60
61    #[error("expected caller principal '{1}' got '{0}'")]
62    NotPrincipal(Principal, Principal),
63
64    #[error("caller '{0}' is not root")]
65    NotRoot(Principal),
66
67    #[error("caller '{0}' is not the current canister")]
68    NotSameCanister(Principal),
69
70    #[error("caller '{0}' is not registered on the subnet registry")]
71    NotRegisteredToSubnet(Principal),
72
73    #[error("caller '{0}' is not on the whitelist")]
74    NotWhitelisted(Principal),
75}
76
77///
78/// RuleAccessError
79///
80
81#[derive(Debug, ThisError)]
82pub enum RuleAccessError {
83    #[error("this endpoint requires a build-time network (DFX_NETWORK) of either 'ic' or 'local'")]
84    BuildNetworkUnknown,
85
86    #[error(
87        "this endpoint is only available when built for '{expected}' (DFX_NETWORK), but was built for '{actual}'"
88    )]
89    BuildNetworkMismatch {
90        expected: BuildNetwork,
91        actual: BuildNetwork,
92    },
93}
94
95///
96/// AccessError
97///
98
99#[derive(Debug, ThisError)]
100pub enum AccessError {
101    #[error(transparent)]
102    Auth(#[from] AccessRuleError),
103
104    #[error(transparent)]
105    Env(#[from] env::EnvAccessError),
106
107    #[error(transparent)]
108    Guard(#[from] guard::GuardAccessError),
109
110    #[error(transparent)]
111    Rule(#[from] RuleAccessError),
112
113    #[error("access denied: {0}")]
114    Denied(String),
115}
116
117/// Callable issuing an authorization decision for a given caller.
118pub type AccessRuleFn = Box<
119    dyn Fn(Principal) -> Pin<Box<dyn Future<Output = Result<(), AccessError>> + Send>>
120        + Send
121        + Sync,
122>;
123
124/// Future produced by an [`AccessRuleFn`].
125pub type AccessRuleResult = Pin<Box<dyn Future<Output = Result<(), AccessError>> + Send>>;
126
127/// Require that all provided rules pass for the current caller.
128///
129/// Returns the first failing rule error, or [`AccessRuleError::NoRulesDefined`] if
130/// `rules` is empty.
131pub async fn require_all(rules: Vec<AccessRuleFn>) -> Result<(), AccessError> {
132    let caller = msg_caller();
133
134    if rules.is_empty() {
135        return Err(AccessRuleError::NoRulesDefined.into());
136    }
137
138    for rule in rules {
139        if let Err(err) = rule(caller).await {
140            log!(
141                Topic::Auth,
142                Warn,
143                "auth failed (all) caller={caller}: {err}",
144            );
145
146            return Err(err);
147        }
148    }
149
150    Ok(())
151}
152
153/// Require that any one of the provided rules passes for the current caller.
154///
155/// Returns the last failing rule error if none pass, or
156/// [`AccessRuleError::NoRulesDefined`] if `rules` is empty.
157pub async fn require_any(rules: Vec<AccessRuleFn>) -> Result<(), AccessError> {
158    let caller = msg_caller();
159
160    if rules.is_empty() {
161        return Err(AccessRuleError::NoRulesDefined.into());
162    }
163
164    let mut last_error = None;
165    for rule in rules {
166        match rule(caller).await {
167            Ok(()) => return Ok(()),
168            Err(e) => last_error = Some(e),
169        }
170    }
171
172    let err = last_error.unwrap_or_else(|| AccessRuleError::InvalidState.into());
173    log!(
174        Topic::Auth,
175        Warn,
176        "auth failed (any) caller={caller}: {err}",
177    );
178
179    Err(err)
180}
181
182/// Use this to return a custom access failure from endpoint-specific rules.
183#[must_use]
184pub fn deny(reason: impl Into<String>) -> AccessError {
185    AccessError::Denied(reason.into())
186}