1use 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#[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 pub schema: String,
32 pub relationship_files: Vec<PolicyTextFile>,
34}
35
36impl PolicyText {
37 #[must_use]
39 pub fn new(schema: String, relationship_files: Vec<PolicyTextFile>) -> Self {
40 Self {
41 schema,
42 relationship_files,
43 }
44 }
45
46 #[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#[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 pub path: String,
69 pub contents: String,
71}
72
73#[derive(Debug, Error)]
75pub enum PolicyIoError {
76 #[error("policy io failed")]
78 Io {
79 #[source]
81 source: io::Error,
82 },
83
84 #[error("policy operation failed")]
86 Zanzibar {
87 #[source]
89 source: ZanzibarError,
90 },
91
92 #[error("policy snapshot operation failed")]
94 Snapshot {
95 #[source]
97 source: SnapshotIoError,
98 },
99
100 #[error("policy export path is invalid: {reason}")]
102 InvalidExportPath {
103 reason: &'static str,
105 },
106
107 #[error("engine lock poisoned during {operation}")]
109 LockPoisoned {
110 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}