canic_core/access/
auth.rs

1//! Authorization helpers for canister-to-canister and user calls.
2//!
3//! Compose rule futures and enforce them with [`require_all`] or
4//! [`require_any`]. For ergonomics, prefer the macros [`auth_require_all!`]
5//! and [`auth_require_any!`], which accept async closures or functions that
6//! return [`AuthRuleResult`].
7
8use crate::{
9    Error, ThisError,
10    access::AccessError,
11    cdk::api::{canister_self, msg_caller},
12    ids::CanisterRole,
13    log,
14    log::Topic,
15    ops::{
16        runtime::env::EnvOps,
17        storage::{
18            children::CanisterChildrenOps,
19            directory::{app::AppDirectoryOps, subnet::SubnetDirectoryOps},
20            registry::subnet::SubnetRegistryOps,
21        },
22    },
23};
24use candid::Principal;
25use std::pin::Pin;
26
27///
28/// AuthAccessError
29///
30/// Each variant captures the principal that failed a rule (where relevant),
31/// making it easy to emit actionable diagnostics in logs.
32///
33
34#[derive(Debug, ThisError)]
35pub enum AuthAccessError {
36    /// Guardrail for unreachable states (should not be observed in practice).
37    #[error("invalid error state - this should never happen")]
38    InvalidState,
39
40    /// No rules were provided to an authorization check.
41    #[error("one or more rules must be defined")]
42    NoRulesDefined,
43
44    #[error("access dependency unavailable: {0}")]
45    DependencyUnavailable(String),
46
47    #[error("caller '{0}' does not match the app directory's canister role '{1}'")]
48    NotAppDirectoryType(Principal, CanisterRole),
49
50    #[error("caller '{0}' does not match the subnet directory's canister role '{1}'")]
51    NotSubnetDirectoryType(Principal, CanisterRole),
52
53    #[error("caller '{0}' is not a child of this canister")]
54    NotChild(Principal),
55
56    #[error("caller '{0}' is not a controller of this canister")]
57    NotController(Principal),
58
59    #[error("caller '{0}' is not the parent of this canister")]
60    NotParent(Principal),
61
62    #[error("expected caller principal '{1}' got '{0}'")]
63    NotPrincipal(Principal, Principal),
64
65    #[error("caller '{0}' is not root")]
66    NotRoot(Principal),
67
68    #[error("caller '{0}' is not the current canister")]
69    NotSameCanister(Principal),
70
71    #[error("caller '{0}' is not registered on the subnet registry")]
72    NotRegisteredToSubnet(Principal),
73
74    #[error("caller '{0}' is not on the whitelist")]
75    NotWhitelisted(Principal),
76}
77
78impl From<AuthAccessError> for Error {
79    fn from(err: AuthAccessError) -> Self {
80        AccessError::Auth(err).into()
81    }
82}
83
84/// Callable issuing an authorization decision for a given caller.
85pub type AuthRuleFn = Box<
86    dyn Fn(Principal) -> Pin<Box<dyn Future<Output = Result<(), AccessError>> + Send>>
87        + Send
88        + Sync,
89>;
90
91/// Future produced by an [`AuthRuleFn`].
92pub type AuthRuleResult = Pin<Box<dyn Future<Output = Result<(), AccessError>> + Send>>;
93
94/// Require that all provided rules pass for the current caller.
95///
96/// Returns the first failing rule error, or [`AuthError::NoRulesDefined`] if
97/// `rules` is empty.
98pub async fn require_all(rules: Vec<AuthRuleFn>) -> Result<(), AccessError> {
99    let caller = msg_caller();
100
101    if rules.is_empty() {
102        return Err(AuthAccessError::NoRulesDefined.into());
103    }
104
105    for rule in rules {
106        if let Err(err) = rule(caller).await {
107            log!(
108                Topic::Auth,
109                Warn,
110                "auth failed (all) caller={caller}: {err}",
111            );
112
113            return Err(err);
114        }
115    }
116
117    Ok(())
118}
119
120/// Require that any one of the provided rules passes for the current caller.
121///
122/// Returns the last failing rule error if none pass, or
123/// [`AuthError::NoRulesDefined`] if `rules` is empty.
124pub async fn require_any(rules: Vec<AuthRuleFn>) -> Result<(), AccessError> {
125    let caller = msg_caller();
126
127    if rules.is_empty() {
128        return Err(AuthAccessError::NoRulesDefined.into());
129    }
130
131    let mut last_error = None;
132    for rule in rules {
133        match rule(caller).await {
134            Ok(()) => return Ok(()),
135            Err(e) => last_error = Some(e),
136        }
137    }
138
139    let err = last_error.unwrap_or_else(|| AuthAccessError::InvalidState.into());
140    log!(
141        Topic::Auth,
142        Warn,
143        "auth failed (any) caller={caller}: {err}",
144    );
145
146    Err(err)
147}
148
149/// Enforce that every supplied rule future succeeds for the current caller.
150///
151/// This is a convenience wrapper around [`require_all`], allowing guard
152/// checks to stay in expression position within async endpoints.
153#[macro_export]
154macro_rules! auth_require_all {
155    ($($f:expr),* $(,)?) => {{
156        $crate::access::auth::require_all(vec![
157            $( Box::new(move |caller| Box::pin($f(caller))) ),*
158        ]).await
159    }};
160}
161
162/// Enforce that at least one supplied rule future succeeds for the current
163/// caller.
164///
165/// See [`auth_require_all!`] for details on accepted rule shapes.
166#[macro_export]
167macro_rules! auth_require_any {
168    ($($f:expr),* $(,)?) => {{
169        $crate::access::auth::require_any(vec![
170            $( Box::new(move |caller| Box::pin($f(caller))) ),*
171        ]).await
172    }};
173}
174
175// -----------------------------------------------------------------------------
176// Rule functions
177// -----------------------------------------------------------------------------
178
179/// Ensure the caller matches the subnet directory entry recorded for `role`.
180/// Use for admin endpoints that expect specific app directory canisters.
181#[must_use]
182pub fn is_app_directory_role(caller: Principal, role: CanisterRole) -> AuthRuleResult {
183    Box::pin(async move {
184        if AppDirectoryOps::matches(&role, caller) {
185            Ok(())
186        } else {
187            Err(AuthAccessError::NotAppDirectoryType(caller, role).into())
188        }
189    })
190}
191
192/// Require that the caller is a direct child of the current canister.
193/// Protects child-only endpoints (e.g., sync) from sibling/root callers.
194#[must_use]
195pub fn is_child(caller: Principal) -> AuthRuleResult {
196    Box::pin(async move {
197        if CanisterChildrenOps::contains_pid(&caller) {
198            Ok(())
199        } else {
200            Err(AuthAccessError::NotChild(caller).into())
201        }
202    })
203}
204
205/// Require that the caller controls the current canister.
206/// Allows controller-only maintenance calls.
207#[must_use]
208pub fn is_controller(caller: Principal) -> AuthRuleResult {
209    Box::pin(async move {
210        if crate::cdk::api::is_controller(&caller) {
211            Ok(())
212        } else {
213            Err(AuthAccessError::NotController(caller).into())
214        }
215    })
216}
217
218/// Require that the caller is the configured parent canister.
219/// Use on child sync endpoints to enforce parent-only calls.
220#[must_use]
221pub fn is_parent(caller: Principal) -> AuthRuleResult {
222    Box::pin(async move {
223        let parent_pid = EnvOps::parent_pid().map_err(to_access)?;
224
225        if parent_pid == caller {
226            Ok(())
227        } else {
228            Err(AuthAccessError::NotParent(caller).into())
229        }
230    })
231}
232
233/// Require that the caller equals the provided `expected` principal.
234/// Handy for single-tenant or pre-registered callers.
235#[must_use]
236pub fn is_principal(caller: Principal, expected: Principal) -> AuthRuleResult {
237    Box::pin(async move {
238        if caller == expected {
239            Ok(())
240        } else {
241            Err(AuthAccessError::NotPrincipal(caller, expected).into())
242        }
243    })
244}
245
246/// Require that the caller is registered as a canister on this subnet.
247///
248/// NOTE: Currently enforced only on the root canister.
249#[must_use]
250pub fn is_registered_to_subnet(caller: Principal) -> AuthRuleResult {
251    Box::pin(async move {
252        if SubnetRegistryOps::is_registered(caller) {
253            Ok(())
254        } else {
255            Err(AuthAccessError::NotRegisteredToSubnet(caller).into())
256        }
257    })
258}
259
260/// Require that the caller equals the configured root canister.
261/// Gate root-only operations (e.g., topology mutations).
262#[must_use]
263pub fn is_root(caller: Principal) -> AuthRuleResult {
264    Box::pin(async move {
265        let root_pid = EnvOps::root_pid().map_err(to_access)?;
266
267        if caller == root_pid {
268            Ok(())
269        } else {
270            Err(AuthAccessError::NotRoot(caller).into())
271        }
272    })
273}
274
275/// Require that the caller is the currently executing canister.
276/// For self-calls only.
277#[must_use]
278pub fn is_same_canister(caller: Principal) -> AuthRuleResult {
279    Box::pin(async move {
280        if caller == canister_self() {
281            Ok(())
282        } else {
283            Err(AuthAccessError::NotSameCanister(caller).into())
284        }
285    })
286}
287
288/// Ensure the caller matches the subnet directory entry recorded for `role`.
289/// Use for admin endpoints that expect specific app directory canisters.
290#[must_use]
291pub fn is_subnet_directory_role(caller: Principal, role: CanisterRole) -> AuthRuleResult {
292    Box::pin(async move {
293        match SubnetDirectoryOps::get(&role) {
294            Some(pid) if pid == caller => Ok(()),
295            _ => Err(AuthAccessError::NotSubnetDirectoryType(caller, role).into()),
296        }
297    })
298}
299
300/// Require that the caller appears in the active whitelist (IC deployments).
301/// No-op on local builds; enforces whitelist on IC.
302#[must_use]
303pub fn is_whitelisted(caller: Principal) -> AuthRuleResult {
304    Box::pin(async move {
305        use crate::config::Config;
306        let cfg = Config::get().map_err(to_access)?;
307
308        if !cfg.is_whitelisted(&caller) {
309            return Err(AuthAccessError::NotWhitelisted(caller).into());
310        }
311
312        Ok(())
313    })
314}
315
316/// to_access
317/// helper function
318fn to_access(err: Error) -> AccessError {
319    AuthAccessError::DependencyUnavailable(err.to_string()).into()
320}