1use std::collections::HashMap;
2use std::error::Error;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8use zanzibar::{NamespaceConfig, Schema};
9
10use crate::{
11 Capability, Namespace, Relation, RelationRule, default_capability_bindings, default_manifest,
12 default_schema,
13};
14
15use super::*;
16
17#[derive(Debug, Clone)]
18pub struct LoadedAuthModelPackage {
19 manifest: AuthModelManifest,
20 schema: Schema,
21 capability_bindings: HashMap<Capability, CapabilityBinding>,
22}
23
24impl AuthModelPackage for LoadedAuthModelPackage {
25 fn manifest(&self) -> &AuthModelManifest {
26 &self.manifest
27 }
28
29 fn schema(&self) -> &Schema {
30 &self.schema
31 }
32
33 fn capability_bindings(&self) -> &HashMap<Capability, CapabilityBinding> {
34 &self.capability_bindings
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum AuthModelPackageLoadError {
40 Io {
41 path: PathBuf,
42 message: String,
43 },
44 Parse {
45 path: PathBuf,
46 message: String,
47 },
48 ManifestNameMismatch {
49 expected: String,
50 actual: String,
51 },
52 MissingImportBase {
53 package: String,
54 },
55 UnsupportedImportFanIn {
56 package: String,
57 imports: Vec<String>,
58 },
59 UnsupportedModelSyntax {
60 path: PathBuf,
61 line: usize,
62 message: String,
63 },
64 UnknownCapability {
65 package: String,
66 capability: String,
67 },
68 UnknownNamespace {
69 package: String,
70 namespace: String,
71 },
72 UnknownRelation {
73 package: String,
74 relation: String,
75 },
76 UnknownPackage {
77 package: String,
78 expected_path: PathBuf,
79 },
80}
81
82impl fmt::Display for AuthModelPackageLoadError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::Io { path, message } => {
86 write!(
87 f,
88 "failed to read auth package file `{}`: {message}",
89 path.display()
90 )
91 }
92 Self::Parse { path, message } => {
93 write!(
94 f,
95 "failed to parse auth package file `{}`: {message}",
96 path.display()
97 )
98 }
99 Self::ManifestNameMismatch { expected, actual } => write!(
100 f,
101 "auth package name mismatch: configured `{expected}` but package manifest declares `{actual}`"
102 ),
103 Self::MissingImportBase { package } => write!(
104 f,
105 "extend-mode auth package `{package}` must import a base package"
106 ),
107 Self::UnsupportedImportFanIn { package, imports } => write!(
108 f,
109 "auth package `{package}` imports multiple base packages ({}) which the current loader does not support yet",
110 imports.join(", ")
111 ),
112 Self::UnsupportedModelSyntax {
113 path,
114 line,
115 message,
116 } => write!(
117 f,
118 "unsupported auth model syntax in `{}` at line {}: {message}",
119 path.display(),
120 line
121 ),
122 Self::UnknownCapability {
123 package,
124 capability,
125 } => write!(
126 f,
127 "auth package `{package}` references unsupported capability `{capability}`"
128 ),
129 Self::UnknownNamespace { package, namespace } => write!(
130 f,
131 "auth package `{package}` references unsupported namespace `{namespace}`"
132 ),
133 Self::UnknownRelation { package, relation } => write!(
134 f,
135 "auth package `{package}` references unsupported relation `{relation}`"
136 ),
137 Self::UnknownPackage {
138 package,
139 expected_path,
140 } => write!(
141 f,
142 "auth package `{package}` was not found under `{}`",
143 expected_path.display()
144 ),
145 }
146 }
147}
148
149impl Error for AuthModelPackageLoadError {}
150
151#[derive(Debug, Deserialize)]
152struct AuthPackageDocument {
153 name: String,
154 version: String,
155 mode: String,
156 storage_schema_version: u32,
157 model_version: u32,
158 capability_binding_version: u32,
159 #[serde(default)]
160 imports: Vec<String>,
161}
162
163#[derive(Debug, Deserialize)]
164struct CapabilityBindingsDocument {
165 #[serde(default)]
166 bindings: HashMap<String, CapabilityBindingDocument>,
167}
168
169#[derive(Debug, Deserialize)]
170struct CapabilityBindingDocument {
171 resource_type: String,
172 permission: String,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum ModelSection {
177 Relations,
178 Permissions,
179}
180
181pub fn load_auth_model_package_at(
182 name: impl AsRef<str>,
183 app_root: impl AsRef<Path>,
184) -> Result<LoadedAuthModelPackage, AuthModelPackageLoadError> {
185 load_auth_model_package_inner(name.as_ref(), app_root.as_ref())
186}
187
188pub fn load_auth_model_package_selection_at(
189 name: impl AsRef<str>,
190 app_root: impl AsRef<Path>,
191) -> Result<AuthModelPackageSelection, AuthModelPackageLoadError> {
192 Ok(AuthModelPackageSelection::new(load_auth_model_package_at(
193 name, app_root,
194 )?))
195}
196
197fn load_auth_model_package_inner(
198 name: &str,
199 app_root: &Path,
200) -> Result<LoadedAuthModelPackage, AuthModelPackageLoadError> {
201 if name == default_manifest().name {
202 return Ok(LoadedAuthModelPackage {
203 manifest: default_manifest(),
204 schema: default_schema(),
205 capability_bindings: default_capability_bindings(),
206 });
207 }
208
209 let package_root = app_root.join("auth").join(name);
210 if !package_root.is_dir() {
211 return Err(AuthModelPackageLoadError::UnknownPackage {
212 package: name.to_string(),
213 expected_path: package_root,
214 });
215 }
216
217 let manifest_path = package_root.join("package.toml");
218 let manifest_document = read_toml::<AuthPackageDocument>(&manifest_path)?;
219 if manifest_document.name != name {
220 return Err(AuthModelPackageLoadError::ManifestNameMismatch {
221 expected: name.to_string(),
222 actual: manifest_document.name,
223 });
224 }
225
226 let manifest = AuthModelManifest {
227 name: manifest_document.name,
228 version: parse_package_version(&manifest_document.version, &manifest_path)?,
229 mode: parse_package_mode(&manifest_document.mode, &manifest_path)?,
230 storage_schema_version: manifest_document.storage_schema_version,
231 model_version: manifest_document.model_version,
232 capability_binding_version: manifest_document.capability_binding_version,
233 imports: manifest_document.imports.clone(),
234 };
235
236 match manifest.mode {
237 PackageMode::Replace => {
238 let schema = load_model_schema(
239 &package_root.join("model.auth"),
240 &manifest.name,
241 None,
242 true,
243 )?;
244 let capability_bindings = load_capability_bindings(
245 &package_root.join("capabilities.toml"),
246 &manifest.name,
247 )?;
248
249 Ok(LoadedAuthModelPackage {
250 manifest,
251 schema,
252 capability_bindings,
253 })
254 }
255 PackageMode::Extend => {
256 if manifest.imports.is_empty() {
257 return Err(AuthModelPackageLoadError::MissingImportBase {
258 package: manifest.name,
259 });
260 }
261 if manifest.imports.len() > 1 {
262 return Err(AuthModelPackageLoadError::UnsupportedImportFanIn {
263 package: manifest.name,
264 imports: manifest.imports.clone(),
265 });
266 }
267
268 let imported = load_auth_model_package_inner(&manifest.imports[0], app_root)?;
269 let schema = load_model_schema(
270 &package_root.join("model.auth"),
271 &manifest.name,
272 Some(imported.schema()),
273 false,
274 )?;
275 let mut capability_bindings = imported.capability_bindings().clone();
276 capability_bindings.extend(load_capability_bindings(
277 &package_root.join("capabilities.toml"),
278 &manifest.name,
279 )?);
280
281 Ok(LoadedAuthModelPackage {
282 manifest,
283 schema,
284 capability_bindings,
285 })
286 }
287 }
288}
289
290fn read_toml<T>(path: &Path) -> Result<T, AuthModelPackageLoadError>
291where
292 T: for<'de> Deserialize<'de>,
293{
294 let input = fs::read_to_string(path).map_err(|error| AuthModelPackageLoadError::Io {
295 path: path.to_path_buf(),
296 message: error.to_string(),
297 })?;
298 toml::from_str(&input).map_err(|error| AuthModelPackageLoadError::Parse {
299 path: path.to_path_buf(),
300 message: error.to_string(),
301 })
302}
303
304fn parse_package_version(
305 value: &str,
306 path: &Path,
307) -> Result<PackageVersion, AuthModelPackageLoadError> {
308 let mut components = value.split('.');
309 let parse_component = |component: Option<&str>| -> Result<u16, AuthModelPackageLoadError> {
310 component
311 .ok_or_else(|| AuthModelPackageLoadError::Parse {
312 path: path.to_path_buf(),
313 message: format!("invalid package version `{value}`"),
314 })?
315 .parse::<u16>()
316 .map_err(|error| AuthModelPackageLoadError::Parse {
317 path: path.to_path_buf(),
318 message: format!("invalid package version `{value}`: {error}"),
319 })
320 };
321
322 let major = parse_component(components.next())?;
323 let minor = parse_component(components.next())?;
324 let patch = parse_component(components.next())?;
325 if components.next().is_some() {
326 return Err(AuthModelPackageLoadError::Parse {
327 path: path.to_path_buf(),
328 message: format!("invalid package version `{value}`"),
329 });
330 }
331 Ok(PackageVersion::new(major, minor, patch))
332}
333
334fn parse_package_mode(value: &str, path: &Path) -> Result<PackageMode, AuthModelPackageLoadError> {
335 match value {
336 "replace" => Ok(PackageMode::Replace),
337 "extend" => Ok(PackageMode::Extend),
338 other => Err(AuthModelPackageLoadError::Parse {
339 path: path.to_path_buf(),
340 message: format!("unsupported auth package mode `{other}`"),
341 }),
342 }
343}
344
345fn load_capability_bindings(
346 path: &Path,
347 package: &str,
348) -> Result<HashMap<Capability, CapabilityBinding>, AuthModelPackageLoadError> {
349 if !path.is_file() {
350 return Ok(HashMap::new());
351 }
352
353 let document = read_toml::<CapabilityBindingsDocument>(path)?;
354 let mut bindings = HashMap::with_capacity(document.bindings.len());
355 for (capability_name, binding) in document.bindings {
356 let capability = Capability::from_str(&capability_name).ok_or_else(|| {
357 AuthModelPackageLoadError::UnknownCapability {
358 package: package.to_string(),
359 capability: capability_name.clone(),
360 }
361 })?;
362 let namespace = Namespace::from_str(&binding.resource_type).ok_or_else(|| {
363 AuthModelPackageLoadError::UnknownNamespace {
364 package: package.to_string(),
365 namespace: binding.resource_type.clone(),
366 }
367 })?;
368 let relation = Relation::from_str(&binding.permission).ok_or_else(|| {
369 AuthModelPackageLoadError::UnknownRelation {
370 package: package.to_string(),
371 relation: binding.permission.clone(),
372 }
373 })?;
374
375 bindings.insert(
376 capability,
377 CapabilityBinding {
378 capability,
379 resource_namespaces: vec![namespace],
380 relation,
381 },
382 );
383 }
384
385 Ok(bindings)
386}
387
388fn load_model_schema(
389 path: &Path,
390 package: &str,
391 base_schema: Option<&Schema>,
392 replacement_mode: bool,
393) -> Result<Schema, AuthModelPackageLoadError> {
394 if !path.is_file() {
395 return Ok(base_schema.cloned().unwrap_or_default());
396 }
397
398 let input = fs::read_to_string(path).map_err(|error| AuthModelPackageLoadError::Io {
399 path: path.to_path_buf(),
400 message: error.to_string(),
401 })?;
402
403 let mut schema = base_schema.cloned().unwrap_or_default();
404 let mut current_namespace = None;
405 let mut section = None;
406
407 for (index, raw_line) in input.lines().enumerate() {
408 let line_number = index + 1;
409 let line = raw_line.trim();
410 if line.is_empty() || line.starts_with('#') || line.starts_with("--") {
411 continue;
412 }
413 if let Some(namespace_name) = line.strip_prefix("type ") {
414 let namespace_name = namespace_name.trim();
415 let namespace = Namespace::from_str(namespace_name).ok_or_else(|| {
416 AuthModelPackageLoadError::UnknownNamespace {
417 package: package.to_string(),
418 namespace: namespace_name.to_string(),
419 }
420 })?;
421 if !replacement_mode && !schema.namespaces.contains_key(namespace.as_str()) {
422 return Err(AuthModelPackageLoadError::UnsupportedModelSyntax {
423 path: path.to_path_buf(),
424 line: line_number,
425 message: format!(
426 "extend-mode packages may only refine known namespaces; `{namespace_name}` is not available"
427 ),
428 });
429 }
430 schema
431 .namespaces
432 .entry(namespace.as_str().to_string())
433 .or_insert_with(NamespaceConfig::default);
434 current_namespace = Some(namespace);
435 section = None;
436 continue;
437 }
438 if line == "relations" {
439 section = Some(ModelSection::Relations);
440 continue;
441 }
442 if line == "permissions" {
443 section = Some(ModelSection::Permissions);
444 continue;
445 }
446
447 let namespace =
448 current_namespace.ok_or_else(|| AuthModelPackageLoadError::UnsupportedModelSyntax {
449 path: path.to_path_buf(),
450 line: line_number,
451 message: "entries must appear inside a `type <namespace>` block".to_string(),
452 })?;
453 match section {
454 Some(ModelSection::Relations) => {
455 let relation_name = line
456 .split_once(':')
457 .map(|(name, _)| name.trim())
458 .ok_or_else(|| AuthModelPackageLoadError::UnsupportedModelSyntax {
459 path: path.to_path_buf(),
460 line: line_number,
461 message: "relation entries must use `<relation>: ...` syntax".to_string(),
462 })?;
463 Relation::from_str(relation_name).ok_or_else(|| {
464 AuthModelPackageLoadError::UnknownRelation {
465 package: package.to_string(),
466 relation: relation_name.to_string(),
467 }
468 })?;
469 let namespace_rules = schema
470 .namespaces
471 .get_mut(namespace.as_str())
472 .expect("validated namespace exists in the schema");
473 namespace_rules
474 .rules
475 .entry(relation_name.to_string())
476 .or_insert_with(Vec::new);
477 }
478 Some(ModelSection::Permissions) => {
479 let (permission_name, source_name) = line.split_once('=').ok_or_else(|| {
480 AuthModelPackageLoadError::UnsupportedModelSyntax {
481 path: path.to_path_buf(),
482 line: line_number,
483 message: "permission entries must use `<permission> = <relation>` syntax"
484 .to_string(),
485 }
486 })?;
487 if source_name.contains('|') {
488 return Err(AuthModelPackageLoadError::UnsupportedModelSyntax {
489 path: path.to_path_buf(),
490 line: line_number,
491 message:
492 "multi-source permission expressions are not supported by the current file-backed loader"
493 .to_string(),
494 });
495 }
496 let permission = Relation::from_str(permission_name.trim()).ok_or_else(|| {
497 AuthModelPackageLoadError::UnknownRelation {
498 package: package.to_string(),
499 relation: permission_name.trim().to_string(),
500 }
501 })?;
502 let source = Relation::from_str(source_name.trim()).ok_or_else(|| {
503 AuthModelPackageLoadError::UnknownRelation {
504 package: package.to_string(),
505 relation: source_name.trim().to_string(),
506 }
507 })?;
508 let namespace_rules = schema
509 .namespaces
510 .get_mut(namespace.as_str())
511 .expect("validated namespace exists in the schema");
512 namespace_rules.rules.insert(
513 permission.as_str().to_string(),
514 vec![RelationRule::Inherit(source.as_str().into())],
515 );
516 }
517 None => {
518 return Err(AuthModelPackageLoadError::UnsupportedModelSyntax {
519 path: path.to_path_buf(),
520 line: line_number,
521 message: "entries must appear under `relations` or `permissions`".to_string(),
522 });
523 }
524 }
525 }
526
527 Ok(schema)
528}