Skip to main content

rocketmq_auth/
maintenance.rs

1// Copyright 2023 The RocketMQ Rust Authors
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
15//! Maintenance-policy loading adapter.
16//!
17//! Runtime-neutral policy validation and authorization live in
18//! [`rocketmq_security_api::maintenance`]. This module owns only external I/O,
19//! path confinement, JSON decoding, and SHA-256 pin verification. The public
20//! policy contract remains owned by `rocketmq-security-api`.
21
22use std::error::Error;
23use std::fmt;
24use std::fs;
25use std::path::Component;
26use std::path::Path;
27use std::path::PathBuf;
28
29use rocketmq_security_api::maintenance::MaintenancePolicy;
30use rocketmq_security_api::ValidatedMaintenancePolicy;
31use serde::Deserialize;
32use serde::Serialize;
33use sha2::Digest;
34use sha2::Sha256;
35
36use crate::AuthFailureKind;
37use crate::AuthOperation;
38use crate::AuthServiceError;
39use crate::AuthServiceResult;
40
41/// Immutable reference used to pin a policy by path, version, and SHA-256.
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields, rename_all = "snake_case")]
44pub struct MaintenancePolicyReference {
45    /// Absolute path or path relative to the service configuration root.
46    pub path: PathBuf,
47    /// Expected policy version.
48    pub version: u64,
49    /// Expected lowercase SHA-256 of the exact policy bytes.
50    pub sha256: String,
51}
52
53impl MaintenancePolicyReference {
54    /// Loads and validates a policy relative to `configuration_root`.
55    ///
56    /// # Errors
57    ///
58    /// Returns a typed error when the reference is unsafe, the file cannot be
59    /// read, the digest or version does not match, JSON decoding fails, or the
60    /// policy is not fail closed.
61    pub fn load_from(&self, configuration_root: impl AsRef<Path>) -> AuthServiceResult<LoadedMaintenancePolicy> {
62        self.load_from_inner(configuration_root)
63            .map_err(|source| AuthServiceError::with_source(AuthOperation::MaintainService, source.kind(), source))
64    }
65
66    fn load_from_inner(
67        &self,
68        configuration_root: impl AsRef<Path>,
69    ) -> Result<LoadedMaintenancePolicy, MaintenancePolicyLoadError> {
70        validate_sha256(&self.sha256)?;
71        if self.version == 0 {
72            return Err(MaintenancePolicyLoadError::InvalidReference(
73                "policy reference version must be greater than zero".to_string(),
74            ));
75        }
76        if self.path.as_os_str().is_empty() {
77            return Err(MaintenancePolicyLoadError::InvalidReference(
78                "policy reference path is empty".to_string(),
79            ));
80        }
81        if self.path.is_relative()
82            && self.path.components().any(|component| {
83                matches!(
84                    component,
85                    Component::ParentDir | Component::RootDir | Component::Prefix(_)
86                )
87            })
88        {
89            return Err(MaintenancePolicyLoadError::InvalidReference(
90                "relative policy path cannot escape the configuration root".to_string(),
91            ));
92        }
93
94        let resolved_path = if self.path.is_absolute() {
95            self.path.clone()
96        } else {
97            configuration_root.as_ref().join(&self.path)
98        };
99        let bytes = fs::read(&resolved_path).map_err(|source| MaintenancePolicyLoadError::Read {
100            path: resolved_path.clone(),
101            source,
102        })?;
103        let actual_sha256 = hex::encode(Sha256::digest(&bytes));
104        if actual_sha256 != self.sha256 {
105            return Err(MaintenancePolicyLoadError::DigestMismatch {
106                expected: self.sha256.clone(),
107                actual: actual_sha256,
108            });
109        }
110
111        let policy: MaintenancePolicy =
112            serde_json::from_slice(&bytes).map_err(|source| MaintenancePolicyLoadError::Decode {
113                path: resolved_path.clone(),
114                source,
115            })?;
116        let policy = policy
117            .into_validated()
118            .map_err(|source| MaintenancePolicyLoadError::Contract(Box::new(source)))?;
119        if policy.policy().policy_version != self.version {
120            return Err(MaintenancePolicyLoadError::VersionMismatch {
121                expected: self.version,
122                actual: policy.policy().policy_version,
123            });
124        }
125
126        Ok(LoadedMaintenancePolicy {
127            policy,
128            reference: self.clone(),
129            resolved_path,
130        })
131    }
132}
133
134/// A policy whose bytes, version, and semantic invariants were validated.
135#[derive(Clone, Debug)]
136pub struct LoadedMaintenancePolicy {
137    policy: ValidatedMaintenancePolicy,
138    reference: MaintenancePolicyReference,
139    resolved_path: PathBuf,
140}
141
142impl LoadedMaintenancePolicy {
143    /// Returns the validated policy.
144    pub const fn policy(&self) -> &MaintenancePolicy {
145        self.policy.policy()
146    }
147
148    /// Returns the immutable policy reference.
149    pub const fn reference(&self) -> &MaintenancePolicyReference {
150        &self.reference
151    }
152
153    /// Returns the resolved file used during validation.
154    pub fn resolved_path(&self) -> &Path {
155        &self.resolved_path
156    }
157}
158
159impl From<LoadedMaintenancePolicy> for ValidatedMaintenancePolicy {
160    fn from(loaded: LoadedMaintenancePolicy) -> Self {
161        loaded.policy
162    }
163}
164
165#[derive(Debug)]
166enum MaintenancePolicyLoadError {
167    InvalidReference(String),
168    Read { path: PathBuf, source: std::io::Error },
169    Decode { path: PathBuf, source: serde_json::Error },
170    DigestMismatch { expected: String, actual: String },
171    VersionMismatch { expected: u64, actual: u64 },
172    Contract(Box<dyn Error + Send + Sync + 'static>),
173}
174
175impl MaintenancePolicyLoadError {
176    const fn kind(&self) -> AuthFailureKind {
177        match self {
178            Self::InvalidReference(_) | Self::Contract(_) => AuthFailureKind::InvalidConfiguration,
179            Self::Read { .. } => AuthFailureKind::Unavailable,
180            Self::Decode { .. } | Self::DigestMismatch { .. } | Self::VersionMismatch { .. } => {
181                AuthFailureKind::InvalidData
182            }
183        }
184    }
185}
186
187impl fmt::Display for MaintenancePolicyLoadError {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::InvalidReference(reason) => write!(formatter, "invalid maintenance policy reference: {reason}"),
191            Self::Read { path, source } => write!(formatter, "failed to read maintenance policy {path:?}: {source}"),
192            Self::Decode { path, source } => {
193                write!(formatter, "failed to decode maintenance policy {path:?}: {source}")
194            }
195            Self::DigestMismatch { expected, actual } => {
196                write!(
197                    formatter,
198                    "maintenance policy digest mismatch: expected {expected}, actual {actual}"
199                )
200            }
201            Self::VersionMismatch { expected, actual } => {
202                write!(
203                    formatter,
204                    "maintenance policy version mismatch: expected {expected}, actual {actual}"
205                )
206            }
207            Self::Contract(source) => write!(formatter, "maintenance policy contract violation: {source}"),
208        }
209    }
210}
211
212impl Error for MaintenancePolicyLoadError {
213    fn source(&self) -> Option<&(dyn Error + 'static)> {
214        match self {
215            Self::Read { source, .. } => Some(source),
216            Self::Decode { source, .. } => Some(source),
217            Self::Contract(source) => Some(source.as_ref()),
218            Self::InvalidReference(_) | Self::DigestMismatch { .. } | Self::VersionMismatch { .. } => None,
219        }
220    }
221}
222
223fn validate_sha256(value: &str) -> Result<(), MaintenancePolicyLoadError> {
224    if value.len() == 64
225        && value
226            .bytes()
227            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
228    {
229        Ok(())
230    } else {
231        Err(MaintenancePolicyLoadError::InvalidReference(
232            "policy SHA-256 must be 64 lowercase hexadecimal characters".to_string(),
233        ))
234    }
235}