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