feat(modules): GroupFilesService + files::shared_collection handle (§11.6 files C3)
- shared/group_files: GroupFilesService trait (mirror FilesService, no group id arg — owner resolved from cx.app_id), GroupFilesError (clone of FilesError + CollectionNotShared) with From<FilesError>, Noop. - services: group_files field + with_group_files setter (default Noop). - group_files_service: GroupFilesServiceImpl — owning_group via the registry resolver (kind=files) → CollectionNotShared; reads open (script_gate), writes fail closed (script_gate_require_principal); reuses validate_files_collection + the NewFile/FileUpdate validators + sanitize_stored_content_type + the per-file size cap; no events. - sdk/files: GroupFilesHandle + files::shared_collection(name) + the create/head/get/update/delete/list group methods (Blob in/out). - picloud: construct FsGroupFilesRepo (sharing the files root + size cap) + GroupFilesServiceImpl, .with_group_files(...). Unit tests: off-chain → CollectionNotShared; anon read OK / anon write Forbidden; oversize → TooLarge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::bridge::block_on;
|
||||
use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services};
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
@@ -36,8 +38,20 @@ pub struct FilesHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||
/// Same method surface as `FilesHandle`, but routes through the
|
||||
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
|
||||
/// ancestor chain inside the service (the isolation boundary).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupFilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupFilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let files_service = services.files.clone();
|
||||
let group_files_service = services.group_files.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -57,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_files_service = group_files_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("files::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupFilesHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("files", module.into());
|
||||
|
||||
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
||||
@@ -67,6 +98,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
|
||||
register_group_create(engine);
|
||||
register_group_head(engine);
|
||||
register_group_get(engine);
|
||||
register_group_update(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
@@ -220,6 +260,163 @@ fn list_call(
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
|
||||
// Bodies mirror the app handle's; only the receiver type and service differ
|
||||
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
|
||||
|
||||
fn register_group_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let name = require_string(&meta, "name")?;
|
||||
let content_type = require_string(&meta, "content_type")?;
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let h = handle.clone();
|
||||
let new = NewFile {
|
||||
name,
|
||||
content_type,
|
||||
data,
|
||||
};
|
||||
let id = block_on("files", async move {
|
||||
h.service.create(&h.cx, &h.collection, new).await
|
||||
})?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_head(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"head",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let meta = block_on("files", async move {
|
||||
h.service.head(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let bytes = block_on("files", async move {
|
||||
h.service.get(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let name = optional_string(&meta, "name")?;
|
||||
let content_type = optional_string(&meta, "content_type")?;
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let upd = FileUpdate {
|
||||
data,
|
||||
name,
|
||||
content_type,
|
||||
};
|
||||
block_on("files", async move {
|
||||
h.service.update(&h.cx, &h.collection, &id, upd).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
block_on("files", async move {
|
||||
h.service.delete(&h.cx, &h.collection, &id).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle,
|
||||
cursor: &str,
|
||||
limit: i64|
|
||||
-> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"files: list cursor must be a string".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
group_list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupFilesHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("files", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let files: Array = page
|
||||
.files
|
||||
.iter()
|
||||
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||
.collect();
|
||||
m.insert("files".into(), files.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
||||
/// `head` / `list`.
|
||||
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
||||
|
||||
@@ -255,12 +255,7 @@ pub(crate) fn shard_dir_at(
|
||||
.join(&id_str[..2])
|
||||
}
|
||||
|
||||
pub(crate) fn final_path_at(
|
||||
root: &Path,
|
||||
owner_rel: &Path,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> PathBuf {
|
||||
pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf {
|
||||
let id_str = id.to_string();
|
||||
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
|
||||
}
|
||||
|
||||
492
crates/manager-core/src/group_files_service.rs
Normal file
492
crates/manager-core/src/group_files_service.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
|
||||
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
|
||||
//! reads-open / writes-authed authz) with the files surface (collection
|
||||
//! path-validation, field + size-cap validation, content-type sanitization). No
|
||||
//! event emission in the MVP (the "group trigger has no app to watch" deferral).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
|
||||
GroupFilesError, GroupFilesService, GroupId, NewFile, SdkCallCx,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::files_repo::FileUpdated;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_FILES: &str = "files";
|
||||
|
||||
pub struct GroupFilesServiceImpl {
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupFilesServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_file_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupFilesError> {
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_FILES)
|
||||
.await
|
||||
.map_err(|e| GroupFilesError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupFilesRead(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
// 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::GroupFilesWrite(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they
|
||||
/// simply mean "no such file" (mirrors `files_service::parse_id`).
|
||||
fn parse_id(id: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(id).ok()
|
||||
}
|
||||
|
||||
impl From<GroupFilesRepoError> for GroupFilesError {
|
||||
fn from(e: GroupFilesRepoError) -> Self {
|
||||
match e {
|
||||
GroupFilesRepoError::Corrupted => Self::Corrupted,
|
||||
GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesService for GroupFilesServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
mut new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
new.validate(self.max_file_size_bytes)?;
|
||||
// Coerce dangerous render types to application/octet-stream after the
|
||||
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
||||
new.content_type = sanitize_stored_content_type(&new.content_type);
|
||||
self.check_write(cx, group_id).await?;
|
||||
Ok(self.repo.create(group_id, collection, new).await?.id)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.head(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.get(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
mut upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
upd.validate(self.max_file_size_bytes)?;
|
||||
if let Some(ct) = upd.content_type.as_deref() {
|
||||
upd.content_type = Some(sanitize_stored_content_type(ct));
|
||||
}
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Err(GroupFilesError::NotFound);
|
||||
};
|
||||
match self.repo.update(group_id, collection, uuid, upd).await? {
|
||||
Some(FileUpdated { .. }) => Ok(()),
|
||||
None => Err(GroupFilesError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(self
|
||||
.repo
|
||||
.delete(group_id, collection, uuid)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
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 or
|
||||
// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the
|
||||
// tempdir tests in `files_repo.rs`.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::group_files_repo::GroupFilesRepoError;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupFilesRepo {
|
||||
#[allow(clippy::type_complexity)]
|
||||
data: Mutex<HashMap<(GroupId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
|
||||
FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: new.name.clone(),
|
||||
content_type: new.content_type.clone(),
|
||||
size: new.data.len() as u64,
|
||||
checksum: String::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesRepo for InMemoryGroupFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let m = meta(id, collection, &new);
|
||||
self.data.lock().await.insert(
|
||||
(group_id, collection.to_string(), id),
|
||||
(m.clone(), new.data),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m.clone()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(_, b)| b.clone()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||
let mut data = self.data.lock().await;
|
||||
let key = (group_id, collection.to_string(), id);
|
||||
let Some((prev, _)) = data.get(&key).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut m = prev.clone();
|
||||
m.size = upd.data.len() as u64;
|
||||
if let Some(n) = &upd.name {
|
||||
m.name = n.clone();
|
||||
}
|
||||
data.insert(key, (m.clone(), upd.data));
|
||||
Ok(Some(FileUpdated { new: m, prev }))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||
let files = self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
||||
.map(|(_, (m, _))| m.clone())
|
||||
.collect();
|
||||
Ok(FilesListPage {
|
||||
files,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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 svc(resolver: FakeResolver) -> GroupFilesServiceImpl {
|
||||
GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_file(name: &str, data: &[u8]) -> NewFile {
|
||||
NewFile {
|
||||
name: name.to_string(),
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
let id = files
|
||||
.create(&cx_a, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(files
|
||||
.get(&cx_a, "assets", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
|
||||
}
|
||||
|
||||
#[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, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
let id = files
|
||||
.create(&owner_cx, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous READ (get) is allowed.
|
||||
let anon = cx_with(app, None);
|
||||
let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
|
||||
assert_eq!(bytes, Some(b"hi".to_vec()));
|
||||
|
||||
// Anonymous WRITE (create) fails closed.
|
||||
let err = files
|
||||
.create(&anon, "assets", new_file("x.txt", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversize_blob_rejected() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "assets".into()), group);
|
||||
let files = GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
8, // tiny cap
|
||||
);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
let err = files
|
||||
.create(&cx, "assets", new_file("big", b"123456789"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ pub mod group_collection_repo;
|
||||
pub mod group_docs_repo;
|
||||
pub mod group_docs_service;
|
||||
pub mod group_files_repo;
|
||||
pub mod group_files_service;
|
||||
pub mod group_kv_repo;
|
||||
pub mod group_kv_service;
|
||||
pub mod group_members_repo;
|
||||
@@ -186,6 +187,7 @@ pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollection
|
||||
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
|
||||
pub use group_docs_service::GroupDocsServiceImpl;
|
||||
pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError};
|
||||
pub use group_files_service::GroupFilesServiceImpl;
|
||||
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
|
||||
pub use group_kv_service::GroupKvServiceImpl;
|
||||
pub use group_members_repo::{
|
||||
|
||||
@@ -19,10 +19,10 @@ use picloud_manager_core::{
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
|
||||
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, GroupDocsServiceImpl, GroupKvServiceImpl,
|
||||
GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl,
|
||||
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo,
|
||||
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl,
|
||||
GroupKvServiceImpl, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
@@ -214,6 +214,16 @@ pub async fn build_app(
|
||||
events.clone(),
|
||||
files_max_size,
|
||||
));
|
||||
// §11.6 shared group collections (files): group-keyed blobs under
|
||||
// `<root>/files/groups/<group_id>/...`, resolved from cx.app_id's chain.
|
||||
// Shares the per-file size cap; no events (no app to attribute a trigger to).
|
||||
let group_files: Arc<dyn picloud_shared::GroupFilesService> =
|
||||
Arc::new(GroupFilesServiceImpl::new(
|
||||
Arc::new(FsGroupFilesRepo::new(pool.clone(), files_root.clone())),
|
||||
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
|
||||
authz.clone(),
|
||||
files_max_size,
|
||||
));
|
||||
// v1.1.6 realtime: the in-process broadcaster is shared between the
|
||||
// publish path (PubsubServiceImpl fans out to SSE subscribers after
|
||||
// the durable outbox fan-out) and the SSE endpoint (subscribe side).
|
||||
@@ -360,7 +370,8 @@ pub async fn build_app(
|
||||
vars,
|
||||
)
|
||||
.with_group_kv(group_kv)
|
||||
.with_group_docs(group_docs);
|
||||
.with_group_docs(group_docs)
|
||||
.with_group_files(group_files);
|
||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||
// trigger-depth bound (same counter under the hood).
|
||||
let engine_limits = Limits {
|
||||
|
||||
203
crates/shared/src/group_files.rs
Normal file
203
crates/shared/src/group_files.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
//! `GroupFilesService` — the §11.6 shared group-collection FILES contract.
|
||||
//!
|
||||
//! The cross-app-sharing counterpart to [`crate::FilesService`], analogous to
|
||||
//! [`crate::GroupKvService`] / [`crate::GroupDocsService`]. A script reaches it
|
||||
//! via the explicit `files::shared_collection("name")` handle. 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 shared with `kind='files'`. That ancestry walk is the isolation
|
||||
//! boundary — the trait surface takes **no** group id (owner derivation stays in
|
||||
//! the impl).
|
||||
//!
|
||||
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
|
||||
//! public HTTP included); **writes require an authenticated principal** with
|
||||
//! editor+ on the owning group. Same shape as group KV/docs.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{FileMeta, FileUpdate, FilesListPage, NewFile, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupFilesService: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError>;
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError>;
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, GroupFilesError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared files collections.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupFilesService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesService for NoopGroupFilesService {
|
||||
async fn create(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
_upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: &str,
|
||||
) -> Result<bool, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError> {
|
||||
Err(GroupFilesError::Backend(
|
||||
"group files is not wired in".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::FilesError`] plus
|
||||
/// the §11.6 `CollectionNotShared` boundary.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupFilesError {
|
||||
#[error("invalid collection name: {0}")]
|
||||
InvalidCollection(String),
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this collection
|
||||
/// shared with `kind='files'` — the structural "not shared with you"
|
||||
/// boundary (also the outcome for a foreign app).
|
||||
#[error("collection {0:?} is not a shared group files collection for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(&'static str),
|
||||
|
||||
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
|
||||
TooLarge { size: usize, limit: usize },
|
||||
|
||||
#[error("file name too long: {0} bytes exceeds 255")]
|
||||
NameTooLong(usize),
|
||||
|
||||
#[error("content_type too long: {0} bytes exceeds 127")]
|
||||
ContentTypeTooLong(usize),
|
||||
|
||||
#[error("file not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("file content corrupted (checksum mismatch)")]
|
||||
Corrupted,
|
||||
|
||||
/// 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).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("group files backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// Map the shared [`crate::FilesError`] — returned by `validate_collection`
|
||||
/// (re-exported as `validate_files_collection`) and the `NewFile`/`FileUpdate`
|
||||
/// validators that the group-files service reuses — onto the group error. Lives
|
||||
/// here (not in manager-core) so the orphan rule is satisfied.
|
||||
impl From<crate::FilesError> for GroupFilesError {
|
||||
fn from(e: crate::FilesError) -> Self {
|
||||
use crate::FilesError as F;
|
||||
match e {
|
||||
F::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
F::MissingField(f) => Self::MissingField(f),
|
||||
F::TooLarge { size, limit } => Self::TooLarge { size, limit },
|
||||
F::NameTooLong(n) => Self::NameTooLong(n),
|
||||
F::ContentTypeTooLong(n) => Self::ContentTypeTooLong(n),
|
||||
F::NotFound => Self::NotFound,
|
||||
F::Corrupted => Self::Corrupted,
|
||||
F::Forbidden => Self::Forbidden,
|
||||
F::Backend(s) => Self::Backend(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod group_docs;
|
||||
pub mod group_files;
|
||||
pub mod group_kv;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
@@ -68,6 +69,7 @@ pub use files::{
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
|
||||
pub use group_files::{GroupFilesError, GroupFilesService, NoopGroupFilesService};
|
||||
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, GroupKvService,
|
||||
HttpService, InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService, NoopGroupKvService,
|
||||
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService,
|
||||
QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
||||
GroupFilesService, GroupKvService, HttpService, InvokeService, KvService, ModuleSource,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService,
|
||||
NoopGroupDocsService, NoopGroupFilesService, 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
|
||||
@@ -127,6 +128,11 @@ pub struct Services {
|
||||
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
|
||||
/// defaults to `NoopGroupDocsService`.
|
||||
pub group_docs: Arc<dyn GroupDocsService>,
|
||||
|
||||
/// Shared cross-app group FILES collections (§11.6). Scripts get
|
||||
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
|
||||
/// defaults to `NoopGroupFilesService`.
|
||||
pub group_files: Arc<dyn GroupFilesService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -171,6 +177,7 @@ impl Services {
|
||||
// (mostly tests) unchanged — a field addition, not a param.
|
||||
group_kv: Arc::new(NoopGroupKvService),
|
||||
group_docs: Arc::new(NoopGroupDocsService),
|
||||
group_files: Arc::new(NoopGroupFilesService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +196,13 @@ impl Services {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop).
|
||||
#[must_use]
|
||||
pub fn with_group_files(mut self, group_files: Arc<dyn GroupFilesService>) -> Self {
|
||||
self.group_files = group_files;
|
||||
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