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}