feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
@@ -1,9 +1,27 @@
mod replacement;
mod types;
mod usage_policy;
pub use replacement::{
entitlements_have_replacement_selector, entitlements_should_replace_existing,
validate_entitlement_replacement_groups, EntitlementReplacementGroupValidationError,
ENTITLEMENT_REPLACEMENT_GROUP_FIELD, MAX_ENTITLEMENT_REPLACEMENT_GROUP_LENGTH,
};
pub use types::{
checked_plan_duration_days, checked_plan_duration_days_from_snapshot,
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingMutationOutcome,
AdminBillingPresetApplyResult, AdminBillingRuleRecord, AdminBillingRuleWriteInput,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository, PaymentGatewayConfigRecord,
PaymentGatewayConfigWriteInput, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
BillingPlanRecord, BillingPlanWriteInput, BillingReadRepository,
PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord, PaymentGatewayConfigWriteInput,
PaymentGatewaySecretCasUpdate, StoredBillingModelContext, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord,
};
pub use usage_policy::{
nonnegative_usd_to_usage_policy_cost_units, parse_usage_policy_entitlements,
usd_to_usage_policy_cost_units, UsagePolicyEnforcement, UsagePolicyEntitlement,
UsagePolicyEntitlementType, UsagePolicyMetric, UsagePolicyParseError, UsagePolicyRule,
UsagePolicyValidationError, UsagePolicyWindow, MAX_USAGE_POLICY_ENTITLEMENTS,
MAX_USAGE_POLICY_EXACT_INTEGER, MAX_USAGE_POLICY_ROLLING_WINDOW_SECONDS,
MAX_USAGE_POLICY_RULES, MAX_USAGE_POLICY_TEXT_LENGTH, MAX_USAGE_POLICY_TOTAL_RULES,
USAGE_POLICY_COST_UNITS_PER_USD, USAGE_POLICY_ENTITLEMENT_TYPE,
};
@@ -0,0 +1,200 @@
use std::collections::HashSet;
use serde_json::Value;
pub const ENTITLEMENT_REPLACEMENT_GROUP_FIELD: &str = "replacement_group";
pub const MAX_ENTITLEMENT_REPLACEMENT_GROUP_LENGTH: usize = 128;
const LEGACY_REPLACEMENT_ENTITLEMENT_TYPES: [&str; 2] = ["daily_quota", "membership_group"];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EntitlementReplacementGroupValidationError {
#[error("entitlements must be an array")]
EntitlementsMustBeArray,
#[error("entitlements[{index}].replacement_group must be a string")]
InvalidType { index: usize },
#[error("entitlements[{index}].replacement_group must not be empty")]
Empty { index: usize },
#[error("entitlements[{index}].replacement_group exceeds maximum length {max_len}")]
TooLong { index: usize, max_len: usize },
}
pub fn validate_entitlement_replacement_groups(
entitlements: &Value,
) -> Result<(), EntitlementReplacementGroupValidationError> {
let items = entitlements
.as_array()
.ok_or(EntitlementReplacementGroupValidationError::EntitlementsMustBeArray)?;
for (index, item) in items.iter().enumerate() {
let Some(group) = item.get(ENTITLEMENT_REPLACEMENT_GROUP_FIELD) else {
continue;
};
let group = group
.as_str()
.ok_or(EntitlementReplacementGroupValidationError::InvalidType { index })?
.trim();
if group.is_empty() {
return Err(EntitlementReplacementGroupValidationError::Empty { index });
}
if group.chars().count() > MAX_ENTITLEMENT_REPLACEMENT_GROUP_LENGTH {
return Err(EntitlementReplacementGroupValidationError::TooLong {
index,
max_len: MAX_ENTITLEMENT_REPLACEMENT_GROUP_LENGTH,
});
}
}
Ok(())
}
pub fn entitlements_have_replacement_selector(entitlements: &Value) -> bool {
let Some(items) = entitlements.as_array() else {
return false;
};
items.iter().any(|item| {
let entitlement_type = item.get("type").and_then(Value::as_str);
LEGACY_REPLACEMENT_ENTITLEMENT_TYPES.contains(&entitlement_type.unwrap_or_default())
|| replacement_group(item).is_some()
})
}
pub fn entitlements_should_replace_existing(incoming: &Value, existing: &Value) -> bool {
let (Some(incoming), Some(existing)) = (incoming.as_array(), existing.as_array()) else {
return false;
};
if LEGACY_REPLACEMENT_ENTITLEMENT_TYPES.iter().any(|kind| {
entitlement_items_have_type(incoming, kind) && entitlement_items_have_type(existing, kind)
}) {
return true;
}
let incoming_groups = incoming
.iter()
.filter_map(replacement_group)
.collect::<HashSet<_>>();
!incoming_groups.is_empty()
&& existing
.iter()
.filter_map(replacement_group)
.any(|group| incoming_groups.contains(group))
}
fn entitlement_items_have_type(items: &[Value], entitlement_type: &str) -> bool {
items
.iter()
.any(|item| item.get("type").and_then(Value::as_str) == Some(entitlement_type))
}
fn replacement_group(item: &Value) -> Option<&str> {
item.get(ENTITLEMENT_REPLACEMENT_GROUP_FIELD)
.and_then(Value::as_str)
.map(str::trim)
.filter(|group| !group.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn legacy_daily_quota_and_membership_groups_remain_mutually_exclusive() {
assert!(entitlements_should_replace_existing(
&json!([{"type": "daily_quota", "daily_quota_usd": 20}]),
&json!([
{"type": "daily_quota", "daily_quota_usd": 10},
{"type": "usage_policy", "rules": []}
]),
));
assert!(entitlements_should_replace_existing(
&json!([{"type": "membership_group", "grant_user_groups": ["pro"]}]),
&json!([{"type": "membership_group", "grant_user_groups": ["basic"]}]),
));
}
#[test]
fn usage_policies_stack_by_default() {
let incoming = json!([{"type": "usage_policy", "policy_id": "weekly", "rules": []}]);
let existing = json!([{"type": "usage_policy", "policy_id": "five-hour", "rules": []}]);
assert!(!entitlements_have_replacement_selector(&incoming));
assert!(!entitlements_should_replace_existing(&incoming, &existing));
}
#[test]
fn matching_explicit_groups_replace_the_whole_package() {
let incoming = json!([{
"type": "usage_policy",
"replacement_group": "pro-tier",
"rules": []
}]);
let existing = json!([
{"type": "wallet_credit", "amount_usd": 10},
{
"type": "usage_policy",
"replacement_group": "pro-tier",
"rules": []
}
]);
assert!(entitlements_have_replacement_selector(&incoming));
assert!(entitlements_should_replace_existing(&incoming, &existing));
assert!(!entitlements_should_replace_existing(
&incoming,
&json!([{
"type": "usage_policy",
"replacement_group": "team-tier",
"rules": []
}]),
));
}
#[test]
fn explicit_groups_can_span_entitlement_types_and_ignore_outer_whitespace() {
assert!(entitlements_should_replace_existing(
&json!([{
"type": "usage_policy",
"replacement_group": " traffic-tier ",
"rules": []
}]),
&json!([{
"type": "wallet_credit",
"replacement_group": "traffic-tier",
"amount_usd": 10
}]),
));
}
#[test]
fn validates_explicit_group_shape_and_bounds() {
assert!(validate_entitlement_replacement_groups(&json!([{
"type": "usage_policy",
"replacement_group": "pro-tier"
}]))
.is_ok());
assert_eq!(
validate_entitlement_replacement_groups(&json!([{
"type": "usage_policy",
"replacement_group": " "
}])),
Err(EntitlementReplacementGroupValidationError::Empty { index: 0 })
);
assert_eq!(
validate_entitlement_replacement_groups(&json!([{
"type": "usage_policy",
"replacement_group": 42
}])),
Err(EntitlementReplacementGroupValidationError::InvalidType { index: 0 })
);
assert!(matches!(
validate_entitlement_replacement_groups(&json!([{
"type": "usage_policy",
"replacement_group": "x".repeat(MAX_ENTITLEMENT_REPLACEMENT_GROUP_LENGTH + 1)
}])),
Err(EntitlementReplacementGroupValidationError::TooLong { index: 0, .. })
));
}
}
@@ -150,7 +150,7 @@ pub enum AdminBillingMutationOutcome<T> {
Unavailable,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PaymentGatewayConfigRecord {
pub provider: String,
pub enabled: bool,
@@ -166,7 +166,30 @@ pub struct PaymentGatewayConfigRecord {
pub updated_at_unix_secs: u64,
}
#[derive(Debug, Clone, PartialEq)]
impl std::fmt::Debug for PaymentGatewayConfigRecord {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PaymentGatewayConfigRecord")
.field("provider", &self.provider)
.field("enabled", &self.enabled)
.field("endpoint_url", &"[REDACTED]")
.field(
"callback_base_url",
&self.callback_base_url.as_ref().map(|_| "[REDACTED]"),
)
.field("merchant_id", &self.merchant_id)
.field(
"merchant_key_encrypted",
&self.merchant_key_encrypted.as_ref().map(|_| "[REDACTED]"),
)
.field("channels_json", &"[REDACTED]")
.field("created_at_unix_secs", &self.created_at_unix_secs)
.field("updated_at_unix_secs", &self.updated_at_unix_secs)
.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq)]
pub struct PaymentGatewayConfigWriteInput {
pub provider: String,
pub enabled: bool,
@@ -181,6 +204,113 @@ pub struct PaymentGatewayConfigWriteInput {
pub channels_json: Value,
}
impl std::fmt::Debug for PaymentGatewayConfigWriteInput {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PaymentGatewayConfigWriteInput")
.field("provider", &self.provider)
.field("enabled", &self.enabled)
.field("endpoint_url", &"[REDACTED]")
.field(
"callback_base_url",
&self.callback_base_url.as_ref().map(|_| "[REDACTED]"),
)
.field("merchant_id", &self.merchant_id)
.field(
"merchant_key_encrypted",
&self.merchant_key_encrypted.as_ref().map(|_| "[REDACTED]"),
)
.field("preserve_existing_secret", &self.preserve_existing_secret)
.field("channels_json", &"[REDACTED]")
.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq)]
pub struct PaymentGatewaySecretCasUpdate {
pub provider: String,
pub expected_merchant_key_encrypted: String,
pub merchant_key_encrypted: String,
}
impl std::fmt::Debug for PaymentGatewaySecretCasUpdate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PaymentGatewaySecretCasUpdate")
.field("provider", &self.provider)
.field("expected_merchant_key_encrypted", &"[REDACTED]")
.field("merchant_key_encrypted", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, PartialEq)]
pub struct PaymentGatewayConfigCasWriteInput {
pub input: PaymentGatewayConfigWriteInput,
pub expected_existing: bool,
pub expected_merchant_key_encrypted: Option<String>,
}
impl std::fmt::Debug for PaymentGatewayConfigCasWriteInput {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PaymentGatewayConfigCasWriteInput")
.field("input", &self.input)
.field("expected_existing", &self.expected_existing)
.field(
"expected_merchant_key_encrypted",
&self
.expected_merchant_key_encrypted
.as_ref()
.map(|_| "[REDACTED]"),
)
.finish()
}
}
#[cfg(test)]
mod payment_gateway_debug_tests {
use super::{PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigWriteInput};
#[test]
fn payment_gateway_config_debug_output_redacts_credential_material() {
let input = PaymentGatewayConfigCasWriteInput {
input: PaymentGatewayConfigWriteInput {
provider: "stripe".to_string(),
enabled: true,
endpoint_url: "https://endpoint.example/?key=endpoint-canary".to_string(),
callback_base_url: Some(
"https://callback.example/?token=callback-canary".to_string(),
),
merchant_id: "merchant".to_string(),
merchant_key_encrypted: Some("merchant-key-canary".to_string()),
preserve_existing_secret: false,
pay_currency: "USD".to_string(),
usd_exchange_rate: 1.0,
min_recharge_usd: 1.0,
channels_json: serde_json::json!({"secret": "channels-canary"}),
},
expected_existing: true,
expected_merchant_key_encrypted: Some("expected-merchant-key-canary".to_string()),
};
let debug = format!("{input:?}");
assert!(debug.contains("[REDACTED]"));
for secret in [
"endpoint-canary",
"callback-canary",
"merchant-key-canary",
"channels-canary",
"expected-merchant-key-canary",
] {
assert!(
!debug.contains(secret),
"debug output leaked {secret}: {debug}"
);
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BillingPlanRecord {
pub id: String,
@@ -214,6 +344,43 @@ pub struct BillingPlanWriteInput {
pub entitlements_json: Value,
}
/// Convert a plan duration into whole days without allowing integer or
/// `chrono::TimeDelta` overflow. Plan snapshots are persisted and may later be
/// fulfilled by any database adapter, so the accepted range must be portable
/// across all of them.
pub fn checked_plan_duration_days(duration_unit: &str, duration_value: i64) -> Result<i64, String> {
if duration_value <= 0 {
return Err("plan duration_value must be positive".to_string());
}
let days = match duration_unit.trim() {
"day" | "custom" => Some(duration_value),
"month" => duration_value.checked_mul(30),
"year" => duration_value.checked_mul(365),
_ => return Err("plan duration_unit is invalid".to_string()),
}
.ok_or_else(|| "plan duration exceeds the supported range".to_string())?;
chrono::TimeDelta::try_days(days)
.ok_or_else(|| "plan duration exceeds the supported range".to_string())?;
Ok(days)
}
/// Read a persisted plan snapshot using the historical month/one defaults,
/// while rejecting malformed or unrepresentable explicit values.
pub fn checked_plan_duration_days_from_snapshot(snapshot: &Value) -> Result<i64, String> {
let duration_unit = match snapshot.get("duration_unit") {
None => "month",
Some(Value::String(value)) => value.as_str(),
Some(_) => return Err("product_snapshot.duration_unit is invalid".to_string()),
};
let duration_value = match snapshot.get("duration_value") {
None => 1,
Some(value) => value
.as_i64()
.ok_or_else(|| "product_snapshot.duration_value must be an integer".to_string())?,
};
checked_plan_duration_days(duration_unit, duration_value)
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct UserPlanEntitlementRecord {
pub id: String,
@@ -369,6 +536,37 @@ pub trait BillingReadRepository: Send + Sync {
Ok(None)
}
/// Re-read a gateway configuration from the authoritative backing store.
/// Implementations without a read cache may delegate to the normal read.
async fn find_payment_gateway_config_strong(
&self,
provider: &str,
) -> Result<Option<PaymentGatewayConfigRecord>, crate::DataLayerError> {
self.find_payment_gateway_config(provider).await
}
/// Replace only the encrypted merchant secret when the exact previously
/// observed ciphertext is still stored. Timestamps and all other fields
/// must remain unchanged.
async fn compare_and_swap_payment_gateway_secret(
&self,
update: &PaymentGatewaySecretCasUpdate,
) -> Result<bool, crate::DataLayerError> {
let _ = update;
Ok(false)
}
/// Create a configuration only when absent, or update it only when the
/// exact nullable merchant-secret fence still matches.
async fn compare_and_swap_payment_gateway_config(
&self,
input: &PaymentGatewayConfigCasWriteInput,
) -> Result<AdminBillingMutationOutcome<PaymentGatewayConfigRecord>, crate::DataLayerError>
{
let _ = input;
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn upsert_payment_gateway_config(
&self,
input: &PaymentGatewayConfigWriteInput,
File diff suppressed because it is too large Load Diff