fabrica

hanna/fabrica

11411 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The permission model: one function, [`access`], that every route funnels
6//! through.
7//!
8//! Keeping authorization in a single pure function is what makes it testable to
9//! exhaustion (see the matrix test below) and impossible for a route to get
10//! subtly wrong. The function takes everything it needs by value or reference and
11//! returns a verdict; the *consequences* of that verdict — in particular that
12//! [`Access::None`] on a private repo renders `404`, never `403`, so existence
13//! does not leak — belong to the web and API layers.
14
15use model::{Repo, Visibility};
16
17/// The access level a viewer has to a repository, in increasing order of power.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Access {
20 /// No access. On a private repo the caller MUST render `404`, not `403`.
21 None,
22 /// May read: browse, clone, and read-only API.
23 Read,
24 /// May read and push.
25 Write,
26 /// May read, push, and administer (settings, collaborators, deletion).
27 Admin,
28}
29
30/// A stored collaborator permission — the `repo_collaborators.permission` column.
31///
32/// Distinct from [`Access`] because a collaborator row can never say "no access"
33/// (its absence does); the two convert one way, [`Permission`] into [`Access`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Permission {
36 /// Read-only collaborator.
37 Read,
38 /// Read-write collaborator.
39 Write,
40 /// Administrative collaborator.
41 Admin,
42}
43
44impl Permission {
45 /// The wire/database spelling (`read` | `write` | `admin`).
46 #[must_use]
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Read => "read",
50 Self::Write => "write",
51 Self::Admin => "admin",
52 }
53 }
54
55 /// Parse a stored permission string, returning `None` for anything
56 /// unrecognized (a corrupt row grants nothing rather than panicking).
57 #[must_use]
58 pub fn parse(s: &str) -> Option<Self> {
59 match s {
60 "read" => Some(Self::Read),
61 "write" => Some(Self::Write),
62 "admin" => Some(Self::Admin),
63 _ => None,
64 }
65 }
66}
67
68impl From<Permission> for Access {
69 fn from(permission: Permission) -> Self {
70 match permission {
71 Permission::Read => Self::Read,
72 Permission::Write => Self::Write,
73 Permission::Admin => Self::Admin,
74 }
75 }
76}
77
78/// The authenticated identity making a request, as far as authorization cares.
79///
80/// Anonymous requests pass `None` in place of a `Viewer`. A disabled account must
81/// never be turned into a `Viewer` — that check belongs to the authentication
82/// layer, upstream of every call here.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Viewer {
85 /// The viewer's user id (`users.id`).
86 pub id: String,
87 /// Whether the viewer is a site administrator.
88 pub is_admin: bool,
89}
90
91/// Resolve the access `viewer` has to `repo`.
92///
93/// The rules are applied strictly in order; the first that matches wins:
94///
95/// 1. a site admin gets [`Access::Admin`];
96/// 2. the repo's owner gets [`Access::Admin`];
97/// 3. an explicit collaborator gets exactly their stored permission;
98/// 4. otherwise the repo's [`Visibility`] decides:
99/// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous
100/// viewers when the instance allows anonymous access;
101/// - **internal**: [`Access::Read`] for any signed-in viewer, never anonymous;
102/// - **private**: [`Access::None`].
103///
104/// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if
105/// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`
106/// and now gates only anonymous access to *public* repos.
107#[must_use]
108pub fn access(
109 viewer: Option<&Viewer>,
110 repo: &Repo,
111 collaborator: Option<Permission>,
112 allow_anonymous: bool,
113) -> Access {
114 if let Some(v) = viewer {
115 if v.is_admin {
116 return Access::Admin;
117 }
118 if v.id == repo.owner_id {
119 return Access::Admin;
120 }
121 if let Some(permission) = collaborator {
122 return permission.into();
123 }
124 }
125 match repo.visibility {
126 Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read,
127 Visibility::Internal if viewer.is_some() => Access::Read,
128 _ => Access::None,
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 #![allow(clippy::unwrap_used)]
135
136 use super::*;
137
138 /// A repo owned by `owner`, with the given visibility. Only the fields
139 /// `access` reads are meaningful; the rest are filler.
140 fn repo(owner: &str, visibility: Visibility) -> Repo {
141 Repo {
142 id: "01reporeporeporeporeporepo".to_string(),
143 owner_id: owner.to_string(),
144 group_id: None,
145 name: "r".to_string(),
146 path: "r".to_string(),
147 description: None,
148 visibility,
149 default_branch: "main".to_string(),
150 object_format: "sha1".to_string(),
151 issues_enabled: true,
152 pulls_enabled: true,
153 releases_enabled: true,
154 is_mirror: false,
155 fork_parent_id: None,
156 next_iid: 1,
157 archived_at: None,
158 size_bytes: 0,
159 pushed_at: None,
160 created_at: 0,
161 updated_at: 0,
162 }
163 }
164
165 /// Every visibility, for exhaustive iteration.
166 const ALL_VIS: [Visibility; 3] = [
167 Visibility::Public,
168 Visibility::Internal,
169 Visibility::Private,
170 ];
171
172 fn viewer(id: &str, is_admin: bool) -> Viewer {
173 Viewer {
174 id: id.to_string(),
175 is_admin,
176 }
177 }
178
179 #[test]
180 fn permission_strings_round_trip() {
181 for p in [Permission::Read, Permission::Write, Permission::Admin] {
182 assert_eq!(Permission::parse(p.as_str()), Some(p));
183 }
184 assert_eq!(Permission::parse("owner"), None);
185 }
186
187 #[test]
188 fn access_is_ordered() {
189 assert!(Access::None < Access::Read);
190 assert!(Access::Read < Access::Write);
191 assert!(Access::Write < Access::Admin);
192 }
193
194 /// One row of the access matrix: the inputs and the verdict they must yield.
195 struct Case {
196 viewer: Option<Viewer>,
197 collaborator: Option<Permission>,
198 visibility: Visibility,
199 allow_anonymous: bool,
200 expect: Access,
201 }
202
203 /// The whole point of this crate: enumerate every combination of viewer kind,
204 /// visibility, collaborator permission, and the anonymous-access flag, and
205 /// assert the exact verdict. Nothing here is sampled — it is the full matrix.
206 #[test]
207 fn access_matrix_is_exhaustive() {
208 // Viewer kinds, expressed relative to a repo owned by "owner".
209 let admin = viewer("boss", true);
210 let owner = viewer("owner", false);
211 let other = viewer("stranger", false);
212
213 let mut cases = Vec::new();
214 let mut push = |viewer: Option<&Viewer>,
215 collaborator: Option<Permission>,
216 visibility: Visibility,
217 allow_anonymous: bool,
218 expect: Access| {
219 cases.push(Case {
220 viewer: viewer.cloned(),
221 collaborator,
222 visibility,
223 allow_anonymous,
224 expect,
225 });
226 };
227
228 for &vis in &ALL_VIS {
229 for &anon in &[true, false] {
230 // Rule 1: a site admin is Admin everywhere, regardless of every
231 // other dimension.
232 for collab in [None, Some(Permission::Read)] {
233 push(Some(&admin), collab, vis, anon, Access::Admin);
234 }
235 // Rule 2: the owner is Admin everywhere, even with a lesser
236 // collaborator row present and anonymous access off.
237 for collab in [None, Some(Permission::Read), Some(Permission::Write)] {
238 push(Some(&owner), collab, vis, anon, Access::Admin);
239 }
240 // Rule 3: an explicit collaborator gets exactly their permission,
241 // beating visibility and the anonymous flag.
242 push(
243 Some(&other),
244 Some(Permission::Read),
245 vis,
246 anon,
247 Access::Read,
248 );
249 push(
250 Some(&other),
251 Some(Permission::Write),
252 vis,
253 anon,
254 Access::Write,
255 );
256 push(
257 Some(&other),
258 Some(Permission::Admin),
259 vis,
260 anon,
261 Access::Admin,
262 );
263 }
264 }
265
266 // Rule 4: a non-collaborator, by visibility.
267 // Signed-in stranger: reads public and internal always; never private.
268 for &anon in &[true, false] {
269 push(Some(&other), None, Visibility::Public, anon, Access::Read);
270 push(Some(&other), None, Visibility::Internal, anon, Access::Read);
271 push(Some(&other), None, Visibility::Private, anon, Access::None);
272 }
273 // Anonymous: reads public only when allowed; never internal or private.
274 push(None, None, Visibility::Public, true, Access::Read);
275 push(None, None, Visibility::Public, false, Access::None);
276 push(None, None, Visibility::Internal, true, Access::None);
277 push(None, None, Visibility::Internal, false, Access::None);
278 push(None, None, Visibility::Private, true, Access::None);
279 push(None, None, Visibility::Private, false, Access::None);
280
281 for case in &cases {
282 let repo = repo("owner", case.visibility);
283 let got = access(
284 case.viewer.as_ref(),
285 &repo,
286 case.collaborator,
287 case.allow_anonymous,
288 );
289 assert_eq!(
290 got, case.expect,
291 "viewer={:?} collab={:?} vis={} anon={}",
292 case.viewer, case.collaborator, case.visibility, case.allow_anonymous
293 );
294 }
295 }
296
297 #[test]
298 fn private_repo_never_leaks_to_a_stranger() {
299 // The security-critical invariant: no combination of a non-privileged
300 // stranger on a private repo yields anything but None. Internal repos also
301 // never leak to anonymous visitors.
302 let stranger = viewer("stranger", false);
303 for &anon in &[true, false] {
304 assert_eq!(
305 access(
306 Some(&stranger),
307 &repo("owner", Visibility::Private),
308 None,
309 anon
310 ),
311 Access::None
312 );
313 assert_eq!(
314 access(None, &repo("owner", Visibility::Private), None, anon),
315 Access::None
316 );
317 assert_eq!(
318 access(None, &repo("owner", Visibility::Internal), None, anon),
319 Access::None
320 );
321 }
322 }
323}