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>
This commit is contained in:
MechaCat02
2026-07-01 21:17:12 +02:00
parent 9a4b83dfcf
commit 2bc3fb677b
12 changed files with 213 additions and 5 deletions

View File

@@ -30,6 +30,9 @@ pub struct GroupFilesServiceImpl {
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>,
}
@@ -47,6 +50,7 @@ impl GroupFilesServiceImpl {
resolver,
authz,
max_file_size_bytes,
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
events: Arc::new(NoopEventEmitter),
}
}
@@ -160,6 +164,15 @@ impl GroupFilesService for GroupFilesServiceImpl {
// 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;