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}