Skip to main content

silicon_browser_shared/
access.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::validation::{MAX_IDENTIFIER_CHARS, collection_len, identifier};
6use crate::{Identity, Validate, ValidationError};
7
8/// Normalized union of explicit principals (`@id`) and IAM tags (`tag`).
9///
10/// Serialization is deliberately a plain string array so it maps directly to
11/// `--access [@carbon,@silicon,growth]`.
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct AccessList(Vec<String>);
15
16impl AccessList {
17    /// Generous wire limit that bounds ACL evaluation and serialized profile rows.
18    pub const MAX_ENTRIES: usize = 256;
19    pub const MAX_ENTRY_CHARS: usize = MAX_IDENTIFIER_CHARS;
20
21    pub fn new<I, S>(entries: I) -> Result<Self, ValidationError>
22    where
23        I: IntoIterator<Item = S>,
24        S: AsRef<str>,
25    {
26        let mut seen = HashSet::new();
27        let mut normalized = Vec::new();
28        for (index, entry) in entries.into_iter().enumerate() {
29            collection_len(index + 1, "access", Self::MAX_ENTRIES)?;
30            let entry = normalize_entry(entry.as_ref())?;
31            if seen.insert(entry.clone()) {
32                normalized.push(entry);
33            }
34        }
35        Ok(Self(normalized))
36    }
37
38    /// Adds the immutable owner grant, retaining the first occurrence of every
39    /// other grant. IAM IDs remain case-sensitive.
40    pub fn with_owner<I, S>(owner_id: &str, entries: I) -> Result<Self, ValidationError>
41    where
42        I: IntoIterator<Item = S>,
43        S: AsRef<str>,
44    {
45        let owner = canonical_principal(owner_id)?;
46        let entries = Self::new(entries)?;
47        let mut values = vec![owner.clone()];
48        values.extend(entries.into_iter().filter(|entry| entry != &owner));
49        Self::new(values)
50    }
51
52    pub fn normalized_with_owner(&self, owner_id: &str) -> Result<Self, ValidationError> {
53        Self::with_owner(owner_id, self.0.iter().map(String::as_str))
54    }
55
56    pub fn allows(&self, identity: &Identity) -> bool {
57        identity.principal_ids().any(|principal_id| self.allows_principal_and_tags(principal_id, &identity.tags))
58    }
59
60    pub fn allows_principal_and_tags<S: AsRef<str>>(&self, principal_id: &str, tags: &[S]) -> bool {
61        let Ok(principal) = canonical_principal(principal_id) else {
62            return false;
63        };
64        self.0.iter().any(|grant| {
65            grant == &principal
66                || (!grant.starts_with('@') && tags.iter().any(|tag| tag.as_ref().trim() == grant.as_str()))
67        })
68    }
69
70    pub fn contains_principal(&self, principal_id: &str) -> bool {
71        canonical_principal(principal_id).is_ok_and(|principal| self.0.contains(&principal))
72    }
73
74    pub fn as_slice(&self) -> &[String] {
75        &self.0
76    }
77
78    pub fn iter(&self) -> impl Iterator<Item = &str> {
79        self.0.iter().map(String::as_str)
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.0.is_empty()
84    }
85
86    pub fn len(&self) -> usize {
87        self.0.len()
88    }
89}
90
91impl Validate for AccessList {
92    fn validate(&self) -> Result<(), ValidationError> {
93        collection_len(self.0.len(), "access", Self::MAX_ENTRIES)?;
94        for entry in &self.0 {
95            normalize_entry(entry)?;
96        }
97        Ok(())
98    }
99}
100
101impl TryFrom<Vec<String>> for AccessList {
102    type Error = ValidationError;
103
104    fn try_from(value: Vec<String>) -> Result<Self, Self::Error> {
105        Self::new(value)
106    }
107}
108
109impl IntoIterator for AccessList {
110    type Item = String;
111    type IntoIter = std::vec::IntoIter<String>;
112
113    fn into_iter(self) -> Self::IntoIter {
114        self.0.into_iter()
115    }
116}
117
118fn canonical_principal(value: &str) -> Result<String, ValidationError> {
119    let value = value.trim().strip_prefix('@').unwrap_or(value.trim());
120    identifier(value, "access")?;
121    if value.starts_with('@') || has_access_delimiter(value) {
122        return Err(invalid_access());
123    }
124    Ok(format!("@{value}"))
125}
126
127fn normalize_entry(value: &str) -> Result<String, ValidationError> {
128    let value = value.trim();
129    if value.is_empty() || has_access_delimiter(value) {
130        return Err(invalid_access());
131    }
132    if let Some(principal) = value.strip_prefix('@') {
133        if principal.starts_with('@') {
134            return Err(invalid_access());
135        }
136        canonical_principal(principal)
137    } else {
138        if value.chars().count() > AccessList::MAX_ENTRY_CHARS {
139            return Err(ValidationError::TooLong { field: "access", max: AccessList::MAX_ENTRY_CHARS });
140        }
141        if value.chars().any(|character| character.is_whitespace() || character.is_control()) {
142            return Err(invalid_access());
143        }
144        Ok(value.to_owned())
145    }
146}
147
148fn has_access_delimiter(value: &str) -> bool {
149    value.chars().any(|ch| matches!(ch, ',' | '[' | ']'))
150}
151
152fn invalid_access() -> ValidationError {
153    ValidationError::Invalid {
154        field: "access",
155        reason: "expected @principal or a tag without whitespace, controls, commas, or brackets".into(),
156    }
157}
158
159#[cfg(test)]
160mod access_list_tests {
161    use super::*;
162    use crate::IdentityKind;
163
164    fn identity(id: &str, tags: &[&str]) -> Identity {
165        Identity {
166            id: id.into(),
167            name: id.into(),
168            kind: IdentityKind::Silicon,
169            tags: tags.iter().map(ToString::to_string).collect(),
170            verified_aliases: Vec::new(),
171        }
172    }
173
174    /// Test group: profile access is a set union with an owner who cannot be omitted.
175    #[test]
176    fn owner_is_first_and_duplicates_are_removed() {
177        let access = AccessList::with_owner("silicon-1", ["growth", "@silicon-1", "growth"]).unwrap();
178        assert_eq!(access.as_slice(), ["@silicon-1", "growth"]);
179    }
180
181    /// Test group: either an explicit identity or any IAM tag grants access.
182    #[test]
183    fn matches_principals_and_tags() {
184        let access = AccessList::new(["@carbon-1", "growth"]).unwrap();
185        assert!(access.allows(&identity("carbon-1", &[])));
186        assert!(access.allows(&identity("silicon-2", &["growth"])));
187        assert!(!access.allows(&identity("silicon-3", &["sales"])));
188    }
189
190    /// Test group: malformed CLI list fragments never become ambiguous grants.
191    #[test]
192    fn rejects_empty_or_delimited_entries() {
193        assert!(AccessList::new([""]).is_err());
194        assert!(AccessList::new(["@one,@two"]).is_err());
195        assert!(AccessList::new(["sales team"]).is_err());
196        assert!(AccessList::new(["@@principal"]).is_err());
197        assert!(AccessList::new(["sales\0team"]).is_err());
198    }
199
200    /// Test group: ACL work and persisted rows remain bounded before normalization.
201    #[test]
202    fn bounds_entry_count_and_size() {
203        let entries: Vec<_> = (0..=AccessList::MAX_ENTRIES).map(|index| format!("team-{index}")).collect();
204        assert!(AccessList::new(&entries[..AccessList::MAX_ENTRIES]).is_ok());
205        assert_eq!(
206            AccessList::new(&entries),
207            Err(ValidationError::TooMany { field: "access", max: AccessList::MAX_ENTRIES })
208        );
209        assert!(AccessList::new(["x".repeat(AccessList::MAX_ENTRY_CHARS)]).is_ok());
210        assert!(AccessList::new([format!("@{}", "x".repeat(AccessList::MAX_ENTRY_CHARS))]).is_ok());
211        assert_eq!(
212            AccessList::new(["x".repeat(AccessList::MAX_ENTRY_CHARS + 1)]),
213            Err(ValidationError::TooLong { field: "access", max: AccessList::MAX_ENTRY_CHARS })
214        );
215    }
216
217    #[test]
218    fn full_acl_with_owner_can_be_normalized_again() {
219        let entries = (0..AccessList::MAX_ENTRIES - 1).map(|index| format!("team-{index}"));
220        let access = AccessList::with_owner("owner", entries).unwrap();
221        assert_eq!(access.len(), AccessList::MAX_ENTRIES);
222        assert_eq!(access.normalized_with_owner("owner").unwrap(), access);
223        assert!(access.normalized_with_owner("another-owner").is_err());
224    }
225
226    /// Test group: access remains a minimal string-array wire representation.
227    #[test]
228    fn serializes_as_string_array() {
229        let access = AccessList::new(["@one", "growth"]).unwrap();
230        assert_eq!(serde_json::to_string(&access).unwrap(), r#"["@one","growth"]"#);
231    }
232}