feat(antienum): 403 when sub can read, 404 otherwise
this is a UX improvement, always return a 404 not found when subject do not have any access on the resource
but returns an explicit 403 forbidden is subject try a forbidden action on a resourse it can read
regarding performance, the role is already in cache for the second call with read perm
This commit is contained in:
@@ -16,6 +16,28 @@ use crate::domain::services::authorization::{
|
||||
ResourceKind, Role, Subject,
|
||||
};
|
||||
|
||||
/// Discriminates the two denial shapes surfaced by
|
||||
/// [`AuthorizationEngine::require_visible`] in the `authz.denied` audit line.
|
||||
/// Log-aggregation consumers key off the string form via `as_str`; keep the
|
||||
/// values stable — a new denial shape means a new variant, never a renamed
|
||||
/// existing one.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum AuthzDenialVisibility {
|
||||
/// Caller has `Read` on the resource — 403 Forbidden.
|
||||
Visible,
|
||||
/// Caller has no `Read` — 404 anti-enum.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl AuthzDenialVisibility {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Visible => "visible",
|
||||
Self::Hidden => "hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||
/// owner short-circuit AND cascading from folder ancestors.
|
||||
@@ -53,9 +75,28 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
Ok(allowed)
|
||||
}
|
||||
|
||||
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
|
||||
/// `DomainError::not_found` when denied (anti-enumeration — same error as
|
||||
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
|
||||
/// Graduated-denial wrapper around `check`. Semantics:
|
||||
///
|
||||
/// - `permission` granted → `Ok(())`
|
||||
/// - `permission` denied, `Read` also denied → `DomainError::not_found`
|
||||
/// (404, anti-enumeration — same shape as "doesn't exist" so a probing
|
||||
/// caller can't distinguish "wrong id" from "no access")
|
||||
/// - `permission` denied, `Read` granted → `DomainError::access_denied`
|
||||
/// (403 — the caller can already see the resource, so hiding existence
|
||||
/// leaks nothing new; a clear 403 beats a confusing 404 for UX and for
|
||||
/// API-first clients like rclone)
|
||||
///
|
||||
/// Special case: when `permission == Read`, the visibility gate collapses
|
||||
/// onto itself — a `Read` denial IS a "hidden" outcome by definition, so
|
||||
/// the method short-circuits to the strict anti-enum 404 without a second
|
||||
/// DB round-trip. That's why there's only one method: strict Read-denial
|
||||
/// and graduated write-denial fall out of the same signature.
|
||||
///
|
||||
/// Do NOT use this in search / enumeration paths where existence itself is
|
||||
/// the attack vector — those must filter at the SQL/index layer, never
|
||||
/// touch this method with per-row ids. Cross-tenant probes on ids the
|
||||
/// caller has no prior read handle for degrade to the 404 shape naturally
|
||||
/// (Read denied → `Hidden`).
|
||||
async fn require(
|
||||
&self,
|
||||
subject: Subject,
|
||||
@@ -80,39 +121,68 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Visibility probe. Short-circuit: when the target permission IS
|
||||
// `Read` and the check above returned false, we already know Read is
|
||||
// denied — visibility is `Hidden` by definition, no second DB hop.
|
||||
// Otherwise probe Read; a DB-hop failure here degrades to `Hidden` so
|
||||
// the caller sees the strict anti-enum shape (safe default).
|
||||
let visibility = if permission == Permission::Read {
|
||||
AuthzDenialVisibility::Hidden
|
||||
} else if self
|
||||
.check(subject, Permission::Read, resource)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
AuthzDenialVisibility::Visible
|
||||
} else {
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
Resource::Playlist(id) => ("Playlist", id),
|
||||
};
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
// surface them separately from operational debug traffic.
|
||||
// Span context (request_id, client_ip, user_id) is attached
|
||||
// automatically by the request-scope span set in
|
||||
// `interfaces/middleware/trace_span.rs`, so this log line
|
||||
// doesn't need to duplicate those fields — they appear in
|
||||
// the structured output of every log written inside the
|
||||
// request span.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
resource
|
||||
);
|
||||
Err(DomainError::not_found(kind, id.to_string()))
|
||||
AuthzDenialVisibility::Hidden
|
||||
};
|
||||
|
||||
let (kind, id) = match resource {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
Resource::Drive(id) => ("Drive", id),
|
||||
Resource::Calendar(id) => ("Calendar", id),
|
||||
Resource::AddressBook(id) => ("AddressBook", id),
|
||||
Resource::Playlist(id) => ("Playlist", id),
|
||||
};
|
||||
|
||||
// Audit-worthy: denials are the interesting signal. Routed through
|
||||
// the `audit` tracing target so log aggregators can surface them
|
||||
// separately from operational debug traffic. Span context
|
||||
// (request_id, client_ip, user_id) comes from the request-scope
|
||||
// span set in `interfaces/middleware/trace_span.rs`, so this line
|
||||
// doesn't need to duplicate those fields.
|
||||
//
|
||||
// The `visibility` field discriminates the two denial shapes for
|
||||
// operators grepping exists-but-denied vs fully-hidden. `visible`
|
||||
// denials are the ones surfaced to the caller as 403 (and safe to
|
||||
// detail in the UI); `hidden` denials are the 404 anti-enum path.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
visibility = visibility.as_str(),
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}' (visibility={})",
|
||||
subject,
|
||||
permission,
|
||||
resource,
|
||||
visibility.as_str()
|
||||
);
|
||||
|
||||
match visibility {
|
||||
AuthzDenialVisibility::Visible => Err(DomainError::access_denied(
|
||||
kind,
|
||||
format!("Missing '{}' permission on {} {}", permission, kind, id),
|
||||
)),
|
||||
AuthzDenialVisibility::Hidden => Err(DomainError::not_found(kind, id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user