Skip to main content

rustfs_targets/runtime/
ops_diagnostics.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, OPS_DIAGNOSTICS_CAPABILITY, OpsDiagnosticSurface,
19    OpsDiagnosticsContract, validate_ops_diagnostics_contract,
20};
21use thiserror::Error;
22
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum OpsDiagnosticsRegistryError {
25    #[error(transparent)]
26    InvalidContract(#[from] ExtensionContractError),
27
28    #[error("extension {extension_id} is {kind:?}, not an ops diagnostics extension")]
29    UnsupportedExtensionKind { extension_id: String, kind: ExtensionKind },
30
31    #[error("ops diagnostics 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 OpsDiagnosticsRegistration {
37    pub extension_id: String,
38    pub surface: OpsDiagnosticSurface,
39}
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
42pub struct OpsDiagnosticsRegistry {
43    registrations: BTreeMap<OpsDiagnosticSurface, Vec<OpsDiagnosticsRegistration>>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct OpsDiagnosticsReadRequest<'a> {
48    pub surface: OpsDiagnosticSurface,
49    pub capability: &'a str,
50    pub admin_action_authorized: bool,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OpsDiagnosticsAccessDecision {
55    AllowReadOnly,
56    DenyMissingAdminAction,
57    DenyMissingCapability,
58    DenyUnknownSurface,
59}
60
61impl OpsDiagnosticsRegistry {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    pub fn register_schema(
67        &mut self,
68        schema: &ExtensionSchema,
69        contract: &OpsDiagnosticsContract,
70    ) -> Result<(), OpsDiagnosticsRegistryError> {
71        if schema.kind != ExtensionKind::OpsDiagnostics {
72            return Err(OpsDiagnosticsRegistryError::UnsupportedExtensionKind {
73                extension_id: schema.extension_id.clone(),
74                kind: schema.kind,
75            });
76        }
77
78        if !schema
79            .capabilities
80            .iter()
81            .any(|capability| capability.as_str() == OPS_DIAGNOSTICS_CAPABILITY)
82        {
83            return Err(OpsDiagnosticsRegistryError::MissingCapability {
84                extension_id: schema.extension_id.clone(),
85                capability: OPS_DIAGNOSTICS_CAPABILITY,
86            });
87        }
88
89        validate_ops_diagnostics_contract(contract)?;
90
91        for surface in &contract.surfaces {
92            self.registrations
93                .entry(*surface)
94                .or_default()
95                .push(OpsDiagnosticsRegistration {
96                    extension_id: schema.extension_id.clone(),
97                    surface: *surface,
98                });
99        }
100
101        Ok(())
102    }
103
104    pub fn registered_surface_count(&self) -> usize {
105        self.registrations.values().map(Vec::len).sum()
106    }
107
108    pub fn registrations_for(&self, surface: OpsDiagnosticSurface) -> impl Iterator<Item = &OpsDiagnosticsRegistration> {
109        self.registrations.get(&surface).into_iter().flatten()
110    }
111
112    pub fn authorize_read(&self, request: OpsDiagnosticsReadRequest<'_>) -> OpsDiagnosticsAccessDecision {
113        // Authorize before probing the registry so an unauthorized caller cannot
114        // distinguish a registered surface from an unknown one (existence leak).
115        if !request.admin_action_authorized {
116            return OpsDiagnosticsAccessDecision::DenyMissingAdminAction;
117        }
118
119        if !self.registrations.contains_key(&request.surface) {
120            return OpsDiagnosticsAccessDecision::DenyUnknownSurface;
121        }
122
123        if request.capability != OPS_DIAGNOSTICS_CAPABILITY {
124            return OpsDiagnosticsAccessDecision::DenyMissingCapability;
125        }
126
127        OpsDiagnosticsAccessDecision::AllowReadOnly
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::{OpsDiagnosticsAccessDecision, OpsDiagnosticsReadRequest, OpsDiagnosticsRegistry, OpsDiagnosticsRegistryError};
134    use crate::{builtin_ops_diagnostics_contract, builtin_ops_diagnostics_extension_schema};
135    use rustfs_extension_schema::{ExtensionContractError, ExtensionKind, OPS_DIAGNOSTICS_CAPABILITY, OpsDiagnosticSurface};
136
137    #[test]
138    fn default_registry_denies_unknown_diagnostic_surface() {
139        let registry = OpsDiagnosticsRegistry::new();
140
141        assert_eq!(
142            registry.authorize_read(OpsDiagnosticsReadRequest {
143                surface: OpsDiagnosticSurface::Health,
144                capability: OPS_DIAGNOSTICS_CAPABILITY,
145                admin_action_authorized: true,
146            }),
147            OpsDiagnosticsAccessDecision::DenyUnknownSurface
148        );
149    }
150
151    #[test]
152    fn registered_ops_diagnostics_are_read_only_and_capability_limited() {
153        let mut registry = OpsDiagnosticsRegistry::new();
154        let schema = builtin_ops_diagnostics_extension_schema();
155        let contract = builtin_ops_diagnostics_contract();
156
157        registry
158            .register_schema(&schema, &contract)
159            .expect("builtin ops diagnostics contract should register");
160
161        assert_eq!(registry.registered_surface_count(), contract.surfaces.len());
162        assert_eq!(registry.registrations_for(OpsDiagnosticSurface::Metrics).count(), 1);
163        assert_eq!(
164            registry.authorize_read(OpsDiagnosticsReadRequest {
165                surface: OpsDiagnosticSurface::Health,
166                capability: OPS_DIAGNOSTICS_CAPABILITY,
167                admin_action_authorized: true,
168            }),
169            OpsDiagnosticsAccessDecision::AllowReadOnly
170        );
171        assert_eq!(
172            registry.authorize_read(OpsDiagnosticsReadRequest {
173                surface: OpsDiagnosticSurface::Health,
174                capability: "target.notify.v1",
175                admin_action_authorized: true,
176            }),
177            OpsDiagnosticsAccessDecision::DenyMissingCapability
178        );
179        assert_eq!(
180            registry.authorize_read(OpsDiagnosticsReadRequest {
181                surface: OpsDiagnosticSurface::Health,
182                capability: OPS_DIAGNOSTICS_CAPABILITY,
183                admin_action_authorized: false,
184            }),
185            OpsDiagnosticsAccessDecision::DenyMissingAdminAction
186        );
187    }
188
189    #[test]
190    fn unauthorized_read_does_not_leak_surface_existence() {
191        let mut registry = OpsDiagnosticsRegistry::new();
192        // Register only the Metrics surface so Health is genuinely unknown.
193        let mut contract = builtin_ops_diagnostics_contract();
194        contract.surfaces = vec![OpsDiagnosticSurface::Metrics];
195        registry
196            .register_schema(&builtin_ops_diagnostics_extension_schema(), &contract)
197            .expect("subset ops diagnostics contract should register");
198
199        // A registered surface and an unknown surface must be indistinguishable
200        // to an unauthorized caller.
201        let registered = registry.authorize_read(OpsDiagnosticsReadRequest {
202            surface: OpsDiagnosticSurface::Metrics,
203            capability: OPS_DIAGNOSTICS_CAPABILITY,
204            admin_action_authorized: false,
205        });
206        let unknown = registry.authorize_read(OpsDiagnosticsReadRequest {
207            surface: OpsDiagnosticSurface::Health,
208            capability: OPS_DIAGNOSTICS_CAPABILITY,
209            admin_action_authorized: false,
210        });
211
212        assert_eq!(registered, OpsDiagnosticsAccessDecision::DenyMissingAdminAction);
213        assert_eq!(unknown, OpsDiagnosticsAccessDecision::DenyMissingAdminAction);
214    }
215
216    #[test]
217    fn rejects_non_diagnostics_schema_and_mutating_contracts() {
218        let mut registry = OpsDiagnosticsRegistry::new();
219        let mut schema = builtin_ops_diagnostics_extension_schema();
220        schema.kind = ExtensionKind::TargetPlugin;
221
222        assert_eq!(
223            registry
224                .register_schema(&schema, &builtin_ops_diagnostics_contract())
225                .expect_err("target plugin schema should not register as ops diagnostics"),
226            OpsDiagnosticsRegistryError::UnsupportedExtensionKind {
227                extension_id: "builtin:ops-diagnostics".to_string(),
228                kind: ExtensionKind::TargetPlugin
229            }
230        );
231
232        schema.kind = ExtensionKind::OpsDiagnostics;
233        let mut contract = builtin_ops_diagnostics_contract();
234        contract.mutates_object_data = true;
235
236        assert_eq!(
237            registry
238                .register_schema(&schema, &contract)
239                .expect_err("object mutation should be rejected"),
240            OpsDiagnosticsRegistryError::InvalidContract(ExtensionContractError::OpsDiagnosticsMutatesObjectData)
241        );
242    }
243}