use std::collections::BTreeSet;
use std::fmt;
use serde::Deserialize;
use serde::Serialize;
use crate::MaintenancePolicyRule;
use crate::Secret;
use crate::SecurityContractViolation;
pub const MAINTENANCE_POLICY_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MaintenanceCapability {
ReleaseCheckpoint,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MaintenanceRole {
Administrator,
ReleaseOperator,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MaintenanceRequestClass {
PrivilegedMaintenance,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct MaintenanceResourceBudget {
pub max_checkpoint_bytes: u64,
pub max_store_members: u32,
pub max_concurrent_operations: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct MaintenancePrincipalBinding {
pub principal: String,
pub roles: BTreeSet<MaintenanceRole>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct MaintenanceRoleGrant {
pub role: MaintenanceRole,
pub capabilities: BTreeSet<MaintenanceCapability>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct MaintenancePolicy {
pub schema_version: u16,
pub policy_id: String,
pub policy_version: u64,
pub require_authentication: bool,
pub require_authorization: bool,
pub require_fencing_token: bool,
pub max_request_lifetime_millis: u64,
pub resource_budget: MaintenanceResourceBudget,
pub principal_bindings: Vec<MaintenancePrincipalBinding>,
pub role_grants: Vec<MaintenanceRoleGrant>,
}
impl MaintenancePolicy {
pub fn validate(&self) -> Result<(), SecurityContractViolation> {
validate_policy(self)
}
pub fn into_validated(self) -> Result<ValidatedMaintenancePolicy, SecurityContractViolation> {
validate_policy(&self)?;
Ok(ValidatedMaintenancePolicy(self))
}
fn roles_for(&self, principal: &str) -> Option<&BTreeSet<MaintenanceRole>> {
self.principal_bindings
.iter()
.find(|binding| binding.principal == principal)
.map(|binding| &binding.roles)
}
fn role_allows(&self, role: MaintenanceRole, capability: MaintenanceCapability) -> bool {
self.role_grants
.iter()
.find(|grant| grant.role == role)
.is_some_and(|grant| grant.capabilities.contains(&capability))
}
}
#[derive(Clone, Debug)]
pub struct ValidatedMaintenancePolicy(MaintenancePolicy);
impl ValidatedMaintenancePolicy {
pub const fn policy(&self) -> &MaintenancePolicy {
&self.0
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct MaintenanceAuthorizationContext {
pub authentication_enabled: bool,
pub authorization_enabled: bool,
pub principal: Option<String>,
pub request_class: MaintenanceRequestClass,
pub capability: MaintenanceCapability,
pub deadline_unix_millis: u64,
pub fencing_token: Option<u64>,
}
impl fmt::Debug for MaintenanceAuthorizationContext {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MaintenanceAuthorizationContext")
.field("authentication_enabled", &self.authentication_enabled)
.field("authorization_enabled", &self.authorization_enabled)
.field("principal", &Secret::new(self.principal.as_deref()))
.field("request_class", &self.request_class)
.field("capability", &self.capability)
.field("deadline_unix_millis", &self.deadline_unix_millis)
.field("fencing_token", &Secret::new(self.fencing_token))
.finish()
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct MaintenanceAuthorizationGrant {
principal: String,
role: MaintenanceRole,
capability: MaintenanceCapability,
policy_version: u64,
deadline_unix_millis: u64,
fencing_token: u64,
resource_budget: MaintenanceResourceBudget,
}
impl fmt::Debug for MaintenanceAuthorizationGrant {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MaintenanceAuthorizationGrant")
.field("principal", &Secret::new(&self.principal))
.field("role", &self.role)
.field("capability", &self.capability)
.field("policy_version", &self.policy_version)
.field("deadline_unix_millis", &self.deadline_unix_millis)
.field("fencing_token", &Secret::new(self.fencing_token))
.field("resource_budget", &self.resource_budget)
.finish()
}
}
impl MaintenanceAuthorizationGrant {
pub fn principal(&self) -> &str {
&self.principal
}
pub const fn role(&self) -> MaintenanceRole {
self.role
}
pub const fn capability(&self) -> MaintenanceCapability {
self.capability
}
pub const fn policy_version(&self) -> u64 {
self.policy_version
}
pub const fn deadline_unix_millis(&self) -> u64 {
self.deadline_unix_millis
}
pub const fn fencing_token(&self) -> u64 {
self.fencing_token
}
pub const fn resource_budget(&self) -> &MaintenanceResourceBudget {
&self.resource_budget
}
}
#[derive(Clone, Debug)]
pub struct MaintenanceAuthorizer {
policy: ValidatedMaintenancePolicy,
}
impl MaintenanceAuthorizer {
pub fn new(policy: impl Into<ValidatedMaintenancePolicy>) -> Self {
Self { policy: policy.into() }
}
pub const fn policy(&self) -> &MaintenancePolicy {
self.policy.policy()
}
pub fn authorize(
&self,
context: Option<&MaintenanceAuthorizationContext>,
now_unix_millis: u64,
) -> Result<MaintenanceAuthorizationGrant, MaintenanceAuthorizationDenial> {
let context = context.ok_or(MaintenanceAuthorizationDenial::MissingAuthorizationContext)?;
if !context.authentication_enabled {
return Err(MaintenanceAuthorizationDenial::AuthenticationDisabled);
}
if !context.authorization_enabled {
return Err(MaintenanceAuthorizationDenial::AuthorizationDisabled);
}
if context.request_class != MaintenanceRequestClass::PrivilegedMaintenance {
return Err(MaintenanceAuthorizationDenial::InvalidRequestClass);
}
let principal = context
.principal
.as_deref()
.filter(|principal| !principal.trim().is_empty())
.ok_or(MaintenanceAuthorizationDenial::Anonymous)?;
let roles = self
.policy()
.roles_for(principal)
.ok_or(MaintenanceAuthorizationDenial::PrincipalUnbound)?;
if !roles.contains(&MaintenanceRole::ReleaseOperator) {
return Err(MaintenanceAuthorizationDenial::MissingRole(
MaintenanceRole::ReleaseOperator,
));
}
if !self
.policy()
.role_allows(MaintenanceRole::ReleaseOperator, context.capability)
{
return Err(MaintenanceAuthorizationDenial::CapabilityDenied(context.capability));
}
if context.deadline_unix_millis <= now_unix_millis {
return Err(MaintenanceAuthorizationDenial::DeadlineExpired);
}
let lifetime = context.deadline_unix_millis - now_unix_millis;
if lifetime > self.policy().max_request_lifetime_millis {
return Err(MaintenanceAuthorizationDenial::DeadlineTooFar {
requested_millis: lifetime,
maximum_millis: self.policy().max_request_lifetime_millis,
});
}
let fencing_token = context
.fencing_token
.filter(|token| *token != 0)
.ok_or(MaintenanceAuthorizationDenial::MissingFencingToken)?;
Ok(MaintenanceAuthorizationGrant {
principal: principal.to_string(),
role: MaintenanceRole::ReleaseOperator,
capability: context.capability,
policy_version: self.policy().policy_version,
deadline_unix_millis: context.deadline_unix_millis,
fencing_token,
resource_budget: self.policy().resource_budget.clone(),
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MaintenanceAuthorizationDenial {
MissingAuthorizationContext,
AuthenticationDisabled,
AuthorizationDisabled,
InvalidRequestClass,
Anonymous,
PrincipalUnbound,
MissingRole(MaintenanceRole),
CapabilityDenied(MaintenanceCapability),
DeadlineExpired,
DeadlineTooFar {
requested_millis: u64,
maximum_millis: u64,
},
MissingFencingToken,
}
fn validate_policy(policy: &MaintenancePolicy) -> Result<(), SecurityContractViolation> {
if policy.schema_version != MAINTENANCE_POLICY_SCHEMA_VERSION {
return invalid_policy(MaintenancePolicyRule::SchemaVersion);
}
if !is_canonical_identifier(&policy.policy_id) {
return invalid_policy(MaintenancePolicyRule::PolicyIdentifier);
}
if policy.policy_version == 0 {
return invalid_policy(MaintenancePolicyRule::PolicyVersion);
}
if !policy.require_authentication {
return invalid_policy(MaintenancePolicyRule::AuthenticationRequired);
}
if !policy.require_authorization {
return invalid_policy(MaintenancePolicyRule::AuthorizationRequired);
}
if !policy.require_fencing_token {
return invalid_policy(MaintenancePolicyRule::FencingTokenRequired);
}
if !(1_000..=86_400_000).contains(&policy.max_request_lifetime_millis) {
return invalid_policy(MaintenancePolicyRule::RequestLifetime);
}
if policy.resource_budget.max_checkpoint_bytes == 0
|| policy.resource_budget.max_store_members == 0
|| policy.resource_budget.max_concurrent_operations == 0
{
return invalid_policy(MaintenancePolicyRule::ResourceLimits);
}
let mut principals = BTreeSet::new();
let mut has_release_operator = false;
for binding in &policy.principal_bindings {
if !is_canonical_principal(&binding.principal) {
return invalid_policy(MaintenancePolicyRule::PrincipalIdentifier);
}
if !principals.insert(binding.principal.as_str()) {
return invalid_policy(MaintenancePolicyRule::UniquePrincipalBinding);
}
if binding.roles.is_empty() {
return invalid_policy(MaintenancePolicyRule::PrincipalRoleRequired);
}
has_release_operator |= binding.roles.contains(&MaintenanceRole::ReleaseOperator);
}
if !has_release_operator {
return invalid_policy(MaintenancePolicyRule::ReleaseOperatorRequired);
}
let mut granted_roles = BTreeSet::new();
let mut release_checkpoint_granted = false;
for grant in &policy.role_grants {
if !granted_roles.insert(grant.role) {
return invalid_policy(MaintenancePolicyRule::UniqueRoleGrant);
}
if grant.capabilities.is_empty() {
return invalid_policy(MaintenancePolicyRule::RoleCapabilityRequired);
}
if grant.role == MaintenanceRole::Administrator
&& grant.capabilities.contains(&MaintenanceCapability::ReleaseCheckpoint)
{
return invalid_policy(MaintenancePolicyRule::AdministratorCheckpointForbidden);
}
release_checkpoint_granted |= grant.role == MaintenanceRole::ReleaseOperator
&& grant.capabilities.contains(&MaintenanceCapability::ReleaseCheckpoint);
}
if !release_checkpoint_granted {
return invalid_policy(MaintenancePolicyRule::ReleaseCheckpointRequired);
}
Ok(())
}
fn invalid_policy<T>(rule: MaintenancePolicyRule) -> Result<T, SecurityContractViolation> {
Err(SecurityContractViolation::MaintenancePolicy { rule })
}
fn is_canonical_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-'))
}
fn is_canonical_principal(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 256
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'@' | b':' | b'/'))
}
#[cfg(test)]
mod tests {
use super::*;
fn policy() -> MaintenancePolicy {
MaintenancePolicy {
schema_version: MAINTENANCE_POLICY_SCHEMA_VERSION,
policy_id: "rocketmq.production-maintenance".to_string(),
policy_version: 7,
require_authentication: true,
require_authorization: true,
require_fencing_token: true,
max_request_lifetime_millis: 30_000,
resource_budget: MaintenanceResourceBudget {
max_checkpoint_bytes: 1024,
max_store_members: 3,
max_concurrent_operations: 1,
},
principal_bindings: vec![MaintenancePrincipalBinding {
principal: "release-operator".to_string(),
roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
}],
role_grants: vec![MaintenanceRoleGrant {
role: MaintenanceRole::ReleaseOperator,
capabilities: BTreeSet::from([MaintenanceCapability::ReleaseCheckpoint]),
}],
}
}
fn context() -> MaintenanceAuthorizationContext {
MaintenanceAuthorizationContext {
authentication_enabled: true,
authorization_enabled: true,
principal: Some("release-operator".to_string()),
request_class: MaintenanceRequestClass::PrivilegedMaintenance,
capability: MaintenanceCapability::ReleaseCheckpoint,
deadline_unix_millis: 120_000,
fencing_token: Some(42),
}
}
#[test]
fn grant_pins_policy_deadline_fencing_and_budget() {
let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
let grant = authorizer.authorize(Some(&context()), 100_000).expect("authorized");
assert_eq!(grant.policy_version(), 7);
assert_eq!(grant.deadline_unix_millis(), 120_000);
assert_eq!(grant.fencing_token(), 42);
assert_eq!(grant.resource_budget().max_checkpoint_bytes, 1024);
}
#[test]
fn grant_cannot_bypass_fail_closed_inputs() {
let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
let mut request = context();
request.authentication_enabled = false;
assert_eq!(
authorizer.authorize(Some(&request), 100_000),
Err(MaintenanceAuthorizationDenial::AuthenticationDisabled)
);
request.authentication_enabled = true;
request.fencing_token = None;
assert_eq!(
authorizer.authorize(Some(&request), 100_000),
Err(MaintenanceAuthorizationDenial::MissingFencingToken)
);
request.fencing_token = Some(42);
request.deadline_unix_millis = 100_000;
assert_eq!(
authorizer.authorize(Some(&request), 100_000),
Err(MaintenanceAuthorizationDenial::DeadlineExpired)
);
}
#[test]
fn policy_and_denial_errors_never_retain_principals() {
let sentinel = "principal-secret-sentinel";
let mut invalid = policy();
invalid.principal_bindings = vec![
MaintenancePrincipalBinding {
principal: sentinel.to_string(),
roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
},
MaintenancePrincipalBinding {
principal: sentinel.to_string(),
roles: BTreeSet::from([MaintenanceRole::ReleaseOperator]),
},
];
let error = invalid.validate().expect_err("duplicate principals must fail closed");
assert_eq!(
error,
SecurityContractViolation::MaintenancePolicy {
rule: MaintenancePolicyRule::UniquePrincipalBinding,
}
);
assert!(!error.to_string().contains(sentinel));
assert!(!format!("{error:?}").contains(sentinel));
let authorizer = MaintenanceAuthorizer::new(policy().into_validated().expect("valid policy"));
let mut request = context();
request.principal = Some(sentinel.to_string());
let denial = authorizer
.authorize(Some(&request), 100_000)
.expect_err("unbound principal must be denied");
assert_eq!(denial, MaintenanceAuthorizationDenial::PrincipalUnbound);
assert!(!format!("{denial:?}").contains(sentinel));
}
}