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