dpp_crypto/access/policy.rs
1//! Sector access policy types and tier lookup.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use dpp_domain::{AccessTier, SectorCatalog};
7
8/// Maps JSON field names to their minimum access tier.
9///
10/// Fields not listed fall back to [`Self::default_tier`]. Matching is by
11/// **normalized leaf key name** (case- and separator-insensitive), so a policy
12/// key `disassemblyInstructions` also covers a payload key
13/// `disassembly_instructions` — closing the casing/nesting drift that let
14/// elevated fields leak at the Public tier (crypto Gap 6).
15///
16/// **Caution — leaf matching is path-insensitive.** A policy key matches that
17/// leaf *wherever* it appears, at any depth. Do **not** elevate a generic leaf
18/// name shared across objects (e.g. `name`, `value`, `country`, `address`): such
19/// a key would redact `facility.address` *and* `manufacturer.address` alike,
20/// over-redacting Annex III public fields. Use only specific, unambiguous field
21/// names (e.g. `dueDiligenceUrl`, `svhcSubstances`). Gating a shared leaf on a
22/// single path would require making the matcher path-aware first.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct SectorAccessPolicy {
26 /// Human-readable policy name (e.g., `"textile-v1.1"`).
27 pub name: String,
28 /// The sector this policy applies to.
29 pub sector: String,
30 /// Map of JSON field name → minimum access tier. A listed field is matched
31 /// wherever it appears in the document (any nesting depth), by normalized key.
32 pub field_tiers: HashMap<String, AccessTier>,
33 /// Tier applied to fields **not** listed in `field_tiers`. Defaults to
34 /// `Public` (backward-compatible: only elevated fields need listing). Set to
35 /// `Confidential` for a true default-deny (fail-closed) policy, where every
36 /// public field must be explicitly listed as `Public`.
37 #[serde(default = "tier_public")]
38 pub default_tier: AccessTier,
39}
40
41fn tier_public() -> AccessTier {
42 AccessTier::Public
43}
44
45/// Normalize a JSON key for tier matching: drop non-alphanumerics (`_`, `-`)
46/// and lowercase, so `disassemblyInstructions` == `disassembly_instructions`.
47pub(super) fn normalize_key(key: &str) -> String {
48 key.chars()
49 .filter(|c| c.is_ascii_alphanumeric())
50 .map(|c| c.to_ascii_lowercase())
51 .collect()
52}
53
54/// Universal confidential fields present on every published passport payload
55/// (signatures, audit trails). Folded into each sector's policy so they are not
56/// repeated in every manifest.
57const COMMON_CONFIDENTIAL: &[&str] = &[
58 "jwsSignature",
59 "complianceReport",
60 "auditHistory",
61 "supplyChainTrace",
62];
63
64impl SectorAccessPolicy {
65 /// Build a sector's access policy from the catalog's declared per-field
66 /// tiers, folding in the universal confidential fields.
67 ///
68 /// This works for **every** sector with no per-sector Rust code — the tiers
69 /// are data in the sector manifests (`access_tiers`). Returns `None` if
70 /// `sector_key` is not in the catalog.
71 pub fn from_catalog(catalog: &SectorCatalog, sector_key: &str) -> Option<Self> {
72 let descriptor = catalog.get(sector_key)?;
73 let mut field_tiers: HashMap<String, AccessTier> = descriptor.access_tiers.clone();
74 for field in COMMON_CONFIDENTIAL {
75 field_tiers
76 .entry((*field).to_owned())
77 .or_insert(AccessTier::Confidential);
78 }
79 Some(Self {
80 name: format!("{sector_key}-{}", descriptor.current_schema_version),
81 sector: sector_key.to_owned(),
82 field_tiers,
83 default_tier: AccessTier::Public,
84 })
85 }
86
87 /// Default access policy for top-level passport fields (sector-agnostic).
88 pub fn passport_default() -> Self {
89 let mut field_tiers = HashMap::new();
90
91 // Professional tier
92 field_tiers.insert("batchId".into(), AccessTier::Professional);
93
94 // Confidential tier — signature / internal
95 field_tiers.insert("jwsSignature".into(), AccessTier::Confidential);
96 field_tiers.insert("retentionLocked".into(), AccessTier::Confidential);
97
98 Self {
99 name: "passport-v1.0".into(),
100 sector: "passport".into(),
101 field_tiers,
102 default_tier: AccessTier::Public,
103 }
104 }
105
106 /// Get the minimum access tier for a field, matched by normalized key name
107 /// (case/separator-insensitive). Unlisted fields fall back to `default_tier`.
108 pub fn tier_for_field(&self, field_name: &str) -> AccessTier {
109 let target = normalize_key(field_name);
110 self.field_tiers
111 .iter()
112 .find(|(k, _)| normalize_key(k) == target)
113 .map(|(_, t)| *t)
114 .unwrap_or(self.default_tier)
115 }
116}