feat(modules): GroupKvService + kv::shared SDK handle (§11.6 C3)
The runtime for shared group collections. A script reaches a shared store via
the explicit kv::shared("name") handle (distinct GroupKvHandle Rhai type, so
private-vs-shared scope is a deliberate choice). The service resolves the
owning group from cx.app_id's ancestor chain — the script never names a group,
and that walk is the isolation boundary (a foreign app's chain never contains
the owning group → CollectionNotShared).
- shared: GroupKvService trait + GroupKvError + NoopGroupKvService; threaded
into the Services bundle as a field defaulted to noop, opted into via a new
with_group_kv() (keeps the ~14 Services::new call sites unchanged).
- manager-core: GroupKvServiceImpl with the reads-open/writes-authed split —
reads use script_gate (anonymous public scripts skip), writes use the new
script_gate_require_principal (anonymous fails closed). Owner resolution
behind a GroupCollectionResolver trait (Postgres impl + injectable fake);
unit tests cover off-chain CollectionNotShared, anonymous-read-OK/
anonymous-write-Forbidden, authed-no-role-Forbidden.
- executor-core: GroupKvHandle + kv::shared constructor + get/set/has/delete/
list (Rhai dispatches by receiver type, reusing the block_on bridge).
- picloud: wire PostgresGroupKvRepo + resolver + GroupKvServiceImpl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
crates/shared/src/group_kv.rs
Normal file
141
crates/shared/src/group_kv.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! `GroupKvService` — the §11.6 shared group-collection KV contract.
|
||||
//!
|
||||
//! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches
|
||||
//! it via the explicit `kv::shared("name")` handle (distinct from the
|
||||
//! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of
|
||||
//! the data is not `cx.app_id`: the impl resolves the **owning group** by
|
||||
//! walking the calling app's ancestor chain for the nearest group that declares
|
||||
//! the collection group-shared. That ancestry walk is the isolation boundary —
|
||||
//! a foreign app's chain never contains the owning group, so the name does not
|
||||
//! resolve. The trait surface therefore takes **no** group id (same discipline
|
||||
//! as `KvService` omitting `app_id`): owner derivation stays inside the impl.
|
||||
//!
|
||||
//! Trust model (§11.6): **reads are open** to any script in the subtree
|
||||
//! (anonymous public HTTP included — the operator opted in by declaring the
|
||||
//! collection); **writes require an authenticated principal** with editor+ on
|
||||
//! the owning group.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{KvListPage, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupKvService: Send + Sync {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvError>;
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), GroupKvError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvError>;
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared collections. Every call
|
||||
/// errors so accidental use surfaces clearly.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupKvService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvService for NoopGroupKvService {
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvError> {
|
||||
Err(GroupKvError::Backend("group kv is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_key: &str,
|
||||
_value: serde_json::Value,
|
||||
) -> Result<(), GroupKvError> {
|
||||
Err(GroupKvError::Backend("group kv is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_key: &str,
|
||||
) -> Result<bool, GroupKvError> {
|
||||
Err(GroupKvError::Backend("group kv is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_key: &str,
|
||||
) -> Result<bool, GroupKvError> {
|
||||
Err(GroupKvError::Backend("group kv is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<KvListPage, GroupKvError> {
|
||||
Err(GroupKvError::Backend("group kv is not wired in".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupKvError {
|
||||
/// Empty collection name; rejected at the SDK boundary.
|
||||
#[error("collection name must not be empty")]
|
||||
InvalidCollection,
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this collection
|
||||
/// group-shared — the structural "not shared with you" boundary. Also the
|
||||
/// outcome for a foreign app naming another subtree's collection.
|
||||
#[error("collection {0:?} is not a shared group collection for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
/// Caller lacked the required capability. For reads this is only raised
|
||||
/// when `cx.principal.is_some()`; for WRITES it is also raised when the
|
||||
/// principal is absent (writes fail closed — shared mutation always needs
|
||||
/// an authenticated editor+ on the owning group).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
/// JSON-encoded value exceeded the per-value `max_value_bytes` cap
|
||||
/// (`PICLOUD_KV_MAX_VALUE_BYTES`).
|
||||
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
|
||||
ValueTooLarge { limit: usize, actual: usize },
|
||||
|
||||
/// Anything else — Postgres unavailable, serialization failure, etc.
|
||||
#[error("group kv backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod group_kv;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -65,6 +66,7 @@ pub use files::{
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, EmailService, FilesService, HttpService, InvokeService,
|
||||
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
||||
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
||||
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||
UsersService, VarsService,
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupKvService, HttpService,
|
||||
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupKvService, NoopHttpService,
|
||||
NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService,
|
||||
NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService,
|
||||
SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -114,6 +114,14 @@ pub struct Services {
|
||||
/// (§3). Backed by `config_resolver` over Postgres in the picloud
|
||||
/// binary; `NoopVarsService` in tests that don't read config.
|
||||
pub vars: Arc<dyn VarsService>,
|
||||
|
||||
/// Shared cross-app group collections (§11.6). Scripts get
|
||||
/// `kv::shared(name)` — read/write KV scoped to the nearest ancestor
|
||||
/// group that declares the collection shared. Backed by Postgres in the
|
||||
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
|
||||
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
|
||||
/// unchanged.
|
||||
pub group_kv: Arc<dyn GroupKvService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -153,9 +161,21 @@ impl Services {
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
// §11.6 group collections default to noop; the picloud binary opts
|
||||
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
|
||||
// (mostly tests) unchanged — a field addition, not a param.
|
||||
group_kv: Arc::new(NoopGroupKvService),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-collection service (the picloud binary wires
|
||||
/// the Postgres-backed impl; tests leave the noop default).
|
||||
#[must_use]
|
||||
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
|
||||
self.group_kv = group_kv;
|
||||
self
|
||||
}
|
||||
|
||||
/// All-noop bundle for tests that build an `Engine` but don't
|
||||
/// exercise the stateful services. Returns the same shape as
|
||||
/// `Services::new` so callers can't accidentally rely on a stub
|
||||
|
||||
Reference in New Issue
Block a user