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