Skip to main content

heddle_object_model/object/
audience_tier.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The reader-side audience tier.
3//!
4//! [`AudienceTier`] is who is *asking*; [`VisibilityTier`](super::VisibilityTier)
5//! is who the content is *for*. Both live here so the who-sees-what mapping
6//! ([`visible`]) sits beside the two vocabularies it joins instead of a layer
7//! above them.
8//!
9//! The mapping from [`VisibilityTier`](super::VisibilityTier) to [`AudienceTier`]
10//! is the single source of truth for "who sees what":
11//!
12//! | annotation visibility    | shown to `Internal` | `Public` | `Team(X)`               | `Restricted` |
13//! |--------------------------|---------------------|----------|-------------------------|--------------|
14//! | `Public`                 | yes                 | yes      | yes                     | yes          |
15//! | `Internal`               | yes                 | no       | yes                     | no           |
16//! | `TeamScoped { team }`    | yes                 | no       | only if `team == X`     | no           |
17//! | `Restricted { ... }`     | yes                 | no       | no                      | only equal label |
18//! | `Private { ... }`        | no                  | no       | no                      | only equal label |
19//!
20//! `Internal` is the broadest ordinary audience (used by the
21//! workspace-internal reader); `Public` is the anonymous/public audience.
22//! `Private` is stricter than both: only the matching restricted-scope holder
23//! can read it.
24
25use std::str::FromStr;
26
27use super::VisibilityTier;
28
29/// Audience reading the annotation set. Matches the CLI's
30/// `--audience <internal|public|team:NAME>` flag and the web's payload-
31/// shaping context.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub enum AudienceTier {
34    /// Workspace-internal viewer — sees every annotation regardless of
35    /// scope. The `--audience internal` value on Git projection export.
36    Internal,
37    /// Anonymous public viewer — sees only `Public` annotations. Default
38    /// for Git projection export and the public-PR review surface.
39    Public,
40    /// A specific team. Sees Public, Internal (assumed in-network), and
41    /// `TeamScoped` annotations whose team matches.
42    Team(String),
43    /// A restricted scope label (legal, security, etc.). Sees Public and
44    /// `Restricted` annotations whose label matches.
45    Restricted(String),
46}
47
48/// Error from [`AudienceTier::from_str`]. The string form is what the
49/// CLI's `--audience` flag accepts; bad input here is a usage error.
50#[derive(Debug, thiserror::Error)]
51pub enum AudienceParseError {
52    #[error("audience must be one of: internal, public, team:<NAME>, restricted:<LABEL>")]
53    Unknown,
54    #[error("`team:` audience requires a non-empty NAME")]
55    MissingTeamName,
56    #[error("`restricted:` audience requires a non-empty LABEL")]
57    MissingRestrictedLabel,
58}
59
60impl FromStr for AudienceTier {
61    type Err = AudienceParseError;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        let trimmed = s.trim();
65        if trimmed.eq_ignore_ascii_case("internal") {
66            return Ok(AudienceTier::Internal);
67        }
68        if trimmed.eq_ignore_ascii_case("public") {
69            return Ok(AudienceTier::Public);
70        }
71        if let Some(rest) = trimmed.strip_prefix("team:") {
72            let name = rest.trim();
73            if name.is_empty() {
74                return Err(AudienceParseError::MissingTeamName);
75            }
76            return Ok(AudienceTier::Team(name.to_string()));
77        }
78        if let Some(rest) = trimmed.strip_prefix("restricted:") {
79            let label = rest.trim();
80            if label.is_empty() {
81                return Err(AudienceParseError::MissingRestrictedLabel);
82            }
83            return Ok(AudienceTier::Restricted(label.to_string()));
84        }
85        Err(AudienceParseError::Unknown)
86    }
87}
88
89/// Single source-of-truth for the visibility×audience mapping. Pure over
90/// the two tier enums, so every consumer (annotation filtering, bridge
91/// export gating) shares the exact same rules — drift between consumers
92/// would be invisible at the call site and catastrophic for the Git
93/// projection export footer.
94pub fn visible(visibility: &VisibilityTier, audience: &AudienceTier) -> bool {
95    match (visibility, audience) {
96        // Public is universally visible.
97        (VisibilityTier::Public, _) => true,
98        // Private is the strictest tier: visible ONLY to the holder of the
99        // exact matching Restricted scope label, and withheld from everyone
100        // else — *including* the otherwise all-seeing Internal audience. These
101        // two arms MUST stay above `(_, AudienceTier::Internal) => true`:
102        // match arms evaluate top-to-bottom, so a Private arm below it would
103        // never be reached for an Internal audience and the embargo would
104        // silently leak to internal callers.
105        (VisibilityTier::Private { scope_label }, AudienceTier::Restricted(viewer)) => {
106            scope_label == viewer
107        }
108        (VisibilityTier::Private { .. }, _) => false,
109        // Internal sees everything else (internal viewers are the trusted set).
110        (_, AudienceTier::Internal) => true,
111        // Internal annotations to a public/restricted viewer are hidden.
112        (VisibilityTier::Internal, AudienceTier::Public)
113        | (VisibilityTier::Internal, AudienceTier::Restricted(_)) => false,
114        // Internal annotations to a team viewer are visible — the team
115        // is part of the workspace-internal trusted set. (Public-only
116        // export still hides them via the previous arm.)
117        (VisibilityTier::Internal, AudienceTier::Team(_)) => true,
118        // Team-scoped: visible only to that exact team.
119        (VisibilityTier::TeamScoped { team_id }, AudienceTier::Team(name)) => team_id == name,
120        (VisibilityTier::TeamScoped { .. }, _) => false,
121        // Restricted: visible only to a viewer holding the matching label.
122        (VisibilityTier::Restricted { scope_label }, AudienceTier::Restricted(viewer_label)) => {
123            scope_label == viewer_label
124        }
125        (VisibilityTier::Restricted { .. }, _) => false,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn public_is_universally_visible_and_team_matches_exact_id() {
135        assert!(visible(&VisibilityTier::Public, &AudienceTier::Public));
136        assert!(visible(
137            &VisibilityTier::TeamScoped {
138                team_id: "infra".into()
139            },
140            &AudienceTier::Team("infra".into())
141        ));
142        assert!(!visible(
143            &VisibilityTier::TeamScoped {
144                team_id: "infra".into()
145            },
146            &AudienceTier::Team("design".into())
147        ));
148        assert!(visible(
149            &VisibilityTier::Restricted {
150                scope_label: "legal".into()
151            },
152            &AudienceTier::Internal
153        ));
154        assert!(!visible(
155            &VisibilityTier::Internal,
156            &AudienceTier::Restricted("legal".into())
157        ));
158    }
159
160    #[test]
161    fn private_visible_only_to_matching_restricted_audience() {
162        let vis = VisibilityTier::Private {
163            scope_label: "sec-embargo".into(),
164        };
165        // The one authorized scope sees it.
166        assert!(visible(
167            &vis,
168            &AudienceTier::Restricted("sec-embargo".into())
169        ));
170        // A non-matching restricted label does not.
171        assert!(!visible(&vis, &AudienceTier::Restricted("legal".into())));
172    }
173
174    #[test]
175    fn private_is_hidden_even_from_the_all_seeing_internal_audience() {
176        // The whole point of Private over Restricted: the otherwise
177        // all-seeing Internal audience is denied. The Private arm MUST sit
178        // above the `(_, Internal) => true` arm.
179        let vis = VisibilityTier::Private {
180            scope_label: "sec-embargo".into(),
181        };
182        assert!(!visible(&vis, &AudienceTier::Internal));
183        assert!(!visible(&vis, &AudienceTier::Public));
184        assert!(!visible(&vis, &AudienceTier::Team("infra".into())));
185    }
186
187    #[test]
188    fn parse_audience_strings() {
189        assert_eq!(
190            "internal".parse::<AudienceTier>().unwrap(),
191            AudienceTier::Internal
192        );
193        assert_eq!(
194            "public".parse::<AudienceTier>().unwrap(),
195            AudienceTier::Public
196        );
197        assert_eq!(
198            "team:infra".parse::<AudienceTier>().unwrap(),
199            AudienceTier::Team("infra".into())
200        );
201        assert_eq!(
202            "restricted:legal".parse::<AudienceTier>().unwrap(),
203            AudienceTier::Restricted("legal".into())
204        );
205        assert!("team:".parse::<AudienceTier>().is_err());
206        assert!("nonsense".parse::<AudienceTier>().is_err());
207    }
208}