mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-28 23:07:39 +00:00
feat(admin): expose scoped artifact seal and retirement controls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -227,4 +227,5 @@ mod tests {
|
||||
|
||||
pub mod plugin_delegation;
|
||||
|
||||
pub mod plugin_artifact;
|
||||
pub mod plugin_attempt;
|
||||
|
||||
@@ -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::<rustfs_iam::store::object::ObjectStore>::ensure_restricted_credential_vending()
|
||||
.await
|
||||
.map_err(|_| s3_error!(AccessDenied, "external policy plugin unsupported for artifact controls"))
|
||||
}
|
||||
|
||||
async fn request_body<T: DeserializeOwned>(req: &mut S3Request<Body>) -> S3Result<T> {
|
||||
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<Body>) -> S3Result<Arc<crate::admin::storage_api::ECStore>> {
|
||||
// These new controls require the owning server slot, never an ambient store.
|
||||
if req.extensions.get::<Arc<ServerContextSlot>>().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<AdminOperation>) -> 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<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
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<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
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::<SealRequest>(seal).is_err());
|
||||
let mut bad_target = target();
|
||||
bad_target["unknown"] = true.into();
|
||||
assert!(serde_json::from_value::<AttemptIdentity>(bad_target).is_err());
|
||||
let mut target = target();
|
||||
target["bucket_incarnation"] = Uuid::nil().to_string().into();
|
||||
assert!(
|
||||
serde_json::from_value::<AttemptIdentity>(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::<RetireRequest>(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::<RetireRequest>(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::<SealRequest>(&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");
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,14 @@ pub(super) fn storage_error(error: DelegationError) -> s3s::S3Error {
|
||||
pub(super) async fn owner(
|
||||
req: &S3Request<Body>,
|
||||
action: AdminAction,
|
||||
) -> S3Result<(std::sync::Arc<rustfs_iam::sys::IamSys<rustfs_iam::store::object::ObjectStore>>, String)> {
|
||||
owner_for_resource(req, action, AdminResourceScope::bucket_object("", "")).await
|
||||
}
|
||||
|
||||
pub(super) async fn owner_for_resource(
|
||||
req: &S3Request<Body>,
|
||||
action: AdminAction,
|
||||
resource: AdminResourceScope<'_>,
|
||||
) -> S3Result<(std::sync::Arc<rustfs_iam::sys::IamSys<rustfs_iam::store::object::ObjectStore>>, String)> {
|
||||
let signed = req
|
||||
.credentials
|
||||
@@ -98,7 +106,7 @@ pub(super) async fn owner(
|
||||
false,
|
||||
Action::AdminAction(action),
|
||||
req.extensions.get::<Option<RemoteAddr>>().and_then(|a| a.map(|a| a.0)),
|
||||
AdminResourceScope::bucket_object("", ""),
|
||||
resource,
|
||||
)
|
||||
.await?;
|
||||
Ok((iam, credentials.access_key))
|
||||
|
||||
@@ -67,6 +67,7 @@ fn register_admin_routes(r: &mut S3Router<AdminOperation>) -> 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)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -116,6 +116,8 @@ fn registered_admin_router() -> S3Router<AdminOperation> {
|
||||
|
||||
fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user