Skip to main content

canic_core/access/auth/
mod.rs

1//! Module: access::auth
2//!
3//! Responsibility: resolve endpoint caller identity and enforce auth predicates.
4//! Does not own: endpoint response mapping, operation replay safety, or storage schema.
5//! Boundary: access expressions call auth predicates before endpoint workflow execution.
6
7mod attestation;
8mod identity;
9mod predicates;
10mod token;
11
12use crate::{access::AccessError, cdk::types::Principal};
13use std::fmt;
14
15///
16/// AuthenticatedIdentitySource
17///
18/// Source used to resolve the authenticated endpoint subject.
19/// Owned by access auth and stored in access evaluation context.
20///
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum AuthenticatedIdentitySource {
24    RawCaller,
25    DelegatedSession,
26}
27
28///
29/// ResolvedAuthenticatedIdentity
30///
31/// Transport caller plus resolved authenticated subject for access evaluation.
32/// Owned by access auth and returned to endpoint access plumbing.
33///
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub struct ResolvedAuthenticatedIdentity {
37    pub transport_caller: Principal,
38    pub authenticated_subject: Principal,
39    pub identity_source: AuthenticatedIdentitySource,
40}
41
42///
43/// DelegatedSessionSubjectRejection
44///
45/// Reason a delegated session subject cannot be accepted as a user identity.
46/// Owned by access auth and used to reject infrastructure principals.
47///
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum DelegatedSessionSubjectRejection {
51    Anonymous,
52    ManagementCanister,
53    LocalCanister,
54    RootCanister,
55    ParentCanister,
56    SubnetCanister,
57    FleetSubnetRootCanister,
58    DirectChildCanister,
59}
60
61impl fmt::Display for DelegatedSessionSubjectRejection {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        let reason = match self {
64            Self::Anonymous => "anonymous principals are not allowed",
65            Self::ManagementCanister => "management canister principal is not allowed",
66            Self::LocalCanister => "current canister principal is not allowed",
67            Self::RootCanister => "root canister principal is not allowed",
68            Self::ParentCanister => "parent canister principal is not allowed",
69            Self::SubnetCanister => "subnet principal is not allowed",
70            Self::FleetSubnetRootCanister => "Fleet Subnet Root principal is not allowed",
71            Self::DirectChildCanister => "direct child canister principal is not allowed",
72        };
73        f.write_str(reason)
74    }
75}
76
77/// resolve_authenticated_identity
78///
79/// Resolve transport caller and authenticated subject for user auth checks.
80#[must_use]
81pub fn resolve_authenticated_identity(
82    transport_caller: Principal,
83) -> ResolvedAuthenticatedIdentity {
84    identity::resolve_authenticated_identity(transport_caller)
85}
86
87/// validate_delegated_session_subject
88///
89/// Reject obvious canister and infrastructure identities for delegated user sessions.
90pub fn validate_delegated_session_subject(
91    subject: Principal,
92) -> Result<(), DelegatedSessionSubjectRejection> {
93    identity::validate_delegated_session_subject(subject)
94}
95
96pub(crate) fn delegated_token_verified(
97    authenticated_subject: Principal,
98    required_scope: Option<&str>,
99) -> Result<Principal, AccessError> {
100    token::delegated_token_verified(authenticated_subject, required_scope)
101}
102
103// -----------------------------------------------------------------------------
104// Caller & topology predicates
105// -----------------------------------------------------------------------------
106
107/// Require that the caller controls the current canister.
108/// Allows controller-only maintenance calls.
109pub async fn is_controller(caller: Principal) -> Result<(), AccessError> {
110    predicates::is_controller(caller).await
111}
112
113/// Require that the caller appears in the configured whitelist.
114/// Missing whitelist configuration fails closed.
115pub async fn is_whitelisted(caller: Principal) -> Result<(), AccessError> {
116    predicates::is_whitelisted(caller).await
117}
118
119/// Require that the caller is a direct child of the current canister.
120pub async fn is_child(caller: Principal) -> Result<(), AccessError> {
121    predicates::is_child(caller).await
122}
123
124/// Require that the caller is the configured parent canister.
125pub async fn is_parent(caller: Principal) -> Result<(), AccessError> {
126    predicates::is_parent(caller).await
127}
128
129/// Require that the caller equals the configured root canister.
130pub async fn is_root(caller: Principal) -> Result<(), AccessError> {
131    predicates::is_root(caller).await
132}
133
134/// Require that the caller is the currently executing canister.
135pub async fn is_same_canister(caller: Principal) -> Result<(), AccessError> {
136    predicates::is_same_canister(caller).await
137}
138
139/// Require a root-signed caller attestation bound to this canister's live Subnet.
140pub async fn is_attested_local_subnet(caller: Principal) -> Result<(), AccessError> {
141    attestation::is_attested_local_subnet(caller).await
142}
143
144const fn dependency_unavailable(error: crate::InternalError) -> AccessError {
145    AccessError::Internal(error)
146}
147
148// -----------------------------------------------------------------------------
149// Tests
150// -----------------------------------------------------------------------------
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::{
156        ids::CanisterRole,
157        ops::runtime::metrics::auth::{
158            AuthMetricOperation, AuthMetricOutcome, AuthMetricReason, AuthMetricSurface,
159            AuthMetrics,
160        },
161        test::seams,
162    };
163
164    fn p(id: u8) -> Principal {
165        Principal::from_slice(&[id; 29])
166    }
167
168    fn auth_identity_fallback_metric_count(reason: AuthMetricReason) -> u64 {
169        AuthMetrics::snapshot()
170            .into_iter()
171            .find_map(|(key, count)| {
172                if key.surface == AuthMetricSurface::Session
173                    && key.operation == AuthMetricOperation::IdentityFallback
174                    && key.outcome == AuthMetricOutcome::Completed
175                    && key.reason == reason
176                {
177                    Some(count)
178                } else {
179                    None
180                }
181            })
182            .unwrap_or(0)
183    }
184
185    #[test]
186    fn resolve_authenticated_identity_defaults_to_wallet_when_no_override_exists() {
187        let _guard = seams::lock();
188        AuthMetrics::reset();
189        let wallet = p(9);
190        crate::ops::storage::auth::AuthStateOps::clear_delegated_session(wallet);
191        let resolved = resolve_authenticated_identity(wallet);
192        assert_eq!(resolved.authenticated_subject, wallet);
193        assert_eq!(
194            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
195            1,
196            "missing delegated session should record raw-caller fallback"
197        );
198    }
199
200    #[test]
201    fn resolve_authenticated_identity_prefers_active_delegated_session() {
202        let _guard = seams::lock();
203        AuthMetrics::reset();
204        let wallet = p(8);
205        let delegated = p(7);
206        crate::ops::storage::auth::AuthStateOps::upsert_delegated_session(
207            crate::ops::storage::auth::DelegatedSession {
208                wallet_pid: wallet,
209                delegated_pid: delegated,
210                issued_at: 100,
211                expires_at: 200,
212                bootstrap_token_fingerprint: None,
213            },
214            100,
215        );
216
217        let resolved = identity::resolve_authenticated_identity_at(wallet, 150);
218        assert_eq!(resolved.transport_caller, wallet);
219        assert_eq!(resolved.authenticated_subject, delegated);
220        assert_eq!(
221            resolved.identity_source,
222            AuthenticatedIdentitySource::DelegatedSession
223        );
224        assert_eq!(
225            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
226            0,
227            "active delegated session should not fallback to raw caller"
228        );
229
230        crate::ops::storage::auth::AuthStateOps::clear_delegated_session(wallet);
231    }
232
233    #[test]
234    fn resolve_authenticated_identity_falls_back_when_session_expired() {
235        let _guard = seams::lock();
236        AuthMetrics::reset();
237        let wallet = p(6);
238        let delegated = p(5);
239        crate::ops::storage::auth::AuthStateOps::upsert_delegated_session(
240            crate::ops::storage::auth::DelegatedSession {
241                wallet_pid: wallet,
242                delegated_pid: delegated,
243                issued_at: 100,
244                expires_at: 120,
245                bootstrap_token_fingerprint: None,
246            },
247            100,
248        );
249
250        let resolved = identity::resolve_authenticated_identity_at(wallet, 121);
251        assert_eq!(resolved.authenticated_subject, wallet);
252        assert_eq!(
253            resolved.identity_source,
254            AuthenticatedIdentitySource::RawCaller
255        );
256        assert_eq!(
257            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
258            1,
259            "expired delegated session should fallback to raw caller"
260        );
261
262        crate::ops::storage::auth::AuthStateOps::clear_delegated_session(wallet);
263    }
264
265    #[test]
266    fn resolve_authenticated_identity_falls_back_at_session_expiry_boundary() {
267        let _guard = seams::lock();
268        AuthMetrics::reset();
269        let wallet = p(16);
270        let delegated = p(15);
271        crate::ops::storage::auth::AuthStateOps::upsert_delegated_session(
272            crate::ops::storage::auth::DelegatedSession {
273                wallet_pid: wallet,
274                delegated_pid: delegated,
275                issued_at: 100,
276                expires_at: 120,
277                bootstrap_token_fingerprint: None,
278            },
279            100,
280        );
281
282        let resolved = identity::resolve_authenticated_identity_at(wallet, 120);
283        assert_eq!(resolved.authenticated_subject, wallet);
284        assert_eq!(
285            resolved.identity_source,
286            AuthenticatedIdentitySource::RawCaller
287        );
288        assert_eq!(
289            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
290            1,
291            "delegated session expiry must match token expiry boundary"
292        );
293
294        crate::ops::storage::auth::AuthStateOps::clear_delegated_session(wallet);
295    }
296
297    #[test]
298    fn resolve_authenticated_identity_falls_back_after_clear() {
299        let _guard = seams::lock();
300        AuthMetrics::reset();
301        let wallet = p(4);
302        let delegated = p(3);
303        crate::ops::storage::auth::AuthStateOps::upsert_delegated_session(
304            crate::ops::storage::auth::DelegatedSession {
305                wallet_pid: wallet,
306                delegated_pid: delegated,
307                issued_at: 50,
308                expires_at: 500,
309                bootstrap_token_fingerprint: None,
310            },
311            50,
312        );
313        crate::ops::storage::auth::AuthStateOps::clear_delegated_session(wallet);
314
315        let resolved = identity::resolve_authenticated_identity_at(wallet, 100);
316        assert_eq!(resolved.authenticated_subject, wallet);
317        assert_eq!(
318            resolved.identity_source,
319            AuthenticatedIdentitySource::RawCaller
320        );
321        assert_eq!(
322            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
323            1
324        );
325    }
326
327    #[test]
328    fn resolve_authenticated_identity_records_invalid_subject_fallback() {
329        let _guard = seams::lock();
330        AuthMetrics::reset();
331        let wallet = p(23);
332        crate::ops::storage::auth::AuthStateOps::upsert_delegated_session(
333            crate::ops::storage::auth::DelegatedSession {
334                wallet_pid: wallet,
335                delegated_pid: Principal::management_canister(),
336                issued_at: 10,
337                expires_at: 100,
338                bootstrap_token_fingerprint: None,
339            },
340            10,
341        );
342
343        let resolved = identity::resolve_authenticated_identity_at(wallet, 20);
344        assert_eq!(resolved.authenticated_subject, wallet);
345        assert_eq!(
346            resolved.identity_source,
347            AuthenticatedIdentitySource::RawCaller
348        );
349        assert_eq!(
350            auth_identity_fallback_metric_count(AuthMetricReason::InvalidSubject),
351            1
352        );
353        assert_eq!(
354            auth_identity_fallback_metric_count(AuthMetricReason::RawCaller),
355            1
356        );
357        assert!(
358            crate::ops::storage::auth::AuthStateOps::delegated_session(wallet, 20).is_none(),
359            "invalid delegated session should be cleared"
360        );
361    }
362
363    #[test]
364    fn validate_delegated_session_subject_rejects_anonymous() {
365        let _guard = seams::lock();
366        let err = validate_delegated_session_subject(Principal::anonymous())
367            .expect_err("anonymous must be rejected");
368        assert_eq!(err, DelegatedSessionSubjectRejection::Anonymous);
369    }
370
371    #[test]
372    fn validate_delegated_session_subject_rejects_management_canister() {
373        let _guard = seams::lock();
374        let err = validate_delegated_session_subject(Principal::management_canister())
375            .expect_err("management canister must be rejected");
376        assert_eq!(err, DelegatedSessionSubjectRejection::ManagementCanister);
377    }
378
379    #[test]
380    fn validate_delegated_session_subject_rejects_direct_child() {
381        let _guard = seams::lock();
382        let child = p(31);
383        crate::ops::storage::children::CanisterChildrenOps::import_direct_children(
384            p(30),
385            vec![(child, CanisterRole::new("session_subject_child"))],
386        );
387
388        let err = validate_delegated_session_subject(child)
389            .expect_err("direct child canister must be rejected");
390        assert_eq!(err, DelegatedSessionSubjectRejection::DirectChildCanister);
391
392        crate::ops::storage::children::CanisterChildrenOps::import_direct_children(p(30), vec![]);
393    }
394}