Skip to main content
Authorization isn’t a single decision—it’s layers of progressively finer-grained access control. From validating OAuth tokens to filtering database queries by ownership, each layer defends against different threat models.

The Challenge

A multi-tenant CRM with partner portals requires complex authorization: Authentication Layers:
  • OAuth2 token validation (who you are)
  • OAuth2 scope checking (what you’re allowed to do)
  • Step-up authentication (re-verify for sensitive operations)
Context Layers:
  • Partner context validation (which organization you represent)
  • Capsule isolation (which tenant’s data you can access)
Data Access Layers:
  • Role-based permissions (admin vs sales rep)
  • Record-level scopes (own records vs team vs territory vs all)
  • Query-level filtering (enforce at database, not post-filter)
Each layer addresses different security requirements:
  • OAuth scopes prevent unauthorized API access
  • Step-up auth protects sensitive operations like financial transactions
  • Partner context ensures multi-org isolation
  • Data scopes implement CRM-style “who sees what records”
The challenge: implement defense-in-depth without authorization logic scattered across the codebase.

The Solution

We built five authorization layers, each enforced at the appropriate architectural boundary.

Layer 1: OAuth2 Scope Validation

API endpoints require specific OAuth2 scopes:
#[utoipa::path(
    get,
    path = "/api/v1/leads",
    security(
        ("oauth2" = ["crm:leads:read"])
    )
)]
pub async fn list_leads(
    State(app_state): State<AppState>,
    RequireScope(scopes): RequireScope,
) -> Result<Json<Vec<Lead>>, ApiError> {
    // Middleware already validated "crm:leads:read" scope
    // Handler can safely assume authorization
}
The RequireScope middleware validates tokens before handlers execute:
pub struct RequireScope(pub Vec<String>);

#[async_trait]
impl<S> FromRequestParts<S> for RequireScope
where
    S: Send + Sync,
{
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        // Extract required scopes from OpenAPI security annotation
        let required_scopes = extract_required_scopes(parts)?;

        // Extract actual scopes from OAuth token
        let token_scopes = parts
            .extensions
            .get::<TokenClaims>()
            .ok_or_else(|| ApiError::unauthorized("No token found"))?
            .scopes
            .clone();

        // Validate token has required scopes
        for required in &required_scopes {
            if !token_scopes.contains(required) {
                return Err(ApiError::forbidden(&format!(
                    "Missing required scope: {}",
                    required
                )));
            }
        }

        Ok(RequireScope(token_scopes))
    }
}
Scopes are defined in OpenAPI security schemes and automatically validated, preventing scope checks from scattering across business logic.

Layer 2: Step-Up Authentication

Sensitive operations require recent authentication:
#[utoipa::path(
    post,
    path = "/api/v1/financial/journal-entries",
    security(
        ("oauth2" = ["accounting:write"]),
        ("step_up" = [])
    )
)]
pub async fn create_journal_entry(
    State(app_state): State<AppState>,
    RequireStepUp: RequireStepUp,
    Json(entry): Json<CreateJournalEntryRequest>,
) -> Result<Json<JournalEntry>, ApiError> {
    // Middleware validated recent authentication
    // Proceed with sensitive financial operation
}
The middleware checks authentication freshness:
pub struct RequireStepUp;

#[async_trait]
impl<S> FromRequestParts<S> for RequireStepUp
where
    S: Send + Sync,
{
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        let session = parts
            .extensions
            .get::<SessionData>()
            .ok_or_else(|| ApiError::unauthorized("No session found"))?;

        let authenticated_at = session.authenticated_at;
        let elapsed = Utc::now() - authenticated_at;

        // Require authentication within last 5 minutes
        if elapsed.num_minutes() > 5 {
            return Err(ApiError::forbidden(
                "Step-up authentication required"
            ).with_metadata(json!({
                "authenticated_at": authenticated_at,
                "elapsed_minutes": elapsed.num_minutes(),
                "max_minutes": 5,
            })));
        }

        Ok(RequireStepUp)
    }
}
Financial operations, user management, and security settings require fresh authentication, preventing session hijacking from accessing sensitive features.

Layer 3: Partner Context Validation

Multi-organization portals require partner context:
#[utoipa::path(
    get,
    path = "/api/v1/partners/{partner_id}/accounts",
    security(
        ("oauth2" = ["partner:accounts:read"]),
        ("partner_context" = [])
    )
)]
pub async fn list_partner_accounts(
    State(app_state): State<AppState>,
    RequirePartnerContext(partner_id): RequirePartnerContext,
    Path(requested_partner_id): Path<PartnerId>,
) -> Result<Json<Vec<Account>>, ApiError> {
    // Middleware validated partner_id matches session
    // Query safely scoped to authorized partner
}
The middleware prevents cross-partner data access:
pub struct RequirePartnerContext(pub PartnerId);

