diff --git a/docs/architecture/artifact-storage.md b/docs/architecture/artifact-storage.md index 84d2573ba..d755a9a76 100644 --- a/docs/architecture/artifact-storage.md +++ b/docs/architecture/artifact-storage.md @@ -179,3 +179,85 @@ These checks establish bucket identity, versioning, ordinary PUT sealing, and exact-version repair retirement. They do not certify all-writes sealing, backup retention, or safe artifact deletion. + +## Controller API + +The admin router exposes two SigV4-authenticated POST operations: + +- `/rustfs/admin/v3/plugin-artifacts/seal-attempt`, requiring + `admin:SealPluginArtifactAttempt` on the attempt namespace. +- `/rustfs/admin/v3/plugin-artifacts/retire-version`, requiring + `admin:RetirePluginArtifactVersion` on the exact logical object key. + +Both require a permanent, non-root original IAM user and an installed request +server context. Temporary credentials and service accounts are rejected. The +initial profile requires built-in IAM; external authorization plugins cannot +establish its explicit local Resource contract. No separate signer map is used. +The controller grant is independent of an execution delegation's expiration. + +Seal request: + +```json +{ + "version": 1, + "target": { + "bucket": "artifacts", + "bucket_incarnation": "bdbf4d55-7ae3-42f1-a8ea-88cb2836d35d", + "attempt_prefix": "out/61/8694133a-8a38-4911-b2cc-614556a852a9/301c4039-9739-4a70-a88f-6d83cf721509/" + } +} +``` + +Retirement uses the same fields plus `object` (the complete logical key below +that attempt) and `version_id` (a non-nil UUID). Unknown fields are rejected at +both levels. Bodies are limited to 16 KiB and a 10-second read deadline. +The storage call has a 30-second caller deadline. + +IAM uses the existing S3 resource grammar, including its path normalization: +the namespace's trailing slash is removed during matching. An exact attempt +controller policy can therefore use these two statements: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "admin:SealPluginArtifactAttempt", + "Resource": "arn:aws:s3:::artifacts/out/61/8694133a-8a38-4911-b2cc-614556a852a9/301c4039-9739-4a70-a88f-6d83cf721509" + }, + { + "Effect": "Allow", + "Action": "admin:RetirePluginArtifactVersion", + "Resource": "arn:aws:s3:::artifacts/out/61/8694133a-8a38-4911-b2cc-614556a852a9/301c4039-9739-4a70-a88f-6d83cf721509/*" + } + ] +} +``` + +An Allow with neither Resource nor NotResource does not grant either new action. +Explicit unscoped Deny, including `admin:*`, remains effective. Existing admin +and table/KMS action semantics are unchanged. A single-object grant cannot seal +its containing attempt. IAM scopes the logical key, not individual versions; +exact bucket generation and version identity are validated by storage. + +Only a completed storage operation returns HTTP 200 with `Cache-Control: no-store`. +Seal returns `version`, `target`, and `ordinary_put_sealed: true`. Retirement +returns `version`, `target`, `object`, `version_id`, and +`exact_version_repair_retired: true`. These are idempotent confirmations of +specific storage facts. They do not attest that all writers are sealed, that +data was deleted, or that GC is permitted. + +Malformed requests return InvalidRequest; authentication/resource denial returns +AccessDenied. All storage failures and caller deadlines return a sanitized +ServiceUnavailable response: `artifact operation unconfirmed; retry the same identity`. +This deliberately does not reveal internal record paths or distinguish durable +record corruption from unavailable storage. It also does not mean the operation +was rolled back: the cancellation-safe storage transaction may still complete. +Retry the identical target to obtain confirmation; operators must investigate +persistent failures. Current distributed-topology rejection is retained. + +This interface does not create dedicated buckets or change STS vending. Existing +vending still requires input and output to share the delegation's bucket; +cross-bucket output credentials need a separate change. GC remains disabled +until the remaining storage and backup/PITR requirements are satisfied. diff --git a/rustfs/src/admin/handlers/mod.rs b/rustfs/src/admin/handlers/mod.rs index 2b6764fa9..720c29e04 100644 --- a/rustfs/src/admin/handlers/mod.rs +++ b/rustfs/src/admin/handlers/mod.rs @@ -227,4 +227,5 @@ mod tests { pub mod plugin_delegation; +pub mod plugin_artifact; pub mod plugin_attempt; diff --git a/rustfs/src/admin/handlers/plugin_artifact.rs b/rustfs/src/admin/handlers/plugin_artifact.rs new file mode 100644 index 000000000..4ce6f2cfa --- /dev/null +++ b/rustfs/src/admin/handlers/plugin_artifact.rs @@ -0,0 +1,286 @@ +// Copyright 2026 RustFS Team +// SPDX-License-Identifier: Apache-2.0 +//! Controller operations; receipts are storage facts, never GC authorization. +use super::plugin_delegation::{owner_for_resource, response}; +use crate::{ + admin::{ + auth::AdminResourceScope, + router::{AdminOperation, Operation, S3Router}, + runtime_sources::{self, ServerContextSlot}, + }, + server::ADMIN_PREFIX, +}; +use http::{Method, StatusCode}; +use matchit::Params; +use rustfs_policy::policy::action::AdminAction; +use s3s::{Body, S3Request, S3Response, S3Result, s3_error}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::{sync::Arc, time::Duration}; +use uuid::Uuid; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct AttemptIdentity { + bucket: String, + bucket_incarnation: Uuid, + attempt_prefix: String, +} + +impl AttemptIdentity { + fn validate(&self) -> S3Result<()> { + if self.bucket.is_empty() + || self.bucket.len() > 255 + || self.bucket.contains('/') + || self.bucket.chars().any(char::is_control) + || self.bucket_incarnation.is_nil() + || self.attempt_prefix.is_empty() + || self.attempt_prefix.len() >= 1024 + || !self.attempt_prefix.ends_with('/') + || self.attempt_prefix.chars().any(char::is_control) + { + return Err(s3_error!(InvalidRequest, "invalid artifact attempt identity")); + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SealRequest { + version: u32, + target: AttemptIdentity, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RetireRequest { + version: u32, + target: AttemptIdentity, + object: String, + version_id: Uuid, +} + +impl RetireRequest { + fn validate(&self) -> S3Result<()> { + self.target.validate()?; + if self.version != 1 + || self.version_id.is_nil() + || self.object.len() > 1024 + || !self.object.starts_with(&self.target.attempt_prefix) + || self.object.len() == self.target.attempt_prefix.len() + || self.object.chars().any(char::is_control) + { + return Err(s3_error!(InvalidRequest, "invalid artifact version identity")); + } + Ok(()) + } +} + +#[derive(Serialize)] +struct SealReceipt { + version: u32, + target: AttemptIdentity, + ordinary_put_sealed: bool, +} + +#[derive(Serialize)] +struct RetirementReceipt { + version: u32, + target: AttemptIdentity, + object: String, + version_id: Uuid, + exact_version_repair_retired: bool, +} + +async fn require_builtin_authorization() -> S3Result<()> { + // The external policy backend can replace local IAM evaluation, so it + // cannot establish the explicit Resource contract of these controls. + rustfs_iam::sys::IamSys::::ensure_restricted_credential_vending() + .await + .map_err(|_| s3_error!(AccessDenied, "external policy plugin unsupported for artifact controls")) +} + +async fn request_body(req: &mut S3Request) -> S3Result { + let bytes = tokio::time::timeout(Duration::from_secs(10), req.input.store_all_limited(16 * 1024)) + .await + .map_err(|_| s3_error!(InvalidRequest, "artifact body unavailable"))? + .map_err(|_| s3_error!(InvalidRequest, "artifact body unavailable"))?; + serde_json::from_slice(&bytes).map_err(|_| s3_error!(InvalidRequest, "invalid artifact request")) +} + +fn request_store(req: &S3Request) -> S3Result> { + // These new controls require the owning server slot, never an ambient store. + if req.extensions.get::>().is_none() { + return Err(s3_error!(ServiceUnavailable, "artifact application context unavailable")); + } + runtime_sources::object_store_from_req(req).ok_or_else(|| s3_error!(ServiceUnavailable, "artifact storage unavailable")) +} + +fn unconfirmed() -> s3s::S3Error { + // Storage may finish after a timeout or caller cancellation. Only an exact + // successful retry confirms the durable fact; never expose internal paths. + s3_error!(ServiceUnavailable, "artifact operation unconfirmed; retry the same identity") +} + +pub fn register_plugin_artifact_routes(router: &mut S3Router) -> std::io::Result<()> { + router.insert( + Method::POST, + &format!("{ADMIN_PREFIX}/v3/plugin-artifacts/seal-attempt"), + AdminOperation(&Seal), + )?; + router.insert( + Method::POST, + &format!("{ADMIN_PREFIX}/v3/plugin-artifacts/retire-version"), + AdminOperation(&Retire), + )?; + Ok(()) +} + +struct Seal; +#[async_trait::async_trait] +impl Operation for Seal { + async fn call(&self, mut req: S3Request, _params: Params<'_, '_>) -> S3Result> { + require_builtin_authorization().await?; + let input: SealRequest = request_body(&mut req).await?; + input.target.validate()?; + if input.version != 1 { + return Err(s3_error!(InvalidRequest, "unsupported artifact protocol version")); + } + let store = request_store(&req)?; + owner_for_resource( + &req, + AdminAction::SealPluginArtifactAttemptAction, + AdminResourceScope::bucket_object(&input.target.bucket, &input.target.attempt_prefix), + ) + .await?; + tokio::time::timeout( + Duration::from_secs(30), + store.seal_artifact_attempt_puts(&input.target.bucket, input.target.bucket_incarnation, &input.target.attempt_prefix), + ) + .await + .map_err(|_| unconfirmed())? + .map_err(|_| unconfirmed())?; + response(&SealReceipt { + version: 1, + target: input.target, + ordinary_put_sealed: true, + }) + } +} + +struct Retire; +#[async_trait::async_trait] +impl Operation for Retire { + async fn call(&self, mut req: S3Request, _params: Params<'_, '_>) -> S3Result> { + require_builtin_authorization().await?; + let input: RetireRequest = request_body(&mut req).await?; + input.validate()?; + let store = request_store(&req)?; + owner_for_resource( + &req, + AdminAction::RetirePluginArtifactVersionAction, + AdminResourceScope::bucket_object(&input.target.bucket, &input.object), + ) + .await?; + tokio::time::timeout( + Duration::from_secs(30), + store.retire_artifact_object_version( + &input.target.bucket, + input.target.bucket_incarnation, + &input.target.attempt_prefix, + &input.object, + input.version_id, + ), + ) + .await + .map_err(|_| unconfirmed())? + .map_err(|_| unconfirmed())?; + response(&RetirementReceipt { + version: 1, + target: input.target, + object: input.object, + version_id: input.version_id, + exact_version_repair_retired: true, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn target() -> Value { + json!({"bucket":"artifacts", "bucket_incarnation":Uuid::new_v4(), + "attempt_prefix":format!("out/61/{}/{}/",Uuid::new_v4(),Uuid::new_v4())}) + } + + #[test] + fn plugin_artifact_strict_request_identity() { + let mut seal = json!({"version":1,"target":target()}); + let request: SealRequest = serde_json::from_value(seal.clone()).expect("seal request"); + request.target.validate().expect("valid target"); + seal["unknown"] = true.into(); + assert!(serde_json::from_value::(seal).is_err()); + let mut bad_target = target(); + bad_target["unknown"] = true.into(); + assert!(serde_json::from_value::(bad_target).is_err()); + let mut target = target(); + target["bucket_incarnation"] = Uuid::nil().to_string().into(); + assert!( + serde_json::from_value::(target) + .expect("nil parses") + .validate() + .is_err() + ); + let valid = json!({"version":1,"target":request.target, + "object":format!("{}image.png",request.target.attempt_prefix),"version_id":Uuid::new_v4()}); + serde_json::from_value::(valid.clone()) + .expect("retire request") + .validate() + .expect("valid version"); + for (key, value) in [ + ("object", json!("elsewhere/image.png")), + ("version_id", json!(Uuid::nil())), + ("version", json!(2)), + ] { + let mut bad = valid.clone(); + bad[key] = value; + assert!( + serde_json::from_value::(bad) + .expect("well-formed request") + .validate() + .is_err() + ); + } + } + + #[test] + fn plugin_artifact_receipts_are_not_gc_permission() { + let target: AttemptIdentity = serde_json::from_value(target()).expect("target"); + let seal = serde_json::to_value(SealReceipt { + version: 1, + target, + ordinary_put_sealed: true, + }) + .expect("receipt"); + assert_eq!(seal["ordinary_put_sealed"], true); + for absent in ["gc_safe", "deleted", "all_writes_sealed"] { + assert!(seal.get(absent).is_none()); + } + } + + #[tokio::test] + async fn plugin_artifact_routes_and_body_limit() { + let mut router = S3Router::new(false); + register_plugin_artifact_routes(&mut router).expect("register controls"); + for path in ["seal-attempt", "retire-version"] { + assert!(router.contains_route(Method::POST, &format!("{ADMIN_PREFIX}/v3/plugin-artifacts/{path}"))); + } + let mut req = S3Request::new(Body::from(vec![b' '; 16 * 1024 + 1])); + assert!(request_body::(&mut req).await.is_err()); + assert!(request_store(&req).is_err(), "ARTIFACT_REQUIRES_REQUEST_INSTANCE"); + req.extensions.insert(Arc::new(ServerContextSlot::default())); + assert!(request_store(&req).is_err(), "ARTIFACT_UNREADY_INSTANCE_NO_FALLBACK"); + } +} diff --git a/rustfs/src/admin/handlers/plugin_delegation.rs b/rustfs/src/admin/handlers/plugin_delegation.rs index 6fa1dceec..f79e5d636 100644 --- a/rustfs/src/admin/handlers/plugin_delegation.rs +++ b/rustfs/src/admin/handlers/plugin_delegation.rs @@ -73,6 +73,14 @@ pub(super) fn storage_error(error: DelegationError) -> s3s::S3Error { pub(super) async fn owner( req: &S3Request, action: AdminAction, +) -> S3Result<(std::sync::Arc>, String)> { + owner_for_resource(req, action, AdminResourceScope::bucket_object("", "")).await +} + +pub(super) async fn owner_for_resource( + req: &S3Request, + action: AdminAction, + resource: AdminResourceScope<'_>, ) -> S3Result<(std::sync::Arc>, String)> { let signed = req .credentials @@ -98,7 +106,7 @@ pub(super) async fn owner( false, Action::AdminAction(action), req.extensions.get::>().and_then(|a| a.map(|a| a.0)), - AdminResourceScope::bucket_object("", ""), + resource, ) .await?; Ok((iam, credentials.access_key)) diff --git a/rustfs/src/admin/mod.rs b/rustfs/src/admin/mod.rs index 3234e351f..2721d4034 100644 --- a/rustfs/src/admin/mod.rs +++ b/rustfs/src/admin/mod.rs @@ -67,6 +67,7 @@ fn register_admin_routes(r: &mut S3Router) -> std::io::Result<() sts::register_admin_auth_route(r)?; handlers::plugin_delegation::register_plugin_delegation_route(r)?; handlers::plugin_attempt::register_plugin_attempt_route(r)?; + handlers::plugin_artifact::register_plugin_artifact_routes(r)?; account::register_account_route(r)?; mfa::register_mfa_route(r)?; diff --git a/rustfs/src/admin/route_policy.rs b/rustfs/src/admin/route_policy.rs index 29a1a6287..4894e4397 100644 --- a/rustfs/src/admin/route_policy.rs +++ b/rustfs/src/admin/route_policy.rs @@ -17,6 +17,8 @@ use rustfs_security_governance::{ validate_admin_route_specs, }; +const SEAL_PLUGIN_ARTIFACT: AdminActionRef = AdminActionRef::new("SealPluginArtifactAttemptAction"); +const RETIRE_PLUGIN_ARTIFACT: AdminActionRef = AdminActionRef::new("RetirePluginArtifactVersionAction"); const ALL_ADMIN: AdminActionRef = AdminActionRef::new("AllAdminActions"); const ADD_USER_TO_GROUP: AdminActionRef = AdminActionRef::new("AddUserToGroupAdminAction"); const ATTACH_POLICY: AdminActionRef = AdminActionRef::new("AttachPolicyAdminAction"); @@ -645,6 +647,18 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[ SERVER_INFO, RouteRiskLevel::Sensitive, ), + admin( + HttpMethod::Post, + "/rustfs/admin/v3/plugin-artifacts/seal-attempt", + SEAL_PLUGIN_ARTIFACT, + RouteRiskLevel::High, + ), + admin( + HttpMethod::Post, + "/rustfs/admin/v3/plugin-artifacts/retire-version", + RETIRE_PLUGIN_ARTIFACT, + RouteRiskLevel::High, + ), admin( HttpMethod::Post, "/rustfs/admin/v3/plugin-delegations/{id}/issue-attempt", diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index 3d85e3c11..ebeb7164a 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -116,6 +116,8 @@ fn registered_admin_router() -> S3Router { fn expected_admin_route_matrix() -> Vec { vec![ + admin_route(Method::POST, "/v3/plugin-artifacts/seal-attempt"), + admin_route(Method::POST, "/v3/plugin-artifacts/retire-version"), route(Method::GET, HEALTH_PREFIX), route(Method::HEAD, HEALTH_PREFIX), route(Method::GET, HEALTH_READY_PATH),