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