Skip to main content

canic_core/access/
auth.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 the currently stored delegation proof.
11//! - Delegation rotation invalidates all previously issued delegated tokens.
12//! - All temporal validation (iat/exp/now) is enforced before access is granted.
13//! - Endpoint-required scopes are enforced against delegated token claims.
14
15use crate::{
16    access::AccessError,
17    cdk::{
18        api::{canister_self, is_controller as caller_is_controller, msg_arg_data},
19        candid::de::IDLDeserialize,
20        types::Principal,
21    },
22    config::Config,
23    dto::auth::DelegatedToken,
24    ids::CanisterRole,
25    ops::{
26        auth::{DelegatedTokenOps, VerifiedDelegatedToken},
27        ic::IcOps,
28        runtime::env::EnvOps,
29        runtime::metrics::auth::{
30            record_session_fallback_invalid_subject, record_session_fallback_raw_caller,
31        },
32        storage::{
33            auth::DelegationStateOps, children::CanisterChildrenOps,
34            registry::subnet::SubnetRegistryOps,
35        },
36    },
37};
38use std::fmt;
39
40const MAX_INGRESS_BYTES: usize = 64 * 1024; // 64 KiB
41
42pub type Role = CanisterRole;
43
44///
45/// AuthenticatedIdentitySource
46///
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum AuthenticatedIdentitySource {
50    RawCaller,
51    DelegatedSession,
52}
53
54///
55/// ResolvedAuthenticatedIdentity
56///
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct ResolvedAuthenticatedIdentity {
60    pub transport_caller: Principal,
61    pub authenticated_subject: Principal,
62    pub identity_source: AuthenticatedIdentitySource,
63}
64
65///
66/// DelegatedSessionSubjectRejection
67///
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub enum DelegatedSessionSubjectRejection {
71    Anonymous,
72    ManagementCanister,
73    LocalCanister,
74    RootCanister,
75    ParentCanister,
76    SubnetCanister,
77    PrimeRootCanister,
78    RegisteredCanister,
79}
80
81impl fmt::Display for DelegatedSessionSubjectRejection {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let reason = match self {
84            Self::Anonymous => "anonymous principals are not allowed",
85            Self::ManagementCanister => "management canister principal is not allowed",
86            Self::LocalCanister => "current canister principal is not allowed",
87            Self::RootCanister => "root canister principal is not allowed",
88            Self::ParentCanister => "parent canister principal is not allowed",
89            Self::SubnetCanister => "subnet principal is not allowed",
90            Self::PrimeRootCanister => "prime root principal is not allowed",
91            Self::RegisteredCanister => "subnet-registered canister principal is not allowed",
92        };
93        f.write_str(reason)
94    }
95}
96
97///
98/// CallerBoundToken
99///
100/// Verified delegated token that has passed caller-subject binding.
101struct CallerBoundToken {
102    verified: VerifiedDelegatedToken,
103}
104
105impl CallerBoundToken {
106    /// bind_to_caller
107    ///
108    /// Enforce subject binding and return a caller-bound token wrapper.
109    fn bind_to_caller(
110        verified: VerifiedDelegatedToken,
111        caller: Principal,
112    ) -> Result<Self, AccessError> {
113        enforce_subject_binding(verified.claims.sub, caller)?;
114        Ok(Self { verified })
115    }
116
117    /// scopes
118    ///
119    /// Borrow token scopes after caller binding has been enforced.
120    fn scopes(&self) -> &[String] {
121        &self.verified.claims.scopes
122    }
123
124    /// into_verified
125    ///
126    /// Unwrap the verified delegated token for downstream consumers.
127    fn into_verified(self) -> VerifiedDelegatedToken {
128        self.verified
129    }
130}
131
132/// resolve_authenticated_identity
133///
134/// Resolve transport caller and authenticated subject for user auth checks.
135#[must_use]
136pub fn resolve_authenticated_identity(
137    transport_caller: Principal,
138) -> ResolvedAuthenticatedIdentity {
139    resolve_authenticated_identity_at(transport_caller, IcOps::now_secs())
140}
141
142/// resolve_authenticated_caller
143///
144/// Compatibility shim returning the resolved authenticated subject.
145#[must_use]
146pub fn resolve_authenticated_caller(caller: Principal) -> Principal {
147    resolve_authenticated_identity(caller).authenticated_subject
148}
149
150pub(crate) fn resolve_authenticated_identity_at(
151    transport_caller: Principal,
152    now_secs: u64,
153) -> ResolvedAuthenticatedIdentity {
154    if let Some(session) = DelegationStateOps::delegated_session(transport_caller, now_secs) {
155        if validate_delegated_session_subject(session.delegated_pid).is_ok() {
156            return ResolvedAuthenticatedIdentity {
157                transport_caller,
158                authenticated_subject: session.delegated_pid,
159                identity_source: AuthenticatedIdentitySource::DelegatedSession,
160            };
161        }
162
163        DelegationStateOps::clear_delegated_session(transport_caller);
164        record_session_fallback_invalid_subject();
165    }
166
167    record_session_fallback_raw_caller();
168    ResolvedAuthenticatedIdentity {
169        transport_caller,
170        authenticated_subject: transport_caller,
171        identity_source: AuthenticatedIdentitySource::RawCaller,
172    }
173}
174
175/// validate_delegated_session_subject
176///
177/// Reject obvious canister and infrastructure identities for delegated user sessions.
178pub fn validate_delegated_session_subject(
179    subject: Principal,
180) -> Result<(), DelegatedSessionSubjectRejection> {
181    if subject == Principal::anonymous() {
182        return Err(DelegatedSessionSubjectRejection::Anonymous);
183    }
184
185    if subject == Principal::management_canister() {
186        return Err(DelegatedSessionSubjectRejection::ManagementCanister);
187    }
188
189    if try_canister_self().is_some_and(|pid| pid == subject) {
190        return Err(DelegatedSessionSubjectRejection::LocalCanister);
191    }
192
193    let env = EnvOps::snapshot();
194    if env.root_pid.is_some_and(|pid| pid == subject) {
195        return Err(DelegatedSessionSubjectRejection::RootCanister);
196    }
197    if env.parent_pid.is_some_and(|pid| pid == subject) {
198        return Err(DelegatedSessionSubjectRejection::ParentCanister);
199    }
200    if env.subnet_pid.is_some_and(|pid| pid == subject) {
201        return Err(DelegatedSessionSubjectRejection::SubnetCanister);
202    }
203    if env.prime_root_pid.is_some_and(|pid| pid == subject) {
204        return Err(DelegatedSessionSubjectRejection::PrimeRootCanister);
205    }
206    if SubnetRegistryOps::is_registered(subject) {
207        return Err(DelegatedSessionSubjectRejection::RegisteredCanister);
208    }
209
210    Ok(())
211}
212
213#[cfg(target_arch = "wasm32")]
214#[expect(clippy::unnecessary_wraps)]
215fn try_canister_self() -> Option<Principal> {
216    Some(IcOps::canister_self())
217}
218
219#[cfg(not(target_arch = "wasm32"))]
220const fn try_canister_self() -> Option<Principal> {
221    None
222}
223
224pub(crate) async fn delegated_token_verified(
225    authenticated_subject: Principal,
226    required_scope: Option<&str>,
227) -> Result<VerifiedDelegatedToken, AccessError> {
228    let token = delegated_token_from_args()?;
229
230    let authority_pid =
231        EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
232
233    let now_secs = IcOps::now_secs();
234    let self_pid = IcOps::canister_self();
235
236    verify_token(
237        token,
238        authenticated_subject,
239        authority_pid,
240        now_secs,
241        self_pid,
242        required_scope,
243    )
244    .await
245}
246
247/// Verify a delegated token against the configured authority.
248#[expect(clippy::unused_async)]
249async fn verify_token(
250    token: DelegatedToken,
251    caller: Principal,
252    authority_pid: Principal,
253    now_secs: u64,
254    self_pid: Principal,
255    required_scope: Option<&str>,
256) -> Result<VerifiedDelegatedToken, AccessError> {
257    let verified = DelegatedTokenOps::verify_token(&token, authority_pid, now_secs, self_pid)
258        .map_err(|err| AccessError::Denied(err.to_string()))?;
259
260    let caller_bound = CallerBoundToken::bind_to_caller(verified, caller)?;
261    enforce_required_scope(required_scope, caller_bound.scopes())?;
262
263    Ok(caller_bound.into_verified())
264}
265
266fn enforce_subject_binding(sub: Principal, caller: Principal) -> Result<(), AccessError> {
267    if sub == caller {
268        Ok(())
269    } else {
270        Err(AccessError::Denied(format!(
271            "delegated token subject '{sub}' does not match caller '{caller}'"
272        )))
273    }
274}
275
276fn enforce_required_scope(
277    required_scope: Option<&str>,
278    token_scopes: &[String],
279) -> Result<(), AccessError> {
280    let Some(required_scope) = required_scope else {
281        return Ok(());
282    };
283
284    if token_scopes.iter().any(|scope| scope == required_scope) {
285        Ok(())
286    } else {
287        Err(AccessError::Denied(format!(
288            "delegated token missing required scope '{required_scope}'"
289        )))
290    }
291}
292
293// -----------------------------------------------------------------------------
294// Caller & topology predicates
295// -----------------------------------------------------------------------------
296
297/// Require that the caller controls the current canister.
298/// Allows controller-only maintenance calls.
299#[expect(clippy::unused_async)]
300pub async fn is_controller(caller: Principal) -> Result<(), AccessError> {
301    if caller_is_controller(&caller) {
302        Ok(())
303    } else {
304        Err(AccessError::Denied(format!(
305            "caller '{caller}' is not a controller of this canister"
306        )))
307    }
308}
309
310/// Require that the caller appears in the active whitelist (IC deployments).
311/// No-op on local builds; enforces whitelist on IC.
312#[expect(clippy::unused_async)]
313pub async fn is_whitelisted(caller: Principal) -> Result<(), AccessError> {
314    let cfg = Config::try_get().ok_or_else(|| dependency_unavailable("config not initialized"))?;
315
316    if !cfg.is_whitelisted(&caller) {
317        return Err(AccessError::Denied(format!(
318            "caller '{caller}' is not on the whitelist"
319        )));
320    }
321
322    Ok(())
323}
324
325/// Require that the caller is a direct child of the current canister.
326#[expect(clippy::unused_async)]
327pub async fn is_child(caller: Principal) -> Result<(), AccessError> {
328    if CanisterChildrenOps::contains_pid(&caller) {
329        Ok(())
330    } else {
331        Err(AccessError::Denied(format!(
332            "caller '{caller}' is not a child of this canister"
333        )))
334    }
335}
336
337/// Require that the caller is the configured parent canister.
338#[expect(clippy::unused_async)]
339pub async fn is_parent(caller: Principal) -> Result<(), AccessError> {
340    let snapshot = EnvOps::snapshot();
341    let parent_pid = snapshot
342        .parent_pid
343        .ok_or_else(|| dependency_unavailable("parent pid unavailable"))?;
344
345    if parent_pid == caller {
346        Ok(())
347    } else {
348        Err(AccessError::Denied(format!(
349            "caller '{caller}' is not the parent of this canister"
350        )))
351    }
352}
353
354/// Require that the caller equals the configured root canister.
355#[expect(clippy::unused_async)]
356pub async fn is_root(caller: Principal) -> Result<(), AccessError> {
357    let root_pid =
358        EnvOps::root_pid().map_err(|_| dependency_unavailable("root pid unavailable"))?;
359
360    if caller == root_pid {
361        Ok(())
362    } else {
363        Err(AccessError::Denied(format!(
364            "caller '{caller}' is not root"
365        )))
366    }
367}
368
369/// Require that the caller is the currently executing canister.
370#[expect(clippy::unused_async)]
371pub async fn is_same_canister(caller: Principal) -> Result<(), AccessError> {
372    if caller == canister_self() {
373        Ok(())
374    } else {
375        Err(AccessError::Denied(format!(
376            "caller '{caller}' is not the current canister"
377        )))
378    }
379}
380
381// -----------------------------------------------------------------------------
382// Registry predicates
383// -----------------------------------------------------------------------------
384
385/// Require that the caller is registered with the expected canister role.
386#[expect(clippy::unused_async)]
387pub async fn has_role(caller: Principal, role: Role) -> Result<(), AccessError> {
388    if !EnvOps::is_root() {
389        return Err(non_root_subnet_registry_predicate_denial());
390    }
391
392    let record =
393        SubnetRegistryOps::get(caller).ok_or_else(|| caller_not_registered_denial(caller))?;
394
395    if record.role == role {
396        Ok(())
397    } else {
398        Err(AccessError::Denied(format!(
399            "authentication error: caller '{caller}' does not have role '{role}'"
400        )))
401    }
402}
403
404/// Ensure the caller matches the app directory entry recorded for `role`.
405/// Require that the caller is registered as a canister on this subnet.
406#[expect(clippy::unused_async)]
407pub async fn is_registered_to_subnet(caller: Principal) -> Result<(), AccessError> {
408    if !EnvOps::is_root() {
409        return Err(non_root_subnet_registry_predicate_denial());
410    }
411
412    if SubnetRegistryOps::is_registered(caller) {
413        Ok(())
414    } else {
415        Err(caller_not_registered_denial(caller))
416    }
417}
418
419fn delegated_token_from_args() -> Result<DelegatedToken, AccessError> {
420    let bytes = msg_arg_data();
421
422    if bytes.len() > MAX_INGRESS_BYTES {
423        return Err(AccessError::Denied(
424            "delegated token payload exceeds size limit".to_string(),
425        ));
426    }
427
428    let mut decoder = IDLDeserialize::new(&bytes)
429        .map_err(|err| AccessError::Denied(format!("failed to decode ingress arguments: {err}")))?;
430
431    decoder.get_value::<DelegatedToken>().map_err(|err| {
432        AccessError::Denied(format!(
433            "failed to decode delegated token as first argument: {err}"
434        ))
435    })
436}
437
438fn dependency_unavailable(detail: &str) -> AccessError {
439    AccessError::Denied(format!("access dependency unavailable: {detail}"))
440}
441
442fn non_root_subnet_registry_predicate_denial() -> AccessError {
443    AccessError::Denied(
444        "authentication error: illegal access to subnet registry predicate from non-root canister"
445            .to_string(),
446    )
447}
448
449fn caller_not_registered_denial(caller: Principal) -> AccessError {
450    let root = EnvOps::root_pid().map_or_else(|_| "unavailable".to_string(), |pid| pid.to_string());
451    let registry_count = SubnetRegistryOps::data().entries.len();
452    AccessError::Denied(format!(
453        "authentication error: caller '{caller}' is not registered on the subnet registry \
454         (root='{root}', registry_entries={registry_count}); verify caller root routing and \
455         canic_subnet_registry state"
456    ))
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::{
463        ids::{AccessMetricKind, cap},
464        ops::runtime::metrics::access::AccessMetrics,
465        test::seams,
466    };
467
468    fn p(id: u8) -> Principal {
469        Principal::from_slice(&[id; 29])
470    }
471
472    fn auth_session_metric_count(predicate: &str) -> u64 {
473        AccessMetrics::snapshot()
474            .entries
475            .into_iter()
476            .find_map(|(key, count)| {
477                if key.endpoint == "auth_session"
478                    && key.kind == AccessMetricKind::Auth
479                    && key.predicate == predicate
480                {
481                    Some(count)
482                } else {
483                    None
484                }
485            })
486            .unwrap_or(0)
487    }
488
489    #[test]
490    fn subject_binding_allows_matching_subject_and_caller() {
491        let sub = p(1);
492        let caller = p(1);
493        assert!(enforce_subject_binding(sub, caller).is_ok());
494    }
495
496    #[test]
497    fn subject_binding_rejects_mismatched_subject_and_caller() {
498        let sub = p(1);
499        let caller = p(2);
500        let err = enforce_subject_binding(sub, caller).expect_err("expected subject mismatch");
501        assert!(err.to_string().contains("does not match caller"));
502    }
503
504    #[test]
505    fn required_scope_allows_when_scope_present() {
506        let scopes = vec![cap::READ.to_string(), cap::VERIFY.to_string()];
507        assert!(enforce_required_scope(Some(cap::VERIFY), &scopes).is_ok());
508    }
509
510    #[test]
511    fn required_scope_rejects_when_scope_missing() {
512        let scopes = vec![cap::READ.to_string()];
513        let err = enforce_required_scope(Some(cap::VERIFY), &scopes).expect_err("expected denial");
514        assert!(err.to_string().contains("missing required scope"));
515    }
516
517    #[test]
518    fn required_scope_none_is_allowed() {
519        let scopes = vec![cap::READ.to_string()];
520        assert!(enforce_required_scope(None, &scopes).is_ok());
521    }
522
523    #[test]
524    fn resolve_authenticated_caller_defaults_to_wallet_when_no_override_exists() {
525        let _guard = seams::lock();
526        AccessMetrics::reset();
527        let wallet = p(9);
528        DelegationStateOps::clear_delegated_session(wallet);
529        assert_eq!(resolve_authenticated_caller(wallet), wallet);
530        assert_eq!(
531            auth_session_metric_count("session_fallback_raw_caller"),
532            1,
533            "missing delegated session should record raw-caller fallback"
534        );
535    }
536
537    #[test]
538    fn resolve_authenticated_identity_prefers_active_delegated_session() {
539        let _guard = seams::lock();
540        AccessMetrics::reset();
541        let wallet = p(8);
542        let delegated = p(7);
543        DelegationStateOps::upsert_delegated_session(
544            crate::ops::storage::auth::DelegatedSession {
545                wallet_pid: wallet,
546                delegated_pid: delegated,
547                issued_at: 100,
548                expires_at: 200,
549                bootstrap_token_fingerprint: None,
550            },
551            100,
552        );
553
554        let resolved = resolve_authenticated_identity_at(wallet, 150);
555        assert_eq!(resolved.transport_caller, wallet);
556        assert_eq!(resolved.authenticated_subject, delegated);
557        assert_eq!(
558            resolved.identity_source,
559            AuthenticatedIdentitySource::DelegatedSession
560        );
561        assert_eq!(
562            auth_session_metric_count("session_fallback_raw_caller"),
563            0,
564            "active delegated session should not fallback to raw caller"
565        );
566
567        DelegationStateOps::clear_delegated_session(wallet);
568    }
569
570    #[test]
571    fn resolve_authenticated_identity_falls_back_when_session_expired() {
572        let _guard = seams::lock();
573        AccessMetrics::reset();
574        let wallet = p(6);
575        let delegated = p(5);
576        DelegationStateOps::upsert_delegated_session(
577            crate::ops::storage::auth::DelegatedSession {
578                wallet_pid: wallet,
579                delegated_pid: delegated,
580                issued_at: 100,
581                expires_at: 120,
582                bootstrap_token_fingerprint: None,
583            },
584            100,
585        );
586
587        let resolved = resolve_authenticated_identity_at(wallet, 121);
588        assert_eq!(resolved.authenticated_subject, wallet);
589        assert_eq!(
590            resolved.identity_source,
591            AuthenticatedIdentitySource::RawCaller
592        );
593        assert_eq!(
594            auth_session_metric_count("session_fallback_raw_caller"),
595            1,
596            "expired delegated session should fallback to raw caller"
597        );
598
599        DelegationStateOps::clear_delegated_session(wallet);
600    }
601
602    #[test]
603    fn resolve_authenticated_identity_falls_back_after_clear() {
604        let _guard = seams::lock();
605        AccessMetrics::reset();
606        let wallet = p(4);
607        let delegated = p(3);
608        DelegationStateOps::upsert_delegated_session(
609            crate::ops::storage::auth::DelegatedSession {
610                wallet_pid: wallet,
611                delegated_pid: delegated,
612                issued_at: 50,
613                expires_at: 500,
614                bootstrap_token_fingerprint: None,
615            },
616            50,
617        );
618        DelegationStateOps::clear_delegated_session(wallet);
619
620        let resolved = resolve_authenticated_identity_at(wallet, 100);
621        assert_eq!(resolved.authenticated_subject, wallet);
622        assert_eq!(
623            resolved.identity_source,
624            AuthenticatedIdentitySource::RawCaller
625        );
626        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
627    }
628
629    #[test]
630    fn resolve_authenticated_identity_records_invalid_subject_fallback() {
631        let _guard = seams::lock();
632        AccessMetrics::reset();
633        let wallet = p(23);
634        DelegationStateOps::upsert_delegated_session(
635            crate::ops::storage::auth::DelegatedSession {
636                wallet_pid: wallet,
637                delegated_pid: Principal::management_canister(),
638                issued_at: 10,
639                expires_at: 100,
640                bootstrap_token_fingerprint: None,
641            },
642            10,
643        );
644
645        let resolved = resolve_authenticated_identity_at(wallet, 20);
646        assert_eq!(resolved.authenticated_subject, wallet);
647        assert_eq!(
648            resolved.identity_source,
649            AuthenticatedIdentitySource::RawCaller
650        );
651        assert_eq!(
652            auth_session_metric_count("session_fallback_invalid_subject"),
653            1
654        );
655        assert_eq!(auth_session_metric_count("session_fallback_raw_caller"), 1);
656        assert!(
657            DelegationStateOps::delegated_session(wallet, 20).is_none(),
658            "invalid delegated session should be cleared"
659        );
660    }
661
662    #[test]
663    fn validate_delegated_session_subject_rejects_anonymous() {
664        let _guard = seams::lock();
665        let err = validate_delegated_session_subject(Principal::anonymous())
666            .expect_err("anonymous must be rejected");
667        assert_eq!(err, DelegatedSessionSubjectRejection::Anonymous);
668    }
669
670    #[test]
671    fn validate_delegated_session_subject_rejects_management_canister() {
672        let _guard = seams::lock();
673        let err = validate_delegated_session_subject(Principal::management_canister())
674            .expect_err("management canister must be rejected");
675        assert_eq!(err, DelegatedSessionSubjectRejection::ManagementCanister);
676    }
677}