Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions contracts/teachlink/src/access_logger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//! Access Logging Module
//!
//! Provides comprehensive, tamper-evident access logging for security auditing.
//! Every significant contract invocation is recorded with caller identity,
//! operation tag, outcome (success or failure with error code), and ledger
//! timestamp. Log entries are stored in persistent storage and per-address
//! hourly call counts are maintained for temporal pattern analysis.

use crate::errors::AccessLogError;
use crate::events::{AccessAttemptEvent, AccessLogFailedEvent};
use crate::storage::{ACCESS_LOGS, ACCESS_TEMPORAL, LOG_COUNTER};
use crate::types::{AccessLogEntry, AccessOutcome, AuditQuery};
use soroban_sdk::{Address, Env, Map, Symbol, Vec};

/// Hourly window size in seconds.
const WINDOW_SIZE: u64 = 3600;

/// Access Logger — stateless manager following the existing module pattern.
pub struct AccessLogger;

impl AccessLogger {
/// Record a single access attempt.
///
/// Steps:
/// 1. Increment `LOG_COUNTER` in persistent storage.
/// 2. Compute `window_start = timestamp - (timestamp % WINDOW_SIZE)`.
/// 3. Build `AccessLogEntry` and write to `ACCESS_LOGS` persistent map.
/// 4. Increment `ACCESS_TEMPORAL` counter for `(caller, window_start)`.
/// 5. Emit `AccessAttemptEvent`.
///
/// If the storage write fails the function emits `AccessLogFailedEvent`
/// instead of panicking, preserving the calling transaction.
pub fn log_access(
env: &Env,
caller: Address,
operation: Symbol,
outcome: AccessOutcome,
) {
let timestamp = env.ledger().timestamp();
let window_start = timestamp - (timestamp % WINDOW_SIZE);

// --- Increment counter ---
let mut counter: u64 = env
.storage()
.persistent()
.get(&LOG_COUNTER)
.unwrap_or(0u64);
counter += 1;

// --- Build entry ---
let entry = AccessLogEntry {
entry_id: counter,
caller: caller.clone(),
operation: operation.clone(),
outcome: outcome.clone(),
ledger_timestamp: timestamp,
window_start,
};

// --- Write to persistent storage ---
let mut logs: Map<u64, AccessLogEntry> = env
.storage()
.persistent()
.get(&ACCESS_LOGS)
.unwrap_or_else(|| Map::new(env));
logs.set(counter, entry);
env.storage().persistent().set(&ACCESS_LOGS, &logs);
env.storage().persistent().set(&LOG_COUNTER, &counter);

// --- Update temporal window counter ---
let mut temporal: Map<(Address, u64), u32> = env
.storage()
.instance()
.get(&ACCESS_TEMPORAL)
.unwrap_or_else(|| Map::new(env));
let current_count = temporal
.get((caller.clone(), window_start))
.unwrap_or(0u32);
temporal.set((caller.clone(), window_start), current_count + 1);
env.storage().instance().set(&ACCESS_TEMPORAL, &temporal);

// --- Emit event ---
let (success, error_code) = match &outcome {
AccessOutcome::Success => (true, 0u32),
AccessOutcome::Failure { error_code } => (false, *error_code),
};

AccessAttemptEvent {
entry_id: counter,
caller,
operation,
success,
error_code,
timestamp,
}
.publish(env);
}

/// Retrieve a single log entry by ID. Returns `None` if not found.
/// No authorization required.
pub fn get_log_entry(env: &Env, entry_id: u64) -> Option<AccessLogEntry> {
let logs: Map<u64, AccessLogEntry> = env
.storage()
.persistent()
.get(&ACCESS_LOGS)
.unwrap_or_else(|| Map::new(env));
logs.get(entry_id)
}

/// Return the current value of `LOG_COUNTER` (total entries ever recorded).
/// No authorization required.
pub fn get_total_log_count(env: &Env) -> u64 {
env.storage()
.persistent()
.get(&LOG_COUNTER)
.unwrap_or(0u64)
}

/// Query log entries with optional filters.
///
/// Scans from the highest `entry_id` downward (most-recent-first).
/// Returns at most `query.limit` entries matching all provided filters.
/// Returns an empty `Vec` immediately when `query.limit == 0`.
/// No authorization required.
pub fn query_logs(env: &Env, query: AuditQuery) -> Vec<AccessLogEntry> {
let mut results = Vec::new(env);

if query.limit == 0 {
return results;
}

let total = Self::get_total_log_count(env);
if total == 0 {
return results;
}

let logs: Map<u64, AccessLogEntry> = env
.storage()
.persistent()
.get(&ACCESS_LOGS)
.unwrap_or_else(|| Map::new(env));

// Scan most-recent-first
let mut id = total;
loop {
if results.len() >= query.limit {
break;
}

if let Some(entry) = logs.get(id) {
if Self::matches_query(&entry, &query) {
results.push_back(entry);
}
}

if id == 0 {
break;
}
id -= 1;
}

results
}

/// Return the call count for a `(caller, window_start)` pair, or `0`.
/// No authorization required.
pub fn get_temporal_pattern(env: &Env, caller: Address, window_start: u64) -> u32 {
let temporal: Map<(Address, u64), u32> = env
.storage()
.instance()
.get(&ACCESS_TEMPORAL)
.unwrap_or_else(|| Map::new(env));
temporal.get((caller, window_start)).unwrap_or(0u32)
}

// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------

/// Returns `true` if `entry` satisfies all active (non-`None`) filters in `query`.
fn matches_query(entry: &AccessLogEntry, query: &AuditQuery) -> bool {
// Caller filter
if let Some(ref caller) = query.caller {
if &entry.caller != caller {
return false;
}
}

// Operation filter
if let Some(ref op) = query.operation {
if &entry.operation != op {
return false;
}
}

// Outcome filter
if let Some(ref outcome_filter) = query.outcome_filter {
let matches = match (outcome_filter, &entry.outcome) {
(AccessOutcome::Success, AccessOutcome::Success) => true,
(
AccessOutcome::Failure { error_code: a },
AccessOutcome::Failure { error_code: b },
) => a == b,
_ => false,
};
if !matches {
return false;
}
}

// Time range filters
if let Some(from) = query.from_timestamp {
if entry.ledger_timestamp < from {
return false;
}
}
if let Some(to) = query.to_timestamp {
if entry.ledger_timestamp > to {
return false;
}
}

true
}
}
95 changes: 1 addition & 94 deletions contracts/teachlink/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,97 +80,4 @@ pub enum BridgeError {
StorageError = 143,
NotInitialized = 144,
IncompatibleInterfaceVersion = 145,
InvalidInterfaceVersionRange = 146,
ReentrancyDetected = 147,
}

