Skip to main content

Command Palette

Search for a command to run...

Building Trust: Authentication, Authorization, and Audit Logging

Updated
7 min read

← Part 3: Dynamic Mapping Engine

Trust, But Verify (Then Log Everything)

In healthcare IT, "it works" isn't enough. You need to prove:

  • Who accessed data

  • What they accessed

  • When they accessed it

  • Why (in some cases)

  • That you can reconstruct what happened 6 months ago

This isn't paranoia—it's HIPAA compliance. And it's the difference between a minor incident and a career-ending breach.

Authentication: Keycloak Integration

I needed enterprise-grade authentication without building it myself. Enter Keycloak.

Why Keycloak?

  • Battle-tested: Used by Red Hat and many others

  • SSO/SAML/OIDC: Hospitals/healthcare companies love their SAML

  • Realm isolation: Perfect for multi-tenancy

  • Open source: No vendor lock-in

Each tenant gets their own Keycloak realm. Hospital A's users can't log into Hospital B's realm. Period.

The JWT Flow

1. User → Keycloak: "username + password"
2. Keycloak → User: JWT token
3. User → FHIR Adapter: "GET /fhir/Patient/123" + JWT header
4. Adapter → Keycloak: "Is this token valid?"
5. Keycloak → Adapter: "Yes, user=john, tenant=42, roles=[fhir-read]"
6. Adapter: Process request (if authorized)

JWT Validation in Rust

I built an async middleware that validates every request:

pub async fn auth_middleware(
    State(state): State<AppState>,
    mut req: Request,
    next: Next,
) -> Result<Response, AppError> {
    // 1. Extract JWT from Authorization header
    let token = extract_bearer_token(&req)
        .ok_or_else(|| AppError::Unauthorized("Missing token"))?;

    // 2. Validate signature and expiration with Keycloak
    let claims = state.keycloak_client
        .validate_token(&token)
        .await
        .map_err(|e| {
            tracing::warn!("Token validation failed: {}", e);
            AppError::Unauthorized("Invalid token")
        })?;

    // 3. Extract critical claims
    let auth_context = AuthContext {
        user_id: claims.sub,
        client_id: claims.client_id
            .ok_or_else(|| AppError::Unauthorized("Missing client_id"))?,
        roles: claims.realm_access.roles,
        email: claims.email,
    };

    // 4. Inject into request for downstream handlers
    req.extensions_mut().insert(auth_context);

    Ok(next.run(req).await)
}

fn extract_bearer_token(req: &Request) -> Option<String> {
    req.headers()
        .get("Authorization")?
        .to_str()
        .ok()?
        .strip_prefix("Bearer ")
        .map(|s| s.to_string())
}

JWKS Caching

Validating JWTs requires Keycloak's public keys (JWKS). Fetching them on every request would be slow.

I implemented a TTL-based JWKS cache:

pub struct KeycloakClient {
    jwks_cache: Arc<Mutex<Option<(JwkSet, Instant)>>>,
    jwks_cache_ttl: Duration,
    server_url: String,
    realm: String,
}

impl KeycloakClient {
    async fn get_jwks(&self) -> Result<JwkSet> {
        // Check cache
        {
            let cache = self.jwks_cache.lock().unwrap();
            if let Some((jwks, cached_at)) = cache.as_ref() {
                if cached_at.elapsed() < self.jwks_cache_ttl {
                    return Ok(jwks.clone());
                }
            }
        }

        // Fetch from Keycloak
        let url = format!(
            "{}/realms/{}/protocol/openid-connect/certs",
            self.server_url, self.realm
        );

        let jwks: JwkSet = reqwest::get(&url)
            .await?
            .json()
            .await?;

        // Update cache
        let mut cache = self.jwks_cache.lock().unwrap();
        *cache = Some((jwks.clone(), Instant::now()));

        Ok(jwks)
    }
}

Performance: 1st request ~50ms (fetches JWKS), subsequent requests ~2ms (cached).

Authorization: Role-Based Access Control

Authentication answers "who are you?" Authorization answers "what can you do?"

