|
| 1 | +// Location: ./crates/apl-core/src/attribute_source.rs |
| 2 | +// Copyright 2026 |
| 3 | +// SPDX-License-Identifier: Apache-2.0 |
| 4 | +// Authors: Teryl Taylor |
| 5 | +// |
| 6 | +// Static attribute provisioning — the `data.*` bag namespace. |
| 7 | +// |
| 8 | +// `restrict` predicates (and policy predicates generally) read attributes |
| 9 | +// that aren't carried by any token or fetched from anywhere: backend-free |
| 10 | +// policy constants like tenant→region maps, per-agent model allow-lists, |
| 11 | +// org defaults. Those come from a plain, operator-organized data tree that |
| 12 | +// lands in the evaluation bag under `data.*` (see |
| 13 | +// docs/apl-restrict-effect-design.md §4). |
| 14 | +// |
| 15 | +// This module is the pure contract: the `AttributeSource` trait (where a |
| 16 | +// tree comes from) and the `AttributeTree` value (what it is). The default |
| 17 | +// file-backed source and the bag flattening live at the outer layers |
| 18 | +// (apl-cpex / apl-cmf) — apl-core stays free of I/O and config deps. |
| 19 | + |
| 20 | +use serde_json::Value; |
| 21 | +use thiserror::Error; |
| 22 | + |
| 23 | +/// The static attribute tree — the whole `data:` document, a plain nested |
| 24 | +/// value the operator organizes however they like. It carries *literal |
| 25 | +/// values only*: no conditionals, no computed fields (that guardrail is |
| 26 | +/// structural — there is no syntax in a data tree to express logic). |
| 27 | +/// |
| 28 | +/// Flattened into the bag under `data.*` by the bag builder (apl-cmf): |
| 29 | +/// `{ org: { default_region: us } }` → `data.org.default_region = "us"`. |
| 30 | +#[derive(Debug, Clone, PartialEq)] |
| 31 | +pub struct AttributeTree(Value); |
| 32 | + |
| 33 | +impl AttributeTree { |
| 34 | + /// Wrap a loaded `data` document. Expected to be a JSON/YAML object; |
| 35 | + /// a non-object is tolerated but flattens to nothing useful. |
| 36 | + pub fn new(value: Value) -> Self { |
| 37 | + Self(value) |
| 38 | + } |
| 39 | + |
| 40 | + /// The empty tree — no static attributes. The default when no source |
| 41 | + /// is configured. |
| 42 | + pub fn empty() -> Self { |
| 43 | + Self(Value::Object(serde_json::Map::new())) |
| 44 | + } |
| 45 | + |
| 46 | + /// Borrow the underlying value (the bag builder walks this). |
| 47 | + pub fn as_value(&self) -> &Value { |
| 48 | + &self.0 |
| 49 | + } |
| 50 | + |
| 51 | + /// True when the tree holds nothing (no keys). |
| 52 | + pub fn is_empty(&self) -> bool { |
| 53 | + match &self.0 { |
| 54 | + Value::Object(m) => m.is_empty(), |
| 55 | + Value::Null => true, |
| 56 | + _ => false, |
| 57 | + } |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +impl Default for AttributeTree { |
| 62 | + fn default() -> Self { |
| 63 | + Self::empty() |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/// Where the `data.*` tree comes from — a **trait object injected at |
| 68 | +/// construction**, not a CPEX hook-plugin. The host implements it over a |
| 69 | +/// file, etcd, Postgres, a k8s ConfigMap, etc., and hands the object to |
| 70 | +/// the runtime at startup. |
| 71 | +/// |
| 72 | +/// `load` is **synchronous and one-shot**: it runs once at startup, never |
| 73 | +/// on the request hot path, so blocking the init thread on file/network |
| 74 | +/// I/O is fine — and it lets the (synchronous) runtime setup call it |
| 75 | +/// directly, no async plumbing. A source that fronts a genuinely async |
| 76 | +/// store can block on its own runtime at this edge. |
| 77 | +/// |
| 78 | +/// v1 is **snapshot only**. Hot-reload (a `watch` that streams fresh |
| 79 | +/// trees) is deferred — that is the one genuinely-async concern, and its |
| 80 | +/// shape (stream vs channel vs callback) is best decided when it's built, |
| 81 | +/// not stubbed now. A lazy per-key `resolve(path)` for huge stores is |
| 82 | +/// likewise deferred. |
| 83 | +pub trait AttributeSource: Send + Sync { |
| 84 | + /// Load the full attribute tree (a snapshot). |
| 85 | + fn load(&self) -> Result<AttributeTree, AttributeError>; |
| 86 | +} |
| 87 | + |
| 88 | +/// Why an attribute source failed to produce a tree. Loading is a |
| 89 | +/// startup/config concern, so these surface as configuration errors at |
| 90 | +/// the runtime boundary (fail-fast, not per-request). |
| 91 | +#[derive(Debug, Error)] |
| 92 | +pub enum AttributeError { |
| 93 | + /// The backing store could not be read (missing file, I/O error, |
| 94 | + /// unreachable etcd, …). |
| 95 | + #[error("attribute source load failed: {0}")] |
| 96 | + Load(String), |
| 97 | + |
| 98 | + /// The loaded bytes were not valid for the source's format. |
| 99 | + #[error("attribute source parse failed: {0}")] |
| 100 | + Parse(String), |
| 101 | + |
| 102 | + /// Two inputs set the *same* leaf path to different values — a real |
| 103 | + /// conflict the source refuses to silently resolve (fail-fast merge). |
| 104 | + #[error("attribute conflict at `{path}`: `{existing}` vs `{incoming}`")] |
| 105 | + Conflict { |
| 106 | + path: String, |
| 107 | + existing: String, |
| 108 | + incoming: String, |
| 109 | + }, |
| 110 | +} |
| 111 | + |
| 112 | +#[cfg(test)] |
| 113 | +mod tests { |
| 114 | + use super::*; |
| 115 | + use serde_json::json; |
| 116 | + |
| 117 | + #[test] |
| 118 | + fn empty_tree_is_empty() { |
| 119 | + assert!(AttributeTree::empty().is_empty()); |
| 120 | + assert!(AttributeTree::default().is_empty()); |
| 121 | + } |
| 122 | + |
| 123 | + #[test] |
| 124 | + fn populated_tree_is_not_empty() { |
| 125 | + let t = AttributeTree::new(json!({ "org": { "default_region": "us" } })); |
| 126 | + assert!(!t.is_empty()); |
| 127 | + assert_eq!( |
| 128 | + t.as_value().pointer("/org/default_region"), |
| 129 | + Some(&json!("us")) |
| 130 | + ); |
| 131 | + } |
| 132 | +} |
0 commit comments