Skip to main content

Documentation Index

Fetch the complete documentation index at: https://www.aidonow.com/llms.txt

Use this file to discover all available pages before exploring further.

Executive Summary

Authorization in multi-tenant systems is not a single decision point but a layered enforcement hierarchy, each layer defending against a distinct class of threat. A five-layer architecture—OAuth2 token validation, step-up authentication, partner context enforcement, role-based data scoping, and query-level database filtering—was implemented for a multi-tenant customer relationship management platform with partner portal access. The implementation produced 2,816 lines of authorization infrastructure across two commits, comprehensive test coverage across 21 scenarios, and a security posture capable of withstanding token theft, session hijacking, cross-organization data leakage, and unauthorized record visibility. This article documents the architecture, rationale, and implementation approach for engineering teams building comparable systems.

Key Findings

  • Single-layer authorization creates exploitable gaps. Each of the five layers addresses a failure mode that the other four cannot. Removing any layer creates a specific, testable vulnerability.
  • Enforcement at architectural boundaries prevents authorization logic from scattering into business code. Middleware-enforced validation at the request boundary keeps domain logic free of access checks and makes authorization requirements auditable.
  • Declarative authorization through OpenAPI security annotations makes requirements explicit and automatically enforced. This eliminates the drift that occurs when security requirements live only in documentation.
  • Query-level data filtering is architecturally superior to post-retrieval filtering. Filtering at the database prevents unauthorized records from reaching the application layer at all, eliminating a class of inadvertent disclosure bugs.
  • AI tooling is effective for systematic authorization pattern implementation but requires human design of the security model. Middleware boilerplate, scope expressions, and test scenario generation are appropriate AI tasks; threat model design and scope semantic definition require human judgment.

1. Multi-Tenant CRM Systems Present Four Distinct Threat Vectors That No Single Authorization Layer Can Address

A multi-tenant customer relationship management platform serving multiple partner organizations presents authorization requirements that exceed the capacity of simple role-based access control. The threat surface includes:
  • Unauthenticated or incorrectly scoped API access — callers presenting tokens without appropriate OAuth2 scopes
  • Session hijacking — valid tokens used after session expiry to access sensitive financial operations
  • Cross-organization data access — users authenticated to one partner organization accessing another organization’s data
  • Unauthorized record visibility — users with valid roles viewing records outside their designated scope (e.g., sales representatives accessing accounts owned by other territories)
Each of these threats requires a distinct defensive control. An architecture that addresses only some of them provides defense-in-depth on a subset of the attack surface, leaving the remainder exposed. The challenge is implementing comprehensive authorization without scattering access logic across the codebase, which creates maintenance burden, inconsistency, and audit difficulty.

2. A Five-Layer Authorization Architecture Enforces Each Control at the Boundary Most Appropriate to Its Function

The solution implements five authorization layers, each enforced at the architectural boundary most appropriate to its function.

2.1 Layer 1: Declarative OAuth2 Scope Annotations Enforce Token Validation Before Handler Execution

API endpoints declare required OAuth2 scopes via OpenAPI security annotations. Middleware validates token scope before handler execution; handlers operate on the assumption that authorization has been confirmed.
#[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 extractor 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))
    }
}
Threat addressed: Unauthorized API access by callers presenting tokens without sufficient scope, including tokens issued to a different application or with reduced privileges.

2.2 Layer 2: Step-Up Authentication Blocks Session Hijacking Against High-Risk Financial Operations

Sensitive operations require authentication that is recent within a configurable time window. This prevents valid tokens associated with idle or hijacked sessions from accessing high-risk functionality.
#[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)
    }
}
Threat addressed: Session hijacking and token reuse against financial operations, user management endpoints, and security settings where long-lived session context represents unacceptable risk.

2.3 Layer 3: Partner Context Validation Prevents Cross-Organization Data Access Including Enumeration Attacks

Multi-organization portal deployments require that users can only access data belonging to the organization their session was established under. Partner context validation enforces this constraint at the middleware layer.
#[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 extractor 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))
    }
}
Threat addressed: Cross-organization data leakage in multi-partner deployments, including enumeration attacks where an authenticated user modifies partner identifiers in request paths.

2.4 Layer 4: Role-Based Data Scopes Restrict Default Record Visibility to Reflect Organizational Sales Structures

Within a single organization, different user roles carry different default data visibility. CRM platforms require this granularity to reflect organizational sales structures—sales representatives should not view accounts owned by other territory managers.
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,
        }
    }
}
Threat addressed: Unauthorized record visibility where users with valid organizational credentials access data outside their designated scope, including escalation attempts where users request a broader scope than their role permits.

2.5 Layer 5: Query-Level Filtering Ensures Unauthorized Records Never Reach the Application Layer

Data scope constraints are translated into database filter expressions, enforcing access at query execution time rather than as a post-retrieval application-layer filter.
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 applied at query execution:
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)
    }
}
Threat addressed: Inadvertent disclosure through post-retrieval filtering failures, including bugs where application-layer filters are bypassed by code paths that retrieve and use data before applying access checks.

3. All Five Layers Execute Automatically Within a Single Endpoint Handler With No Duplication of Logic

The following example demonstrates all five layers operating together within a single endpoint handler:
#[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 within this handler: OAuth scope is validated by middleware; step-up authentication is checked when configured on the endpoint; partner context is enforced for multi-organization requests; user role determines the default data scope; and the database query is filtered to return only authorized records.

4. Each Threat Vector Maps to a Distinct Defensive Layer; Removing Any Layer Creates a Testable Gap

