Skip to main content

pic_continuity/authority/
indexed.rs

1/*
2 * Copyright Nitro Agility S.r.l.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! The canonical Indexed Authority Map (Profile 0.2).
18//!
19//! Sections are `identity_context`, `invariants`, and `execution_contract`.
20//! Entries are compact tuples addressed by section-local numeric indexes
21//! starting at `0`; JSON/CBOR member order carries no protocol meaning.
22//!
23//! Initial index assignment is deterministic:
24//! - `identity_context` / `execution_contract`: collection memberships are
25//!   denormalized into `[key:member, true]` tuples, candidates are sorted
26//!   lexicographically by canonical key (Unicode code point order), then
27//!   indexed `0, 1, 2, …`;
28//! - `invariants`: candidates are sorted by `scope`, `operation`,
29//!   `resourceType`, `resourceId`, then indexed.
30
31use super::{AuthorityValue, Invariant, LogicalAuthority};
32use crate::error::RejectReason;
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeMap;
35
36/// A canonical tuple value: a string, or the boolean `true` used as the
37/// canonical denormalized membership representation.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum TupleValue {
41    /// A plain string value.
42    Text(String),
43    /// The boolean `true`: the canonical denormalized membership marker.
44    Membership(bool),
45}
46
47impl TupleValue {
48    /// Only `true` is a valid membership value; Profile 0.2 defines no
49    /// false-valued membership semantics.
50    pub fn validate(&self, key: &str) -> Result<(), RejectReason> {
51        match self {
52            TupleValue::Text(s) if !s.is_empty() => Ok(()),
53            TupleValue::Membership(true) => Ok(()),
54            _ => Err(RejectReason::InvalidAuthorityValue(key.to_string())),
55        }
56    }
57}
58
59/// A `[key, value]` tuple for `identity_context` / `execution_contract`.
60pub type KvTuple = (String, TupleValue);
61
62/// A `[scope, operation, resourceType, resourceId]` tuple. Element order is
63/// normative.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
65pub struct InvariantTuple(pub String, pub String, pub String, pub String);
66
67impl From<&Invariant> for InvariantTuple {
68    fn from(i: &Invariant) -> Self {
69        InvariantTuple(
70            i.scope.clone(),
71            i.operation.clone(),
72            i.resource_type.clone(),
73            i.resource_id.clone(),
74        )
75    }
76}
77
78/// The canonical Indexed Authority Map carried by `context_of_authority`
79/// inside the PIC PCA COSE payload.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81pub struct IndexedAuthorityMap {
82    /// Descriptive identity data; grants no execution authority.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub identity_context: Option<BTreeMap<u32, KvTuple>>,
85    /// Executable PIC authority; removal-only across transitions.
86    pub invariants: BTreeMap<u32, InvariantTuple>,
87    /// Execution constraints; additions-only across transitions, never empty.
88    pub execution_contract: BTreeMap<u32, KvTuple>,
89}
90
91/// Denormalizes a logical key/value map into sorted, indexed canonical tuples.
92fn denormalize_and_index(
93    map: &BTreeMap<String, AuthorityValue>,
94) -> Result<BTreeMap<u32, KvTuple>, RejectReason> {
95    let mut candidates: Vec<KvTuple> = Vec::new();
96    for (key, value) in map {
97        value.validate(key)?;
98        match value {
99            AuthorityValue::One(s) => candidates.push((key.clone(), TupleValue::Text(s.clone()))),
100            AuthorityValue::Many(members) => {
101                for m in members {
102                    candidates.push((format!("{key}:{m}"), TupleValue::Membership(true)));
103                }
104            }
105        }
106    }
107    // Sort lexicographically by canonical key. Rust's byte-wise `str` order
108    // coincides with Unicode code point order for UTF-8.
109    candidates.sort_by(|a, b| a.0.cmp(&b.0));
110    // Determinism requires unique canonical keys.
111    for pair in candidates.windows(2) {
112        if pair[0].0 == pair[1].0 {
113            return Err(RejectReason::DuplicateAdditionKey(pair[0].0.clone()));
114        }
115    }
116    Ok(candidates
117        .into_iter()
118        .enumerate()
119        .map(|(i, t)| (i as u32, t))
120        .collect())
121}
122
123impl IndexedAuthorityMap {
124    /// Deterministic canonicalization of a Logical Context of Authority.
125    pub fn from_logical(logical: &LogicalAuthority) -> Result<Self, RejectReason> {
126        logical.validate()?;
127
128        let identity_context = match &logical.identity_context {
129            Some(map) if !map.is_empty() => Some(denormalize_and_index(map)?),
130            _ => None,
131        };
132
133        let mut invariants: Vec<InvariantTuple> = logical
134            .execution
135            .invariants
136            .iter()
137            .map(InvariantTuple::from)
138            .collect();
139        invariants.sort();
140        let invariants = invariants
141            .into_iter()
142            .enumerate()
143            .map(|(i, t)| (i as u32, t))
144            .collect();
145
146        let execution_contract = denormalize_and_index(&logical.execution.contract)?;
147
148        Ok(Self {
149            identity_context,
150            invariants,
151            execution_contract,
152        })
153    }
154
155    /// Number of entries in a section (indexes are contiguous from 0).
156    pub fn invariant_count(&self) -> u32 {
157        self.invariants.len() as u32
158    }
159
160    /// Returns true when the given invariant tuple is present.
161    pub fn contains_invariant(&self, tuple: &InvariantTuple) -> bool {
162        self.invariants.values().any(|t| t == tuple)
163    }
164
165    /// Returns true when the given contract entry is present.
166    pub fn contains_contract_entry(&self, entry: &KvTuple) -> bool {
167        self.execution_contract.values().any(|t| t == entry)
168    }
169
170    /// Validates that this Indexed Authority Map is a Profile 0.2 canonical
171    /// materialized authority map suitable for signing inside a PCA
172    /// checkpoint.
173    pub fn validate(&self) -> Result<(), RejectReason> {
174        validate_contiguous_indexes(&self.invariants, "invariants")?;
175        validate_contiguous_indexes(&self.execution_contract, "execution_contract")?;
176
177        if let Some(identity_context) = &self.identity_context {
178            if identity_context.is_empty() {
179                return Err(RejectReason::Malformed(
180                    "identity_context must be omitted when empty".into(),
181                ));
182            }
183            validate_contiguous_indexes(identity_context, "identity_context")?;
184            validate_kv_section(identity_context)?;
185        }
186
187        if self.execution_contract.is_empty() {
188            return Err(RejectReason::EmptyExecutionContract);
189        }
190        validate_kv_section(&self.execution_contract)?;
191
192        for tuple in self.invariants.values() {
193            if tuple.0.is_empty() || tuple.1.is_empty() || tuple.2.is_empty() || tuple.3.is_empty()
194            {
195                return Err(RejectReason::Malformed(
196                    "invariant tuple members must be non-empty".into(),
197                ));
198            }
199        }
200
201        Ok(())
202    }
203}
204
205fn validate_contiguous_indexes<T>(
206    section: &BTreeMap<u32, T>,
207    section_name: &'static str,
208) -> Result<(), RejectReason> {
209    for (expected, actual) in section.keys().enumerate() {
210        if *actual != expected as u32 {
211            return Err(RejectReason::Malformed(format!(
212                "{section_name} indexes must be contiguous from 0"
213            )));
214        }
215    }
216    Ok(())
217}
218
219fn validate_kv_section(section: &BTreeMap<u32, KvTuple>) -> Result<(), RejectReason> {
220    for (key, value) in section.values() {
221        if key.is_empty() {
222            return Err(RejectReason::InvalidAuthorityValue(key.clone()));
223        }
224        value.validate(key)?;
225    }
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::authority::AuthorityValue;
233
234    /// The payments example of the token/artifact article: canonical
235    /// identity_context indexes must come out sorted by canonical key.
236    #[test]
237    fn canonicalization_matches_reference_example() {
238        let mut identity = BTreeMap::new();
239        identity.insert("type".into(), AuthorityValue::One("user".into()));
240        identity.insert("id".into(), AuthorityValue::One("user-123".into()));
241        identity.insert(
242            "roles".into(),
243            AuthorityValue::Many(vec!["payment-approver".into()]),
244        );
245        identity.insert(
246            "groups".into(),
247            AuthorityValue::Many(vec!["finance".into()]),
248        );
249        identity.insert(
250            "securityDomain".into(),
251            AuthorityValue::One("tenant-a".into()),
252        );
253
254        let mut contract = BTreeMap::new();
255        contract.insert(
256            "purpose".into(),
257            AuthorityValue::One("payment-approval".into()),
258        );
259        contract.insert("currency".into(), AuthorityValue::One("EUR".into()));
260
261        let logical = LogicalAuthority::new(
262            Some(identity),
263            vec![Invariant::new(
264                "payments:approve",
265                "approve",
266                "payments",
267                "*",
268            )],
269            contract,
270        );
271
272        let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
273        let id = map.identity_context.unwrap();
274
275        assert_eq!(id[&0].0, "groups:finance");
276        assert_eq!(id[&0].1, TupleValue::Membership(true));
277        assert_eq!(id[&1].0, "id");
278        assert_eq!(id[&2].0, "roles:payment-approver");
279        assert_eq!(id[&3].0, "securityDomain");
280        assert_eq!(id[&4].0, "type");
281
282        assert_eq!(
283            map.invariants[&0],
284            InvariantTuple(
285                "payments:approve".into(),
286                "approve".into(),
287                "payments".into(),
288                "*".into()
289            )
290        );
291
292        // corporation-style sorting: currency < purpose
293        assert_eq!(map.execution_contract[&0].0, "currency");
294        assert_eq!(map.execution_contract[&1].0, "purpose");
295    }
296
297    #[test]
298    fn invariants_sorted_by_tuple_elements() {
299        let mut contract = BTreeMap::new();
300        contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
301
302        let logical = LogicalAuthority::new(
303            None,
304            vec![
305                Invariant::new("storage:save", "save", "storage", "*"),
306                Invariant::new(
307                    "documents:read:document-42",
308                    "read",
309                    "documents",
310                    "document-42",
311                ),
312            ],
313            contract,
314        );
315
316        let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
317        assert_eq!(map.invariants[&0].0, "documents:read:document-42");
318        assert_eq!(map.invariants[&1].0, "storage:save");
319    }
320
321    #[test]
322    fn rejects_empty_contract_and_invalid_values() {
323        let logical = LogicalAuthority::default();
324        assert_eq!(
325            logical.validate().unwrap_err(),
326            RejectReason::EmptyExecutionContract
327        );
328
329        let mut contract = BTreeMap::new();
330        contract.insert("corporation".into(), AuthorityValue::One("".into()));
331        let logical = LogicalAuthority::new(None, vec![], contract);
332        assert!(matches!(
333            logical.validate().unwrap_err(),
334            RejectReason::InvalidAuthorityValue(_)
335        ));
336
337        let mut contract = BTreeMap::new();
338        contract.insert("departments".into(), AuthorityValue::Many(vec![]));
339        let logical = LogicalAuthority::new(None, vec![], contract);
340        assert!(matches!(
341            logical.validate().unwrap_err(),
342            RejectReason::InvalidAuthorityValue(_)
343        ));
344    }
345
346    #[test]
347    fn cbor_roundtrip() {
348        let mut contract = BTreeMap::new();
349        contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
350        let logical = LogicalAuthority::new(
351            None,
352            vec![Invariant::new("storage:save", "save", "storage", "*")],
353            contract,
354        );
355        let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
356
357        let mut buf = Vec::new();
358        ciborium::into_writer(&map, &mut buf).unwrap();
359        let decoded: IndexedAuthorityMap = ciborium::from_reader(buf.as_slice()).unwrap();
360        assert_eq!(map, decoded);
361    }
362}