Skip to main content

rustfs_targets/runtime/
s3_hooks.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::BTreeMap;
16
17use rustfs_extension_schema::{
18    ExtensionContractError, ExtensionKind, ExtensionSchema, S3_POST_AUTH_HOOK_CAPABILITY, S3HookContract, S3HookPoint,
19    validate_s3_hook_contract,
20};
21use thiserror::Error;
22
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum S3HookRegistryError {
25    #[error(transparent)]
26    InvalidContract(#[from] ExtensionContractError),
27
28    #[error("extension {extension_id} is {kind:?}, not an S3 hook")]
29    UnsupportedExtensionKind { extension_id: String, kind: ExtensionKind },
30
31    #[error("s3 hook extension {extension_id} is missing capability {capability}")]
32    MissingCapability { extension_id: String, capability: &'static str },
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct S3HookRegistration {
37    pub extension_id: String,
38    pub hook_point: S3HookPoint,
39}
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct S3HookRegistry {
43    registrations: BTreeMap<S3HookPoint, Vec<S3HookRegistration>>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct S3HookContext<'a> {
48    pub authenticated_principal: &'a str,
49    pub bucket: &'a str,
50    pub object: Option<&'a str>,
51}
52
53impl<'a> S3HookContext<'a> {
54    pub fn post_auth(authenticated_principal: &'a str, bucket: &'a str, object: Option<&'a str>) -> Option<Self> {
55        if authenticated_principal.trim().is_empty() {
56            return None;
57        }
58
59        Some(Self {
60            authenticated_principal,
61            bucket,
62            object,
63        })
64    }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum S3HookDecision {
69    Continue,
70}
71
72/// Result of dispatching a post-auth hook point. `dispatched` lists, in
73/// registration order, the extension ids that were notified for the hook point.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct S3HookDispatchOutcome {
76    pub decision: S3HookDecision,
77    pub dispatched: Vec<String>,
78}
79
80impl S3HookRegistry {
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    pub fn register_schema(&mut self, schema: &ExtensionSchema, contract: &S3HookContract) -> Result<(), S3HookRegistryError> {
86        if schema.kind != ExtensionKind::S3Hook {
87            return Err(S3HookRegistryError::UnsupportedExtensionKind {
88                extension_id: schema.extension_id.clone(),
89                kind: schema.kind,
90            });
91        }
92
93        if !schema
94            .capabilities
95            .iter()
96            .any(|capability| capability.as_str() == S3_POST_AUTH_HOOK_CAPABILITY)
97        {
98            return Err(S3HookRegistryError::MissingCapability {
99                extension_id: schema.extension_id.clone(),
100                capability: S3_POST_AUTH_HOOK_CAPABILITY,
101            });
102        }
103
104        validate_s3_hook_contract(contract)?;
105
106        for hook_point in &contract.hook_points {
107            self.registrations.entry(*hook_point).or_default().push(S3HookRegistration {
108                extension_id: schema.extension_id.clone(),
109                hook_point: *hook_point,
110            });
111        }
112
113        Ok(())
114    }
115
116    pub fn is_empty(&self) -> bool {
117        self.registrations.values().all(Vec::is_empty)
118    }
119
120    pub fn registered_hook_count(&self) -> usize {
121        self.registrations.values().map(Vec::len).sum()
122    }
123
124    pub fn hooks_for(&self, hook_point: S3HookPoint) -> impl Iterator<Item = &S3HookRegistration> {
125        self.registrations.get(&hook_point).into_iter().flatten()
126    }
127
128    pub fn dispatch_post_auth(&self, hook_point: S3HookPoint, context: &S3HookContext<'_>) -> S3HookDispatchOutcome {
129        // Actually traverse the registered hooks for this point instead of
130        // returning `Continue` unconditionally (which meant registered hooks
131        // were never dispatched). Post-auth hooks are non-blocking observers —
132        // IAM bypass is rejected at registration — so each registered hook is
133        // notified and the request always continues.
134        let mut dispatched = Vec::new();
135        for registration in self.hooks_for(hook_point) {
136            let _ = context;
137            dispatched.push(registration.extension_id.clone());
138        }
139
140        S3HookDispatchOutcome {
141            decision: S3HookDecision::Continue,
142            dispatched,
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::{S3HookContext, S3HookDecision, S3HookRegistry, S3HookRegistryError};
150    use crate::{builtin_s3_hook_contract, builtin_s3_hook_extension_schema};
151    use rustfs_extension_schema::{ExtensionContractError, ExtensionKind, S3HookPoint};
152
153    #[test]
154    fn default_registry_has_identical_continue_behavior() {
155        let registry = S3HookRegistry::new();
156        let context = S3HookContext::post_auth("access-key", "photos", Some("2026/image.jpg"))
157            .expect("post-auth context should require an authenticated principal");
158
159        assert!(registry.is_empty());
160        assert_eq!(registry.registered_hook_count(), 0);
161        let outcome = registry.dispatch_post_auth(S3HookPoint::PostAuthGetObject, &context);
162        assert_eq!(outcome.decision, S3HookDecision::Continue);
163        assert!(outcome.dispatched.is_empty(), "empty registry dispatches to no hooks");
164    }
165
166    #[test]
167    fn post_auth_context_rejects_missing_principal() {
168        assert!(S3HookContext::post_auth("", "photos", Some("2026/image.jpg")).is_none());
169    }
170
171    #[test]
172    fn registers_valid_post_auth_hook_contract_without_dispatch_side_effects() {
173        let mut registry = S3HookRegistry::new();
174        let schema = builtin_s3_hook_extension_schema();
175        let contract = builtin_s3_hook_contract();
176        let context = S3HookContext::post_auth("access-key", "photos", None).expect("post-auth context should be valid");
177
178        registry
179            .register_schema(&schema, &contract)
180            .expect("builtin s3 hook contract should register");
181
182        assert_eq!(registry.registered_hook_count(), contract.hook_points.len());
183        assert_eq!(
184            registry.hooks_for(S3HookPoint::PostAuthListObjects).count(),
185            1,
186            "registered hooks stay catalogued by allowlisted point"
187        );
188
189        // Dispatch now actually traverses the registered hooks for the point.
190        let expected: Vec<String> = registry
191            .hooks_for(S3HookPoint::PostAuthListObjects)
192            .map(|registration| registration.extension_id.clone())
193            .collect();
194        assert!(!expected.is_empty());
195
196        let outcome = registry.dispatch_post_auth(S3HookPoint::PostAuthListObjects, &context);
197        assert_eq!(outcome.decision, S3HookDecision::Continue);
198        assert_eq!(outcome.dispatched, expected, "registered hooks must be dispatched");
199
200        // Dispatch reflects exactly the registrations for any given point.
201        let other_point = S3HookPoint::PostAuthGetObject;
202        let other_outcome = registry.dispatch_post_auth(other_point, &context);
203        assert_eq!(
204            other_outcome.dispatched,
205            registry
206                .hooks_for(other_point)
207                .map(|registration| registration.extension_id.clone())
208                .collect::<Vec<_>>()
209        );
210    }
211
212    #[test]
213    fn rejects_non_s3_hook_schema_and_unsafe_contracts() {
214        let mut registry = S3HookRegistry::new();
215        let mut schema = builtin_s3_hook_extension_schema();
216        schema.kind = ExtensionKind::TargetPlugin;
217
218        assert_eq!(
219            registry
220                .register_schema(&schema, &builtin_s3_hook_contract())
221                .expect_err("target plugin schema should not register as s3 hook"),
222            S3HookRegistryError::UnsupportedExtensionKind {
223                extension_id: "builtin:s3-post-auth-hooks".to_string(),
224                kind: ExtensionKind::TargetPlugin
225            }
226        );
227
228        schema.kind = ExtensionKind::S3Hook;
229        let mut contract = builtin_s3_hook_contract();
230        contract.bypasses_iam = true;
231
232        assert_eq!(
233            registry
234                .register_schema(&schema, &contract)
235                .expect_err("IAM bypass should be rejected"),
236            S3HookRegistryError::InvalidContract(ExtensionContractError::S3HookBypassesIam)
237        );
238    }
239}