silicon_browser_shared/
access.rs1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct AccessList(Vec<String>);
15
16impl AccessList {
17 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 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]
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]
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]
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]
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]
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}