icarus_canister/
auth.rs

1//! Authentication and authorization system for Icarus canisters
2//!
3//! Provides a comprehensive authentication system with audit trails,
4//! role-based access control, and secure principal management.
5
6use crate::{memory_id, stable_storage, IcarusStorable};
7use candid::{CandidType, Deserialize, Principal};
8use ic_stable_structures::memory_manager::VirtualMemory;
9use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap};
10use serde::Serialize;
11
12type Memory = VirtualMemory<DefaultMemoryImpl>;
13
14/// User entry with full audit trail
15#[derive(Debug, Clone, Serialize, Deserialize, CandidType, IcarusStorable)]
16#[icarus_storable(unbounded)]
17pub struct User {
18    pub principal: Principal,
19    pub added_at: u64,
20    pub added_by: Principal,
21    pub role: AuthRole,
22    pub active: bool,
23    pub last_access: Option<u64>,
24    pub access_count: u64,
25}
26
27/// Role-based access control
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, CandidType)]
29pub enum AuthRole {
30    Owner,    // Full access, can manage all users
31    Admin,    // Can add/remove users, view audit logs
32    User,     // Normal tool access
33    ReadOnly, // Query-only access
34}
35
36/// Authentication result with detailed information
37#[derive(Debug, Clone, Serialize, Deserialize, CandidType)]
38pub struct AuthInfo {
39    pub principal: String,
40    pub role: AuthRole,
41    pub is_authenticated: bool,
42    pub last_access: Option<u64>,
43    pub access_count: u64,
44    pub message: String,
45}
46
47/// Audit log entry for authentication events
48#[derive(Debug, Clone, Serialize, Deserialize, CandidType, IcarusStorable)]
49#[icarus_storable(unbounded)]
50pub struct AuthAuditEntry {
51    pub id: String,
52    pub timestamp: u64,
53    pub action: AuthAction,
54    pub principal: Principal,
55    pub target_principal: Option<Principal>,
56    pub performed_by: Principal,
57    pub success: bool,
58    pub details: String,
59}
60
61/// Authentication actions for audit logging
62#[derive(Debug, Clone, Serialize, Deserialize, CandidType)]
63pub enum AuthAction {
64    AddUser,
65    RemoveUser,
66    UpdateRole,
67    DeactivateUser,
68    ReactivateUser,
69    AccessGranted,
70    AccessDenied,
71    ViewAuditLog,
72}
73
74// Declare stable storage for authentication system
75stable_storage! {
76    AUTH_USERS: StableBTreeMap<Principal, User, Memory> = memory_id!(10);
77    AUTH_AUDIT: StableBTreeMap<String, AuthAuditEntry, Memory> = memory_id!(11);
78    AUTH_COUNTER: u64 = 0;
79}
80
81/// Initialize the authentication system with the canister owner
82pub fn init_auth(owner: Principal) {
83    // Security check: prevent anonymous principal from being owner
84    if owner == Principal::anonymous() {
85        ic_cdk::trap("Security Error: Anonymous principal cannot be set as owner");
86    }
87
88    AUTH_USERS.with(|users| {
89        let owner_entry = User {
90            principal: owner,
91            added_at: ic_cdk::api::time(),
92            added_by: owner, // Self-added
93            role: AuthRole::Owner,
94            active: true,
95            last_access: None,
96            access_count: 0,
97        };
98        users.borrow_mut().insert(owner, owner_entry);
99    });
100
101    log_auth_action(
102        AuthAction::AddUser,
103        owner,
104        Some(owner),
105        owner,
106        true,
107        "Initial owner setup".to_string(),
108    );
109}
110
111/// Validate authentication and return detailed auth info, or trap on failure
112pub fn authenticate() -> AuthInfo {
113    let caller = ic_cdk::caller();
114
115    // Security check: anonymous principal is never authenticated
116    if caller == Principal::anonymous() {
117        ic_cdk::trap("Access denied: Anonymous principal cannot be authenticated");
118    }
119
120    AUTH_USERS.with(|users| {
121        // Get and clone the entry to avoid borrow conflicts
122        let mut auth_entry = if let Some(entry) = users.borrow().get(&caller) {
123            entry.clone()
124        } else {
125            log_auth_action(
126                AuthAction::AccessDenied,
127                caller,
128                None,
129                caller,
130                false,
131                "Principal not found in authorized users".to_string(),
132            );
133
134            ic_cdk::trap(&format!(
135                "Access denied: Principal {} not authorized",
136                caller.to_text()
137            ));
138        };
139
140        if !auth_entry.active {
141            log_auth_action(
142                AuthAction::AccessDenied,
143                caller,
144                None,
145                caller,
146                false,
147                "User account deactivated".to_string(),
148            );
149
150            ic_cdk::trap("Access denied: account deactivated");
151        }
152
153        // Update access tracking
154        auth_entry.last_access = Some(ic_cdk::api::time());
155        auth_entry.access_count += 1;
156        users.borrow_mut().insert(caller, auth_entry.clone());
157
158        log_auth_action(
159            AuthAction::AccessGranted,
160            caller,
161            None,
162            caller,
163            true,
164            format!("Access granted with role: {:?}", auth_entry.role),
165        );
166
167        AuthInfo {
168            principal: caller.to_text(),
169            role: auth_entry.role,
170            is_authenticated: true,
171            last_access: auth_entry.last_access,
172            access_count: auth_entry.access_count,
173            message: "Access granted".to_string(),
174        }
175    })
176}
177
178/// Check if caller has specific role or higher (hierarchical)
179/// Owner > Admin > User > ReadOnly
180pub fn require_role_or_higher(minimum_role: AuthRole) -> AuthInfo {
181    let auth_info = authenticate();
182
183    let has_permission = matches!(
184        (&auth_info.role, &minimum_role),
185        (AuthRole::Owner, _)
186            | (
187                AuthRole::Admin,
188                AuthRole::Admin | AuthRole::User | AuthRole::ReadOnly
189            )
190            | (AuthRole::User, AuthRole::User | AuthRole::ReadOnly)
191            | (AuthRole::ReadOnly, AuthRole::ReadOnly)
192    );
193
194    if has_permission {
195        auth_info
196    } else {
197        ic_cdk::trap(&format!(
198            "Insufficient permissions: {:?} or higher required",
199            minimum_role
200        ));
201    }
202}
203
204/// Check if caller has exactly the specified role
205pub fn require_exact_role(role: AuthRole) -> AuthInfo {
206    let auth_info = authenticate();
207    if matches!(auth_info.role, ref r if *r == role) {
208        auth_info
209    } else {
210        ic_cdk::trap(&format!(
211            "Requires exactly {:?} role, but caller has {:?}",
212            role, auth_info.role
213        ));
214    }
215}
216
217/// Check if caller has any of the specified roles
218pub fn require_any_of_roles(roles: &[AuthRole]) -> AuthInfo {
219    let auth_info = authenticate();
220    if roles.contains(&auth_info.role) {
221        auth_info
222    } else {
223        ic_cdk::trap(&format!(
224            "Requires one of: {:?}, but caller has {:?}",
225            roles, auth_info.role
226        ));
227    }
228}
229
230/// Check if caller does NOT have any of the excluded roles
231pub fn require_none_of_roles(excluded: &[AuthRole]) -> AuthInfo {
232    let auth_info = authenticate();
233    if !excluded.contains(&auth_info.role) {
234        auth_info
235    } else {
236        ic_cdk::trap(&format!("Role {:?} is not allowed here", auth_info.role));
237    }
238}
239
240/// Add a new user (requires Admin or Owner role)
241pub fn add_user(principal: Principal, role: AuthRole) -> String {
242    // Security check: prevent anonymous principal from being added
243    if principal == Principal::anonymous() {
244        ic_cdk::trap("Security Error: Anonymous principal cannot be authorized");
245    }
246
247    let auth_info = require_role_or_higher(AuthRole::Admin);
248    let caller = ic_cdk::caller();
249
250    // Prevent self-elevation (Admins can't create Owners)
251    if matches!(role, AuthRole::Owner) && !matches!(auth_info.role, AuthRole::Owner) {
252        ic_cdk::trap("Only owners can create other owners");
253    }
254
255    AUTH_USERS.with(|users| {
256        if users.borrow().contains_key(&principal) {
257            ic_cdk::trap("Principal already authorized");
258        }
259
260        let auth_entry = User {
261            principal,
262            added_at: ic_cdk::api::time(),
263            added_by: caller,
264            role: role.clone(),
265            active: true,
266            last_access: None,
267            access_count: 0,
268        };
269
270        users.borrow_mut().insert(principal, auth_entry);
271
272        log_auth_action(
273            AuthAction::AddUser,
274            principal,
275            Some(principal),
276            caller,
277            true,
278            format!("User added with role: {:?}", role),
279        );
280
281        format!(
282            "Principal {} added with role {:?} by {}",
283            principal.to_text(),
284            role,
285            caller.to_text()
286        )
287    })
288}
289
290/// Remove a user (requires Admin or Owner role)
291pub fn remove_user(principal: Principal) -> String {
292    let auth_info = require_role_or_higher(AuthRole::Admin);
293    let caller = ic_cdk::caller();
294
295    AUTH_USERS.with(|users| {
296        // First, check the user and validate permissions in a separate scope
297        let should_remove = {
298            if let Some(target_entry) = users.borrow().get(&principal) {
299                // Prevent removal of owners by admins
300                if matches!(target_entry.role, AuthRole::Owner)
301                    && !matches!(auth_info.role, AuthRole::Owner)
302                {
303                    ic_cdk::trap("Only owners can remove other owners");
304                }
305
306                // Prevent self-removal
307                if principal == caller {
308                    ic_cdk::trap("Cannot remove yourself");
309                }
310
311                true
312            } else {
313                false
314            }
315        }; // Immutable borrow is dropped here
316
317        // Now we can safely get a mutable borrow
318        if should_remove {
319            users.borrow_mut().remove(&principal);
320
321            log_auth_action(
322                AuthAction::RemoveUser,
323                principal,
324                Some(principal),
325                caller,
326                true,
327                format!("User removed by {}", caller.to_text()),
328            );
329
330            format!(
331                "Principal {} removed by {}",
332                principal.to_text(),
333                caller.to_text()
334            )
335        } else {
336            ic_cdk::trap(&format!("User {} not found", principal.to_text()))
337        }
338    })
339}
340
341/// Update user role (requires Owner role)
342pub fn update_user_role(principal: Principal, new_role: AuthRole) -> String {
343    // Security check: prevent anonymous principal from having any role
344    if principal == Principal::anonymous() {
345        ic_cdk::trap("Security Error: Anonymous principal cannot have a role");
346    }
347
348    require_role_or_higher(AuthRole::Owner); // Only owners can change roles
349    let caller = ic_cdk::caller();
350
351    AUTH_USERS.with(|users| {
352        // Clone the entry to avoid holding a borrow across mutable operations
353        let auth_entry_opt = users.borrow().get(&principal);
354
355        if let Some(mut auth_entry) = auth_entry_opt {
356            let old_role = auth_entry.role.clone();
357            auth_entry.role = new_role.clone();
358            users.borrow_mut().insert(principal, auth_entry);
359
360            log_auth_action(
361                AuthAction::UpdateRole,
362                principal,
363                Some(principal),
364                caller,
365                true,
366                format!("Role changed from {:?} to {:?}", old_role, new_role),
367            );
368
369            format!(
370                "Principal {} role updated from {:?} to {:?}",
371                principal.to_text(),
372                old_role,
373                new_role
374            )
375        } else {
376            ic_cdk::trap("Principal not found");
377        }
378    })
379}
380
381/// Get all authorized users (requires Admin or Owner role)
382pub fn get_authorized_users() -> Vec<User> {
383    require_role_or_higher(AuthRole::Admin);
384    let caller = ic_cdk::caller();
385
386    log_auth_action(
387        AuthAction::ViewAuditLog,
388        caller,
389        None,
390        caller,
391        true,
392        "Viewed authorized users list".to_string(),
393    );
394
395    AUTH_USERS.with(|users| {
396        users
397            .borrow()
398            .iter()
399            .map(|(_, entry)| entry.clone())
400            .collect()
401    })
402}
403
404/// Get authentication audit log (requires Owner role)
405pub fn get_auth_audit(limit: Option<u32>) -> Vec<AuthAuditEntry> {
406    require_role_or_higher(AuthRole::Owner);
407    let caller = ic_cdk::caller();
408    let limit = limit.unwrap_or(100).min(1000) as usize;
409
410    log_auth_action(
411        AuthAction::ViewAuditLog,
412        caller,
413        None,
414        caller,
415        true,
416        format!("Viewed audit log (limit: {})", limit),
417    );
418
419    AUTH_AUDIT.with(|audit| {
420        let mut entries: Vec<AuthAuditEntry> = audit
421            .borrow()
422            .iter()
423            .map(|(_, entry)| entry.clone())
424            .collect();
425
426        // Sort by timestamp (newest first)
427        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
428        entries.truncate(limit);
429
430        entries
431    })
432}
433
434/// Log authentication action for audit trail
435fn log_auth_action(
436    action: AuthAction,
437    principal: Principal,
438    target_principal: Option<Principal>,
439    performed_by: Principal,
440    success: bool,
441    details: String,
442) {
443    let audit_id = AUTH_COUNTER.with(|c| {
444        let current = *c.borrow();
445        *c.borrow_mut() = current + 1;
446        let timestamp = ic_cdk::api::time() / 1_000_000;
447        format!("audit_{}_{}", timestamp, current + 1)
448    });
449
450    let audit_entry = AuthAuditEntry {
451        id: audit_id.clone(),
452        timestamp: ic_cdk::api::time(),
453        action,
454        principal,
455        target_principal,
456        performed_by,
457        success,
458        details,
459    };
460
461    AUTH_AUDIT.with(|audit| {
462        audit.borrow_mut().insert(audit_id, audit_entry);
463    });
464}
465
466/// Get current caller's authentication status
467pub fn get_auth_status() -> AuthInfo {
468    authenticate()
469}
470
471/// Get all users as structured data
472pub fn list_users() -> Vec<User> {
473    get_authorized_users()
474}
475
476/// Get specific user by principal
477pub fn get_user(principal: Principal) -> Option<User> {
478    AUTH_USERS.with(|users| users.borrow().get(&principal))
479}
480
481// Convenience macros for common auth checks
482#[macro_export]
483macro_rules! require_auth {
484    () => {
485        $crate::auth::authenticate();
486    };
487}
488
489#[macro_export]
490macro_rules! require_admin {
491    () => {
492        $crate::auth::require_role_or_higher($crate::auth::AuthRole::Admin);
493    };
494}
495
496#[macro_export]
497macro_rules! require_owner {
498    () => {
499        $crate::auth::require_role_or_higher($crate::auth::AuthRole::Owner);
500    };
501}