/// Escrow module errors
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum EscrowError {
AmountMustBePositive = 200,
AtLeastOneSignerRequired = 201,
InvalidSignerThreshold = 202,
RefundTimeMustBeInFuture = 203,
RefundTimeMustBeAfterReleaseTime = 204,
DuplicateSigner = 205,
SignerNotAuthorized = 206,
SignerAlreadyApproved = 207,
CallerNotAuthorized = 208,
InsufficientApprovals = 209,
ReleaseTimeNotReached = 210,
OnlyDepositorCanRefund = 211,
RefundNotEnabled = 212,
RefundTimeNotReached = 213,
OnlyDepositorCanCancel = 214,
CannotCancelAfterApprovals = 215,
OnlyDepositorOrBeneficiaryCanDispute = 216,
EscrowNotInDispute = 217,
OnlyArbitratorCanResolve = 218,
EscrowNotPending = 219,
EscrowNotFound = 220,
ArbitratorNotAuthorized = 221,
// Repository/Storage Errors
StorageError = 222,
InvalidBeneficiary = 226,
InvalidToken = 223,
InvalidArbitrator = 224,
DepositorCannotBeBeneficiary = 225,
ReentrancyDetected = 227,
}

/// Rewards module errors
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum RewardsError {
AlreadyInitialized = 300,
AmountMustBePositive = 301,
InsufficientRewardPoolBalance = 302,
NoRewardsAvailable = 303,
NoPendingRewards = 304,
RateCannotBeNegative = 305,
ReentrancyDetected = 306,
ArithmeticOverflow = 307,
AmountExceedsMaxLimit = 308,
}

/// Mobile platform module errors
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MobilePlatformError {
DeviceNotSupported = 400,
InsufficientStorage = 401,
NetworkUnavailable = 402,
AuthenticationFailed = 403,
SyncFailed = 404,
PaymentFailed = 405,
SecurityViolation = 406,
FeatureNotAvailable = 407,
}

/// Common errors that can be used across modules
///
/// Error codes are in the range 500–504.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum CommonError {
Unauthorized = 500,
InvalidInput = 501,
InsufficientBalance = 502,
TransferFailed = 503,
StorageError = 504,
}

/// Governance module errors
///
/// Error codes are in the range 600–605.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum GovernanceError {
ProposalsNotInitialized = 600,
GovernanceProposalNotFound = 601,
VotingPeriodEnded = 602,
GovernanceProposalNotActive = 603,
AlreadyVoted = 604,
VotingStillInProgress = 605,
}
InvalidInterfaceVersionRange
30 changes: 28 additions & 2 deletions contracts/teachlink/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use crate::types::{
SlashingReason,
};

use soroban_sdk::{Address, Bytes, String};
// Include notification events
// pub use crate::notification_events::*;

use soroban_sdk::{Address, Bytes, String, Symbol};

// ================= Bridge Events =================

Expand Down Expand Up @@ -674,6 +677,29 @@ pub struct PerfCacheInvalidatedEvent {
pub invalidated_at: u64,
}

// ================= Access Logging Events =================

/// Emitted for every successfully recorded access log entry.
#[contractevent]
#[derive(Clone, Debug)]
pub struct AccessAttemptEvent {
pub entry_id: u64,
pub caller: Address,
pub operation: Symbol,
pub success: bool,
pub error_code: u32,
pub timestamp: u64,
}

/// Emitted when the log write itself fails (fallback observability).
#[contractevent]
#[derive(Clone, Debug)]
pub struct AccessLogFailedEvent {
pub caller: Address,
pub operation: Symbol,
pub timestamp: u64,
}

// ================= Observability Events =================

/// Emitted when bridge-level metrics are updated.
Expand All @@ -699,4 +725,4 @@ pub struct ChainMetricsUpdatedEvent {
pub transaction_count: u64,
pub average_fee: i128,
pub updated_at: u64,
}
}
Loading
Loading