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