I defined three roles:

  • fhir-read: Read resources

  • fhir-write: Create/update resources

  • fhir-admin: Admin panel access, configuration changes

impl AuthContext {
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }

    pub fn require_role(&self, role: &str) -> Result<(), AppError> {
        if self.has_role(role) {
            Ok(())
        } else {
            Err(AppError::Forbidden(
                format!("Role '{}' required", role)
            ))
        }
    }
}

Usage in handlers:

pub async fn patient_read(
    State(state): State<AppState>,
    auth: AuthContext,  // Injected by middleware
    Path(id): Path<String>,
) -> Result<Json<Patient>, AppError> {
    // Check authorization
    auth.require_role("fhir-read")?;

    // Fetch patient (automatically scoped to auth.client_id)
    let patient = state.fetch_patient(&auth.client_id, &id).await?;

    Ok(Json(patient))
}

Audit Logging: The Paper Trail

Every request gets logged. Not just errors—everything.

What We Log

pub struct AuditLogParams {
    pub resource_type: String,     // "Patient"
    pub resource_id: Option<String>,  // "12345"
    pub operation: AuditOperation, // Read, Create, Update, Delete, Search
    pub user_id: String,           // From JWT
    pub client_id: String,         // Tenant ID
    pub ip_address: Option<String>,
    pub user_agent: Option<String>,
    pub request_id: String,        // UUID for correlation
    pub success: bool,
    pub error_message: Option<String>,
    pub request_body: Option<JsonValue>,   // Sanitized!
    pub response_body: Option<JsonValue>,  // Sanitized!
}

pub enum AuditOperation {
    Read,
    Create,
    Update,
    Delete,
    Search,
}

The Audit Log Flow

Every handler follows this pattern:

pub async fn patient_create(
    State(state): State<AppState>,
    auth: AuthContext,
    Json(patient): Json<Patient>,
) -> Result<Response, AppError> {
    let request_id = uuid::Uuid::new_v4().to_string();

    // Prepare audit log params
    let mut audit_params = AuditLogParams {
        resource_type: "Patient".to_string(),
        resource_id: None,
        operation: AuditOperation::Create,
        user_id: auth.user_id.clone(),
        client_id: auth.client_id.clone(),
        request_id: request_id.clone(),
        success: false,  // Will be updated
        error_message: None,
        request_body: serde_json::to_value(&patient).ok(),
        ..Default::default()
    };

    // Execute operation
    let result = async {
        let id = state.create_patient(&auth.client_id, &patient).await?;
        Ok::<_, AppError>(id)
    }.await;

    // Update audit params based on result
    match result {
        Ok(id) => {
            audit_params.success = true;
            audit_params.resource_id = Some(id.clone());

            // Log success
            if let Err(e) = state.audit_logger.log(audit_params).await {
                tracing::error!("Failed to log audit: {}", e);
            }

            Ok((StatusCode::CREATED, Json(json!({"id": id}))).into_response())
        }
        Err(e) => {
            audit_params.error_message = Some(e.to_string());

            // Log failure
            if let Err(log_err) = state.audit_logger.log(audit_params).await {
                tracing::error!("Failed to log audit: {}", log_err);
            }

            Err(e)
        }
    }
}

Critical: Even if the request fails, we log it. Especially if it fails.

Sanitizing Sensitive Data

We log request/response bodies, but we sanitize PHI (Protected Health Information):

fn sanitize_for_audit(value: &JsonValue) -> JsonValue {
    match value {
        JsonValue::Object(map) => {
            let mut sanitized = serde_json::Map::new();
            for (key, val) in map {
                let sanitized_val = if SENSITIVE_FIELDS.contains(&key.as_str()) {
                    json!("[REDACTED]")
                } else {
                    sanitize_for_audit(val)
                };
                sanitized.insert(key.clone(), sanitized_val);
            }
            JsonValue::Object(sanitized)
        }
        JsonValue::Array(arr) => {
            JsonValue::Array(arr.iter().map(sanitize_for_audit).collect())
        }
        _ => value.clone(),
    }
}

