Skip to content

validate_bearer trusts JWT role claim and skips is_active check, diverging from the API-key path #418

Description

@forkwright

Finding

validate_bearer derives the authenticated user's role from the signed JWT claim (claims.role) and never consults the database. It also performs no is_active check. The API-key path (validate_api_key) re-fetches the user row on every call and uses the live DB role plus the live is_active flag. The two authentication paths disagree about a user's current authority.

Evidence

crates/exousia/src/service.rs:281 — role comes straight from the token claim, no DB lookup and no active-status check:

let user_id = UserId::from_uuid(uuid);
let role = UserRole::parse(&claims.role).ok_or_else(|| ExousiaError::TokenInvalid {
    error: "invalid role claim".to_string(),
    location: snafu::location!(),
})?;
Ok(AuthenticatedUser {
    user_id,
    role,
    auth_method: AuthMethod::Bearer,
})

crates/exousia/src/service.rs:320-340 — the API-key path fetches the user row, rejects inactive users, and reads the role from the live DB record:

let user_row = db::get_user(&self.pools.read, &row.user_id)
    .await
    .context(DatabaseSnafu)?
    .ok_or_else(|| ExousiaError::TokenInvalid {
        error: "user not found for API key".to_string(),
        location: snafu::location!(),
    })?;
let user = db_user_to_domain(user_row).ok_or_else(|| ExousiaError::TokenInvalid {
    error: "invalid user data".to_string(),
    location: snafu::location!(),
})?;
if !user.is_active {
    return Err(UserInactiveSnafu.build());
}
// ...
Ok(AuthenticatedUser {
    user_id: user.id,
    role: user.role,
    auth_method: AuthMethod::ApiKey,
})

Why this matters

Revoking or downgrading an account's authority does not take effect until the access token expires (default access_token_ttl_secs = 900 s). During that window:

  • A user demoted from admin to member in the DB keeps role = "admin" in every outstanding Bearer token, so every RequireAdmin-guarded endpoint stays reachable.
  • A deactivated account (is_active = false) is rejected immediately on the API-key path but continues to authenticate on the Bearer path, since validate_bearer never reads the active flag.

Under an adversary capable of capturing or coercing a still-valid Bearer token, privilege revocation is not an effective containment action: the operator's only recourse is to wait out the TTL or rotate the signing secret (invalidating all sessions). The inconsistency between the two paths also means a single hardened mitigation (the API-key active-check) gives a false sense that revocation is enforced everywhere.

Desired correction

In validate_bearer, after parsing and verifying the JWT, re-fetch the user from the DB and derive both role and active status from the live record: reject when the user is absent or !is_active, and populate AuthenticatedUser.role from user.role rather than claims.role. If the TTL-window staleness is instead an intentional trade-off, record it explicitly in _llm/decisions.toml and gate all admin-sensitive mutations to a freshly issued token or the API-key path.

Done when: a role change or account deactivation in the DB is reflected within one request for both the Bearer and API-key auth methods, or the TTL window is recorded as an accepted design decision with admin-sensitive mutations restricted to fresh-token / API-key auth.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions