Skip to main content

canic_core/access/auth/
mod.rs

1//! Auth access checks.
2//!
3//! This bucket includes:
4//! - caller identity checks (controller/whitelist)
5//! - topology checks (parent/child/root/same canister)
6//! - registry-based role checks
7//! - delegated token verification
8//!
9//! Security invariants for delegated tokens:
10//! - Delegated tokens are only valid if their proof matches a verifier-local keyed delegation proof.
11//! - Delegation rotation may retain multiple proofs concurrently until older tokens age out.
12//! - All temporal validation (iat/exp/now) is enforced before access is granted.
13//! - Endpoint-required scopes are enforced against delegated token claims.
14
15mod identity;
16mod predicates;
17mod token;
18
19use crate::{
20    access::AccessError,
21    cdk::types::Principal,
22    ids::CanisterRole,
23    ops::{
24        auth::VerifiedDelegatedToken, runtime::env::EnvOps,
25        storage::registry::subnet::SubnetRegistryOps,
26    },
27};
28use std::fmt;
29
30pub type Role = CanisterRole;
31
32///
33/// AuthenticatedIdentitySource
34///
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum AuthenticatedIdentitySource {
38    RawCaller,
39    DelegatedSession,
40}
41
42///
43/// ResolvedAuthenticatedIdentity
44///
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct ResolvedAuthenticatedIdentity {
48    pub transport_caller: Principal,
49    pub authenticated_subject: Principal,
50    pub identity_source: AuthenticatedIdentitySource,
51}
52
53///
54/// DelegatedSessionSubjectRejection
55///
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub enum DelegatedSessionSubjectRejection {
59    Anonymous,
60    ManagementCanister,
61    LocalCanister,
62    RootCanister,
63    ParentCanister,
64    SubnetCanister,
65    PrimeRootCanister,
66    RegisteredCanister,
67}
68
69impl fmt::Display for DelegatedSessionSubjectRejection {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        let reason = match self {
72            Self::Anonymous => "anonymous principals are not allowed",
73            Self::ManagementCanister => "management canister principal is not allowed",
74            Self::LocalCanister => "current canister principal is not allowed",
75            Self::RootCanister => "root canister principal is not allowed",
76            Self::ParentCanister => "parent canister principal is not allowed",
77            Self::SubnetCanister => "subnet principal is not allowed",
78            Self::PrimeRootCanister => "prime root principal is not allowed",
79            Self::RegisteredCanister => "subnet-registered canister principal is not allowed",
80        };
81        f.write_str(reason)
82    }
83}
84
85/// resolve_authenticated_identity
86///
87/// Resolve transport caller and authenticated subject for user auth checks.
88#[must_use]
89pub fn resolve_authenticated_identity(
90    transport_caller: Principal,
91) -> ResolvedAuthenticatedIdentity {
92    identity::resolve_authenticated_identity(transport_caller)
93}
94
95#[cfg(test)]
96pub(crate) fn resolve_authenticated_identity_at(
97    transport_caller: Principal,
98    now_secs: u64,
99) -> ResolvedAuthenticatedIdentity {
100    identity::resolve_authenticated_identity_at(transport_caller, now_secs)
101}
102
103/// validate_delegated_session_subject
104///
105/// Reject obvious canister and infrastructure identities for delegated user sessions.
106pub fn validate_delegated_session_subject(
107    subject: Principal,
108) -> Result<(), DelegatedSessionSubjectRejection> {
109    identity::validate_delegated_session_subject(subject)
110}
111
112pub(crate) async fn delegated_token_verified(
113    authenticated_subject: Principal,
114    required_scope: Option<&str>,
115) -> Result<VerifiedDelegatedToken, AccessError> {
116    token::delegated_token_verified(authenticated_subject, required_scope).await
117}
118
119#[cfg(test)]
120fn enforce_subject_binding(sub: Principal, caller: Principal) -> Result<(), AccessError> {
121    token::enforce_subject_binding(sub, caller)
122}
123
124#[cfg(test)]
125fn enforce_required_scope(
126    required_scope: Option<&str>,
127    token_scopes: &[String],
128) -> Result<(), AccessError> {
129    token::enforce_required_scope(required_scope, token_scopes)
130}
131
132// -----------------------------------------------------------------------------
133// Caller & topology predicates
134// -----------------------------------------------------------------------------
135
136/// Require that the caller controls the current canister.
137/// Allows controller-only maintenance calls.
138pub async fn is_controller(caller: Principal) -> Result<(), AccessError> {
139    predicates::is_controller(caller).await
140}
141
142/// Require that the caller appears in the active whitelist (IC deployments).
143/// No-op on local builds; enforces whitelist on IC.
144pub async fn is_whitelisted(caller: Principal) -> Result<(), AccessError> {
145    predicates::is_whitelisted(caller).await
146}
147
148/// Require that the caller is a direct child of the current canister.
149pub async fn is_child(caller: Principal) -> Result<(), AccessError> {
150    predicates::is_child(caller).await
151}
152
153/// Require that the caller is the configured parent canister.
154pub async fn is_parent(caller: Principal) -> Result<(), AccessError> {
155    predicates::is_parent(caller).await
156}
157
158/// Require that the caller equals the configured root canister.
159pub async fn is_root(caller: Principal) -> Result<(), AccessError> {
160    predicates::is_root(caller).await
161}
162
163/// Require that the caller is the currently executing canister.
164pub async fn is_same_canister(caller: Principal) -> Result<(), AccessError> {
165    predicates::is_same_canister(caller).await
166}
167
168// -----------------------------------------------------------------------------
169// Registry predicates
170// -----------------------------------------------------------------------------
171
172/// Require that the caller is registered with the expected canister role.
173pub async fn has_role(caller: Principal, role: Role) -> Result<(), AccessError> {
174    predicates::has_role(caller, role).await
175}
176
177/// Ensure the caller matches the app directory entry recorded for `role`.
178/// Require that the caller is registered as a canister on this subnet.
179pub async fn is_registered_to_subnet(caller: Principal) -> Result<(), AccessError> {
180    predicates::is_registered_to_subnet(caller).await
181}
182
183fn dependency_unavailable(detail: &str) -> AccessError {
184    AccessError::Denied(format!("access dependency unavailable: {detail}"))
185}
186
187fn non_root_subnet_registry_predicate_denial() -> AccessError {
188    AccessError::Denied(
189        "authentication error: illegal access to subnet registry predicate from non-root canister"
190            .to_string(),
191    )
192}
193
194fn caller_not_registered_denial(caller: Principal) -> AccessError {
195    let root = EnvOps::root_pid().map_or_else(|_| "unavailable".to_string(), |pid| pid.to_string());
196    let registry_count = SubnetRegistryOps::data().entries.len();
197    AccessError::Denied(format!(
198        "authentication error: caller '{caller}' is not registered on the subnet registry \
199         (root='{root}', registry_entries={registry_count}); verify caller root routing and \
200         canic_subnet_registry state"
201    ))
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::{
208        ids::{AccessMetricKind, cap},
209        ops::runtime::metrics::access::AccessMetrics,
210        test::seams,
211    };
212
213    fn p(id: u8) -> Principal {
214        Principal::from_slice(&[id; 29])
215    }
216
217    fn auth_session_metric_count(predicate: &str) -> u64 {
218        AccessMetrics::snapshot()
219            .entries
220            .into_iter()
221            .find_map(|(key, count)| {
222                if key.endpoint == "auth_session"
223                    && key.kind == AccessMetricKind::Auth
224                    && key.predicate == predicate
225                {
226                    Some(count)
227                } else {
228                    None
229                }
230            })
231            .unwrap_or(0)
232    }
233
234    #[test]
235    fn subject_binding_allows_matching_subject_and_caller() {
236        let sub = p(1);
237        let caller = p(1);
238        assert!(enforce_subject_binding(sub, caller).is_ok());
239    }
240
241    #[test]
242    fn subject_binding_rejects_mismatched_subject_and_caller() {
243        let sub = p(1);
244        let caller = p(2);
245        let err = enforce_subject_binding(sub, caller).expect_err("expected subject mismatch");
246        assert!(err.to_string().contains("does not match caller"));
247    }
248
249    #[test]
250    fn required_scope_allows_when_scope_present() {
251        let scopes = vec![cap::READ.to_string(), cap::VERIFY.to_string()];
252        assert!(enforce_required_scope(Some(cap::VERIFY), &scopes).is_ok());
253    }
254
255    #[test]
256    fn required_scope_rejects_when_scope_missing() {
257        let scopes = vec![cap::READ.to_string()];
258        let err = enforce_required_scope(Some(cap::VERIFY), &scopes).expect_err("expected denial");
259        assert!(err.to_string().contains("missing required scope"));
260    }
261
262    #[test]
263    fn required_scope_none_is_allowed() {
264        let scopes = vec![cap::READ.to_string()];
265        assert!(enforce_required_scope(None, &scopes).is_ok());
266    }
267
268    #[test]
269    fn resolve_authenticated_identity_defaults_to_wallet_when_no_override_exists() {
270        let _guard = seams::lock();
271        AccessMetrics::reset();
272        let wallet = p(9);
273        crate::ops::storage::auth::DelegationStateOps::clear_delegated_session(wallet);
274        let resolved = resolve_authenticated_identity(wallet);
275        assert_eq!(resolved.authenticated_subject, wallet);
276        assert_eq!(
277            auth_session_metric_count("session_fallback_raw_caller"),
278            1,
279            "missing delegated session should record raw-caller fallback"
280        );
281    }
282
283    #[test]
284    fn resolve_authenticated_identity_prefers_active_delegated_session() {
285        let _guard = seams::lock();
286        AccessMetrics::reset();
287        let wallet = p(8);
288        let delegated = p(7);
289        crate::ops::storage::auth::DelegationStateOps::upsert_delegated_session(
290            crate::ops::storage::auth::DelegatedSession {
291                wallet_pid: wallet,
292                delegated_pid: delegated,
293                issued_at: 100,
294                expires_at: 200,
295                bootstrap_token_fingerprint: None,
296            },
297            100,
298        );
299
300        let resolved = resolve_authenticated_identity_at(wallet, 150);
301        assert_eq!(resolved.transport_caller, wallet);
302        assert_eq!(resolved.authenticated_subject, delegated);
303        assert_eq!(
304            resolved.identity_source,
305            AuthenticatedIdentitySource::DelegatedSession
306        );
307        assert_eq!(
308            auth_session_metric_count("session_fallback_raw_caller"),
309            0,
310            "active delegated session should not fallback to raw caller"
311        );
312
313        crate::ops::storage::auth::DelegationStateOps::clear_delegated_session(wallet);
314    }
315
316    #[test]
317    fn resolve_authenticated_identity_falls_back_when_session_expired() {
318        let _guard = seams::lock();
319        AccessMetrics::reset();
320        let wallet = p(6);
321        let delegated = p(5);
322        crate::ops::storage::auth::DelegationStateOps::upsert_delegated_session(
323            crate::ops::storage::auth::DelegatedSession {
324                wallet_pid: wallet,
325                delegated_pid: delegated,
326                issued_at: 100,
327                expires_at: 120,
328                bootstrap_token_fingerprint: None,
329            },
330            100,
331        );
332
333        let resolved = resolve_authenticated_identity_at(wallet, 121);
334        assert_eq!(resolved.authenticated_subject, wallet);
335        assert_eq!(
336            resolved.identity_source,
337            AuthenticatedIdentitySource::RawCaller
338        );
339        assert_eq!(
340            auth_session_metric_count("session_fallback_raw_caller"),
341            1,
342            "expired delegated session should fallback to raw caller"
343        );
344
345        crate::ops::storage::auth::DelegationStateOps::clear_delegated_session(wallet);
346    }
347
348    #[test]
349    fn resolve_authenticated_identity_falls_back_after_clear() {
350        let _guard = seams::lock();
351        AccessMetrics::reset();
352        let wallet = p(4);
353        let delegated = p(3);
354        crate::ops::storage::auth::DelegationStateOps::upsert_delegated_session(
355            crate::ops::storage::auth::DelegatedSession {
356                wallet_pid: wallet,
357                delegated_pid: delegated,
358                issued_at: 50,
359                expires_at: 500,
360                bootstrap_token_fingerprint: None,
361            },
362            50,
363        );
364        crate::ops::storage::auth::DelegationStateOps::clear_delegated_session(wallet);
365
366        let resolved = resolve_authenticated_identity_at(wallet, 100);
367        assert_eq!(resolved.authenticated_subject, wallet);
368        assert_eq!(
369            resolved.identity_source,
370            AuthenticatedIdentitySource::RawCaller
371        );
372        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
373    }
374
375    #[test]
376    fn resolve_authenticated_identity_records_invalid_subject_fallback() {
377        let _guard = seams::lock();
378        AccessMetrics::reset();
379        let wallet = p(23);
380        crate::ops::storage::auth::DelegationStateOps::upsert_delegated_session(
381            crate::ops::storage::auth::DelegatedSession {
382                wallet_pid: wallet,
383                delegated_pid: Principal::management_canister(),
384                issued_at: 10,
385                expires_at: 100,
386                bootstrap_token_fingerprint: None,
387            },
388            10,
389        );
390
391        let resolved = resolve_authenticated_identity_at(wallet, 20);
392        assert_eq!(resolved.authenticated_subject, wallet);
393        assert_eq!(
394            resolved.identity_source,
395            AuthenticatedIdentitySource::RawCaller
396        );
397        assert_eq!(
398            auth_session_metric_count("session_fallback_invalid_subject"),
399            1
400        );
401        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
402        assert!(
403            crate::ops::storage::auth::DelegationStateOps::delegated_session(wallet, 20).is_none(),
404            "invalid delegated session should be cleared"
405        );
406    }
407
408    #[test]
409    fn validate_delegated_session_subject_rejects_anonymous() {
410        let _guard = seams::lock();
411        let err = validate_delegated_session_subject(Principal::anonymous())
412            .expect_err("anonymous must be rejected");
413        assert_eq!(err, DelegatedSessionSubjectRejection::Anonymous);
414    }
415
416    #[test]
417    fn validate_delegated_session_subject_rejects_management_canister() {
418        let _guard = seams::lock();
419        let err = validate_delegated_session_subject(Principal::management_canister())
420            .expect_err("management canister must be rejected");
421        assert_eq!(err, DelegatedSessionSubjectRejection::ManagementCanister);
422    }
423}