use super::{AuthorityValue, Invariant, LogicalAuthority};
use crate::error::RejectReason;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TupleValue {
Text(String),
Membership(bool),
}
impl TupleValue {
pub fn validate(&self, key: &str) -> Result<(), RejectReason> {
match self {
TupleValue::Text(s) if !s.is_empty() => Ok(()),
TupleValue::Membership(true) => Ok(()),
_ => Err(RejectReason::InvalidAuthorityValue(key.to_string())),
}
}
}
pub type KvTuple = (String, TupleValue);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct InvariantTuple(pub String, pub String, pub String, pub String);
impl From<&Invariant> for InvariantTuple {
fn from(i: &Invariant) -> Self {
InvariantTuple(
i.scope.clone(),
i.operation.clone(),
i.resource_type.clone(),
i.resource_id.clone(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct IndexedAuthorityMap {
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_context: Option<BTreeMap<u32, KvTuple>>,
pub invariants: BTreeMap<u32, InvariantTuple>,
pub execution_contract: BTreeMap<u32, KvTuple>,
}
fn denormalize_and_index(
map: &BTreeMap<String, AuthorityValue>,
) -> Result<BTreeMap<u32, KvTuple>, RejectReason> {
let mut candidates: Vec<KvTuple> = Vec::new();
for (key, value) in map {
value.validate(key)?;
match value {
AuthorityValue::One(s) => candidates.push((key.clone(), TupleValue::Text(s.clone()))),
AuthorityValue::Many(members) => {
for m in members {
candidates.push((format!("{key}:{m}"), TupleValue::Membership(true)));
}
}
}
}
candidates.sort_by(|a, b| a.0.cmp(&b.0));
for pair in candidates.windows(2) {
if pair[0].0 == pair[1].0 {
return Err(RejectReason::DuplicateAdditionKey(pair[0].0.clone()));
}
}
Ok(candidates
.into_iter()
.enumerate()
.map(|(i, t)| (i as u32, t))
.collect())
}
impl IndexedAuthorityMap {
pub fn from_logical(logical: &LogicalAuthority) -> Result<Self, RejectReason> {
logical.validate()?;
let identity_context = match &logical.identity_context {
Some(map) if !map.is_empty() => Some(denormalize_and_index(map)?),
_ => None,
};
let mut invariants: Vec<InvariantTuple> = logical
.execution
.invariants
.iter()
.map(InvariantTuple::from)
.collect();
invariants.sort();
let invariants = invariants
.into_iter()
.enumerate()
.map(|(i, t)| (i as u32, t))
.collect();
let execution_contract = denormalize_and_index(&logical.execution.contract)?;
Ok(Self {
identity_context,
invariants,
execution_contract,
})
}
pub fn invariant_count(&self) -> u32 {
self.invariants.len() as u32
}
pub fn contains_invariant(&self, tuple: &InvariantTuple) -> bool {
self.invariants.values().any(|t| t == tuple)
}
pub fn contains_contract_entry(&self, entry: &KvTuple) -> bool {
self.execution_contract.values().any(|t| t == entry)
}
pub fn validate(&self) -> Result<(), RejectReason> {
validate_contiguous_indexes(&self.invariants, "invariants")?;
validate_contiguous_indexes(&self.execution_contract, "execution_contract")?;
if let Some(identity_context) = &self.identity_context {
if identity_context.is_empty() {
return Err(RejectReason::Malformed(
"identity_context must be omitted when empty".into(),
));
}
validate_contiguous_indexes(identity_context, "identity_context")?;
validate_kv_section(identity_context)?;
}
if self.execution_contract.is_empty() {
return Err(RejectReason::EmptyExecutionContract);
}
validate_kv_section(&self.execution_contract)?;
for tuple in self.invariants.values() {
if tuple.0.is_empty() || tuple.1.is_empty() || tuple.2.is_empty() || tuple.3.is_empty()
{
return Err(RejectReason::Malformed(
"invariant tuple members must be non-empty".into(),
));
}
}
Ok(())
}
}
fn validate_contiguous_indexes<T>(
section: &BTreeMap<u32, T>,
section_name: &'static str,
) -> Result<(), RejectReason> {
for (expected, actual) in section.keys().enumerate() {
if *actual != expected as u32 {
return Err(RejectReason::Malformed(format!(
"{section_name} indexes must be contiguous from 0"
)));
}
}
Ok(())
}
fn validate_kv_section(section: &BTreeMap<u32, KvTuple>) -> Result<(), RejectReason> {
for (key, value) in section.values() {
if key.is_empty() {
return Err(RejectReason::InvalidAuthorityValue(key.clone()));
}
value.validate(key)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::AuthorityValue;
#[test]
fn canonicalization_matches_reference_example() {
let mut identity = BTreeMap::new();
identity.insert("type".into(), AuthorityValue::One("user".into()));
identity.insert("id".into(), AuthorityValue::One("user-123".into()));
identity.insert(
"roles".into(),
AuthorityValue::Many(vec!["payment-approver".into()]),
);
identity.insert(
"groups".into(),
AuthorityValue::Many(vec!["finance".into()]),
);
identity.insert(
"securityDomain".into(),
AuthorityValue::One("tenant-a".into()),
);
let mut contract = BTreeMap::new();
contract.insert(
"purpose".into(),
AuthorityValue::One("payment-approval".into()),
);
contract.insert("currency".into(), AuthorityValue::One("EUR".into()));
let logical = LogicalAuthority::new(
Some(identity),
vec![Invariant::new(
"payments:approve",
"approve",
"payments",
"*",
)],
contract,
);
let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
let id = map.identity_context.unwrap();
assert_eq!(id[&0].0, "groups:finance");
assert_eq!(id[&0].1, TupleValue::Membership(true));
assert_eq!(id[&1].0, "id");
assert_eq!(id[&2].0, "roles:payment-approver");
assert_eq!(id[&3].0, "securityDomain");
assert_eq!(id[&4].0, "type");
assert_eq!(
map.invariants[&0],
InvariantTuple(
"payments:approve".into(),
"approve".into(),
"payments".into(),
"*".into()
)
);
assert_eq!(map.execution_contract[&0].0, "currency");
assert_eq!(map.execution_contract[&1].0, "purpose");
}
#[test]
fn invariants_sorted_by_tuple_elements() {
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
let logical = LogicalAuthority::new(
None,
vec![
Invariant::new("storage:save", "save", "storage", "*"),
Invariant::new(
"documents:read:document-42",
"read",
"documents",
"document-42",
),
],
contract,
);
let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
assert_eq!(map.invariants[&0].0, "documents:read:document-42");
assert_eq!(map.invariants[&1].0, "storage:save");
}
#[test]
fn rejects_empty_contract_and_invalid_values() {
let logical = LogicalAuthority::default();
assert_eq!(
logical.validate().unwrap_err(),
RejectReason::EmptyExecutionContract
);
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("".into()));
let logical = LogicalAuthority::new(None, vec![], contract);
assert!(matches!(
logical.validate().unwrap_err(),
RejectReason::InvalidAuthorityValue(_)
));
let mut contract = BTreeMap::new();
contract.insert("departments".into(), AuthorityValue::Many(vec![]));
let logical = LogicalAuthority::new(None, vec![], contract);
assert!(matches!(
logical.validate().unwrap_err(),
RejectReason::InvalidAuthorityValue(_)
));
}
#[test]
fn cbor_roundtrip() {
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
let logical = LogicalAuthority::new(
None,
vec![Invariant::new("storage:save", "save", "storage", "*")],
contract,
);
let map = IndexedAuthorityMap::from_logical(&logical).unwrap();
let mut buf = Vec::new();
ciborium::into_writer(&map, &mut buf).unwrap();
let decoded: IndexedAuthorityMap = ciborium::from_reader(buf.as_slice()).unwrap();
assert_eq!(map, decoded);
}
}