feat(audit): always emit audit log on resource/call not granted / rejected
This commit is contained in:
@@ -125,6 +125,38 @@ Never duplicate logic across handlers or services. If the same behaviour is need
|
|||||||
|
|
||||||
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
|
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
|
||||||
|
|
||||||
|
### Audit logging for denials and rejections
|
||||||
|
|
||||||
|
**Every permission denial or auth rejection MUST emit a structured audit log line before returning the error.** Without one, security-relevant outcomes are invisible to operators and incident response loses its primary signal.
|
||||||
|
|
||||||
|
The convention:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "<domain>.<outcome>", // e.g. "authz.denied", "auth.login_rejected",
|
||||||
|
// "magic_link.redemption_rejected",
|
||||||
|
// "user_profile.rejected"
|
||||||
|
reason = "<short_key>", // stable machine-readable key for filtering
|
||||||
|
// (e.g. "bad_password", "expired", "no_visibility_path")
|
||||||
|
// …structured fields naming the actors / targets…
|
||||||
|
caller_id = %caller_id, // or subject_id, user_id, granted_by, etc.
|
||||||
|
target_id = %target_id, // or resource_id, subject_id, etc.
|
||||||
|
"👮🏻♂️ human-readable message: …", // helpful for live tailing, do not parse
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **`target: "audit"`** routes the line to the audit channel (separable from operational `oxicloud::*` debug noise).
|
||||||
|
- **`event`** uses the dotted form `<domain>.<verb_past_tense>` and stays stable — log aggregators key off it.
|
||||||
|
- **`reason`** is a machine-readable enum-style key. Don't reword across releases. New denial cause → new `reason` value, never repurpose an existing one.
|
||||||
|
- **Structured fields** carry every actor/target involved (`caller_id`, `target_id`, `resource_id`, `subject_id`, role, is_external flag, etc.). Request id and client IP come from the request-scope span automatically — don't duplicate them.
|
||||||
|
- **Anti-enumeration is preserved.** Returning `NotFound` to the caller while logging the real reason internally is the canonical pattern (e.g. `user_profile.rejected` with `reason = "external_caller_no_relationship"` returns 404, never 403). Operators see the truth; the attacker sees the same response shape regardless of whether the user exists.
|
||||||
|
- **Success paths stay quiet** by default — every authorized request would otherwise flood the log. Use `tracing::debug!` with `target: "oxicloud::authz"` (or similar) when a low-volume granted-trace helps debugging. Reserve `tracing::info!(target: "audit", …)` for outcomes worth surfacing in security reviews.
|
||||||
|
|
||||||
|
Canonical examples to mirror: `authz.denied` in `application/ports/authorization_ports.rs::require`, `auth.login_rejected` and `magic_link.redemption_rejected` and `user_profile.rejected` in `application/services/auth_application_service.rs`.
|
||||||
|
|
||||||
# Frontend part
|
# Frontend part
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|||||||
@@ -39,7 +39,18 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
resource: Resource,
|
resource: Resource,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if self.check(subject, permission, resource).await? {
|
if self.check(subject, permission, resource).await? {
|
||||||
|
// Granted path: high-traffic (every authorized request hits
|
||||||
|
// this), so kept at `debug` and structured for grep-friendly
|
||||||
|
// filtering. Not an audit event — the audit trail focuses
|
||||||
|
// on denials and explicit mutations elsewhere.
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
target: "oxicloud::authz",
|
||||||
|
event = "authz.allowed",
|
||||||
|
subject_type = subject.type_str(),
|
||||||
|
subject_id = %subject.id(),
|
||||||
|
permission = permission.as_str(),
|
||||||
|
resource_type = resource.type_str(),
|
||||||
|
resource_id = %resource.id(),
|
||||||
"👮🏻♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
|
"👮🏻♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
|
||||||
subject,
|
subject,
|
||||||
permission,
|
permission,
|
||||||
@@ -51,8 +62,23 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
Resource::Folder(id) => ("Folder", id),
|
Resource::Folder(id) => ("Folder", id),
|
||||||
Resource::File(id) => ("File", id),
|
Resource::File(id) => ("File", id),
|
||||||
};
|
};
|
||||||
// log it for audit
|
// 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!(
|
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 '{}'",
|
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||||
subject,
|
subject,
|
||||||
permission,
|
permission,
|
||||||
|
|||||||
@@ -420,11 +420,32 @@ impl AuthApplicationService {
|
|||||||
.get_user_by_username(&dto.username)
|
.get_user_by_username(&dto.username)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
|
// Audit: unknown-username login attempt. Reason key kept
|
||||||
|
// stable so log search can aggregate without parsing the
|
||||||
|
// human-readable message. Caller's client IP + request id
|
||||||
|
// are attached automatically by the request-scope span.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.login_rejected",
|
||||||
|
reason = "unknown_user",
|
||||||
|
attempted_username = %dto.username,
|
||||||
|
"🔐 login rejected: no such user '{}'",
|
||||||
|
dto.username,
|
||||||
|
);
|
||||||
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
|
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.login_rejected",
|
||||||
|
reason = "account_deactivated",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
"🔐 login rejected: account deactivated for '{}'",
|
||||||
|
user.username(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"Auth",
|
"Auth",
|
||||||
@@ -439,6 +460,15 @@ impl AuthApplicationService {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.login_rejected",
|
||||||
|
reason = "bad_password",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
"🔐 login rejected: bad password for '{}'",
|
||||||
|
user.username(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"Auth",
|
"Auth",
|
||||||
@@ -514,6 +544,19 @@ impl AuthApplicationService {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||||
|
// Audit: unknown / forged magic-link redemption. The first
|
||||||
|
// 8 chars of the bogus token are logged so a recurring
|
||||||
|
// probe pattern is recognisable without dumping the full
|
||||||
|
// secret to the log stream.
|
||||||
|
let token_preview: String = token.chars().take(8).collect();
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_rejected",
|
||||||
|
reason = "unknown_token",
|
||||||
|
token_prefix = %token_preview,
|
||||||
|
"🔗 magic-link rejected: unknown token (prefix='{}…')",
|
||||||
|
token_preview,
|
||||||
|
);
|
||||||
DomainError::new(
|
DomainError::new(
|
||||||
ErrorKind::NotFound,
|
ErrorKind::NotFound,
|
||||||
"MagicLink",
|
"MagicLink",
|
||||||
@@ -524,6 +567,15 @@ impl AuthApplicationService {
|
|||||||
// Friendly early-rejection messages. The atomic `mark_used`
|
// Friendly early-rejection messages. The atomic `mark_used`
|
||||||
// below is the canonical single-use guard.
|
// below is the canonical single-use guard.
|
||||||
if mlt.status() == MagicLinkStatus::Used {
|
if mlt.status() == MagicLinkStatus::Used {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_rejected",
|
||||||
|
reason = "already_used",
|
||||||
|
token_id = %mlt.id(),
|
||||||
|
user_id = %mlt.user_id(),
|
||||||
|
"🔗 magic-link rejected: token already used for user {}",
|
||||||
|
mlt.user_id(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"MagicLink",
|
"MagicLink",
|
||||||
@@ -531,6 +583,15 @@ impl AuthApplicationService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if mlt.is_expired() {
|
if mlt.is_expired() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_rejected",
|
||||||
|
reason = "expired",
|
||||||
|
token_id = %mlt.id(),
|
||||||
|
user_id = %mlt.user_id(),
|
||||||
|
"🔗 magic-link rejected: token expired for user {}",
|
||||||
|
mlt.user_id(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"MagicLink",
|
"MagicLink",
|
||||||
@@ -542,6 +603,15 @@ impl AuthApplicationService {
|
|||||||
if !consumed {
|
if !consumed {
|
||||||
// Either a concurrent redemption beat us, or the row was
|
// Either a concurrent redemption beat us, or the row was
|
||||||
// marked expired by the sweeper between our find and update.
|
// marked expired by the sweeper between our find and update.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_rejected",
|
||||||
|
reason = "race_or_swept",
|
||||||
|
token_id = %mlt.id(),
|
||||||
|
user_id = %mlt.user_id(),
|
||||||
|
"🔗 magic-link rejected: lost race to mark_used (user {})",
|
||||||
|
mlt.user_id(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"MagicLink",
|
"MagicLink",
|
||||||
@@ -551,6 +621,16 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
let mut user = self.user_storage.get_user_by_id(mlt.user_id()).await?;
|
let mut user = self.user_storage.get_user_by_id(mlt.user_id()).await?;
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_rejected",
|
||||||
|
reason = "account_deactivated",
|
||||||
|
token_id = %mlt.id(),
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
"🔗 magic-link rejected: account deactivated for '{}'",
|
||||||
|
user.username(),
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::AccessDenied,
|
ErrorKind::AccessDenied,
|
||||||
"Auth",
|
"Auth",
|
||||||
@@ -947,6 +1027,17 @@ impl AuthApplicationService {
|
|||||||
let target = match self.user_storage.get_user_by_id(target_id).await {
|
let target = match self.user_storage.get_user_by_id(target_id).await {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user_profile.rejected",
|
||||||
|
reason = "target_not_found",
|
||||||
|
caller_id = %caller_id,
|
||||||
|
caller_is_external = caller.is_external(),
|
||||||
|
target_id = %target_id,
|
||||||
|
"👮🏻♂️ user-profile rejected: target '{}' does not exist (caller {})",
|
||||||
|
target_id,
|
||||||
|
caller_id,
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::NotFound,
|
ErrorKind::NotFound,
|
||||||
"User",
|
"User",
|
||||||
@@ -982,6 +1073,20 @@ impl AuthApplicationService {
|
|||||||
|
|
||||||
// (3) External callers stop here — no directory enumeration.
|
// (3) External callers stop here — no directory enumeration.
|
||||||
if caller.is_external() {
|
if caller.is_external() {
|
||||||
|
// Audit: an external user tried to look up someone they
|
||||||
|
// don't share a grant with. Surfaces enumeration probes
|
||||||
|
// from compromised magic-link sessions.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user_profile.rejected",
|
||||||
|
reason = "external_caller_no_relationship",
|
||||||
|
caller_id = %caller_id,
|
||||||
|
target_id = %target_id,
|
||||||
|
target_is_external = target.is_external(),
|
||||||
|
"👮🏻♂️ user-profile rejected: external user '{}' has no grant relationship with '{}'",
|
||||||
|
caller_id,
|
||||||
|
target_id,
|
||||||
|
);
|
||||||
return Err(DomainError::new(
|
return Err(DomainError::new(
|
||||||
ErrorKind::NotFound,
|
ErrorKind::NotFound,
|
||||||
"User",
|
"User",
|
||||||
@@ -1000,6 +1105,21 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// (6) No relationship — anti-enumeration NotFound.
|
// (6) No relationship — anti-enumeration NotFound.
|
||||||
|
// Audit: an internal user with no visibility path probed a user
|
||||||
|
// they don't share with. Usually benign (stale UI state), but
|
||||||
|
// recurring patterns from the same caller are worth surfacing.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "user_profile.rejected",
|
||||||
|
reason = "no_visibility_path",
|
||||||
|
caller_id = %caller_id,
|
||||||
|
target_id = %target_id,
|
||||||
|
target_is_external = target.is_external(),
|
||||||
|
"👮🏻♂️ user-profile rejected: internal user '{}' has no visibility on '{}' (target is_external={})",
|
||||||
|
caller_id,
|
||||||
|
target_id,
|
||||||
|
target.is_external(),
|
||||||
|
);
|
||||||
Err(DomainError::new(
|
Err(DomainError::new(
|
||||||
ErrorKind::NotFound,
|
ErrorKind::NotFound,
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
Reference in New Issue
Block a user