refactor(authz): route users_service through the shared authz-verdict mapper

`users_service::require` open-coded the `AuthzDenied → {Forbidden, Backend}`
match that `authz::script_gate` centralizes for every other stateful service —
the one straggler. Extract the mapping into `authz::require_mapped` (used now by
both `script_gate` variants and `users_service::require`), so the verdict→error
mapping lives in exactly one place. Also refresh the stale `to_app_user` comment
(it referenced an unshipped "commit 8" stub; roles are resolved via
`fetch_roles` and passed in). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 19:46:22 +02:00
parent 5e6bb762f4
commit 486cc44a06
2 changed files with 37 additions and 20 deletions

View File

@@ -458,6 +458,30 @@ pub enum AuthzDenied {
Repo(#[from] AuthzError), Repo(#[from] AuthzError),
} }
/// Run [`require`] for a known principal and map the `AuthzDenied` verdict onto
/// the caller's service-specific error via `forbidden`/`backend`. The single
/// home for the `Denied → forbidden() / Repo(e) → backend(e)` mapping that
/// [`script_gate`], [`script_gate_require_principal`], and the stateful services
/// share.
///
/// # Errors
///
/// `forbidden()` on `AuthzDenied::Denied`; `backend(repo_err.to_string())` on
/// `AuthzDenied::Repo`.
pub async fn require_mapped<E>(
repo: &dyn AuthzRepo,
principal: &Principal,
cap: Capability,
forbidden: impl FnOnce() -> E,
backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
match require(repo, principal, cap).await {
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(forbidden()),
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
}
}
/// Script-as-gate authz: anonymous public-HTTP scripts skip the check /// Script-as-gate authz: anonymous public-HTTP scripts skip the check
/// (`cx.principal` is `None`); authenticated callers must hold `cap`. /// (`cx.principal` is `None`); authenticated callers must hold `cap`.
/// ///
@@ -482,11 +506,7 @@ pub async fn script_gate<E>(
let Some(principal) = cx.principal.as_ref() else { let Some(principal) = cx.principal.as_ref() else {
return Ok(()); return Ok(());
}; };
match require(repo, principal, cap).await { require_mapped(repo, principal, cap, forbidden, backend).await
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(forbidden()),
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
}
} }
/// Like [`script_gate`], but **fails closed on an anonymous principal**: a /// Like [`script_gate`], but **fails closed on an anonymous principal**: a
@@ -510,11 +530,7 @@ pub async fn script_gate_require_principal<E>(
let Some(principal) = cx.principal.as_ref() else { let Some(principal) = cx.principal.as_ref() else {
return Err(forbidden()); return Err(forbidden());
}; };
match require(repo, principal, cap).await { require_mapped(repo, principal, cap, forbidden, backend).await
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(forbidden()),
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
}
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -46,7 +46,7 @@ use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerifica
use crate::auth::{ use crate::auth::{
generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH, generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH,
}; };
use crate::authz::{self, AuthzDenied, AuthzRepo, Capability}; use crate::authz::{self, AuthzRepo, Capability};
/// Runtime configuration. Defaults match the v1.1.8 brief — overridable /// Runtime configuration. Defaults match the v1.1.8 brief — overridable
/// via env vars wired in the picloud binary. /// via env vars wired in the picloud binary.
@@ -268,17 +268,18 @@ impl UsersServiceImpl {
// Public HTTP execution — script-as-gate; not denied here. // Public HTTP execution — script-as-gate; not denied here.
return Ok(()); return Ok(());
}; };
match authz::require(&*self.authz, p, cap).await { authz::require_mapped(
Ok(()) => Ok(()), &*self.authz,
Err(AuthzDenied::Denied) => Err(UsersError::Forbidden), p,
Err(AuthzDenied::Repo(e)) => Err(UsersError::Backend(e.to_string())), cap,
} || UsersError::Forbidden,
UsersError::Backend,
)
.await
} }
/// Compose the public `AppUser` shape from a `AppUserRow`. Roles /// Compose the public `AppUser` shape from an `AppUserRow`, with the caller's
/// are always empty in commit 4 of v1.1.8; commit 8 (per-app /// already-resolved per-app `roles` (see [`Self::fetch_roles`]).
/// roles) replaces this stub with an actual `app_user_roles`
/// query.
fn to_app_user(row: AppUserRow, roles: Vec<String>) -> AppUser { fn to_app_user(row: AppUserRow, roles: Vec<String>) -> AppUser {
AppUser { AppUser {
id: row.id, id: row.id,