Skip to main content

qubit_redact/policy/
redaction_policy.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Immutable field-classification, masking, and diagnostic policy.
9
10use std::sync::Arc;
11use std::sync::LazyLock;
12
13use super::AllowRule;
14use super::FieldClassification;
15use super::FieldNameMatching;
16use super::MaskingPolicy;
17use super::RedactionFloor;
18use super::RedactionPolicyBuilder;
19use super::RedactionRules;
20use super::SensitiveFieldRule;
21use super::Sensitivity;
22#[cfg(feature = "json")]
23use super::UnkeyedJsonValuePolicy;
24use super::UnknownFieldPolicy;
25use super::internal::RedactionPolicyInner;
26use super::redaction_limits::RedactionLimits;
27
28/// Built-in sensitive fields not owned by a named preset.
29pub(super) const STANDARD_EXTRA_FIELDS: &[(&str, Sensitivity)] = &[
30    ("auth_app_token", Sensitivity::High),
31    ("auth_user_token", Sensitivity::High),
32    ("connection_string", Sensitivity::Secret),
33    ("database_uri", Sensitivity::Secret),
34    ("database_url", Sensitivity::Secret),
35    ("license_key", Sensitivity::Medium),
36    ("mysql_pwd", Sensitivity::Secret),
37    ("rediscli_auth", Sensitivity::Secret),
38    ("sig", Sensitivity::Secret),
39    ("signature", Sensitivity::Secret),
40];
41
42/// Lazily initialized fixed standard policy.
43static STANDARD_POLICY: LazyLock<RedactionPolicy> = LazyLock::new(|| {
44    RedactionPolicy::from_rules(
45        RedactionRules::new(
46            RedactionPolicyInner {
47                sensitive: Default::default(),
48                allow_exact: Default::default(),
49                allow_suffix: Default::default(),
50                matching: FieldNameMatching::ExactOrTokenSuffix,
51                unknown_field_policy: UnknownFieldPolicy::PassThrough,
52            },
53            Some(RedactionFloor::standard()),
54        ),
55        MaskingPolicy::default(),
56        RedactionLimits::default(),
57        #[cfg(feature = "http")]
58        crate::formats::http::HttpPolicyBuilder::new()
59            .build()
60            .expect("the built-in HTTP policy must be valid"),
61        #[cfg(feature = "uri")]
62        crate::formats::uri::UriPolicyBuilder::new()
63            .build()
64            .expect("the built-in URI policy must be valid"),
65        #[cfg(feature = "json")]
66        UnkeyedJsonValuePolicy::PassThrough,
67        false,
68    )
69});
70/// Lazily initialized fixed strict policy.
71static STRICT_POLICY: LazyLock<RedactionPolicy> = LazyLock::new(|| {
72    RedactionPolicy::from_rules(
73        RedactionRules::new(
74            RedactionPolicyInner {
75                sensitive: Default::default(),
76                allow_exact: Default::default(),
77                allow_suffix: Default::default(),
78                matching: FieldNameMatching::ExactOrTokenSuffix,
79                unknown_field_policy: UnknownFieldPolicy::Redact(Sensitivity::Secret),
80            },
81            Some(RedactionFloor::standard()),
82        ),
83        MaskingPolicy::default(),
84        RedactionLimits::default(),
85        #[cfg(feature = "http")]
86        {
87            let mut http = crate::formats::http::HttpPolicyBuilder::new();
88            http.url_path_mut(crate::formats::http::UrlPathPolicy::Redact);
89            http.text_body_mut(crate::formats::http::TextBodyPolicy::Redact);
90            http.build().expect("the built-in HTTP policy must be valid")
91        },
92        #[cfg(feature = "uri")]
93        {
94            let mut uri = crate::formats::uri::UriPolicyBuilder::new();
95            uri.path_policy_mut(crate::formats::uri::UriPathPolicy::Redact);
96            uri.build().expect("the built-in URI policy must be valid")
97        },
98        #[cfg(feature = "json")]
99        UnkeyedJsonValuePolicy::Redact,
100        false,
101    )
102});
103/// Immutable field classification, masking, format, and resource policy.
104///
105/// A disabled policy intentionally restores original values while retaining
106/// resource limits. It is a deliberate debugging escape hatch whose
107/// authorization belongs to downstream code.
108///
109/// # Warning
110///
111/// Disabling this policy opts out of confidentiality redaction. Every supported
112/// format, derived field mode, and redaction-specific skip path may publish its
113/// original value. Resource limits and diagnostic control-character escaping
114/// still apply, but they do not make the output redacted. The framework
115/// faithfully executes the chosen policy; it cannot and does not attempt to
116/// prevent downstream code from deliberately or accidentally disabling
117/// redaction. Callers own the authorization, environment, timing, and
118/// consequences of that choice. They can observe it through
119/// [`crate::RedactionSummary::is_redaction_disabled`] and
120/// [`crate::RedactionInspection::is_redaction_disabled`].
121///
122/// # Examples
123///
124/// ```
125/// use qubit_redact::RedactionPolicy;
126///
127/// let mut policy = RedactionPolicy::disabled();
128/// assert!(policy.is_disabled());
129/// policy.set_disabled(false);
130/// assert!(!policy.is_disabled());
131/// ```
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RedactionPolicy {
134    /// Explicit escape switch that restores source values while retaining
135    /// limits.
136    disabled: bool,
137    /// Immutable field-classification layers.
138    rules: RedactionRules,
139    /// Shared masks selected after sensitivity resolution.
140    masking: Arc<MaskingPolicy>,
141    /// Resource ceilings applied to every transaction created from this
142    /// policy.
143    limits: RedactionLimits,
144    /// HTTP-specific immutable policy snapshot.
145    #[cfg(feature = "http")]
146    http: Arc<crate::formats::http::HttpPolicy>,
147    /// URI-specific immutable policy snapshot.
148    #[cfg(feature = "uri")]
149    uri: Arc<crate::formats::uri::UriPolicy>,
150    /// Fallback behavior for JSON scalars without an object key.
151    #[cfg(feature = "json")]
152    unkeyed_json_value_policy: UnkeyedJsonValuePolicy,
153}
154
155impl RedactionPolicy {
156    /// Returns the fixed built-in standard policy.
157    ///
158    /// Its application rules are empty and its explicit floor is
159    /// [`RedactionFloor::standard`], so it never observes later process-wide
160    /// default installations.
161    #[must_use]
162    #[inline(always)]
163    pub fn standard() -> Self {
164        STANDARD_POLICY.clone()
165    }
166
167    /// Returns a strict boundary policy whose unknown fields are masked at
168    /// [`Sensitivity::Secret`] in addition to the standard floor.
169    ///
170    /// This preset is intended for untrusted external boundaries. It is more
171    /// protective than [`Self::standard`] but may reduce diagnostic detail.
172    /// Non-root HTTP and URI paths are hidden when their features are enabled.
173    #[must_use]
174    #[inline(always)]
175    pub fn strict() -> Self {
176        STRICT_POLICY.clone()
177    }
178
179    /// Returns the standard policy with confidentiality redaction globally
180    /// disabled.
181    ///
182    /// # Warning
183    ///
184    /// Outputs produced with this policy may contain every original value.
185    /// Limits and control-character escaping remain active, but masking and
186    /// redaction-specific field decisions do not. This deliberate debugging
187    /// capability transfers confidentiality responsibility to the caller.
188    #[must_use]
189    pub fn disabled() -> Self {
190        let mut policy = Self::standard();
191        policy.disabled = true;
192        policy
193    }
194
195    /// Creates a deterministic builder with no application rules and the
196    /// standard minimum-protection floor.
197    #[must_use]
198    #[inline(always)]
199    pub fn builder() -> RedactionPolicyBuilder {
200        RedactionPolicyBuilder::new()
201    }
202
203    /// Creates a policy from fully resolved field rules and resource limits.
204    ///
205    /// # Parameters
206    ///
207    /// - `rules`: Validated application classification and minimum floors.
208    /// - `masking`: One mask table shared by all sensitivity decisions.
209    /// - `limits`: Validated ceilings copied into each new transaction.
210    /// - `http`: HTTP context policy, when the `http` feature is enabled.
211    /// - `uri`: URI component policy, when the `uri` feature is enabled.
212    /// - `unkeyed_json_value_policy`: Root/array scalar handling with `json`.
213    /// - `disabled`: Whether to restore source values while retaining limits.
214    ///
215    /// # Returns
216    ///
217    /// An owned policy sharing the immutable masking and format configuration.
218    /// This internal constructor assumes its inputs were already validated.
219    #[must_use]
220    pub(crate) fn from_rules(
221        rules: RedactionRules,
222        masking: MaskingPolicy,
223        limits: RedactionLimits,
224        #[cfg(feature = "http")] http: crate::formats::http::HttpPolicy,
225        #[cfg(feature = "uri")] uri: crate::formats::uri::UriPolicy,
226        #[cfg(feature = "json")] unkeyed_json_value_policy: UnkeyedJsonValuePolicy,
227        disabled: bool,
228    ) -> Self {
229        Self {
230            disabled,
231            rules,
232            masking: Arc::new(masking),
233            limits,
234            #[cfg(feature = "http")]
235            http: Arc::new(http),
236            #[cfg(feature = "uri")]
237            uri: Arc::new(uri),
238            #[cfg(feature = "json")]
239            unkeyed_json_value_policy,
240        }
241    }
242
243    /// Returns whether this policy publishes original values while retaining
244    /// limits and control-character escaping.
245    ///
246    /// A `true` result means confidentiality redaction is disabled.
247    #[must_use]
248    #[inline(always)]
249    pub const fn is_disabled(&self) -> bool {
250        self.disabled
251    }
252
253    /// Returns all static limits used by this policy.
254    #[must_use]
255    #[inline(always)]
256    pub const fn limits(&self) -> &RedactionLimits {
257        &self.limits
258    }
259
260    /// Returns the unified HTTP context policy.
261    #[must_use]
262    #[cfg(feature = "http")]
263    #[inline(always)]
264    pub fn http(&self) -> &crate::formats::http::HttpPolicy {
265        self.http.as_ref()
266    }
267
268    /// Returns the unified URI context policy.
269    #[must_use]
270    #[cfg(feature = "uri")]
271    #[inline(always)]
272    pub fn uri(&self) -> &crate::formats::uri::UriPolicy {
273        self.uri.as_ref()
274    }
275
276    /// Returns the behavior for root and array JSON scalar values.
277    #[must_use]
278    #[cfg(feature = "json")]
279    #[inline(always)]
280    pub const fn unkeyed_json_value_policy(&self) -> UnkeyedJsonValuePolicy {
281        self.unkeyed_json_value_policy
282    }
283
284    /// Returns the immutable field rules without diagnostic resource limits.
285    #[must_use]
286    #[inline(always)]
287    pub const fn rules(&self) -> &RedactionRules {
288        &self.rules
289    }
290
291    /// Returns the attached minimum floor, or `None` when it was explicitly
292    /// disabled.
293    #[must_use]
294    #[inline(always)]
295    pub fn floor(&self) -> Option<&RedactionFloor> {
296        self.rules.floor()
297    }
298
299    /// Returns the final sensitivity for `field` after applying application
300    /// rules and the enabled floor.
301    ///
302    /// Returns `None` only when neither layer classifies the field as
303    /// sensitive.
304    #[must_use]
305    #[inline(always)]
306    pub fn sensitivity_for(&self, field: &str) -> Option<Sensitivity> {
307        self.rules.sensitivity_for(field)
308    }
309
310    /// Returns the application layer's field-name matching mode.
311    ///
312    /// An attached floor may use a different matching mode for its independent
313    /// classification.
314    #[must_use]
315    #[inline(always)]
316    pub fn matching(&self) -> FieldNameMatching {
317        self.rules.matching()
318    }
319
320    /// Returns the application layer's fallback for unclassified fields.
321    ///
322    /// An attached floor applies its own fallback independently.
323    #[must_use]
324    #[inline(always)]
325    pub fn unknown_field_policy(&self) -> UnknownFieldPolicy {
326        self.rules.unknown_field_policy()
327    }
328
329    /// Returns the single mask table used by every sensitivity decision.
330    ///
331    /// Field classification determines the effective sensitivity; this table
332    /// determines how that sensitivity is rendered. Floors never own a second
333    /// mask table.
334    #[must_use]
335    #[inline(always)]
336    pub fn masking(&self) -> &MaskingPolicy {
337        self.masking.as_ref()
338    }
339
340    /// Iterates sensitive rules configured in the application layer only.
341    ///
342    /// Use [`Self::floor`] to inspect the independent minimum-protection
343    /// rules.
344    #[inline(always)]
345    pub fn application_sensitive_rules(&self) -> impl Iterator<Item = SensitiveFieldRule<'_>> {
346        self.rules.application_sensitive_rules()
347    }
348
349    /// Iterates allow rules configured in the application layer only.
350    ///
351    /// These rules never bypass an enabled floor.
352    #[inline(always)]
353    pub fn application_allow_rules(&self) -> impl Iterator<Item = AllowRule<'_>> {
354        self.rules.application_allow_rules()
355    }
356
357    /// Changes this policy’s redaction switch and returns this policy for
358    /// chaining.
359    ///
360    /// # Warning
361    ///
362    /// Passing `true` allows every supported redaction entry to publish its
363    /// original value. The caller owns authorization and operational controls;
364    /// the framework does not distinguish debugging use from misuse.
365    #[must_use]
366    #[inline(always)]
367    pub fn set_disabled(&mut self, disabled: bool) -> &mut Self {
368        self.disabled = disabled;
369        self
370    }
371
372    /// Creates a builder that exactly copies `self`.
373    ///
374    /// The copy includes application rules, limits, and the attached floor.
375    #[must_use]
376    #[inline(always)]
377    pub fn to_builder(&self) -> RedactionPolicyBuilder {
378        RedactionPolicyBuilder::from_policy(self)
379    }
380
381    /// Replaces the floor for this immutable policy.
382    #[must_use]
383    #[inline(always)]
384    pub fn with_floor(mut self, floor: RedactionFloor) -> Self {
385        self.rules = self.rules.with_floor(floor);
386        self
387    }
388
389    /// Adds mandatory minimum protection while retaining existing floor rules.
390    #[must_use]
391    #[inline(always)]
392    pub fn add_floor(mut self, floor: RedactionFloor) -> Self {
393        self.rules = self.rules.add_floor(floor);
394        self
395    }
396
397    /// Disables every floor for this immutable policy.
398    ///
399    /// # Security
400    ///
401    /// This explicitly removes minimum protection inherited from any source.
402    #[must_use]
403    #[inline(always)]
404    pub fn disable_floor(mut self) -> Self {
405        self.rules = self.rules.disable_floor();
406        self
407    }
408
409    /// Explains application-rule matching for `field` without applying the
410    /// floor.
411    ///
412    /// This is useful for diagnostics about configured application rules. Use
413    /// [`Self::sensitivity_for`] for the final security decision.
414    #[inline(always)]
415    #[must_use]
416    pub fn classify_field<'a>(&'a self, field: &str) -> FieldClassification<'a> {
417        self.rules.classify_field(field)
418    }
419
420    /// Resolves final sensitivity with exact-only field matching.
421    #[must_use]
422    #[inline(always)]
423    pub(crate) fn sensitivity_for_exact(&self, field: &str) -> Option<Sensitivity> {
424        self.rules.sensitivity_for_exact(field)
425    }
426
427    /// Resolves final sensitivity with exact-only field matching.
428    #[inline(always)]
429    pub(crate) fn resolve_field_exact(&self, field: &str) -> super::ResolvedField {
430        self.rules.resolve_field_exact(field)
431    }
432
433    /// Replaces the mask table while preserving all classification and limit
434    /// settings. This is used by format boundaries that own the mask policy.
435    #[doc(hidden)]
436    #[cfg(feature = "uri")]
437    #[inline(always)]
438    pub(crate) fn with_masking(mut self, masking: MaskingPolicy) -> Self {
439        self.masking = Arc::new(masking);
440        self
441    }
442
443    /// Resolves final sensitivity for `field`.
444    #[inline(always)]
445    #[must_use]
446    pub(crate) fn resolve_field(&self, field: &str) -> super::ResolvedField {
447        self.rules.resolve_field(field)
448    }
449}
450
451impl Default for RedactionPolicy {
452    /// Clones the fixed standard policy.
453    #[inline(always)]
454    fn default() -> Self {
455        STANDARD_POLICY.clone()
456    }
457}