const SENSITIVE_FIELDS: &[&str] = &[
    "ssn", "social_security", "password", "token",
    "birthDate", "deceased", "multipleBirth"  // Some PHI
];

Audit Log Storage

Audit logs go to a separate database from application data:

CREATE TABLE audit_logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    resource_type VARCHAR(50),
    resource_id VARCHAR(255),
    operation ENUM('read', 'create', 'update', 'delete', 'search'),
    user_id VARCHAR(255) NOT NULL,
    client_id VARCHAR(100) NOT NULL,
    ip_address VARCHAR(45),
    user_agent TEXT,
    request_id VARCHAR(36) NOT NULL,
    success BOOLEAN NOT NULL,
    error_message TEXT,
    request_body JSON,
    response_body JSON,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_client_id (client_id),
    INDEX idx_user_id (user_id),
    INDEX idx_request_id (request_id),
    INDEX idx_created_at (created_at)
);

Retention: We keep logs for 7 years (HIPAA requirement).

Rate Limiting: Defense Against Abuse

Without rate limiting, one malicious (or buggy) client could DOS the entire system.

I implemented per-tenant rate limiting using a token bucket algorithm:

pub struct RateLimiter {
    buckets: Arc<Mutex<HashMap<String, TokenBucket>>>,
}

struct TokenBucket {
    tokens: f64,
    last_refill: Instant,
    capacity: f64,
    refill_rate: f64,  // tokens per second
}

impl RateLimiter {
    pub async fn check_rate_limit(
        &self,
        client_id: &str,
        tokens_needed: f64,
    ) -> Result<RateLimitInfo, AppError> {
        let mut buckets = self.buckets.lock().unwrap();

        let bucket = buckets.entry(client_id.to_string())
            .or_insert_with(|| TokenBucket {
                tokens: 100.0,  // Initial capacity
                last_refill: Instant::now(),
                capacity: 100.0,
                refill_rate: 10.0,  // 10 req/s sustained
            });

        // Refill tokens based on elapsed time
        let elapsed = bucket.last_refill.elapsed().as_secs_f64();
        bucket.tokens = (bucket.tokens + elapsed * bucket.refill_rate)
            .min(bucket.capacity);
        bucket.last_refill = Instant::now();

        // Check if we have enough tokens
        if bucket.tokens >= tokens_needed {
            bucket.tokens -= tokens_needed;

            Ok(RateLimitInfo {
                limit: bucket.capacity as i32,
                remaining: bucket.tokens as i32,
                reset: (bucket.capacity - bucket.tokens) / bucket.refill_rate,
            })
        } else {
            Err(AppError::RateLimitExceeded)
        }
    }
}

Limits:

  • Burst: 100 requests

  • Sustained: 10 req/s

  • Scope: Per tenant

Headers returned:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 2.7

Security Checklist

Before going to production, I verified:

  • All endpoints require authentication (except health check)

  • JWT signatures validated against Keycloak JWKS

  • Tenant isolation enforced (client_id in every query)

  • Audit logging on all operations

  • Sensitive data sanitized in logs

  • Rate limiting per tenant

  • Database URLs encrypted at rest

  • No secrets in logs (URL sanitization)

  • HTTPS enforced (TLS 1.3)

  • CORS configured (whitelist only)

What I Learned

✅ What Worked:

  • Keycloak handles the hard stuff (MFA, SAML, etc.)

  • Audit logs helps security review in the future

  • Rate limiting prevent buggy client or attacs from taking us down

❌ What Didn't:

  • Initial design logged full JWT tokens (oops!)

  • Forgot to sanitize database URLs in error messages

🔧 Improvements Made:

  • Added comprehensive sanitization

  • Implemented circuit breakers per tenant

Up Next: The Admin Panel

Security is invisible to users. The admin panel is where they actually interact with the system.

In Part 5, we'll build a Next.js admin interface that lets tenants configure mappings without writing code (or calling me at 2 AM).


Security Resources:

Discussion: How do you handle audit logging in your systems? What's your retention policy? Let's discuss!