Skip to main content

simple_zanzibar/
policy.rs

1//! Policy text import/export helpers.
2
3use std::{
4    collections::{BTreeMap, HashMap},
5    fs, io,
6    path::Path,
7};
8
9use thiserror::Error;
10
11use crate::{
12    domain::Relationship,
13    error::ZanzibarError,
14    model::{NamespaceConfig, RelationConfig, UsersetExpression},
15    snapshot::{SnapshotIoError, SnapshotSaveOptions},
16};
17
18const SCHEMA_FILE_NAME: &str = "schema.zed";
19const RELATIONSHIP_DIRECTORY_NAME: &str = "relationships";
20const RELATIONSHIP_FILE_EXTENSION: &str = "zedtuples";
21
22/// Complete reviewable policy text.
23#[cfg_attr(
24    feature = "serde",
25    derive(serde::Serialize, serde::Deserialize),
26    serde(rename_all = "camelCase", deny_unknown_fields)
27)]
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PolicyText {
30    /// Canonical schema DSL.
31    pub schema: String,
32    /// Grouped relationship text files.
33    pub relationship_files: Vec<PolicyTextFile>,
34}
35
36impl PolicyText {
37    /// Creates policy text from schema and grouped relationship files.
38    #[must_use]
39    pub fn new(schema: String, relationship_files: Vec<PolicyTextFile>) -> Self {
40        Self {
41            schema,
42            relationship_files,
43        }
44    }
45
46    /// Creates policy text with all relationships in one logical file.
47    #[must_use]
48    pub fn from_single_relationship_file(schema: String, relationships: String) -> Self {
49        Self {
50            schema,
51            relationship_files: vec![PolicyTextFile {
52                path: format!("{RELATIONSHIP_DIRECTORY_NAME}/all.{RELATIONSHIP_FILE_EXTENSION}"),
53                contents: relationships,
54            }],
55        }
56    }
57}
58
59/// One reviewable policy text file.
60#[cfg_attr(
61    feature = "serde",
62    derive(serde::Serialize, serde::Deserialize),
63    serde(rename_all = "camelCase", deny_unknown_fields)
64)]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PolicyTextFile {
67    /// Relative file path used when exporting to a directory.
68    pub path: String,
69    /// File contents.
70    pub contents: String,
71}
72
73/// Errors produced while importing or exporting policy files.
74#[derive(Debug, Error)]
75pub enum PolicyIoError {
76    /// Filesystem I/O failed.
77    #[error("policy io failed")]
78    Io {
79        /// Source I/O error.
80        #[source]
81        source: io::Error,
82    },
83
84    /// Policy parsing, validation, or mutation failed.
85    #[error("policy operation failed")]
86    Zanzibar {
87        /// Source Zanzibar error.
88        #[source]
89        source: ZanzibarError,
90    },
91
92    /// Snapshot save/load failed while processing policy text.
93    #[error("policy snapshot operation failed")]
94    Snapshot {
95        /// Source snapshot error.
96        #[source]
97        source: SnapshotIoError,
98    },
99
100    /// The export path was invalid for policy file output.
101    #[error("policy export path is invalid: {reason}")]
102    InvalidExportPath {
103        /// Static invalid path reason.
104        reason: &'static str,
105    },
106
107    /// An engine lock was poisoned before policy IO could run.
108    #[error("engine lock poisoned during {operation}")]
109    LockPoisoned {
110        /// Operation that attempted to acquire the poisoned lock.
111        operation: &'static str,
112    },
113}
114
115impl From<io::Error> for PolicyIoError {
116    fn from(source: io::Error) -> Self {
117        Self::Io { source }
118    }
119}
120
121impl From<ZanzibarError> for PolicyIoError {
122    fn from(source: ZanzibarError) -> Self {
123        Self::Zanzibar { source }
124    }
125}
126
127impl From<SnapshotIoError> for PolicyIoError {
128    fn from(source: SnapshotIoError) -> Self {
129        Self::Snapshot { source }
130    }
131}
132
133pub(crate) fn canonical_schema_source(configs: &HashMap<String, NamespaceConfig>) -> String {
134    let mut output = String::new();
135    let mut namespaces = configs.values().collect::<Vec<_>>();
136    namespaces.sort_by(|left, right| left.name.cmp(&right.name));
137    for namespace in namespaces {
138        output.push_str("namespace ");
139        output.push_str(&namespace.name);
140        output.push_str(" {\n");
141        let mut relations = namespace.relations.values().collect::<Vec<_>>();
142        relations.sort_by(|left, right| left.name.0.cmp(&right.name.0));
143        for relation in relations {
144            push_relation(&mut output, relation);
145        }
146        output.push_str("}\n\n");
147    }
148    output
149}
150
151pub(crate) fn export_policy_text(
152    configs: &HashMap<String, NamespaceConfig>,
153    relationships: Vec<Relationship>,
154) -> PolicyText {
155    PolicyText {
156        schema: canonical_schema_source(configs),
157        relationship_files: relationship_files(relationships),
158    }
159}
160
161pub(crate) fn write_policy_files(
162    directory: &Path,
163    policy: &PolicyText,
164) -> Result<(), PolicyIoError> {
165    if directory.as_os_str().is_empty() {
166        return Err(PolicyIoError::InvalidExportPath {
167            reason: "directory path must not be empty",
168        });
169    }
170
171    fs::create_dir_all(directory)?;
172    fs::write(directory.join(SCHEMA_FILE_NAME), policy.schema.as_bytes())?;
173    let relationship_directory = directory.join(RELATIONSHIP_DIRECTORY_NAME);
174    fs::create_dir_all(&relationship_directory)?;
175    for file in &policy.relationship_files {
176        let relative = Path::new(&file.path);
177        if relative.is_absolute() || relative.components().any(is_parent_component) {
178            return Err(PolicyIoError::InvalidExportPath {
179                reason: "policy file path must be relative and stay inside the export directory",
180            });
181        }
182        fs::write(directory.join(relative), file.contents.as_bytes())?;
183    }
184    Ok(())
185}
186
187pub(crate) fn apply_policy_text_to_service(
188    service: &mut crate::WriterState,
189    policy: &PolicyText,
190) -> Result<crate::revision::ConsistencyToken, ZanzibarError> {
191    let published_state = service.published_state.clone();
192    let mut candidate = crate::WriterState::with_snapshot_retention(service.retained_snapshots)
193        .with_evaluation_limits(service.evaluation_limits);
194    candidate.datastore_id = service.datastore_id;
195    candidate.last_revision = service.last_revision;
196    let mut token = candidate.replace_dsl_with_token(&policy.schema)?;
197    let mut mutations = Vec::with_capacity(policy_import_batch_size());
198    for relationship in parse_relationships(policy)? {
199        mutations.push(crate::relationship::RelationshipMutation::Create(
200            relationship,
201        ));
202        if mutations.len() == policy_import_batch_size() {
203            token = candidate.apply_relationship_mutations(std::mem::take(&mut mutations), [])?;
204        }
205    }
206    if !mutations.is_empty() {
207        token = candidate.apply_relationship_mutations(mutations, [])?;
208    }
209    candidate.replace_publisher(published_state);
210    *service = candidate;
211    Ok(token)
212}
213
214pub(crate) fn save_snapshot_from_policy_text(
215    path: &Path,
216    policy: &PolicyText,
217    options: SnapshotSaveOptions,
218) -> Result<(), PolicyIoError> {
219    let service = crate::WriterState::from_policy_text(policy)?;
220    service.save_snapshot(path, options)?;
221    Ok(())
222}
223
224fn relationship_files(relationships: Vec<Relationship>) -> Vec<PolicyTextFile> {
225    let mut groups = BTreeMap::<String, Vec<String>>::new();
226    for relationship in relationships {
227        groups
228            .entry(relationship.resource().object_type().as_str().to_string())
229            .or_default()
230            .push(relationship.to_string());
231    }
232
233    groups
234        .into_iter()
235        .map(|(resource_type, mut lines)| {
236            lines.sort();
237            let mut contents = lines.join("\n");
238            if !contents.is_empty() {
239                contents.push('\n');
240            }
241            PolicyTextFile {
242                path: format!(
243                    "{RELATIONSHIP_DIRECTORY_NAME}/{resource_type}.{RELATIONSHIP_FILE_EXTENSION}",
244                ),
245                contents,
246            }
247        })
248        .collect()
249}
250
251fn parse_relationships(policy: &PolicyText) -> Result<Vec<Relationship>, ZanzibarError> {
252    let mut relationships = Vec::new();
253    for file in &policy.relationship_files {
254        for line in file.contents.lines() {
255            let trimmed = line.trim();
256            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
257                continue;
258            }
259            relationships.push(trimmed.parse()?);
260        }
261    }
262    Ok(relationships)
263}
264
265fn push_relation(output: &mut String, relation: &RelationConfig) {
266    if let Some(expression) = &relation.userset_rewrite {
267        output.push_str("    relation ");
268        output.push_str(&relation.name.0);
269        output.push_str(" {\n        rewrite ");
270        push_expression(output, expression);
271        output.push_str("\n    }\n");
272    } else {
273        output.push_str("    relation ");
274        output.push_str(&relation.name.0);
275        output.push_str(" {}\n");
276    }
277}
278
279fn push_expression(output: &mut String, expression: &UsersetExpression) {
280    match expression {
281        UsersetExpression::This => output.push_str("this"),
282        UsersetExpression::ComputedUserset { relation } => {
283            output.push_str("computed_userset(relation: \"");
284            output.push_str(&relation.0);
285            output.push_str("\")");
286        }
287        UsersetExpression::TupleToUserset {
288            tupleset_relation,
289            computed_userset_relation,
290        } => {
291            output.push_str("tuple_to_userset(tupleset: \"");
292            output.push_str(&tupleset_relation.0);
293            output.push_str("\", computed_userset: \"");
294            output.push_str(&computed_userset_relation.0);
295            output.push_str("\")");
296        }
297        UsersetExpression::Union(expressions) => {
298            push_expression_list(output, "union", expressions);
299        }
300        UsersetExpression::Intersection(expressions) => {
301            push_expression_list(output, "intersection", expressions);
302        }
303        UsersetExpression::Exclusion { base, exclude } => {
304            output.push_str("exclusion(");
305            push_expression(output, base);
306            output.push_str(", ");
307            push_expression(output, exclude);
308            output.push(')');
309        }
310    }
311}
312
313fn push_expression_list(output: &mut String, name: &str, expressions: &[UsersetExpression]) {
314    output.push_str(name);
315    output.push('(');
316    let mut separator = "";
317    for expression in expressions {
318        output.push_str(separator);
319        push_expression(output, expression);
320        separator = ", ";
321    }
322    output.push(')');
323}
324
325fn is_parent_component(component: std::path::Component<'_>) -> bool {
326    matches!(component, std::path::Component::ParentDir)
327}
328
329const fn policy_import_batch_size() -> usize {
330    10_000
331}