Skip to main content

treetop_core/
labels.rs

1use cedar_policy::{EntityTypeName, RestrictedExpression};
2use regex::Regex;
3use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
4use std::collections::{HashMap, HashSet};
5use std::fmt::{Display, Formatter, Result as FmtResult};
6use std::hash::{Hash, Hasher};
7use std::sync::Arc;
8use utoipa::{PartialSchema, ToSchema};
9
10use crate::error::PolicyError;
11use crate::traits::CedarAtom;
12use crate::types::{AttrValue, Resource};
13
14/// Stable application-defined identity for one complete labeling configuration.
15#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)]
16#[serde(transparent)]
17pub struct LabelSetVersion(Arc<str>);
18
19impl LabelSetVersion {
20    fn new(value: impl AsRef<str>) -> Result<Self, PolicyError> {
21        let value = value.as_ref();
22        if value.trim().is_empty() {
23            return Err(PolicyError::LabelConfigError(
24                "label-set version must not be empty".to_string(),
25            ));
26        }
27        Ok(Self(value.into()))
28    }
29
30    /// Borrow the application-defined version string.
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34}
35
36impl Display for LabelSetVersion {
37    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
38        f.write_str(self.as_str())
39    }
40}
41
42impl<'de> Deserialize<'de> for LabelSetVersion {
43    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
44    where
45        D: Deserializer<'de>,
46    {
47        let value = String::deserialize(deserializer)?;
48        Self::new(value).map_err(D::Error::custom)
49    }
50}
51
52impl PartialSchema for LabelSetVersion {
53    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
54        <String as PartialSchema>::schema()
55    }
56}
57
58impl ToSchema for LabelSetVersion {}
59
60/// Exclusive ownership of one attribute on one exact Cedar resource type.
61///
62/// Resource types include their complete namespace: `App::Host` and `Other::Host`
63/// are distinct scopes. Wildcards and resource entity IDs are not scopes.
64/// Construction and deserialization validate both components once.
65///
66/// ```
67/// use treetop_core::LabelTarget;
68/// let target = LabelTarget::new("App::Host", "labels")?;
69/// assert_eq!(target.resource_type(), "App::Host");
70/// # Ok::<(), treetop_core::PolicyError>(())
71/// ```
72///
73/// ```compile_fail
74/// use treetop_core::LabelTarget;
75/// let target = LabelTarget { resource_type: "*".into(), attribute: "id".into() };
76/// ```
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
78#[serde(deny_unknown_fields)]
79pub struct LabelTarget {
80    resource_type: String,
81    attribute: String,
82    #[serde(skip)]
83    entity_type: EntityTypeName,
84}
85
86impl Hash for LabelTarget {
87    fn hash<H: Hasher>(&self, state: &mut H) {
88        self.entity_type.hash(state);
89        self.attribute.hash(state);
90    }
91}
92
93impl LabelTarget {
94    /// Validate an exact resource type and a nonempty, nonreserved attribute.
95    ///
96    /// Returns `LabelConfigError` for an invalid Cedar type or attribute, including
97    /// wildcards and the reserved canonical `id` attribute.
98    pub fn new(
99        resource_type: impl AsRef<str>,
100        attribute: impl Into<String>,
101    ) -> Result<Self, PolicyError> {
102        let resource_type = resource_type.as_ref();
103        let entity_type: EntityTypeName = resource_type.parse().map_err(|error| {
104            PolicyError::LabelConfigError(format!(
105                "invalid label resource type '{resource_type}': {error}"
106            ))
107        })?;
108        let attribute = attribute.into();
109        validate_output(&attribute)?;
110        Ok(Self {
111            resource_type: entity_type.to_string(),
112            attribute,
113            entity_type,
114        })
115    }
116
117    /// Canonical, fully qualified Cedar resource type.
118    pub fn resource_type(&self) -> &str {
119        &self.resource_type
120    }
121
122    /// The resource attribute owned within this type.
123    pub fn attribute(&self) -> &str {
124        &self.attribute
125    }
126
127    fn matches(&self, resource: &Resource) -> bool {
128        resource.cedar_entity_uid().type_name() == &self.entity_type
129    }
130}
131
132impl<'de> Deserialize<'de> for LabelTarget {
133    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
134    where
135        D: Deserializer<'de>,
136    {
137        #[derive(Deserialize)]
138        #[serde(deny_unknown_fields)]
139        struct WireTarget {
140            resource_type: String,
141            attribute: String,
142        }
143        let wire = WireTarget::deserialize(deserializer)?;
144        Self::new(wire.resource_type, wire.attribute).map_err(D::Error::custom)
145    }
146}
147
148/// Derives one trusted attribute within one declared resource scope.
149///
150/// Implementations declare a validated target and receive an immutable resource.
151/// Core enforces the scope and owns all replacement/removal. A registry captures
152/// the target at construction; later evaluation uses that frozen declaration.
153pub trait Labeler: Send + Sync {
154    /// Declare the exact resource type and attribute this labeler owns.
155    fn target(&self) -> &LabelTarget;
156
157    /// Derive the declared output from trusted identity or input attributes.
158    ///
159    /// Controlled application removes the owned output before calling this
160    /// method. Returning `None` leaves the output absent. Implementations must
161    /// be fast, deterministic, side-effect-free, and must not block.
162    fn derive(&self, resource: &Resource) -> Option<AttrValue>;
163}
164
165/// Controlled, scope-enforcing application available on every labeler.
166///
167/// This blanket implementation cannot be replaced by individual labelers.
168/// `labeler.apply(&mut resource)` leaves other resource types untouched.
169pub trait LabelerApply: Labeler {
170    /// Replace or remove the owned attribute when the exact resource type matches.
171    fn apply(&self, resource: &mut Resource) {
172        let target = self.target();
173        if !target.matches(resource) {
174            return;
175        }
176        resource.attrs().remove(target.attribute());
177        if let Some(value) = self.derive(resource) {
178            resource
179                .attrs()
180                .insert(target.attribute().to_string(), value);
181        }
182    }
183}
184
185impl<T: Labeler + ?Sized> LabelerApply for T {}
186
187/// A labeler that uses regular expressions for matching on resource attributes.
188#[derive(Debug, Clone)]
189pub struct RegexLabeler {
190    target: LabelTarget,
191    /// attribute to read from, e.g. "name"
192    field: String,
193    /// Rulesets for matching resource attributes
194    table: Vec<(String, Regex)>,
195}
196
197impl RegexLabeler {
198    /// Create a regex-based labeler.
199    ///
200    /// - `target`: validated resource type and derived attribute ownership
201    /// - `field`: attribute to read from (e.g., "name")
202    /// - `table`: vector of `(label, regex)` pairs
203    ///
204    /// Configure `field` and the target attribute as distinct attributes so repeated
205    /// application remains idempotent. `field` reads the resource attribute
206    /// map; it does not expose canonical entity fields. In particular, an
207    /// attribute named `id` is not the canonical [`Resource::id`] value during
208    /// labeling. Use a custom [`Labeler`] that reads [`Resource::id`] when
209    /// labels must derive from the resource identity.
210    pub fn new(
211        target: LabelTarget,
212        field: impl Into<String>,
213        table: Vec<(String, Regex)>,
214    ) -> Result<Self, PolicyError> {
215        let field = field.into();
216        if field.trim().is_empty() {
217            return Err(PolicyError::LabelConfigError(
218                "regex labeler input field must not be empty".to_string(),
219            ));
220        }
221        if field == target.attribute() {
222            return Err(PolicyError::LabelConfigError(format!(
223                "regex labeler input and output must differ ('{field}')"
224            )));
225        }
226        Ok(Self {
227            target,
228            field,
229            table,
230        })
231    }
232}
233
234impl Labeler for RegexLabeler {
235    fn target(&self) -> &LabelTarget {
236        &self.target
237    }
238
239    fn derive(&self, resource: &Resource) -> Option<AttrValue> {
240        let Some(AttrValue::String(value)) = resource.attributes().get(&self.field) else {
241            return None;
242        };
243        let out = self
244            .table
245            .iter()
246            .filter(|(_, re)| re.is_match(value))
247            .map(|(label, _)| AttrValue::String(label.clone()))
248            .collect();
249
250        Some(AttrValue::Set(out))
251    }
252}
253
254fn validate_output(output: &str) -> Result<(), PolicyError> {
255    if output.trim().is_empty() {
256        return Err(PolicyError::LabelConfigError(
257            "labeler output must not be empty".to_string(),
258        ));
259    }
260    if output == "id" {
261        return Err(PolicyError::LabelConfigError(
262            "labeler output 'id' is reserved for the canonical resource ID".to_string(),
263        ));
264    }
265    // Construct the same restricted record used by Context::from_pairs without
266    // evaluating it. The value is a literal, so evaluation cannot add validation;
267    // creating a Context would unnecessarily initialize all Cedar extensions.
268    RestrictedExpression::new_record([(output.to_string(), RestrictedExpression::new_bool(true))])
269        .map_err(|error| {
270            PolicyError::LabelConfigError(format!(
271                "invalid Cedar attribute name '{output}': {error}"
272            ))
273        })?;
274    Ok(())
275}
276
277/// Immutable collection of labelers indexed by their declared resource type.
278///
279/// Each `(resource type, attribute)` has one owner. Outputs on unrelated types
280/// remain application-owned inputs. Versions identify complete configurations;
281/// engine generations distinguish atomic registry replacements.
282#[derive(Clone)]
283pub struct LabelRegistry {
284    version: Option<LabelSetVersion>,
285    scopes: Arc<HashMap<EntityTypeName, Vec<RegisteredLabeler>>>,
286}
287
288struct RegisteredLabeler {
289    target: LabelTarget,
290    labeler: Arc<dyn Labeler>,
291}
292
293impl RegisteredLabeler {
294    fn apply(&self, resource: &mut Resource) {
295        resource.attrs().remove(self.target.attribute());
296        if let Some(value) = self.labeler.derive(resource) {
297            resource
298                .attrs()
299                .insert(self.target.attribute().to_string(), value);
300        }
301    }
302}
303
304impl LabelRegistry {
305    /// Application-defined identity of this complete configuration.
306    pub fn version(&self) -> Option<&LabelSetVersion> {
307        self.version.as_ref()
308    }
309
310    /// Clone only when at least one target owns an attribute on this resource type.
311    pub(crate) fn apply_to_clone_if_applicable(&self, resource: &Resource) -> Option<Resource> {
312        let labelers = self.scopes.get(resource.cedar_entity_uid().type_name())?;
313        let mut labelled = resource.clone();
314        Self::apply_scope(labelers, &mut labelled);
315        Some(labelled)
316    }
317
318    /// Clear all outputs owned on this type, then derive in registration order.
319    ///
320    /// Clearing before any derivation prevents an earlier labeler from trusting
321    /// a caller-supplied value owned by a later labeler. Other types are unchanged.
322    pub fn apply(&self, resource: &mut Resource) {
323        if let Some(labelers) = self.scopes.get(resource.cedar_entity_uid().type_name()) {
324            Self::apply_scope(labelers, resource);
325        }
326    }
327
328    fn apply_scope(labelers: &[RegisteredLabeler], resource: &mut Resource) {
329        for labeler in labelers {
330            resource.attrs().remove(labeler.target.attribute());
331        }
332        for labeler in labelers {
333            labeler.apply(resource);
334        }
335    }
336}
337
338/// Builder for creating a LabelRegistry with labelers.
339///
340/// This uses a builder pattern to ensure labelers are properly initialized
341/// before the registry is used.
342///
343/// # Example
344///
345/// ```rust
346/// use std::sync::Arc;
347/// use treetop_core::{LabelTarget, LabelRegistryBuilder, RegexLabeler};
348/// use regex::Regex;
349///
350/// let registry = LabelRegistryBuilder::new()
351///     .add_labeler(Arc::new(RegexLabeler::new(LabelTarget::new("Host", "nameLabels").unwrap(), "name",
352///         vec![("prod".to_string(), Regex::new(r"\.prod\.").unwrap())],
353///     ).unwrap()))
354///     .build()
355///     .unwrap();
356/// ```
357///
358/// Use [`LabelRegistryBuilder::versioned`] when decisions must identify the
359/// label configuration across processes or restarts.
360#[derive(Default)]
361pub struct LabelRegistryBuilder {
362    version: Option<String>,
363    labelers: Vec<Arc<dyn Labeler>>,
364}
365
366impl LabelRegistryBuilder {
367    /// Create an unversioned label registry builder.
368    ///
369    /// Replacements remain distinguishable by the engine's monotonic
370    /// generation. Use [`Self::versioned`] when a stable application identifier
371    /// is also needed for audit correlation across processes or restarts.
372    pub fn new() -> Self {
373        Self::default()
374    }
375
376    /// Create a registry builder with a stable application-defined version.
377    pub fn versioned(version: impl Into<String>) -> Self {
378        Self {
379            version: Some(version.into()),
380            labelers: Vec::new(),
381        }
382    }
383
384    /// Add a labeler to the registry.
385    ///
386    /// This can be called repeatedly to build up a registry before `build()`.
387    pub fn add_labeler(mut self, labeler: Arc<dyn Labeler>) -> Self {
388        self.labelers.push(labeler);
389        self
390    }
391
392    /// Validate and build the immutable label registry.
393    ///
394    /// Fails for an empty configured version or duplicate `(resource type,
395    /// attribute)` ownership. Targets have already validated reserved names.
396    /// Consumes the builder and returns a registry ready to install with
397    /// `PolicyEngine::with_label_registry`.
398    pub fn build(self) -> Result<LabelRegistry, PolicyError> {
399        let version = self.version.map(LabelSetVersion::new).transpose()?;
400        let mut targets = HashSet::with_capacity(self.labelers.len());
401        let mut scopes: HashMap<EntityTypeName, Vec<RegisteredLabeler>> = HashMap::new();
402        for labeler in &self.labelers {
403            let target = labeler.target();
404            if !targets.insert(target) {
405                return Err(PolicyError::LabelConfigError(format!(
406                    "multiple labelers own target ({}, {})",
407                    target.resource_type(),
408                    target.attribute()
409                )));
410            }
411            let target = target.clone();
412            scopes
413                .entry(target.entity_type.clone())
414                .or_default()
415                .push(RegisteredLabeler {
416                    target,
417                    labeler: Arc::clone(labeler),
418                });
419        }
420        Ok(LabelRegistry {
421            version,
422            scopes: Arc::new(scopes),
423        })
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::collections::BTreeSet;
431    use yare::parameterized;
432
433    #[test]
434    fn targets_validate_and_round_trip_through_the_same_boundary() {
435        let target = LabelTarget::new("App::Host", "labels").unwrap();
436        let json = serde_json::json!({"resource_type": "App::Host", "attribute": "labels"});
437        assert_eq!(serde_json::to_value(&target).unwrap(), json);
438        assert_eq!(serde_json::from_value::<LabelTarget>(json).unwrap(), target);
439        for resource_type in ["", "*", "App::*", "App::", "App::Host::\"one\""] {
440            assert!(LabelTarget::new(resource_type, "labels").is_err());
441            assert!(
442                serde_json::from_value::<LabelTarget>(serde_json::json!({
443                    "resource_type": resource_type, "attribute": "labels"
444                }))
445                .is_err()
446            );
447        }
448        for attribute in ["", " ", "id"] {
449            assert!(LabelTarget::new("App::Host", attribute).is_err());
450            assert!(
451                serde_json::from_value::<LabelTarget>(serde_json::json!({
452                    "resource_type": "App::Host", "attribute": attribute
453                }))
454                .is_err()
455            );
456        }
457        for json in [
458            serde_json::json!({"kind": "App::Host", "output": "labels"}),
459            serde_json::json!({"resource_type": "App::Host"}),
460            serde_json::json!({"resource_type": "App::Host", "attribute": "labels", "extra": true}),
461        ] {
462            assert!(serde_json::from_value::<LabelTarget>(json).is_err());
463        }
464    }
465
466    #[test]
467    fn identical_attribute_names_have_independent_qualified_type_owners() {
468        let mut builder = LabelRegistryBuilder::new();
469        let pattern = Regex::new("prod").unwrap();
470        for resource_type in ["App::Host", "Other::Host"] {
471            builder = builder.add_labeler(Arc::new(
472                RegexLabeler::new(
473                    LabelTarget::new(resource_type, "labels").unwrap(),
474                    "name",
475                    vec![(resource_type.to_string(), pattern.clone())],
476                )
477                .unwrap(),
478            ));
479        }
480        let registry = builder.build().unwrap();
481        for resource_type in ["App::Host", "Other::Host"] {
482            let mut resource = Resource::new(resource_type, "one")
483                .unwrap()
484                .with_attr("name", AttrValue::String("prod".into()))
485                .with_attr("labels", AttrValue::String("forged".into()));
486            registry.apply(&mut resource);
487            assert_eq!(
488                resource.attributes().get("labels"),
489                Some(&AttrValue::Set(vec![AttrValue::String(
490                    resource_type.to_string()
491                )]))
492            );
493            let first = resource.clone();
494            registry.apply(&mut resource);
495            assert_eq!(resource, first);
496        }
497    }
498
499    #[test]
500    fn direct_application_enforces_the_declared_scope() {
501        let labeler = EchoOwnedOutput(LabelTarget::new("App::Host", "labels").unwrap());
502        let mut other = Resource::new("Other::Host", "one")
503            .unwrap()
504            .with_attr("labels", AttrValue::String("application input".into()));
505        let original = other.clone();
506        labeler.apply(&mut other);
507        assert_eq!(other, original);
508        let mut matching = Resource::new("App::Host", "one")
509            .unwrap()
510            .with_attr("labels", AttrValue::String("forged".into()));
511        labeler.apply(&mut matching);
512        assert!(!matching.attributes().contains_key("labels"));
513    }
514
515    #[test]
516    fn registry_freezes_targets_at_construction() {
517        use std::sync::atomic::{AtomicBool, Ordering};
518        struct ChangingDeclaration {
519            original: LabelTarget,
520            replacement: LabelTarget,
521            changed: AtomicBool,
522        }
523        impl Labeler for ChangingDeclaration {
524            fn target(&self) -> &LabelTarget {
525                if self.changed.load(Ordering::Relaxed) {
526                    &self.replacement
527                } else {
528                    &self.original
529                }
530            }
531            fn derive(&self, _: &Resource) -> Option<AttrValue> {
532                Some(AttrValue::Bool(true))
533            }
534        }
535        let labeler = Arc::new(ChangingDeclaration {
536            original: LabelTarget::new("App::Host", "labels").unwrap(),
537            replacement: LabelTarget::new("Other::Host", "other").unwrap(),
538            changed: AtomicBool::new(false),
539        });
540        let registry = LabelRegistryBuilder::new()
541            .add_labeler(labeler.clone())
542            .build()
543            .unwrap();
544        labeler.changed.store(true, Ordering::Relaxed);
545        let mut original = Resource::new("App::Host", "one").unwrap();
546        registry.apply(&mut original);
547        assert_eq!(
548            original.attributes().get("labels"),
549            Some(&AttrValue::Bool(true))
550        );
551        assert!(!original.attributes().contains_key("other"));
552        let other = Resource::new("Other::Host", "one").unwrap();
553        assert!(registry.apply_to_clone_if_applicable(&other).is_none());
554    }
555
556    struct EchoOwnedOutput(LabelTarget);
557
558    impl Labeler for EchoOwnedOutput {
559        fn target(&self) -> &LabelTarget {
560            &self.0
561        }
562        fn derive(&self, resource: &Resource) -> Option<AttrValue> {
563            resource
564                .attributes()
565                .get(self.target().attribute())
566                .cloned()
567        }
568    }
569
570    struct CopyAttribute {
571        target: LabelTarget,
572        input: &'static str,
573    }
574
575    impl Labeler for CopyAttribute {
576        fn target(&self) -> &LabelTarget {
577            &self.target
578        }
579        fn derive(&self, resource: &Resource) -> Option<AttrValue> {
580            resource.attributes().get(self.input).cloned()
581        }
582    }
583
584    fn compile(rules: Vec<(&str, &str)>) -> Vec<(String, Regex)> {
585        rules
586            .into_iter()
587            .map(|(l, p)| (l.to_string(), Regex::new(p).unwrap()))
588            .collect()
589    }
590
591    fn get_label_strings(res: &mut Resource, key: &str) -> BTreeSet<String> {
592        match res.attrs().get(key) {
593            Some(AttrValue::Set(v)) => v
594                .iter()
595                .filter_map(|a| {
596                    if let AttrValue::String(s) = a {
597                        Some(s.clone())
598                    } else {
599                        None
600                    }
601                })
602                .collect(),
603            _ => BTreeSet::new(),
604        }
605    }
606
607    #[parameterized(
608        simple_match = {
609            "Host", "name", "nameLabels",
610            vec![("prod", r"(^|\.)prod\.example\.com$")],
611            "db12.prod.example.com",
612            &["prod"]
613        },
614        no_match = {
615            "Host", "name", "nameLabels",
616            vec![("corp", r"(^|\.)corp\.example\.com$")],
617            "web.dev.example.com",
618            &[]
619        },
620        multi_match = {
621            "Host", "name", "nameLabels",
622            vec![("prod", r"(^|\.)prod\."), ("db", r"(^|\.)db\d+\.")],
623            "db42.prod.example.com",
624            &["db","prod"]
625        }
626    )]
627    fn regex_labeler_apply_basic(
628        kind: &str,
629        field: &str,
630        output: &str,
631        rules: Vec<(&str, &str)>,
632        input: &str,
633        expected: &[&str],
634    ) {
635        let labeler = RegexLabeler::new(
636            LabelTarget::new(kind, output).unwrap(),
637            field,
638            compile(rules),
639        )
640        .unwrap();
641
642        let mut res = Resource::new(kind, input).unwrap();
643        res.attrs()
644            .insert(field.to_string(), AttrValue::String(input.to_string()));
645
646        labeler.apply(&mut res);
647
648        let got = get_label_strings(&mut res, output);
649        let want: BTreeSet<String> = expected.iter().map(|s| s.to_string()).collect();
650        assert_eq!(got, want);
651    }
652
653    #[test]
654    fn regex_labeler_missing_input_field_is_noop() {
655        let labeler = RegexLabeler::new(
656            LabelTarget::new("Host", "nameLabels").unwrap(),
657            "name",
658            compile(vec![("prod", r"(^|\.)prod\.")]),
659        )
660        .unwrap();
661
662        let mut res = Resource::new("Host", "db99.prod.example.com").unwrap();
663        // no "name" inserted
664
665        labeler.apply(&mut res);
666        assert!(res.attrs().get("nameLabels").is_none());
667    }
668
669    #[test]
670    fn regex_labeler_replaces_untrusted_existing_set() {
671        let labeler = RegexLabeler::new(
672            LabelTarget::new("Host", "nameLabels").unwrap(),
673            "name",
674            compile(vec![("prod", r"(^|\.)prod\."), ("db", r"(^|\.)db\d+\.")]),
675        )
676        .unwrap();
677
678        let mut res = Resource::new("Host", "db99.prod.example.com").unwrap();
679        res.attrs().insert(
680            "name".into(),
681            AttrValue::String("db99.prod.example.com".into()),
682        );
683        res.attrs().insert(
684            "nameLabels".into(),
685            AttrValue::Set(vec![AttrValue::String("pre".into())]),
686        );
687
688        labeler.apply(&mut res);
689
690        let labels = get_label_strings(&mut res, "nameLabels");
691        assert!(!labels.contains("pre"));
692        assert!(labels.contains("prod"));
693        assert!(labels.contains("db"));
694    }
695
696    #[test]
697    fn regex_labeler_replaces_untrusted_set_when_no_rule_matches() {
698        let labeler = RegexLabeler::new(
699            LabelTarget::new("Host", "nameLabels").unwrap(),
700            "name",
701            compile(vec![("prod", r"(^|\.)prod\.")]),
702        )
703        .unwrap();
704        let mut res = Resource::new("Host", "public.example.com")
705            .unwrap()
706            .with_attr("name", AttrValue::String("public.example.com".into()))
707            .with_attr(
708                "nameLabels",
709                AttrValue::Set(vec![AttrValue::String("prod".into())]),
710            );
711
712        labeler.apply(&mut res);
713
714        assert_eq!(
715            res.attributes().get("nameLabels"),
716            Some(&AttrValue::Set(Vec::new()))
717        );
718    }
719
720    #[test]
721    fn regex_labeler_removes_untrusted_output_when_input_is_missing() {
722        let labeler = RegexLabeler::new(
723            LabelTarget::new("Host", "nameLabels").unwrap(),
724            "name",
725            compile(vec![("prod", r"(^|\.)prod\.")]),
726        )
727        .unwrap();
728        let mut res = Resource::new("Host", "public.example.com")
729            .unwrap()
730            .with_attr(
731                "nameLabels",
732                AttrValue::Set(vec![AttrValue::String("prod".into())]),
733            );
734
735        labeler.apply(&mut res);
736
737        assert!(!res.attributes().contains_key("nameLabels"));
738    }
739
740    #[test]
741    fn custom_labeler_cannot_observe_or_preserve_untrusted_owned_output() {
742        let mut resource = Resource::new("Host", "attacker.invalid")
743            .unwrap()
744            .with_attr("labels", AttrValue::String("forged".into()));
745
746        EchoOwnedOutput(LabelTarget::new("Host", "labels").unwrap()).apply(&mut resource);
747
748        assert!(!resource.attributes().contains_key("labels"));
749    }
750
751    #[test]
752    fn registry_preserves_other_types_attributes_without_cloning() {
753        let registry = LabelRegistryBuilder::new()
754            .add_labeler(Arc::new(EchoOwnedOutput(
755                LabelTarget::new("Host", "labels").unwrap(),
756            )))
757            .build()
758            .unwrap();
759        let resource = Resource::new("Document", "report")
760            .unwrap()
761            .with_attr("labels", AttrValue::String("forged".into()));
762
763        assert!(registry.apply_to_clone_if_applicable(&resource).is_none());
764        let mut applied = resource.clone();
765        registry.apply(&mut applied);
766        assert_eq!(applied, resource);
767        assert_eq!(
768            resource.attributes().get("labels"),
769            Some(&AttrValue::String("forged".into()))
770        );
771    }
772
773    #[test]
774    fn registry_clears_later_owned_output_before_earlier_derivation() {
775        let registry = LabelRegistryBuilder::new()
776            .add_labeler(Arc::new(CopyAttribute {
777                target: LabelTarget::new("Host", "labels").unwrap(),
778                input: "laterLabels",
779            }))
780            .add_labeler(Arc::new(CopyAttribute {
781                target: LabelTarget::new("Host", "laterLabels").unwrap(),
782                input: "unused",
783            }))
784            .build()
785            .unwrap();
786        let mut resource = Resource::new("Host", "attacker.invalid")
787            .unwrap()
788            .with_attr("laterLabels", AttrValue::String("forged".into()));
789
790        registry.apply(&mut resource);
791
792        assert!(!resource.attributes().contains_key("labels"));
793        assert!(!resource.attributes().contains_key("laterLabels"));
794    }
795
796    #[test]
797    fn registry_rejects_ambiguous_or_reserved_outputs() {
798        let first = RegexLabeler::new(
799            LabelTarget::new("Host", "labels").unwrap(),
800            "name",
801            Vec::new(),
802        )
803        .unwrap();
804        let second = RegexLabeler::new(
805            LabelTarget::new("Host", "labels").unwrap(),
806            "owner",
807            Vec::new(),
808        )
809        .unwrap();
810        let duplicate = LabelRegistryBuilder::new()
811            .add_labeler(Arc::new(first))
812            .add_labeler(Arc::new(second))
813            .build();
814        assert!(matches!(duplicate, Err(PolicyError::LabelConfigError(_))));
815
816        assert!(LabelTarget::new("Host", "id").is_err());
817        assert!(
818            RegexLabeler::new(
819                LabelTarget::new("Host", "name").unwrap(),
820                "name",
821                Vec::new()
822            )
823            .is_err()
824        );
825        assert!(LabelRegistryBuilder::versioned("").build().is_err());
826    }
827
828    #[test]
829    fn output_validation_preserves_cedar_record_key_semantics() {
830        // Cedar record keys are strings, including keys accessed with bracket
831        // syntax. Constructing a Context must not narrow that accepted set.
832        for output in [
833            "labels",
834            "has space",
835            "hyphen-name",
836            "名前",
837            "quote\"",
838            "a\0b",
839            "id ",
840        ] {
841            assert!(
842                cedar_policy::Context::from_pairs([(
843                    output.to_string(),
844                    RestrictedExpression::new_bool(true),
845                )])
846                .is_ok()
847            );
848            assert!(validate_output(output).is_ok(), "{output:?}");
849            assert!(
850                RegexLabeler::new(
851                    LabelTarget::new("Host", output).unwrap(),
852                    "name",
853                    Vec::new()
854                )
855                .is_ok()
856            );
857        }
858        for output in ["", " ", "\t\n", "id"] {
859            assert!(matches!(
860                validate_output(output),
861                Err(PolicyError::LabelConfigError(_))
862            ));
863        }
864    }
865
866    #[test]
867    fn controlled_apply_is_idempotent() {
868        let labeler = RegexLabeler::new(
869            LabelTarget::new("Host", "labels").unwrap(),
870            "name",
871            compile(vec![("prod", "prod")]),
872        )
873        .unwrap();
874        let mut resource = Resource::new("Host", "prod")
875            .unwrap()
876            .with_attr("name", AttrValue::String("prod".into()))
877            .with_attr(
878                "labels",
879                AttrValue::Set(vec![AttrValue::String("forged".into())]),
880            );
881
882        labeler.apply(&mut resource);
883        let once = resource.clone();
884        labeler.apply(&mut resource);
885        assert_eq!(resource, once);
886    }
887}