//! `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, resolver: Arc, authz: Arc, max_payload_bytes: usize, } impl GroupQueueServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, ) -> Self { Self { repo, resolver, authz, max_payload_bytes: queue_max_payload_bytes_from_env(), } } async fn owning_group( &self, cx: &SdkCallCx, collection: &str, ) -> Result { 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 { 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 { 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 { 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>, } #[async_trait] impl GroupQueueRepo for RecordingRepo { async fn enqueue( &self, msg: NewGroupQueueMessage, ) -> Result { self.enqueued .lock() .await .push((msg.group_id, msg.collection)); Ok(QueueMessageId::new()) } async fn claim( &self, _: GroupId, _: &str, ) -> Result, GroupQueueRepoError> { unreachable!("the service never consumes") } async fn ack(&self, _: QueueMessageId, _: Uuid) -> Result { unreachable!("the service never consumes") } async fn nack( &self, _: QueueMessageId, _: Uuid, _: chrono::Duration, ) -> Result { unreachable!("the service never consumes") } async fn release( &self, _: QueueMessageId, _: Uuid, _: chrono::Duration, ) -> Result { unreachable!("the service never consumes") } #[allow(clippy::too_many_arguments)] async fn dead_letter( &self, _: QueueMessageId, _: Uuid, _: GroupId, _: &str, _: Option, _: Option, _: u32, _: chrono::DateTime, _: &str, ) -> Result { unreachable!("the service never consumes") } async fn reclaim_visibility_timeouts(&self) -> Result { unreachable!("the service never consumes") } async fn depth(&self, _: GroupId, _: &str) -> Result { Ok(7) } async fn depth_pending(&self, _: GroupId, _: &str) -> Result { 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, 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, AuthzError> { Ok(None) } } fn cx_with(app_id: AppId, principal: Option) -> 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" ); } }