Threat VectorPrimary Defensive LayerDetection Mechanism
Stolen or over-privileged tokensLayer 1 — OAuth Scope ValidationScope mismatch between token claims and endpoint requirements
Session hijacking for sensitive operationsLayer 2 — Step-Up AuthenticationAuthentication timestamp exceeds freshness window
Cross-organization data accessLayer 3 — Partner Context ValidationPath partner ID does not match session partner ID
Unauthorized record visibility within organizationLayer 4 — Role-Based Data ScopesRole-to-scope derivation restricts default visibility
Post-retrieval filter bypassLayer 5 — Query-Level FilteringUnauthorized records never enter application memory
Defense-in-depth is not redundancy. Each layer in the above table addresses a distinct threat vector. An attacker who defeats Layer 1 (for example, by obtaining a validly scoped token) will still be blocked by Layers 2 through 5 depending on the operation and data being accessed. No single layer provides comprehensive coverage.

5. Implementation Across Two Commits Produced 2,816 Lines of Authorization Infrastructure and 21 Test Scenarios

5.1 Partner Portal Security Middleware Delivers Three Extractors Covering Token, Session, and Organization Validation

Commit: b6e4780 Scope: 1,673 lines across 9 files Middleware Components:
  • RequireStepUp (250 lines)
  • RequireScope (234 lines)
  • RequirePartnerContext (197 lines)
Test Coverage:
  • 9 integration tests
  • Validation scenarios covering all middleware paths
  • Step-up freshness verification
  • Scope mismatch detection
  • Partner context isolation

5.2 Owner-Based Access Control Translates Role-Scope Mappings Into Query-Level Database Filters

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

6. Three Architectural Principles Prevent Authorization Logic From Scattering Into Business Code

6.1 Boundary Enforcement Keeps Authorization Logic Auditable and Isolated From Domain Code

Each layer is enforced at the boundary appropriate to its function: middleware handles token and session validation; the application layer derives role-to-scope mappings; the query layer enforces data filtering. This prevents authorization logic from migrating into business code, where it becomes difficult to audit, test, and maintain.

6.2 Declarative Security Annotations Co-Located With Endpoints Cannot Become Desynchronized From Implementation

OpenAPI security annotations make authorization requirements explicit and co-located with the endpoint definition:
security(
    ("oauth2" = ["crm:leads:read"]),
    ("step_up" = []),
    ("partner_context" = [])
)
Authorization requirements documented in this form are automatically enforced by middleware and cannot become desynchronized from implementation.

6.3 Query-Level Filtering Eliminates Inadvertent Disclosure Through Post-Retrieval Filter Bypass

The following anti-pattern must be eliminated from authorization-sensitive code paths:
// 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?;
Post-retrieval filtering is vulnerable to code paths that access the unfiltered collection before the filter is applied, and it exposes full datasets to application memory unnecessarily.
Post-retrieval filtering is not equivalent to query-level filtering from a security standpoint. Any code path that accesses the unfiltered result set—even to apply a subsequent filter—has already exposed data to the application layer. Database-level filtering ensures unauthorized records are never transmitted from the database server.

7. Recommendations

  1. Design authorization layers before writing implementation code. The five layers described here emerged from deliberate threat modeling, not incremental addition. Retrofitting authorization into existing systems is significantly more costly than incorporating it at the outset.
  2. Implement middleware-first, handler-second. Design and test authorization extractors before writing the handlers that depend on them. This ensures that handlers inherit a complete security context rather than having authorization added after the fact.
  3. Require declarative scope annotations on all API endpoints. Endpoints without explicit security annotations represent authorization blind spots. Establish a code review policy that treats missing security annotations as blocking defects.
  4. Adopt query-level filtering as the organizational standard for data access. Prohibit post-retrieval filtering patterns in authorization-sensitive code paths and enforce this through automated linting where feasible.
  5. Test authorization paths as comprehensively as business logic. Each layer requires test coverage for both positive (authorized access succeeds) and negative (unauthorized access is rejected) scenarios. The 21 tests produced in this implementation represent the minimum viable coverage for a five-layer system.
  6. Validate scope escalation explicitly. Allow authenticated users to request a scope narrower than their default but require explicit validation before permitting scope broadening. Default to the most restrictive scope when no override is requested.
When designing authorization middleware, implement and test the rejection path before the acceptance path. A middleware component that correctly rejects unauthorized requests is more valuable than one that correctly grants authorized requests, because the failure mode of over-permitting is a security incident while the failure mode of over-rejecting is a correctable application error.

8. Implementation Results: Zero Production Security Incidents Across 2,816 Lines of Authorization Infrastructure

DimensionOutcome
Authorization layers implemented5
Total lines of authorization infrastructure2,816
Integration and unit tests21
Production security incidents0
Estimated implementation speedup vs. manual6–8x
Compliance readinessAudit trail, explicit scope validation, partner data isolation, role-based access control

9. Multi-Layer Authorization Is the Minimum Viable Security Architecture for Multi-Tenant Systems, Not Over-Engineering

Multi-layer authorization is not over-engineering for complex multi-tenant systems—it is the minimum viable security architecture. Each of the five layers described here addresses a threat vector that the others do not. Removing any single layer creates a testable gap in the security posture. The architectural principles that make this system effective—enforcement at boundaries, declarative requirements, query-level filtering—generalize beyond the specific technology choices documented here. They apply equally to systems built on different databases, authentication providers, and programming languages. As organizations extend AI-assisted development to security-critical infrastructure, the importance of human-defined security models increases. AI tooling is well-suited to implementing authorization patterns once the threat model and scope semantics are defined, but the security architecture itself requires human judgment that no current model can fully replace. Organizations that invest in clear security model design before delegating implementation to AI tooling will realize both the efficiency benefits and the security assurances their systems require.
All content represents personal learning from personal projects. Code examples are sanitized and generalized. No proprietary information is shared. Opinions are my own and do not reflect my employer’s views.