Skip to main content

coil_auth/capability/
load.rs

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}