feat(admin): expose scoped artifact seal and retirement controls

This commit is contained in:
overtrue
2026-10-17 16:42:57 +08:00
parent 57c4d4c719
commit b798d291d7
7 changed files with 395 additions and 1 deletions
+82
View File
@@ -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.
+1
View File
@@ -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))
+1
View File
@@ -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)?;
+14
View File
@@ -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),