Files
PiCloud/crates/manager-core/src/group_queue_service.rs
MechaCat02 36272c8dac test(groups): pin the anon + isolation gates on the queue and pubsub services
`GroupQueueServiceImpl` shipped with ZERO tests; `GroupPubsubServiceImpl` had four
that exercised neither boundary — its resolver always resolved and its principal
was always an instance Owner (who bypasses the capability check). So on the two
newest shared services, both the isolation boundary and the anonymous fail-closed
gate were unpinned — and they matter most here: a shared-queue consumer is
materialized into EVERY descendant app, so an unauthorized enqueue is
attacker-controlled input fanned out as script execution across a whole subtree.

Both now get the pair their three older siblings have:
`unrelated_app_gets_collection_not_shared` (with a positive control, so a
reject-everything impl can't pass it) and an anon/non-editor fail-closed test that
also confirms reads (depth) stay open.

Mutation-verified: swapping `script_gate_require_principal` for `script_gate` —
the one-token edit that would let an unauthenticated public route enqueue into any
ancestor group's shared queue — makes the anonymous enqueue succeed, and the test
catches it exactly there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:27:53 +02:00

401 lines
14 KiB
Rust

//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection
//! registry underneath the `picloud_shared::GroupQueueService` trait that
//! scripts reach via the `queue::shared_collection("name")` Rhai handle
//! (§11.6 D3).
//!
//! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the
//! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on
//! anon — enqueue is a shared write), caps the payload size, then writes to the
//! group-keyed store. Consumption is out of band (competing per-descendant
//! materialized consumers, in the dispatcher).
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage};
use crate::queue_service::queue_max_payload_bytes_from_env;
/// The registry `kind` this service resolves.
const KIND_QUEUE: &str = "queue";
pub struct GroupQueueServiceImpl {
repo: Arc<dyn GroupQueueRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_payload_bytes: usize,
}
impl GroupQueueServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn GroupQueueRepo>,
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
) -> Self {
Self {
repo,
resolver,
authz,
max_payload_bytes: queue_max_payload_bytes_from_env(),
}
}
async fn owning_group(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<GroupId, GroupQueueError> {
if collection.is_empty() {
return Err(GroupQueueError::InvalidCollection);
}
self.resolver
.resolve_owning_group(cx.app_id, collection, KIND_QUEUE)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))?
.ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string()))
}
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> {
authz::script_gate_require_principal(
&*self.authz,
cx,
Capability::GroupQueueEnqueue(group_id),
|| GroupQueueError::Forbidden,
GroupQueueError::Backend,
)
.await
}
}
#[async_trait]
impl GroupQueueService for GroupQueueServiceImpl {
async fn enqueue(
&self,
cx: &SdkCallCx,
collection: &str,
payload: serde_json::Value,
opts: EnqueueOpts,
) -> Result<QueueMessageId, GroupQueueError> {
if collection.is_empty() {
return Err(GroupQueueError::InvalidCollection);
}
// Size cap first (no DB) — a cheap reject before resolve/authz.
let encoded_len = serde_json::to_vec(&payload)
.map(|v| v.len())
.map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?;
if encoded_len > self.max_payload_bytes {
return Err(GroupQueueError::PayloadTooLarge {
limit: self.max_payload_bytes,
actual: encoded_len,
});
}
let max_attempts = opts.max_attempts.unwrap_or(3);
if !(1..=20).contains(&max_attempts) {
return Err(GroupQueueError::InvalidOpts(
"queue::enqueue: max_attempts must be in [1, 20]".into(),
));
}
if let Some(delay) = opts.delay_ms {
if !(0..=86_400_000).contains(&delay) {
return Err(GroupQueueError::InvalidOpts(
"queue::enqueue: delay_ms must be in [0, 86_400_000]".into(),
));
}
}
let group_id = self.owning_group(cx, collection).await?;
self.check_write(cx, group_id).await?;
let deliver_after = opts
.delay_ms
.and_then(chrono::Duration::try_milliseconds)
.map(|d| Utc::now() + d);
self.repo
.enqueue(NewGroupQueueMessage {
group_id,
collection: collection.to_string(),
payload,
deliver_after,
max_attempts,
enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id),
})
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError> {
let group_id = self.owning_group(cx, collection).await?;
// Depth is a read — reads are open (any subtree script).
self.repo
.depth(group_id, collection)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
async fn depth_pending(
&self,
cx: &SdkCallCx,
collection: &str,
) -> Result<u64, GroupQueueError> {
let group_id = self.owning_group(cx, collection).await?;
self.repo
.depth_pending(group_id, collection)
.await
.map_err(|e| GroupQueueError::Backend(e.to_string()))
}
}
/// This service shipped with NO tests at all, while its three older siblings
/// (`group_kv_service`, `group_docs_service`, `group_files_service`) each pin the
/// same two invariants. Both matter more here than anywhere else: a shared-queue
/// consumer is materialized into EVERY descendant app, so an unauthorized enqueue
/// is attacker-controlled input fanned out as script execution across a whole
/// subtree.
///
/// The gap was concrete, not theoretical: swapping `script_gate_require_principal`
/// for `script_gate` (a one-token edit; the latter returns `Ok(())` for an
/// anonymous principal) would let an unauthenticated public HTTP route enqueue
/// into any ancestor group's shared queue — and nothing would have failed.
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::AuthzError;
use crate::group_queue_repo::{ClaimedGroupMessage, GroupQueueRepoError};
use picloud_shared::{
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
UserId,
};
use std::collections::HashMap;
use uuid::Uuid;
/// Only `enqueue` / `depth` / `depth_pending` are reachable from the service —
/// consumption is the dispatcher's job — so the claim side is deliberately
/// unreachable rather than faked.
#[derive(Default)]
struct RecordingRepo {
enqueued: tokio::sync::Mutex<Vec<(GroupId, String)>>,
}
#[async_trait]
impl GroupQueueRepo for RecordingRepo {
async fn enqueue(
&self,
msg: NewGroupQueueMessage,
) -> Result<QueueMessageId, GroupQueueRepoError> {
self.enqueued
.lock()
.await
.push((msg.group_id, msg.collection));
Ok(QueueMessageId::new())
}
async fn claim(
&self,
_: GroupId,
_: &str,
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
async fn ack(&self, _: QueueMessageId, _: Uuid) -> Result<bool, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
async fn nack(
&self,
_: QueueMessageId,
_: Uuid,
_: chrono::Duration,
) -> Result<bool, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
async fn release(
&self,
_: QueueMessageId,
_: Uuid,
_: chrono::Duration,
) -> Result<bool, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
#[allow(clippy::too_many_arguments)]
async fn dead_letter(
&self,
_: QueueMessageId,
_: Uuid,
_: GroupId,
_: &str,
_: Option<picloud_shared::TriggerId>,
_: Option<ScriptId>,
_: u32,
_: chrono::DateTime<Utc>,
_: &str,
) -> Result<picloud_shared::DeadLetterId, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
unreachable!("the service never consumes")
}
async fn depth(&self, _: GroupId, _: &str) -> Result<u64, GroupQueueRepoError> {
Ok(7)
}
async fn depth_pending(&self, _: GroupId, _: &str) -> Result<u64, GroupQueueRepoError> {
Ok(3)
}
}
/// Models the ancestor-chain walk: a queue resolves only for apps whose chain
/// declares it, and only under `kind = "queue"`. The REAL walk is pinned
/// against Postgres in `tests/group_collection_isolation.rs`; this stands in
/// for it so the service's own plumbing can be tested without a DB.
#[derive(Default)]
struct FakeResolver {
map: HashMap<(AppId, String), GroupId>,
}
#[async_trait]
impl GroupCollectionResolver for FakeResolver {
async fn resolve_owning_group(
&self,
app_id: AppId,
name: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
if kind != KIND_QUEUE {
return Ok(None);
}
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
}
}
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(&self, _: UserId, _: AppId) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn owner() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
fn member_no_role() -> Principal {
Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}
}
fn svc(resolver: FakeResolver) -> GroupQueueServiceImpl {
GroupQueueServiceImpl::new(
Arc::new(RecordingRepo::default()),
Arc::new(resolver),
Arc::new(DenyingAuthzRepo),
)
}
#[tokio::test]
async fn unrelated_app_gets_collection_not_shared() {
// app_a's chain declares "jobs"; app_b's does not.
let (app_a, app_b, group) = (AppId::new(), AppId::new(), GroupId::new());
let mut resolver = FakeResolver::default();
resolver.map.insert((app_a, "jobs".into()), group);
let q = svc(resolver);
// Positive control: app_a really can enqueue and read the depth. Without
// this, a service that rejected EVERYTHING would pass the negative below.
let cx_a = cx_with(app_a, Some(owner()));
q.enqueue(
&cx_a,
"jobs",
serde_json::json!({"n": 1}),
EnqueueOpts::default(),
)
.await
.expect("an app on the chain can enqueue");
assert_eq!(q.depth(&cx_a, "jobs").await.unwrap(), 7);
// THE BOUNDARY: app_b is off-chain, so the name does not resolve — on the
// write path and on the read path alike.
let cx_b = cx_with(app_b, Some(owner()));
let err = q
.enqueue(&cx_b, "jobs", serde_json::json!({}), EnqueueOpts::default())
.await
.unwrap_err();
assert!(
matches!(&err, GroupQueueError::CollectionNotShared(c) if c == "jobs"),
"a foreign app must not reach another subtree's shared queue, got {err:?}"
);
let err = q.depth(&cx_b, "jobs").await.unwrap_err();
assert!(matches!(&err, GroupQueueError::CollectionNotShared(c) if c == "jobs"));
}
#[tokio::test]
async fn enqueue_fails_closed_for_anon_while_depth_stays_open() {
let (app, group) = (AppId::new(), GroupId::new());
let mut resolver = FakeResolver::default();
resolver.map.insert((app, "jobs".into()), group);
let q = svc(resolver);
// Reads are open — the declaration IS the grant, anonymous included.
let anon = cx_with(app, None);
assert_eq!(
q.depth(&anon, "jobs").await.unwrap(),
7,
"depth is a read; reads are open to any subtree script"
);
// Writes fail closed. This is the assertion that fires if
// `script_gate_require_principal` is ever swapped for `script_gate`.
let err = q
.enqueue(&anon, "jobs", serde_json::json!({}), EnqueueOpts::default())
.await
.unwrap_err();
assert!(
matches!(err, GroupQueueError::Forbidden),
"an ANONYMOUS enqueue must fail closed — a public route must not be able to \
inject work that every descendant app's consumer will execute; got {err:?}"
);
// Authenticated but without an editor+ role on the owning group: also denied.
let member = cx_with(app, Some(member_no_role()));
let err = q
.enqueue(
&member,
"jobs",
serde_json::json!({}),
EnqueueOpts::default(),
)
.await
.unwrap_err();
assert!(
matches!(err, GroupQueueError::Forbidden),
"authentication alone is not authorization — editor+ on the owning group is required"
);
}
}