1use std::collections::BTreeSet;
18use std::fmt;
19
20use serde::Deserialize;
21use serde::Serialize;
22
23use crate::MaintenancePolicyRule;
24use crate::Secret;
25use crate::SecurityContractViolation;
26
27pub const MAINTENANCE_POLICY_SCHEMA_VERSION: u16 = 1;
29
30#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum MaintenanceCapability {
34 ReleaseCheckpoint,
36}
37
38#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum MaintenanceRole {
42 Administrator,
44 ReleaseOperator,
46}
47
48#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum MaintenanceRequestClass {
52 PrivilegedMaintenance,
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(deny_unknown_fields, rename_all = "snake_case")]
59pub struct MaintenanceResourceBudget {
60 pub max_checkpoint_bytes: u64,
62 pub max_store_members: u32,
64 pub max_concurrent_operations: u32,
66}
67
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70#[serde(deny_unknown_fields, rename_all = "snake_case")]
71pub struct MaintenancePrincipalBinding {
72 pub principal: String,
74 pub roles: BTreeSet<MaintenanceRole>,
76}
77
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(deny_unknown_fields, rename_all = "snake_case")]
81pub struct MaintenanceRoleGrant {
82 pub role: MaintenanceRole,
84 pub capabilities: BTreeSet<MaintenanceCapability>,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(deny_unknown_fields, rename_all = "snake_case")]
91pub struct MaintenancePolicy {
92 pub schema_version: u16,
94 pub policy_id: String,
96 pub policy_version: u64,
98 pub require_authentication: bool,
100 pub require_authorization: bool,
102 pub require_fencing_token: bool,
104 pub max_request_lifetime_millis: u64,
106 pub resource_budget: MaintenanceResourceBudget,
108 pub principal_bindings: Vec<MaintenancePrincipalBinding>,
110 pub role_grants: Vec<MaintenanceRoleGrant>,
112}
113
114impl MaintenancePolicy {
115 pub fn validate(&self) -> Result<(), SecurityContractViolation> {
123 validate_policy(self)
124 }
125
126 pub fn into_validated(self) -> Result<ValidatedMaintenancePolicy, SecurityContractViolation> {
133 validate_policy(&self)?;
134 Ok(ValidatedMaintenancePolicy(self))
135 }
136
137 fn roles_for(&self, principal: &str) -> Option<&BTreeSet<MaintenanceRole>> {
138 self.principal_bindings
139 .iter()
140 .find(|binding| binding.principal == principal)
141 .map(|binding| &binding.roles)
142 }
143
144 fn role_allows(&self, role: MaintenanceRole, capability: MaintenanceCapability) -> bool {
145 self.role_grants
146 .iter()
147 .find(|grant| grant.role == role)
148 .is_some_and(|grant| grant.capabilities.contains(&capability))
149 }
150}
151
152#[derive(Clone, Debug)]
154pub struct ValidatedMaintenancePolicy(MaintenancePolicy);
155
156impl ValidatedMaintenancePolicy {
157 pub const fn policy(&self) -> &MaintenancePolicy {
159 &self.0
160 }
161}
162
163#[derive(Clone, Eq, PartialEq)]
165pub struct MaintenanceAuthorizationContext {
166 pub authentication_enabled: bool,
168 pub authorization_enabled: bool,
170 pub principal: Option<String>,
172 pub request_class: MaintenanceRequestClass,
174 pub capability: MaintenanceCapability,
176 pub deadline_unix_millis: u64,
178 pub fencing_token: Option<u64>,
180}
181
182impl fmt::Debug for MaintenanceAuthorizationContext {
183 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184 formatter
185 .debug_struct("MaintenanceAuthorizationContext")
186 .field("authentication_enabled", &self.authentication_enabled)
187 .field("authorization_enabled", &self.authorization_enabled)
188 .field("principal", &Secret::new(self.principal.as_deref()))
189 .field("request_class", &self.request_class)
190 .field("capability", &self.capability)
191 .field("deadline_unix_millis", &self.deadline_unix_millis)
192 .field("fencing_token", &Secret::new(self.fencing_token))
193 .finish()
194 }
195}
196
197#[derive(Clone, Eq, PartialEq)]
202pub struct MaintenanceAuthorizationGrant {
203 principal: String,
204 role: MaintenanceRole,
205 capability: MaintenanceCapability,
206 policy_version: u64,
207 deadline_unix_millis: u64,
208 fencing_token: u64,
209 resource_budget: MaintenanceResourceBudget,
210}
211
212impl fmt::Debug for MaintenanceAuthorizationGrant {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 formatter
215 .debug_struct("MaintenanceAuthorizationGrant")
216 .field("principal", &Secret::new(&self.principal))
217 .field("role", &self.role)
218 .field("capability", &self.capability)
219 .field("policy_version", &self.policy_version)
220 .field("deadline_unix_millis", &self.deadline_unix_millis)
221 .field("fencing_token", &Secret::new(self.fencing_token))
222 .field("resource_budget", &self.resource_budget)
223 .finish()
224 }
225}
226
227impl MaintenanceAuthorizationGrant {
228 pub fn principal(&self) -> &str {
230 &self.principal
231 }
232
233 pub const fn role(&self) -> MaintenanceRole {
235 self.role
236 }
237
238 pub const fn capability(&self) -> MaintenanceCapability {
240 self.capability
241 }
242
243 pub const fn policy_version(&self) -> u64 {
245 self.policy_version
246 }
247
248 pub const fn deadline_unix_millis(&self) -> u64 {
250 self.deadline_unix_millis
251 }
252
253 pub const fn fencing_token(&self) -> u64 {
255 self.fencing_token
256 }
257
258 pub const fn resource_budget(&self) -> &MaintenanceResourceBudget {
260 &self.resource_budget
261 }
262}
263
264#[derive(Clone, Debug)]
266pub struct MaintenanceAuthorizer {
267 policy: ValidatedMaintenancePolicy,
268}
269
270impl MaintenanceAuthorizer {
271 pub fn new(policy: impl Into<ValidatedMaintenancePolicy>) -> Self {
273 Self { policy: policy.into() }
274 }
275
276 pub const fn policy(&self) -> &MaintenancePolicy {
278 self.policy.policy()
279 }
280
281 pub fn authorize(
289 &self,
290 context: Option<&MaintenanceAuthorizationContext>,
291 now_unix_millis: u64,
292 ) -> Result<MaintenanceAuthorizationGrant, MaintenanceAuthorizationDenial> {
293 let context = context.ok_or(MaintenanceAuthorizationDenial::MissingAuthorizationContext)?;
294 if !context.authentication_enabled {
295 return Err(MaintenanceAuthorizationDenial::AuthenticationDisabled);
296 }
297 if !context.authorization_enabled {
298 return Err(MaintenanceAuthorizationDenial::AuthorizationDisabled);
299 }
300 if context.request_class != MaintenanceRequestClass::PrivilegedMaintenance {
301 return Err(MaintenanceAuthorizationDenial::InvalidRequestClass);
302 }
303 let principal = context
304 .principal
305 .as_deref()
306 .filter(|principal| !principal.trim().is_empty())
307 .ok_or(MaintenanceAuthorizationDenial::Anonymous)?;
308 let roles = self
309 .policy()
310 .roles_for(principal)
311 .ok_or(MaintenanceAuthorizationDenial::PrincipalUnbound)?;
312 if !roles.contains(&MaintenanceRole::ReleaseOperator) {
313 return Err(MaintenanceAuthorizationDenial::MissingRole(
314 MaintenanceRole::ReleaseOperator,
315 ));
316 }
317 if !self
318 .policy()
319 .role_allows(MaintenanceRole::ReleaseOperator, context.capability)
320 {
321 return Err(MaintenanceAuthorizationDenial::CapabilityDenied(context.capability));
322 }
323 if context.deadline_unix_millis <= now_unix_millis {
324 return Err(MaintenanceAuthorizationDenial::DeadlineExpired);
325 }
326 let lifetime = context.deadline_unix_millis - now_unix_millis;
327 if lifetime > self.policy().max_request_lifetime_millis {
328 return Err(MaintenanceAuthorizationDenial::DeadlineTooFar {
329 requested_millis: lifetime,
330 maximum_millis: self.policy().max_request_lifetime_millis,
331 });
332 }
333 let fencing_token = context
334 .fencing_token
335 .filter(|token| *token != 0)
336 .ok_or(MaintenanceAuthorizationDenial::MissingFencingToken)?;
337
338 Ok(MaintenanceAuthorizationGrant {
339 principal: principal.to_string(),
340 role: MaintenanceRole::ReleaseOperator,
341 capability: context.capability,
342 policy_version: self.policy().policy_version,
343 deadline_unix_millis: context.deadline_unix_millis,
344 fencing_token,
345 resource_budget: self.policy().resource_budget.clone(),
346 })
347 }
348}
349
350#[derive(Clone, Debug, Eq, PartialEq)]
355pub enum MaintenanceAuthorizationDenial {
356 MissingAuthorizationContext,
358 AuthenticationDisabled,
360 AuthorizationDisabled,
362 InvalidRequestClass,
364 Anonymous,
366 PrincipalUnbound,
368 MissingRole(MaintenanceRole),
370 CapabilityDenied(MaintenanceCapability),
372 DeadlineExpired,
374 DeadlineTooFar {
376 requested_millis: u64,
378 maximum_millis: u64,
380 },
381 MissingFencingToken,
383}
384
385fn validate_policy(policy: &MaintenancePolicy) -> Result<(), SecurityContractViolation> {
386 if policy.schema_version != MAINTENANCE_POLICY_SCHEMA_VERSION {
387 return invalid_policy(MaintenancePolicyRule::SchemaVersion);
388 }
389 if !is_canonical_identifier(&policy.policy_id) {
390 return invalid_policy(MaintenancePolicyRule::PolicyIdentifier);
391 }
392 if policy.policy_version == 0 {
393 return invalid_policy(MaintenancePolicyRule::PolicyVersion);
394 }
395 if !policy.require_authentication {
396 return invalid_policy(MaintenancePolicyRule::AuthenticationRequired);
397 }
398 if !policy.require_authorization {
399 return invalid_policy(MaintenancePolicyRule::AuthorizationRequired);
400 }
401 if !policy.require_fencing_token {
402 return invalid_policy(MaintenancePolicyRule::FencingTokenRequired);
403 }
404 if !(1_000..=86_400_000).contains(&policy.max_request_lifetime_millis) {
405 return invalid_policy(MaintenancePolicyRule::RequestLifetime);
406 }
407 if policy.resource_budget.max_checkpoint_bytes == 0
408 || policy.resource_budget.max_store_members == 0
409 || policy.resource_budget.max_concurrent_operations == 0
410 {
411 return invalid_policy(MaintenancePolicyRule::ResourceLimits);
412 }
413
414 let mut principals = BTreeSet::new();
415 let mut has_release_operator = false;
416 for binding in &policy.principal_bindings {
417 if !is_canonical_principal(&binding.principal) {
418 return invalid_policy(MaintenancePolicyRule::PrincipalIdentifier);
419 }
420 if !principals.insert(binding.principal.as_str()) {
421 return invalid_policy(MaintenancePolicyRule::UniquePrincipalBinding);
422 }
423 if binding.roles.is_empty() {
424 return invalid_policy(MaintenancePolicyRule::PrincipalRoleRequired);
425 }
426 has_release_operator |= binding.roles.contains(&MaintenanceRole::ReleaseOperator);
427 }
428 if !has_release_operator {
429 return invalid_policy(MaintenancePolicyRule::ReleaseOperatorRequired);
430 }
431
432 let mut granted_roles = BTreeSet::new();
433 let mut release_checkpoint_granted = false;
434 for grant in &policy.role_grants {
435 if !granted_roles.insert(grant.role) {
436 return invalid_policy(MaintenancePolicyRule::UniqueRoleGrant);
437 }
438 if grant.capabilities.is_empty() {
439 return invalid_policy(MaintenancePolicyRule::RoleCapabilityRequired);
440 }
441 if grant.role == MaintenanceRole::Administrator
442 && grant.capabilities.contains(&MaintenanceCapability::ReleaseCheckpoint)
443 {
444 return invalid_policy(MaintenancePolicyRule::AdministratorCheckpointForbidden);
445 }
446 release_checkpoint_granted |= grant.role == MaintenanceRole::ReleaseOperator
447 && grant.capabilities.contains(&MaintenanceCapability::ReleaseCheckpoint);
448 }
449 if !release_checkpoint_granted {
450 return invalid_policy(MaintenancePolicyRule::ReleaseCheckpointRequired);
451 }
452 Ok(())
453}
454
455fn invalid_policy<T>(rule: MaintenancePolicyRule) -> Result<T, SecurityContractViolation> {
456 Err(SecurityContractViolation::MaintenancePolicy { rule })
457}
458
459fn is_canonical_identifier(value: &str) -> bool {
460 !value.is_empty()
461 && value.len() <= 128
462 && value
463 .bytes()
464 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-'))
465}
466
467fn is_canonical_principal(value: &str) -> bool {
468 !value.is_empty()
469 && value.len() <= 256
470 && value
471 .bytes()
472 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@' | b':' | b'/'))
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn policy() -> MaintenancePolicy {
480 MaintenancePolicy {
481 schema_version: MAINTENANCE_POLICY_SCHEMA_VERSION,
482 policy_id: "rocketmq.production-maintenance".to_string(),
483 policy_version: 7,
484 require_authentication: true,
485 require_authorization: true,
486 require_fencing_token: true,
487 max_request_lifetime_millis: 30_000,
488 resource_budget: MaintenanceResourceBudget {
489 max_checkpoint_bytes: 1024,
490 max_store_members: 3,
491 max_concurrent_operations: 1,
492 },
493 principal_bindings: vec![MaintenancePrincipalBinding {
494 principal: "release-operator".to_string(),
495 roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
496 }],
497 role_grants: vec![MaintenanceRoleGrant {
498 role: MaintenanceRole::ReleaseOperator,
499 capabilities: BTreeSet::from([MaintenanceCapability::ReleaseCheckpoint]),
500 }],
501 }
502 }
503
504 fn context() -> MaintenanceAuthorizationContext {
505 MaintenanceAuthorizationContext {
506 authentication_enabled: true,
507 authorization_enabled: true,
508 principal: Some("release-operator".to_string()),
509 request_class: MaintenanceRequestClass::PrivilegedMaintenance,
510 capability: MaintenanceCapability::ReleaseCheckpoint,
511 deadline_unix_millis: 120_000,
512 fencing_token: Some(42),
513 }
514 }
515
516 #[test]
517 fn grant_pins_policy_deadline_fencing_and_budget() {
518 let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
519 let grant = authorizer.authorize(Some(&context()), 100_000).expect("authorized");
520
521 assert_eq!(grant.policy_version(), 7);
522 assert_eq!(grant.deadline_unix_millis(), 120_000);
523 assert_eq!(grant.fencing_token(), 42);
524 assert_eq!(grant.resource_budget().max_checkpoint_bytes, 1024);
525 }
526
527 #[test]
528 fn grant_cannot_bypass_fail_closed_inputs() {
529 let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
530 let mut request = context();
531 request.authentication_enabled = false;
532 assert_eq!(
533 authorizer.authorize(Some(&request), 100_000),
534 Err(MaintenanceAuthorizationDenial::AuthenticationDisabled)
535 );
536 request.authentication_enabled = true;
537 request.fencing_token = None;
538 assert_eq!(
539 authorizer.authorize(Some(&request), 100_000),
540 Err(MaintenanceAuthorizationDenial::MissingFencingToken)
541 );
542 request.fencing_token = Some(42);
543 request.deadline_unix_millis = 100_000;
544 assert_eq!(
545 authorizer.authorize(Some(&request), 100_000),
546 Err(MaintenanceAuthorizationDenial::DeadlineExpired)
547 );
548 }
549
550 #[test]
551 fn policy_and_denial_errors_never_retain_principals() {
552 let sentinel = "principal-secret-sentinel";
553 let mut invalid = policy();
554 invalid.principal_bindings = vec![
555 MaintenancePrincipalBinding {
556 principal: sentinel.to_string(),
557 roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
558 },
559 MaintenancePrincipalBinding {
560 principal: sentinel.to_string(),
561 roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
562 },
563 ];
564 let error = invalid.validate().expect_err("duplicate principals must fail closed");
565 assert_eq!(
566 error,
567 SecurityContractViolation::MaintenancePolicy {
568 rule: MaintenancePolicyRule::UniquePrincipalBinding,
569 }
570 );
571 assert!(!error.to_string().contains(sentinel));
572 assert!(!format!("{error:?}").contains(sentinel));
573
574 let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
575 let mut request = context();
576 request.principal = Some(sentinel.to_string());
577 let denial = authorizer
578 .authorize(Some(&request), 100_000)
579 .expect_err("unbound principal must be denied");
580 assert_eq!(denial, MaintenanceAuthorizationDenial::PrincipalUnbound);
581 assert!(!format!("{denial:?}").contains(sentinel));
582 }
583}