Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").
- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
and add the group-keyed group_docs store (clone of docs/0013, keyed by
group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
(name,kind) for the kind-aware diff (D4). Relocate the injectable
GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
are compile-time literals (no injection); app docs passes ("docs","app_id"),
group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
pub(crate) for reuse.
Schema snapshot re-blessed (55 migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
420 lines
13 KiB
Rust
420 lines
13 KiB
Rust
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
|
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
|
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
|
|
//!
|
|
//! Layers added over the raw repo:
|
|
//!
|
|
//! 1. Empty-collection rejection.
|
|
//! 2. **Owner resolution** (the structural isolation boundary): the collection
|
|
//! name is resolved to the nearest ancestor group that declares it shared,
|
|
//! walking the calling app's chain. No declaration on the chain → a clean
|
|
//! `CollectionNotShared`, BEFORE any authz or storage access.
|
|
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
|
|
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
|
//! (anonymous fails closed — shared mutation always needs an authenticated
|
|
//! editor+ on the owning group).
|
|
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`).
|
|
//!
|
|
//! No event emission in the MVP: a write to a shared collection has no single
|
|
//! app to attribute a trigger to (the "group trigger has no app to watch"
|
|
//! problem). Documented as a deferral in docs §11.6.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{GroupId, GroupKvError, GroupKvService, KvListPage, SdkCallCx};
|
|
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::group_collection_repo::GroupCollectionResolver;
|
|
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
|
|
use crate::kv_service::kv_max_value_bytes_from_env;
|
|
|
|
/// The registry `kind` this service resolves.
|
|
const KIND_KV: &str = "kv";
|
|
|
|
pub struct GroupKvServiceImpl {
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_value_bytes: usize,
|
|
}
|
|
|
|
impl GroupKvServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
) -> Self {
|
|
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_max_value_bytes(
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_value_bytes: usize,
|
|
) -> Self {
|
|
Self {
|
|
repo,
|
|
resolver,
|
|
authz,
|
|
max_value_bytes,
|
|
}
|
|
}
|
|
|
|
/// The structural boundary: resolve the collection to its owning group on
|
|
/// the calling app's chain. `CollectionNotShared` when no ancestor group
|
|
/// declares it — the same outcome a foreign app gets, by construction.
|
|
async fn owning_group(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
) -> Result<GroupId, GroupKvError> {
|
|
if collection.is_empty() {
|
|
return Err(GroupKvError::InvalidCollection);
|
|
}
|
|
self.resolver
|
|
.resolve_owning_group(cx.app_id, collection, KIND_KV)
|
|
.await
|
|
.map_err(|e| GroupKvError::Backend(e.to_string()))?
|
|
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
|
|
}
|
|
|
|
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
|
authz::script_gate(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::GroupKvRead(group_id),
|
|
|| GroupKvError::Forbidden,
|
|
GroupKvError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
|
// Fails closed for an anonymous principal: shared mutation always needs
|
|
// an authenticated editor+ on the owning group.
|
|
authz::script_gate_require_principal(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::GroupKvWrite(group_id),
|
|
|| GroupKvError::Forbidden,
|
|
GroupKvError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
impl From<GroupKvRepoError> for GroupKvError {
|
|
fn from(e: GroupKvRepoError) -> Self {
|
|
Self::Backend(e.to_string())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupKvService for GroupKvServiceImpl {
|
|
async fn get(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.get(group_id, collection, key).await?)
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<(), GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
let encoded_len = serde_json::to_vec(&value)
|
|
.map(|v| v.len())
|
|
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
|
|
if encoded_len > self.max_value_bytes {
|
|
return Err(GroupKvError::ValueTooLarge {
|
|
limit: self.max_value_bytes,
|
|
actual: encoded_len,
|
|
});
|
|
}
|
|
self.check_write(cx, group_id).await?;
|
|
self.repo.set(group_id, collection, key, value).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_write(cx, group_id).await?;
|
|
Ok(self.repo.delete(group_id, collection, key).await?.is_some())
|
|
}
|
|
|
|
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.has(group_id, collection, key).await?)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
use picloud_shared::{
|
|
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
|
UserId,
|
|
};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryGroupKvRepo {
|
|
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupKvRepo for InMemoryGroupKvRepo {
|
|
async fn get(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(group_id, collection.to_string(), key.to_string()))
|
|
.cloned())
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.insert((group_id, collection.to_string(), key.to_string()), value))
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.remove(&(group_id, collection.to_string(), key.to_string())))
|
|
}
|
|
|
|
async fn has(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvRepoError> {
|
|
Ok(self.data.lock().await.contains_key(&(
|
|
group_id,
|
|
collection.to_string(),
|
|
key.to_string(),
|
|
)))
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
_cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvRepoError> {
|
|
let data = self.data.lock().await;
|
|
let mut keys: Vec<String> = data
|
|
.iter()
|
|
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
|
.map(|((_, _, k), _)| k.clone())
|
|
.collect();
|
|
keys.sort();
|
|
keys.truncate((limit as usize).max(1));
|
|
Ok(KvListPage {
|
|
keys,
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
|
/// "not shared with you".
|
|
#[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> {
|
|
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct DenyingAuthzRepo;
|
|
|
|
#[async_trait]
|
|
impl AuthzRepo for DenyingAuthzRepo {
|
|
async fn membership(
|
|
&self,
|
|
_user_id: UserId,
|
|
_app_id: 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) -> GroupKvServiceImpl {
|
|
GroupKvServiceImpl::new(
|
|
Arc::new(InMemoryGroupKvRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unrelated_app_gets_collection_not_shared() {
|
|
// app_a's chain declares "catalog"; app_b's does not.
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app_a, "catalog".into()), group);
|
|
let kv = svc(resolver);
|
|
|
|
// app_a (owner principal) can write+read.
|
|
let cx_a = cx_with(app_a, Some(owner()));
|
|
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
kv.get(&cx_a, "catalog", "k").await.unwrap(),
|
|
Some(serde_json::json!(1))
|
|
);
|
|
|
|
// app_b is off-chain — the name does not resolve.
|
|
let cx_b = cx_with(app_b, Some(owner()));
|
|
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reads_open_writes_require_auth() {
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "catalog".into()), group);
|
|
let kv = svc(resolver);
|
|
|
|
// Seed a value as owner.
|
|
let owner_cx = cx_with(app, Some(owner()));
|
|
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Anonymous (principal=None) READ is allowed (reads-open).
|
|
let anon = cx_with(app, None);
|
|
assert_eq!(
|
|
kv.get(&anon, "catalog", "k").await.unwrap(),
|
|
Some(serde_json::json!("v"))
|
|
);
|
|
|
|
// Anonymous WRITE fails closed.
|
|
let err = kv
|
|
.set(&anon, "catalog", "k", serde_json::json!("x"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::Forbidden));
|
|
|
|
// Authenticated member with no group role: write forbidden.
|
|
let member = cx_with(app, Some(member_no_role()));
|
|
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_collection_rejected_before_resolve() {
|
|
let kv = svc(FakeResolver::default());
|
|
let cx = cx_with(AppId::new(), None);
|
|
let err = kv.get(&cx, "", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::InvalidCollection));
|
|
}
|
|
}
|