Skip to main content

pic_continuity/authority/
logical.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 Logical Context of Authority: the application-facing JSON form.
18//!
19//! It is never embedded in a signed artifact directly;
20//! [`super::indexed::IndexedAuthorityMap::from_logical`] canonicalizes it
21//! into the Indexed Authority Map signed inside the PIC PCA COSE.
22
23use crate::error::RejectReason;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26
27/// A logical authority value: a non-empty string or a non-empty array of
28/// non-empty strings. Profile 0.2 rejects numbers, booleans, objects, null,
29/// empty strings, and empty arrays in the logical input domain.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(untagged)]
32pub enum AuthorityValue {
33    /// A single string value.
34    One(String),
35    /// A collection value; canonicalization denormalizes each member into a
36    /// `[key:member, true]` tuple.
37    Many(Vec<String>),
38}
39
40impl AuthorityValue {
41    /// Validates the Profile 0.2 logical value rules.
42    pub fn validate(&self, key: &str) -> Result<(), RejectReason> {
43        let ok = match self {
44            AuthorityValue::One(s) => !s.is_empty(),
45            AuthorityValue::Many(v) => !v.is_empty() && v.iter().all(|s| !s.is_empty()),
46        };
47        if ok {
48            Ok(())
49        } else {
50            Err(RejectReason::InvalidAuthorityValue(key.to_string()))
51        }
52    }
53}
54
55/// One executable authority invariant: `(scope, operation, resourceType, resourceId)`.
56///
57/// The logical JSON form uses camelCase member names (`resourceType`,
58/// `resourceId`), matching the application-facing Context of Authority.
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct Invariant {
62    /// The authority scope, e.g. `documents:read:document-42`.
63    pub scope: String,
64    /// The operation the invariant permits.
65    pub operation: String,
66    /// The resource type the operation applies to.
67    pub resource_type: String,
68    /// The resource identifier, or `"*"` for the whole type.
69    pub resource_id: String,
70}
71
72impl Invariant {
73    /// An invariant from its four normative elements, in normative order.
74    pub fn new(
75        scope: impl Into<String>,
76        operation: impl Into<String>,
77        resource_type: impl Into<String>,
78        resource_id: impl Into<String>,
79    ) -> Self {
80        Self {
81            scope: scope.into(),
82            operation: operation.into(),
83            resource_type: resource_type.into(),
84            resource_id: resource_id.into(),
85        }
86    }
87}
88
89/// The `execution` member of the Logical Context of Authority.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
91pub struct LogicalExecution {
92    /// Executable PIC authority: what may be preserved or attenuated across
93    /// continuity.
94    pub invariants: Vec<Invariant>,
95    /// Execution constraints. Constrains execution; grants no authority.
96    pub contract: BTreeMap<String, AuthorityValue>,
97}
98
99/// The Logical Context of Authority: the application-facing JSON form
100/// `{ identity_context?, execution: { invariants, contract } }`.
101///
102/// It is never embedded in a signed artifact directly.
103/// [`super::indexed::IndexedAuthorityMap::from_logical`] produces the
104/// canonical form that is signed inside the PIC PCA COSE; in that flattened
105/// canonical representation, logical `execution.contract` maps to the
106/// `execution_contract` section.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
108pub struct LogicalAuthority {
109    /// Descriptive or contextual identity data. Optional; it does not grant
110    /// execution authority.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub identity_context: Option<BTreeMap<String, AuthorityValue>>,
113    /// Executable authority and its execution constraints.
114    pub execution: LogicalExecution,
115}
116
117impl LogicalAuthority {
118    /// A Logical Context of Authority from its three parts.
119    pub fn new(
120        identity_context: Option<BTreeMap<String, AuthorityValue>>,
121        invariants: Vec<Invariant>,
122        contract: BTreeMap<String, AuthorityValue>,
123    ) -> Self {
124        Self {
125            identity_context,
126            execution: LogicalExecution {
127                invariants,
128                contract,
129            },
130        }
131    }
132
133    /// Validates the Profile 0.2 logical input rules: the execution contract
134    /// contains at least one attribute, and every value (identity and
135    /// contract) is a non-empty string or a non-empty array of non-empty
136    /// strings.
137    pub fn validate(&self) -> Result<(), RejectReason> {
138        if self.execution.contract.is_empty() {
139            return Err(RejectReason::EmptyExecutionContract);
140        }
141        for (k, v) in &self.execution.contract {
142            v.validate(k)?;
143        }
144        if let Some(identity) = &self.identity_context {
145            for (k, v) in identity {
146                v.validate(k)?;
147            }
148        }
149        Ok(())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::authority::indexed::{IndexedAuthorityMap, InvariantTuple, TupleValue};
157
158    /// The logical PCA 0 JSON of the centralized-exchange walkthrough,
159    /// verbatim: nested `execution`, camelCase invariant members. It must
160    /// parse as a Logical Context of Authority and canonicalize into the
161    /// walkthrough's Indexed Authority Map.
162    #[test]
163    fn logical_json_matches_reference_walkthrough() {
164        let json = r#"{
165          "execution": {
166            "invariants": [
167              {
168                "scope": "documents:read:document-42",
169                "operation": "read",
170                "resourceType": "documents",
171                "resourceId": "document-42"
172              },
173              {
174                "scope": "storage:save",
175                "operation": "save",
176                "resourceType": "storage",
177                "resourceId": "*"
178              }
179            ],
180            "contract": {
181              "corporation": "ACME",
182              "department": "sensitive-documents"
183            }
184          }
185        }"#;
186
187        let logical: LogicalAuthority = serde_json::from_str(json).unwrap();
188        assert!(logical.identity_context.is_none());
189
190        let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
191        assert_eq!(
192            map.invariants[&0],
193            InvariantTuple(
194                "documents:read:document-42".into(),
195                "read".into(),
196                "documents".into(),
197                "document-42".into()
198            )
199        );
200        assert_eq!(
201            map.invariants[&1],
202            InvariantTuple(
203                "storage:save".into(),
204                "save".into(),
205                "storage".into(),
206                "*".into()
207            )
208        );
209        // corporation sorts before department.
210        assert_eq!(
211            map.execution_contract[&0],
212            ("corporation".into(), TupleValue::Text("ACME".into()))
213        );
214        assert_eq!(
215            map.execution_contract[&1],
216            (
217                "department".into(),
218                TupleValue::Text("sensitive-documents".into())
219            )
220        );
221
222        // Round-trip: serialization keeps the article's member names.
223        let back = serde_json::to_value(&logical).unwrap();
224        assert_eq!(
225            back["execution"]["invariants"][0]["resourceType"],
226            "documents"
227        );
228        assert_eq!(
229            back["execution"]["invariants"][0]["resourceId"],
230            "document-42"
231        );
232        assert_eq!(back["execution"]["contract"]["corporation"], "ACME");
233    }
234}