Remediate the HIGH and security-relevant findings from the 2026-07-11 audit. H1 — the per-env approval gate is now server-authoritative. The governing project is resolved from the target node's nearest-claimed ancestor (`governing_env_policy`/`_tree` + `governing_project_id` + `ProjectRepository::get_environments_by_id`), independent of the client-supplied `[project]`. Omitting or spoofing the project block can no longer skip a gate the owning project established; a to-create group resolves its declared parent's chain so a fresh subtree node inherits the gate. Fails closed on any read error. H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe `rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request task (unauthenticated per-request DoS). Regression test added. C1 — admin sessions gain an absolute lifetime cap (migration 0070, `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch` clamps the sliding bump to it, so a continuously-used or stolen-but-warm token self-expires. Mirrors the data-plane app-user cap. C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two that return a raw credential), so a proxy/CDN/browser cache can't retain it. B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and group download paths now set attachment + `X-Content-Type-Options: nosniff` + a restrictive CSP, closing a group-path stored-XSS gap. Also folds in the server-side plan-warning plumbing (`plan_warnings`, `PlanResult::warnings`) and the `app_only_reject` message helper that the CLI plan-preview change builds on, plus operator security notes (reads-open shared- topic SSE; the `--env` label is advisory, not a boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
404 lines
13 KiB
Rust
404 lines
13 KiB
Rust
//! `/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_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
|
|
use axum::http::StatusCode;
|
|
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_dead_letter_repo::GroupDeadLetterRepo;
|
|
use crate::group_docs_repo::GroupDocsRepo;
|
|
use crate::group_files_repo::GroupFilesRepo;
|
|
use crate::group_kv_repo::GroupKvRepo;
|
|
use crate::group_repo::GroupRepository;
|
|
|
|
/// Default/max page size for the dead-letters listing (an operator view, not a
|
|
/// bulk export).
|
|
const DEAD_LETTERS_LIMIT_DEFAULT: i64 = 100;
|
|
const DEAD_LETTERS_LIMIT_MAX: i64 = 500;
|
|
|
|
#[derive(Clone)]
|
|
pub struct GroupBlobsState {
|
|
pub kv: Arc<dyn GroupKvRepo>,
|
|
pub docs: Arc<dyn GroupDocsRepo>,
|
|
pub files: Arc<dyn GroupFilesRepo>,
|
|
pub dead_letters: Arc<dyn GroupDeadLetterRepo>,
|
|
pub groups: Arc<dyn GroupRepository>,
|
|
pub authz: Arc<dyn AuthzRepo>,
|
|
}
|
|
|
|
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))
|
|
.route("/groups/{id}/dead-letters", get(list_dead_letters))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ListQuery {
|
|
pub collection: String,
|
|
#[serde(default)]
|
|
pub cursor: Option<String>,
|
|
#[serde(default)]
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ListKeysResponse {
|
|
keys: Vec<String>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DeadLettersQuery {
|
|
#[serde(default)]
|
|
pub unresolved: bool,
|
|
#[serde(default)]
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
/// One group dead-letter, as returned by the operator listing.
|
|
#[derive(Debug, Serialize)]
|
|
struct DeadLetterDto {
|
|
id: Uuid,
|
|
collection: String,
|
|
source: String,
|
|
op: String,
|
|
attempt_count: u32,
|
|
last_error: String,
|
|
created_at: String,
|
|
resolved_at: Option<String>,
|
|
payload: serde_json::Value,
|
|
}
|
|
|
|
impl From<crate::group_dead_letter_repo::GroupDeadLetterRow> for DeadLetterDto {
|
|
fn from(r: crate::group_dead_letter_repo::GroupDeadLetterRow) -> Self {
|
|
Self {
|
|
id: r.id.into_inner(),
|
|
collection: r.collection,
|
|
source: r.source,
|
|
op: r.op,
|
|
attempt_count: r.attempt_count,
|
|
last_error: r.last_error,
|
|
created_at: r.created_at.to_rfc3339(),
|
|
resolved_at: r.resolved_at.map(|t| t.to_rfc3339()),
|
|
payload: r.payload,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn list_kv(
|
|
State(s): State<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(ident): Path<String>,
|
|
Query(q): Query<ListQuery>,
|
|
) -> Result<Json<ListKeysResponse>, 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<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((ident, collection, key)): Path<(String, String, String)>,
|
|
) -> Result<Json<serde_json::Value>, 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<DocEntry>,
|
|
next_cursor: Option<String>,
|
|
}
|
|
|
|
async fn list_docs(
|
|
State(s): State<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(ident): Path<String>,
|
|
Query(q): Query<ListQuery>,
|
|
) -> Result<Json<ListDocsResponse>, 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<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((ident, collection, doc_id)): Path<(String, String, String)>,
|
|
) -> Result<Json<serde_json::Value>, 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::<Uuid>()
|
|
.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<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(ident): Path<String>,
|
|
Query(q): Query<ListQuery>,
|
|
) -> Result<Json<serde_json::Value>, 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<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path((ident, collection, file_id)): Path<(String, String, String)>,
|
|
) -> Result<Response, GroupBlobsError> {
|
|
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::<Uuid>()
|
|
.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)?;
|
|
// Same download hardening as the per-app path (`files_api::get_file`): a
|
|
// sanitized content-type, forced `attachment` disposition with a header-safe
|
|
// filename, plus nosniff/CSP so a stored HTML/SVG shared file can't render
|
|
// inline (stored-XSS) and no attacker-influenced byte can panic the builder.
|
|
let safe_ct = picloud_shared::sanitize_stored_content_type(&meta.content_type);
|
|
let disposition = format!(
|
|
"attachment; filename=\"{}\"",
|
|
picloud_shared::sanitize_stored_filename(&meta.name)
|
|
);
|
|
let len = bytes.len();
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(CONTENT_TYPE, safe_ct)
|
|
.header(CONTENT_DISPOSITION, disposition)
|
|
.header(CONTENT_LENGTH, len)
|
|
.header("X-Content-Type-Options", "nosniff")
|
|
.header(
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; sandbox; frame-ancestors 'none'",
|
|
)
|
|
.header("Referrer-Policy", "no-referrer")
|
|
.body(axum::body::Body::from(bytes))
|
|
.map_err(|e| GroupBlobsError::Backend(e.to_string()))
|
|
}
|
|
|
|
/// §11.6 D3: list a group's shared-queue dead-letters (newest-first). Guarded by
|
|
/// `GroupKvRead` (the same read tier as the shared collections above; no new
|
|
/// capability). `?unresolved=true` filters to still-open rows; `?limit=` caps
|
|
/// the page (default 100, max 500).
|
|
async fn list_dead_letters(
|
|
State(s): State<GroupBlobsState>,
|
|
Extension(principal): Extension<Principal>,
|
|
Path(ident): Path<String>,
|
|
Query(q): Query<DeadLettersQuery>,
|
|
) -> Result<Json<Vec<DeadLetterDto>>, GroupBlobsError> {
|
|
let group_id = resolve_group(&*s.groups, &ident).await?;
|
|
require(
|
|
s.authz.as_ref(),
|
|
&principal,
|
|
Capability::GroupKvRead(group_id),
|
|
)
|
|
.await?;
|
|
let limit = q.limit.map_or(DEAD_LETTERS_LIMIT_DEFAULT, |n| {
|
|
i64::from(n).clamp(1, DEAD_LETTERS_LIMIT_MAX)
|
|
});
|
|
let rows = s
|
|
.dead_letters
|
|
.list_for_group(group_id, q.unresolved, limit)
|
|
.await
|
|
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
|
|
Ok(Json(rows.into_iter().map(DeadLetterDto::from).collect()))
|
|
}
|
|
|
|
async fn resolve_group(
|
|
groups: &dyn GroupRepository,
|
|
ident: &str,
|
|
) -> Result<GroupId, GroupBlobsError> {
|
|
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
|
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<AuthzDenied> 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()
|
|
}
|
|
}
|