fix(manager-core): F-Q-003 promote authz::script_gate helper, migrate 7 service call sites

Replace nine open-coded `if cx.principal.is_some() {
authz::require(...).await.map_err(...) }` blocks with a single
`authz::script_gate(repo, cx, cap, forbidden_fn, backend_fn)` helper.

The helper enshrines the script-as-gate semantics (anonymous public-
HTTP scripts skip the check) and the AuthzDenied::{Denied,Repo}
mapping in one place — eliminating drift between services.

Call sites migrated:
- kv_service::check_read / check_write
- docs_service::check_read / check_write
- files_service::check_read / check_write
- pubsub_service::check_publish
- queue_service::enqueue

The pubsub_service::mint_subscriber_token path keeps the explicit
match because it does a separate `principal` early-bind for other
validation; converting it would obscure intent.

AUDIT.md anchor: F-Q-003. Depends on F-Q-004 (Backend variant) and
F-Q-005 (Repo-passthrough pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 19:48:23 +02:00
parent 655c3ab97e
commit 04dc81115e
6 changed files with 95 additions and 85 deletions

View File

@@ -299,6 +299,37 @@ pub enum AuthzDenied {
Repo(#[from] AuthzError), Repo(#[from] AuthzError),
} }
/// Script-as-gate authz: anonymous public-HTTP scripts skip the check
/// (`cx.principal` is `None`); authenticated callers must hold `cap`.
///
/// Replaces the open-coded
/// `if let Some(p) = cx.principal { authz::require(...).await.map_err(...)? }`
/// pattern across every stateful service. `forbidden` is called when
/// the membership lookup returns `Denied`; `backend` is called when the
/// underlying repo errors. Both closures map to the caller's service-
/// specific error enum.
///
/// # Errors
///
/// Returns the result of `forbidden(())` on `AuthzDenied::Denied`, or
/// `backend(repo_err.to_string())` on `AuthzDenied::Repo(repo_err)`.
pub async fn script_gate<E>(
repo: &dyn AuthzRepo,
cx: &picloud_shared::SdkCallCx,
cap: Capability,
forbidden: impl FnOnce() -> E,
backend: impl FnOnce(String) -> E,
) -> Result<(), E> {
let Some(principal) = cx.principal.as_ref() else {
return Ok(());
};
match require(repo, principal, cap).await {
Ok(()) => Ok(()),
Err(AuthzDenied::Denied) => Err(forbidden()),
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Layer 1: role-derived grant // Layer 1: role-derived grant
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -55,27 +55,25 @@ impl DocsServiceImpl {
} }
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), DocsError> { async fn check_read(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(&*self.authz, principal, Capability::AppDocsRead(cx.app_id)).await &*self.authz,
{ cx,
Ok(()) => {} Capability::AppDocsRead(cx.app_id),
Err(authz::AuthzDenied::Denied) => return Err(DocsError::Forbidden), || DocsError::Forbidden,
Err(authz::AuthzDenied::Repo(e)) => return Err(DocsError::Backend(e.to_string())), DocsError::Backend,
} )
} .await
Ok(())
} }
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), DocsError> { async fn check_write(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(&*self.authz, principal, Capability::AppDocsWrite(cx.app_id)).await &*self.authz,
{ cx,
Ok(()) => {} Capability::AppDocsWrite(cx.app_id),
Err(authz::AuthzDenied::Denied) => return Err(DocsError::Forbidden), || DocsError::Forbidden,
Err(authz::AuthzDenied::Repo(e)) => return Err(DocsError::Backend(e.to_string())), DocsError::Backend,
} )
} .await
Ok(())
} }
} }

View File

@@ -49,33 +49,25 @@ impl FilesServiceImpl {
} }
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> { async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id)) &*self.authz,
cx,
Capability::AppFilesRead(cx.app_id),
|| FilesError::Forbidden,
FilesError::Backend,
)
.await .await
{
Ok(()) => {}
Err(authz::AuthzDenied::Denied) => return Err(FilesError::Forbidden),
Err(authz::AuthzDenied::Repo(e)) => return Err(FilesError::Backend(e.to_string())),
}
}
Ok(())
} }
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> { async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(
&*self.authz, &*self.authz,
principal, cx,
Capability::AppFilesWrite(cx.app_id), Capability::AppFilesWrite(cx.app_id),
|| FilesError::Forbidden,
FilesError::Backend,
) )
.await .await
{
Ok(()) => {}
Err(authz::AuthzDenied::Denied) => return Err(FilesError::Forbidden),
Err(authz::AuthzDenied::Repo(e)) => return Err(FilesError::Backend(e.to_string())),
}
}
Ok(())
} }
/// Best-effort `ServiceEvent` emission. A failed emit is logged but /// Best-effort `ServiceEvent` emission. A failed emit is logged but

View File

@@ -46,25 +46,25 @@ impl KvServiceImpl {
} }
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), KvError> { async fn check_read(&self, cx: &SdkCallCx) -> Result<(), KvError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(&*self.authz, principal, Capability::AppKvRead(cx.app_id)).await { &*self.authz,
Ok(()) => {} cx,
Err(authz::AuthzDenied::Denied) => return Err(KvError::Forbidden), Capability::AppKvRead(cx.app_id),
Err(authz::AuthzDenied::Repo(e)) => return Err(KvError::Backend(e.to_string())), || KvError::Forbidden,
} KvError::Backend,
} )
Ok(()) .await
} }
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), KvError> { async fn check_write(&self, cx: &SdkCallCx) -> Result<(), KvError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(&*self.authz, principal, Capability::AppKvWrite(cx.app_id)).await { &*self.authz,
Ok(()) => {} cx,
Err(authz::AuthzDenied::Denied) => return Err(KvError::Forbidden), Capability::AppKvWrite(cx.app_id),
Err(authz::AuthzDenied::Repo(e)) => return Err(KvError::Backend(e.to_string())), || KvError::Forbidden,
} KvError::Backend,
} )
Ok(()) .await
} }
} }

View File

@@ -114,20 +114,14 @@ impl PubsubServiceImpl {
} }
async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> { async fn check_publish(&self, cx: &SdkCallCx) -> Result<(), PubsubError> {
if let Some(ref principal) = cx.principal { authz::script_gate(
match authz::require(
&*self.authz, &*self.authz,
principal, cx,
Capability::AppPubsubPublish(cx.app_id), Capability::AppPubsubPublish(cx.app_id),
|| PubsubError::Forbidden,
PubsubError::Backend,
) )
.await .await
{
Ok(()) => {}
Err(authz::AuthzDenied::Denied) => return Err(PubsubError::Forbidden),
Err(authz::AuthzDenied::Repo(e)) => return Err(PubsubError::Backend(e.to_string())),
}
}
Ok(())
} }
} }

View File

@@ -58,19 +58,14 @@ impl QueueService for QueueServiceImpl {
// Script-as-gate authz: anonymous public-HTTP scripts skip the // Script-as-gate authz: anonymous public-HTTP scripts skip the
// check (cx.principal is None); authenticated callers must hold // check (cx.principal is None); authenticated callers must hold
// AppQueueEnqueue. // AppQueueEnqueue.
if let Some(principal) = cx.principal.as_ref() { authz::script_gate(
match authz::require(
&*self.authz, &*self.authz,
principal, cx,
Capability::AppQueueEnqueue(cx.app_id), Capability::AppQueueEnqueue(cx.app_id),
|| QueueError::Forbidden,
QueueError::Backend,
) )
.await .await?;
{
Ok(()) => {}
Err(authz::AuthzDenied::Denied) => return Err(QueueError::Forbidden),
Err(authz::AuthzDenied::Repo(e)) => return Err(QueueError::Backend(e.to_string())),
}
}
let deliver_after = opts let deliver_after = opts
.delay_ms .delay_ms