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.
Finding
validate_bearerderives the authenticated user's role from the signed JWT claim (claims.role) and never consults the database. It also performs nois_activecheck. The API-key path (validate_api_key) re-fetches the user row on every call and uses the live DB role plus the liveis_activeflag. 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: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: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:role = "admin"in every outstanding Bearer token, so everyRequireAdmin-guarded endpoint stays reachable.is_active = false) is rejected immediately on the API-key path but continues to authenticate on the Bearer path, sincevalidate_bearernever 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 bothroleand active status from the live record: reject when the user is absent or!is_active, and populateAuthenticatedUser.rolefromuser.rolerather thanclaims.role. If the TTL-window staleness is instead an intentional trade-off, record it explicitly in_llm/decisions.tomland 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.