#[async_trait]
impl<S> FromRequestParts<S> for RequirePartnerContext
where
    S: Send + Sync,
{
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        // Extract partner_id from path
        let path_partner_id = parts
            .uri
            .path()
            .split('/')
            .find_map(|segment| PartnerId::parse(segment).ok())
            .ok_or_else(|| ApiError::bad_request("No partner_id in path"))?;

        // Validate against session's partner context
        let session_partner_id = parts
            .extensions
            .get::<PartnerSession>()
            .ok_or_else(|| ApiError::unauthorized("No partner session"))?
            .partner_id;

        if path_partner_id != session_partner_id {
            return Err(ApiError::forbidden(
                "Partner context mismatch"
            ).with_metadata(json!({
                "requested": path_partner_id,
                "authorized": session_partner_id,
            })));
        }

        Ok(RequirePartnerContext(session_partner_id))
    }
}
Users can only access data for their organization, enforced at the middleware layer before handlers execute.

Layer 4: Role-Based Data Scopes

CRM users need different data visibility based on their role:
pub enum CrmRecordScope {
    Own,       // Only records I own
    Team,      // Records owned by my team
    Territory, // Records in my territory
    All,       // All records (admin)
}

pub enum CrmRole {
    SalesRep,
    SalesManager,
    TerritoryManager,
    Admin,
    PartnerUser,
    PartnerAdmin,
}

impl CrmRole {
    pub fn default_scope(&self) -> CrmRecordScope {
        match self {
            CrmRole::SalesRep => CrmRecordScope::Own,
            CrmRole::SalesManager => CrmRecordScope::Team,
            CrmRole::TerritoryManager => CrmRecordScope::Territory,
            CrmRole::Admin => CrmRecordScope::All,
            CrmRole::PartnerUser => CrmRecordScope::Own,
            CrmRole::PartnerAdmin => CrmRecordScope::All,
        }
    }
}
Each role has a default data scope, implementing CRM-style visibility rules.

Layer 5: Query-Level Scope Filtering

Data scopes filter at the database query layer:
pub struct ScopedAccessContext {
    pub scope: CrmRecordScope,
    pub user_id: UserId,
    pub team_id: Option<TeamId>,
    pub territory_id: Option<TerritoryId>,
}

impl ScopedAccessContext {
    pub fn to_filter_expression(&self) -> Option<ScopeFilterExpression> {
        match self.scope {
            CrmRecordScope::Own => {
                Some(ScopeFilterExpression::Equals {
                    attribute: "owner_id".to_string(),
                    value: self.user_id.to_string(),
                })
            }
            CrmRecordScope::Team => {
                self.team_id.as_ref().map(|team_id| {
                    ScopeFilterExpression::Equals {
                        attribute: "team_id".to_string(),
                        value: team_id.to_string(),
                    }
                })
            }
            CrmRecordScope::Territory => {
                self.territory_id.as_ref().map(|territory_id| {
                    ScopeFilterExpression::Equals {
                        attribute: "territory_id".to_string(),
                        value: territory_id.to_string(),
                    }
                })
            }
            CrmRecordScope::All => None, // No filtering for admin
        }
    }
}
Scopes are converted to DynamoDB filter expressions, enforcing access at query time:
impl LeadQueryHandler {
    pub async fn list_with_scope(
        &self,
        context: &ScopedAccessContext,
    ) -> Result<Vec<Lead>, QueryError> {
        let mut query = self.base_query();

        // Add scope filter to query
        if let Some(filter) = context.to_filter_expression() {
            query = query.filter_expression(filter.to_dynamodb());
        }

        // Database only returns authorized records
        let results = self.repository.query(query).await?;
        Ok(results)
    }
}
This prevents unauthorized data from ever reaching the application layer—the database only returns records the user can access.

Integration Example

Here’s how all layers work together:
#[utoipa::path(
    get,
    path = "/api/v1/leads",
    security(
        ("oauth2" = ["crm:leads:read"])
    ),
    params(
        ("scope" = Option<CrmRecordScope>, Query, description = "Override scope")
    )
)]
pub async fn list_leads(
    State(app_state): State<AppState>,
    RequireScope(_scopes): RequireScope,              // Layer 1: OAuth scope
    Query(params): Query<ListLeadsParams>,
) -> Result<Json<Vec<Lead>>, ApiError> {
    // Extract user context from token
    let user_id = extract_user_id(&app_state)?;

    // Layer 4: Derive data scope from role
    let user_role = extract_user_role(&app_state, &user_id).await?;
    let default_scope = user_role.default_scope();

    // Allow scope override if authorized
    let requested_scope = params.scope.unwrap_or(default_scope);
    validate_scope_escalation(&user_role, requested_scope)?;

    // Layer 5: Build scoped context
    let scope_context = ScopedAccessContext {
        scope: requested_scope,
        user_id,
        team_id: extract_team_id(&app_state, &user_id).await?,
        territory_id: extract_territory_id(&app_state, &user_id).await?,
    };

    // Query with scope filtering
    let leads = app_state
        .lead_query_handler
        .list_with_scope(&scope_context)
        .await?;

    Ok(Json(leads))
}
Five authorization layers execute automatically:
  1. OAuth scope validated by middleware
  2. Step-up auth checked if endpoint requires it
  3. Partner context validated if multi-org
  4. User role determines default data scope
  5. Database query filtered by scope

Implementation Scale

Partner Portal Security Middleware

Commit: b6e4780 Scope: 1,673 lines across 9 files Middleware Components:
  • RequireStepUp (250 lines)
  • RequireScope (234 lines)
  • RequirePartnerContext (197 lines)
Testing:
  • 9 integration tests
  • All validation scenarios covered
  • Step-up freshness verification
  • Scope mismatch detection
  • Partner context isolation

Owner-Based Access Control

Commit: e27a8d6 Scope: 1,143 lines across 7 files Domain Components:
  • CrmRecordScope enum with access evaluation (633 lines)
  • ScopedAccessContext for query filtering
  • CrmRole with default scopes
  • Scope extractor logic (223 lines)
Query Integration:
  • list_with_scope method for scoped queries (214 lines)
  • DynamoDB filter expression generation
  • Handler integration (62 lines)
Testing:
  • 12 unit tests for scope filtering
  • Authorization escalation tests
  • Query-level filtering verification

Key Learnings

1. Defense-in-Depth Works

Multiple authorization layers catch different failure modes:
  • Stolen tokens caught by scope validation
  • Session hijacking caught by step-up auth
  • Cross-partner access caught by context validation
  • Unauthorized data viewing caught by scope filtering
No single layer provides complete security—defense-in-depth requires all layers.

2. Enforce at Architectural Boundaries

Each layer enforces at the appropriate boundary:
  • Middleware: OAuth scopes, step-up auth, partner context
  • Application layer: Role-to-scope derivation
  • Query layer: Scope filtering
This prevents authorization logic from scattering across the codebase.

3. Make Authorization Declarative

OpenAPI security annotations make requirements explicit:
security(
    ("oauth2" = ["crm:leads:read"]),
    ("step_up" = []),
    ("partner_context" = [])
)
Authorization requirements are documented and automatically enforced.

4. Filter at the Database

Query-level filtering is more secure than post-filtering:
// WRONG: Fetch all, filter in memory
let all_leads = repository.list_all().await?;
let filtered = all_leads
    .into_iter()
    .filter(|lead| lead.owner_id == user_id)
    .collect();

// RIGHT: Filter in database query
let leads = repository
    .list_with_scope(&scope_context)
    .await?;
Database filtering prevents unauthorized data from ever reaching the application.

5. AI Handles Systematic Patterns

The AI excelled at:
  • Middleware boilerplate - Extractors follow patterns
  • Scope expression generation - Formulaic DynamoDB filters
  • Test scenario coverage - Systematic authorization cases
  • Integration wiring - Connecting layers consistently
The AI required guidance on:
  • Security model design - Which layers to implement
  • Scope semantics - What “Team” vs “Territory” means
  • Escalation rules - When scope override is allowed

Results

Security Posture:
  • Defense-in-depth across 5 layers
  • Declarative authorization requirements
  • Query-level data filtering
  • Comprehensive test coverage (21 tests)
Development Speed:
  • Complete implementation in 2 commits
  • 2,816 lines of authorization infrastructure
  • Estimated 6-8x faster than manual implementation
Maintainability:
  • Authorization centralized in middleware
  • Business logic free of access checks
  • OpenAPI documents security requirements
  • Scope rules encoded in domain types
Compliance Ready:
  • Audit trail through middleware logging
  • Explicit scope validation
  • Partner data isolation
  • Role-based access control

Conclusion

Multi-layer authorization isn’t over-engineering—it’s defense-in-depth: Layer 1 (OAuth): Validates who you are and what you can do at the API level. Layer 2 (Step-Up): Protects sensitive operations with freshness checks. Layer 3 (Partner Context): Enforces multi-organization isolation. Layer 4 (Roles): Determines default data visibility. Layer 5 (Scopes): Filters database queries by ownership. Each layer addresses different threat models. Removing any layer creates vulnerabilities. The key insights:
  • Enforce at architectural boundaries (middleware, query layer)
  • Make requirements declarative (OpenAPI security)
  • Filter at the database (not post-filtering)
  • Test systematically (all authorization paths)
AI agents excel at implementing authorization patterns once the security model is designed. The systematic nature of middleware extractors, scope expressions, and test scenarios aligns perfectly with AI strengths. When building multi-tenant systems, plan your authorization layers upfront. The investment in defense-in-depth creates secure, maintainable systems that scale with complexity.