//! `/api/v1/admin/groups/{id}/{kv,docs,files}*` — read-only operator inspection //! of a group's §11.6 SHARED collections (M4). Mirrors the per-app `kv_api` / //! `files_api` admin surface for groups so `pic {kv,docs,files} ls --group` (and //! a future dashboard tab) can browse shared data without a script. //! //! **Read-only by design** — shared writes go through the SDK //! (`kv::shared_collection(...)` etc.), which run the reads-open / writes-authed //! authz + fire `shared = true` triggers; an admin write would bypass both. The //! deferral (operator write/delete on shared blobs) stands. //! //! Capabilities: `GroupKvRead` / `GroupDocsRead` / `GroupFilesRead`, resolved //! against the group loaded from the path (the same tier the SDK read path uses). use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::{Extension, Router}; use picloud_shared::{GroupId, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::group_docs_repo::GroupDocsRepo; use crate::group_files_repo::GroupFilesRepo; use crate::group_kv_repo::GroupKvRepo; use crate::group_repo::GroupRepository; #[derive(Clone)] pub struct GroupBlobsState { pub kv: Arc, pub docs: Arc, pub files: Arc, pub groups: Arc, pub authz: Arc, } pub fn group_blobs_router(state: GroupBlobsState) -> Router { Router::new() .route("/groups/{id}/kv", get(list_kv)) .route("/groups/{id}/kv/{collection}/{key}", get(get_kv)) .route("/groups/{id}/docs", get(list_docs)) .route("/groups/{id}/docs/{collection}/{doc_id}", get(get_doc)) .route("/groups/{id}/files", get(list_files)) .route("/groups/{id}/files/{collection}/{file_id}", get(get_file)) .with_state(state) } #[derive(Debug, Deserialize)] pub struct ListQuery { pub collection: String, #[serde(default)] pub cursor: Option, #[serde(default)] pub limit: Option, } #[derive(Debug, Serialize)] struct ListKeysResponse { keys: Vec, next_cursor: Option, } async fn list_kv( State(s): State, Extension(principal): Extension, Path(ident): Path, Query(q): Query, ) -> Result, GroupBlobsError> { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupKvRead(group_id), ) .await?; let page = s.kv.list( group_id, &q.collection, q.cursor.as_deref(), q.limit.unwrap_or(0), ) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))?; Ok(Json(ListKeysResponse { keys: page.keys, next_cursor: page.next_cursor, })) } async fn get_kv( State(s): State, Extension(principal): Extension, Path((ident, collection, key)): Path<(String, String, String)>, ) -> Result, GroupBlobsError> { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupKvRead(group_id), ) .await?; let value = s.kv.get(group_id, &collection, &key) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? .ok_or(GroupBlobsError::NotFound)?; Ok(Json(json!({ "value": value }))) } #[derive(Debug, Serialize)] struct DocEntry { id: String, data: serde_json::Value, } #[derive(Debug, Serialize)] struct ListDocsResponse { docs: Vec, next_cursor: Option, } async fn list_docs( State(s): State, Extension(principal): Extension, Path(ident): Path, Query(q): Query, ) -> Result, GroupBlobsError> { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupDocsRead(group_id), ) .await?; let page = s .docs .list( group_id, &q.collection, q.cursor.as_deref(), q.limit.unwrap_or(0), ) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))?; Ok(Json(ListDocsResponse { docs: page .docs .into_iter() .map(|d| DocEntry { id: d.id.to_string(), data: d.data, }) .collect(), next_cursor: page.next_cursor, })) } async fn get_doc( State(s): State, Extension(principal): Extension, Path((ident, collection, doc_id)): Path<(String, String, String)>, ) -> Result, GroupBlobsError> { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupDocsRead(group_id), ) .await?; let id = doc_id .parse::() .map_err(|_| GroupBlobsError::NotFound)?; let row = s .docs .get(group_id, &collection, id) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? .ok_or(GroupBlobsError::NotFound)?; Ok(Json(json!({ "id": row.id.to_string(), "data": row.data }))) } async fn list_files( State(s): State, Extension(principal): Extension, Path(ident): Path, Query(q): Query, ) -> Result, GroupBlobsError> { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupFilesRead(group_id), ) .await?; let page = s .files .list( group_id, &q.collection, q.cursor.as_deref(), q.limit.unwrap_or(0), ) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))?; // FileMeta is Serialize; return the metadata list + cursor. Ok(Json(json!({ "files": page.files, "next_cursor": page.next_cursor, }))) } async fn get_file( State(s): State, Extension(principal): Extension, Path((ident, collection, file_id)): Path<(String, String, String)>, ) -> Result { let group_id = resolve_group(&*s.groups, &ident).await?; require( s.authz.as_ref(), &principal, Capability::GroupFilesRead(group_id), ) .await?; let id = file_id .parse::() .map_err(|_| GroupBlobsError::NotFound)?; let meta = s .files .head(group_id, &collection, id) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? .ok_or(GroupBlobsError::NotFound)?; let bytes = s .files .get(group_id, &collection, id) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? .ok_or(GroupBlobsError::NotFound)?; Ok(( [ (CONTENT_TYPE, meta.content_type), (CONTENT_LENGTH, bytes.len().to_string()), ], bytes, ) .into_response()) } async fn resolve_group( groups: &dyn GroupRepository, ident: &str, ) -> Result { let found = if let Ok(uuid) = ident.parse::() { groups .get_by_id(uuid.into()) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? } else { groups .get_by_slug(ident) .await .map_err(|e| GroupBlobsError::Backend(e.to_string()))? }; found.map(|g| g.id).ok_or(GroupBlobsError::GroupNotFound) } #[derive(Debug, thiserror::Error)] pub enum GroupBlobsError { #[error("group not found")] GroupNotFound, #[error("not found")] NotFound, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("backend: {0}")] Backend(String), } impl From for GroupBlobsError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl IntoResponse for GroupBlobsError { fn into_response(self) -> Response { use axum::http::StatusCode; let (status, body) = match &self { Self::GroupNotFound | Self::NotFound => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) | Self::Backend(e) => { tracing::error!(error = %e, "group blobs admin error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }