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:
@@ -81,6 +81,13 @@ pub trait GroupDocsRepo: Send + Sync {
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError>;
|
||||
|
||||
/// §11.6 quota: total doc count across the group's shared-docs collections.
|
||||
/// Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresGroupDocsRepo {
|
||||
@@ -266,6 +273,14 @@ impl GroupDocsRepo for PostgresGroupDocsRepo {
|
||||
|
||||
Ok(DocsListPage { docs, next_cursor })
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_id: &Uuid) -> String {
|
||||
|
||||
@@ -29,6 +29,9 @@ pub struct GroupDocsServiceImpl {
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
/// §11.6 per-group quota: max total docs across the group's shared-docs
|
||||
/// collections (`PICLOUD_GROUP_DOCS_MAX_ROWS`).
|
||||
max_rows: u64,
|
||||
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
@@ -59,6 +62,7 @@ impl GroupDocsServiceImpl {
|
||||
) -> Self {
|
||||
Self {
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
max_rows: crate::group_quota::group_docs_max_rows_from_env(),
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
@@ -189,6 +193,14 @@ impl GroupDocsService for GroupDocsServiceImpl {
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
// §11.6 quota: a new doc must fit under the group's row ceiling.
|
||||
let count = self.repo.count_rows(group_id).await?;
|
||||
if count >= self.max_rows {
|
||||
return Err(GroupDocsError::QuotaExceeded {
|
||||
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
});
|
||||
}
|
||||
let created = self.repo.create(group_id, collection, data.clone()).await?;
|
||||
self.emit_shared(
|
||||
cx,
|
||||
|
||||
@@ -114,6 +114,13 @@ pub trait GroupFilesRepo: Send + Sync {
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError>;
|
||||
|
||||
/// §11.6 quota: total stored bytes across the group's shared-files
|
||||
/// collections. Default `Ok(0)` so non-Postgres impls skip the check.
|
||||
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
||||
@@ -369,6 +376,16 @@ impl GroupFilesRepo for FsGroupFilesRepo {
|
||||
};
|
||||
Ok(FilesListPage { files, next_cursor })
|
||||
}
|
||||
|
||||
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM group_files WHERE group_id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -61,6 +61,13 @@ pub trait GroupKvRepo: Send + Sync {
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError>;
|
||||
|
||||
/// §11.6 quota: total key count across all of the group's shared-KV
|
||||
/// collections. Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresGroupKvRepo {
|
||||
@@ -208,6 +215,15 @@ impl GroupKvRepo for PostgresGroupKvRepo {
|
||||
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let (n,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1")
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_key: &str) -> String {
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
//! (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.
|
||||
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
|
||||
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
|
||||
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
|
||||
//! triggers on the owning group, under the writer app (`emit_shared`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -40,6 +39,10 @@ pub struct GroupKvServiceImpl {
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
/// §11.6 per-group quota: max total keys across the group's shared-KV
|
||||
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
|
||||
/// NEW key.
|
||||
max_rows: u64,
|
||||
/// §11.6: fires `shared = true` triggers on a shared-collection write.
|
||||
/// Defaults to the noop emitter; the host wires the outbox emitter via
|
||||
/// [`Self::with_events`].
|
||||
@@ -64,6 +67,14 @@ impl GroupKvServiceImpl {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
|
||||
/// the env-var default.
|
||||
#[must_use]
|
||||
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
|
||||
self.max_rows = max_rows;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
@@ -73,6 +84,7 @@ impl GroupKvServiceImpl {
|
||||
) -> Self {
|
||||
Self {
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
max_rows: crate::group_quota::group_kv_max_rows_from_env(),
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
@@ -196,6 +208,17 @@ impl GroupKvService for GroupKvServiceImpl {
|
||||
// per-app path — the has+set is non-transactional but triggers are
|
||||
// fire-and-forget).
|
||||
let existed = self.repo.has(group_id, collection, key).await?;
|
||||
// §11.6 quota: a NEW key must fit under the group's row ceiling; an
|
||||
// update of an existing key is net-zero and exempt.
|
||||
if !existed {
|
||||
let count = self.repo.count_rows(group_id).await?;
|
||||
if count >= self.max_rows {
|
||||
return Err(GroupKvError::QuotaExceeded {
|
||||
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.repo
|
||||
.set(group_id, collection, key, value.clone())
|
||||
.await?;
|
||||
@@ -342,6 +365,11 @@ mod tests {
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
||||
@@ -417,6 +445,49 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
|
||||
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
|
||||
// cap is rejected, but updating an existing key (net-zero) still works.
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "catalog".into()), group);
|
||||
let kv = GroupKvServiceImpl::new(
|
||||
Arc::new(InMemoryGroupKvRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
.with_max_rows(2);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
|
||||
kv.set(&cx, "catalog", "a", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
kv.set(&cx, "catalog", "b", serde_json::json!(2))
|
||||
.await
|
||||
.unwrap();
|
||||
// A third distinct key exceeds the cap.
|
||||
let err = kv
|
||||
.set(&cx, "catalog", "c", serde_json::json!(3))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
GroupKvError::QuotaExceeded {
|
||||
limit: 2,
|
||||
actual: 2
|
||||
}
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
// Updating an existing key is exempt (net-zero).
|
||||
kv.set(&cx, "catalog", "a", serde_json::json!(11))
|
||||
.await
|
||||
.expect("update of an existing key must be allowed at the cap");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
// app_a's chain declares "catalog"; app_b's does not.
|
||||
|
||||
44
crates/manager-core/src/group_quota.rs
Normal file
44
crates/manager-core/src/group_quota.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! §11.6 per-group quotas — global env-var ceilings on a group's shared
|
||||
//! collections, enforced in the group write path (mirrors the per-value
|
||||
//! `PICLOUD_*_MAX_*_BYTES` caps). One default applies to every group; per-group
|
||||
//! configurable limits are deferred.
|
||||
//!
|
||||
//! - KV / docs: a per-group ROW-count ceiling (across the group's collections of
|
||||
//! that kind). An update of an existing key/doc is net-zero and exempt.
|
||||
//! - files: a per-group TOTAL-BYTES ceiling (sum of stored blob sizes).
|
||||
|
||||
/// Default per-group shared-KV row ceiling. Override `PICLOUD_GROUP_KV_MAX_ROWS`.
|
||||
pub const DEFAULT_GROUP_KV_MAX_ROWS: u64 = 100_000;
|
||||
/// Default per-group shared-docs row ceiling. Override `PICLOUD_GROUP_DOCS_MAX_ROWS`.
|
||||
pub const DEFAULT_GROUP_DOCS_MAX_ROWS: u64 = 100_000;
|
||||
/// Default per-group shared-files total-bytes ceiling (10 GiB). Override
|
||||
/// `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`.
|
||||
pub const DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
fn u64_from_env(var: &str, default: u64) -> u64 {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
match v.trim().parse::<u64>() {
|
||||
Ok(n) if n > 0 => return n,
|
||||
_ => tracing::warn!(value = %v, "ignoring invalid {var} (want a positive integer)"),
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_kv_max_rows_from_env() -> u64 {
|
||||
u64_from_env("PICLOUD_GROUP_KV_MAX_ROWS", DEFAULT_GROUP_KV_MAX_ROWS)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_docs_max_rows_from_env() -> u64 {
|
||||
u64_from_env("PICLOUD_GROUP_DOCS_MAX_ROWS", DEFAULT_GROUP_DOCS_MAX_ROWS)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_files_max_total_bytes_from_env() -> u64 {
|
||||
u64_from_env(
|
||||
"PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES",
|
||||
DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES,
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,7 @@ pub mod group_files_service;
|
||||
pub mod group_kv_repo;
|
||||
pub mod group_kv_service;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_quota;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
pub mod groups_api;
|
||||
|
||||
@@ -176,6 +176,11 @@ pub enum GroupDocsError {
|
||||
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
|
||||
ValueTooLarge { limit: usize, actual: usize },
|
||||
|
||||
/// The owning group's shared-docs store is at its row quota
|
||||
/// (`PICLOUD_GROUP_DOCS_MAX_ROWS`); a new doc is rejected.
|
||||
#[error("group docs: quota exceeded ({actual} rows; limit {limit})")]
|
||||
QuotaExceeded { limit: usize, actual: usize },
|
||||
|
||||
#[error("group docs backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
@@ -160,6 +160,11 @@ pub enum GroupFilesError {
|
||||
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
|
||||
TooLarge { size: usize, limit: usize },
|
||||
|
||||
/// The owning group's shared-files store is at its total-bytes quota
|
||||
/// (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`); the write is rejected.
|
||||
#[error("group files: quota exceeded ({actual} bytes; limit {limit})")]
|
||||
QuotaExceeded { limit: u64, actual: u64 },
|
||||
|
||||
#[error("file name too long: {0} bytes exceeds 255")]
|
||||
NameTooLong(usize),
|
||||
|
||||
|
||||
@@ -135,6 +135,12 @@ pub enum GroupKvError {
|
||||
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
|
||||
ValueTooLarge { limit: usize, actual: usize },
|
||||
|
||||
/// The owning group's shared-KV store is at its row quota
|
||||
/// (`PICLOUD_GROUP_KV_MAX_ROWS`); a new key is rejected (an update of an
|
||||
/// existing key is exempt).
|
||||
#[error("group kv: quota exceeded ({actual} rows; limit {limit})")]
|
||||
QuotaExceeded { limit: usize, actual: usize },
|
||||
|
||||
/// Anything else — Postgres unavailable, serialization failure, etc.
|
||||
#[error("group kv backend error: {0}")]
|
||||
Backend(String),
|
||||
|
||||
Reference in New Issue
Block a user