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}