Files
PiCloud/crates/manager-core/src/group_files_service.rs
MechaCat02 2bc3fb677b feat(group-quota): per-group shared-collection quotas (M3)
Global env-var ceilings enforced in the group write path (mirrors the
per-value caps): PICLOUD_GROUP_KV_MAX_ROWS / _DOCS_MAX_ROWS (per-group row
count, checked only on a NEW key/doc — updates exempt) and
_FILES_MAX_TOTAL_BYTES (per-group total blob bytes). New group_quota env
helpers; count_rows/total_bytes repo methods (trait-default 0 for non-Postgres);
QuotaExceeded error variants on the three group error enums; a with_max_rows
test builder. Pinned by a group_kv_service unit test (3rd key rejected, update
of an existing key at the cap allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:17:12 +02:00

558 lines
18 KiB
Rust

//! `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, NoopEventEmitter, SdkCallCx,
ServiceEvent, ServiceEventEmitter,
};
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,
/// §11.6 per-group quota: max total stored bytes across the group's
/// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
max_total_bytes: u64,
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
events: Arc<dyn ServiceEventEmitter>,
}
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,
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
events: Arc::new(NoopEventEmitter),
}
}
/// Wire the event emitter (§11.6 shared-collection triggers).
#[must_use]
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
self.events = events;
self
}
/// §11.6: fire `shared = true` files triggers on the owning group.
/// Best-effort; an emit failure is logged, never surfaced.
async fn emit_shared(
&self,
cx: &SdkCallCx,
group_id: GroupId,
op: &'static str,
collection: &str,
meta: &FileMeta,
old: Option<&FileMeta>,
) {
if let Err(e) = self
.events
.emit_shared(
cx,
group_id,
ServiceEvent {
source: "files",
op,
collection: Some(collection.to_string()),
key: Some(meta.id.to_string()),
payload: serde_json::to_value(meta).ok(),
old_payload: old.and_then(|m| serde_json::to_value(m).ok()),
},
)
.await
{
tracing::error!(error = %e, source = "files", op, event_emit_failure = true, "shared event emit failed");
}
}
/// 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?;
// §11.6 quota: the new file's bytes must fit under the group's total.
let used = self.repo.total_bytes(group_id).await?;
let incoming = new.data.len() as u64;
if used.saturating_add(incoming) > self.max_total_bytes {
return Err(GroupFilesError::QuotaExceeded {
limit: self.max_total_bytes,
actual: used.saturating_add(incoming),
});
}
let meta = self.repo.create(group_id, collection, new).await?;
self.emit_shared(cx, group_id, "create", collection, &meta, None)
.await;
Ok(meta.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 { new, prev }) => {
self.emit_shared(cx, group_id, "update", collection, &new, Some(&prev))
.await;
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);
};
match self.repo.delete(group_id, collection, uuid).await? {
Some(meta) => {
self.emit_shared(cx, group_id, "delete", collection, &meta, Some(&meta))
.await;
Ok(true)
}
None => Ok(false),
}
}
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, .. }));
}
}