1use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[non_exhaustive]
16pub enum Right {
17 #[serde(rename = "git.ns.admin")]
19 NsAdmin,
20 #[serde(rename = "git.repo.create")]
22 RepoCreate,
23 #[serde(rename = "git.repo.own")]
25 RepoOwn,
26 #[serde(rename = "git.repo.maintain")]
28 RepoMaintain,
29 #[serde(rename = "git.commit.sign")]
32 CommitSign,
33}
34
35impl Right {
36 pub const ALL: [Right; 5] = [
38 Right::NsAdmin,
39 Right::RepoCreate,
40 Right::RepoOwn,
41 Right::RepoMaintain,
42 Right::CommitSign,
43 ];
44
45 pub fn action(self) -> &'static str {
47 match self {
48 Right::NsAdmin => "git.ns.admin",
49 Right::RepoCreate => "git.repo.create",
50 Right::RepoOwn => "git.repo.own",
51 Right::RepoMaintain => "git.repo.maintain",
52 Right::CommitSign => "git.commit.sign",
53 }
54 }
55
56 pub fn from_action(action: &str) -> Option<Right> {
59 Right::ALL.into_iter().find(|r| r.action() == action)
60 }
61
62 fn bit(self) -> u8 {
63 1 << (self as u8)
64 }
65}
66
67impl fmt::Display for Right {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.write_str(self.action())
70 }
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
94pub struct EffectiveRights {
95 held: u8,
97 projectable: u8,
100}
101
102impl EffectiveRights {
103 pub const NONE: EffectiveRights = EffectiveRights {
105 held: 0,
106 projectable: 0,
107 };
108
109 pub fn from_granted(granted: impl IntoIterator<Item = Right>) -> Self {
111 let mut rights = EffectiveRights::NONE;
112 for right in granted {
113 rights.insert(right);
114 }
115 rights
116 }
117
118 pub fn insert(&mut self, right: Right) {
120 let closure = Self::closure(right);
121 self.held |= closure;
122 match right {
123 Right::NsAdmin | Right::RepoCreate => {}
126 Right::RepoOwn | Right::RepoMaintain | Right::CommitSign => {
127 self.projectable |= closure;
128 }
129 }
130 }
131
132 fn closure(right: Right) -> u8 {
134 right.bit()
135 | match right {
136 Right::NsAdmin => Self::closure(Right::RepoCreate) | Self::closure(Right::RepoOwn),
137 Right::RepoOwn => Self::closure(Right::RepoMaintain),
138 Right::RepoMaintain => Self::closure(Right::CommitSign),
139 Right::RepoCreate | Right::CommitSign => 0,
140 }
141 }
142
143 pub fn holds(self, right: Right) -> bool {
145 self.held & right.bit() != 0
146 }
147
148 pub fn is_empty(self) -> bool {
150 self.held == 0
151 }
152
153 pub fn iter(self) -> impl Iterator<Item = Right> {
155 Right::ALL.into_iter().filter(move |r| self.holds(*r))
156 }
157
158 pub fn repo_tier(self) -> Option<Right> {
163 Self::tier(self.held)
164 }
165
166 pub fn forge_tier(self) -> Option<Right> {
171 Self::tier(self.projectable)
172 }
173
174 pub fn granted(self) -> Vec<Right> {
179 let mut out = Vec::new();
180 if self.holds(Right::NsAdmin) {
181 out.push(Right::NsAdmin);
182 } else if self.holds(Right::RepoCreate) {
183 out.push(Right::RepoCreate);
184 }
185 out.extend(self.forge_tier());
186 out
187 }
188
189 fn tier(bits: u8) -> Option<Right> {
190 [Right::RepoOwn, Right::RepoMaintain, Right::CommitSign]
191 .into_iter()
192 .find(|r| bits & r.bit() != 0)
193 }
194}
195
196impl Serialize for EffectiveRights {
200 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
201 s.collect_seq(self.granted())
202 }
203}
204
205impl<'de> Deserialize<'de> for EffectiveRights {
206 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
207 Ok(EffectiveRights::from_granted(Vec::<Right>::deserialize(d)?))
208 }
209}
210
211#[derive(
218 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
219)]
220#[serde(rename_all = "lowercase")]
221#[non_exhaustive]
222pub enum ForgeRole {
223 #[default]
225 None,
226 Read,
228 Triage,
230 Write,
232 Maintain,
234 Admin,
236}
237
238impl fmt::Display for ForgeRole {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 f.write_str(match self {
241 ForgeRole::None => "none",
242 ForgeRole::Read => "read",
243 ForgeRole::Triage => "triage",
244 ForgeRole::Write => "write",
245 ForgeRole::Maintain => "maintain",
246 ForgeRole::Admin => "admin",
247 })
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase", try_from = "RawRoleMap")]
274pub struct RoleMap {
275 own: ForgeRole,
276 maintain: ForgeRole,
277 commit: ForgeRole,
278}
279
280#[derive(Deserialize)]
282#[serde(rename_all = "camelCase", deny_unknown_fields)]
283struct RawRoleMap {
284 own: ForgeRole,
285 maintain: ForgeRole,
286 commit: ForgeRole,
287}
288
289impl TryFrom<RawRoleMap> for RoleMap {
290 type Error = String;
291 fn try_from(r: RawRoleMap) -> Result<Self, String> {
292 RoleMap::new(r.own, r.maintain, r.commit)
293 }
294}
295
296impl Default for RoleMap {
297 fn default() -> Self {
298 RoleMap {
299 own: ForgeRole::Admin,
300 maintain: ForgeRole::Maintain,
301 commit: ForgeRole::None,
302 }
303 }
304}
305
306impl RoleMap {
307 pub const MAX_COMMIT: ForgeRole = ForgeRole::Write;
309
310 pub fn new(own: ForgeRole, maintain: ForgeRole, commit: ForgeRole) -> Result<Self, String> {
314 for (tier, role) in [("a maintainer", maintain), ("a committer", commit)] {
315 if role >= ForgeRole::Admin {
316 return Err(format!(
317 "{tier} may not get `{role}`: only an owner (`own`) may map to the forge's \
318 administrator role, since maintain and commit are rights their holder may \
319 grant themselves"
320 ));
321 }
322 }
323 if maintain > own {
324 return Err(format!(
325 "a maintainer (`{maintain}`) may not get more than an owner (`{own}`)"
326 ));
327 }
328 if commit > maintain {
329 return Err(format!(
330 "a committer (`{commit}`) may not get more than a maintainer (`{maintain}`)"
331 ));
332 }
333 if commit > Self::MAX_COMMIT {
334 return Err(format!(
335 "a committer may get at most `{}`, not `{commit}`: the check decides whose \
336 commits land, and merging is a maintainer's",
337 Self::MAX_COMMIT
338 ));
339 }
340 Ok(RoleMap {
341 own,
342 maintain,
343 commit,
344 })
345 }
346
347 pub fn own(&self) -> ForgeRole {
349 self.own
350 }
351
352 pub fn maintain(&self) -> ForgeRole {
354 self.maintain
355 }
356
357 pub fn commit(&self) -> ForgeRole {
361 self.commit
362 }
363
364 pub fn with_committer_write() -> Self {
367 RoleMap {
368 commit: ForgeRole::Write,
369 ..RoleMap::default()
370 }
371 }
372
373 pub fn requested(&self, rights: EffectiveRights) -> ForgeRole {
378 match rights.forge_tier() {
379 Some(Right::RepoOwn) => self.own,
380 Some(Right::RepoMaintain) => self.maintain,
381 Some(Right::CommitSign) => self.commit,
382 _ => ForgeRole::None,
383 }
384 }
385}
386
387pub fn collapse_to_ladder(requested: ForgeRole, ladder: &[ForgeRole]) -> ForgeRole {
394 ladder
395 .iter()
396 .copied()
397 .filter(|level| *level <= requested)
398 .max()
399 .unwrap_or(ForgeRole::None)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn implication_closes_own_to_commit_and_admin_to_own() {
408 let own = EffectiveRights::from_granted([Right::RepoOwn]);
409 assert!(own.holds(Right::RepoMaintain) && own.holds(Right::CommitSign));
410 assert!(!own.holds(Right::NsAdmin) && !own.holds(Right::RepoCreate));
411
412 let admin = EffectiveRights::from_granted([Right::NsAdmin]);
413 assert_eq!(admin.iter().count(), 5);
414 assert_eq!(admin.repo_tier(), Some(Right::RepoOwn));
415 assert_eq!(admin.forge_tier(), None, "ns.admin never projects");
416
417 let commit = EffectiveRights::from_granted([Right::CommitSign]);
418 assert_eq!(commit.iter().collect::<Vec<_>>(), vec![Right::CommitSign]);
419 assert_eq!(commit.repo_tier(), Some(Right::CommitSign));
420
421 let create = EffectiveRights::from_granted([Right::RepoCreate]);
422 assert_eq!(create.repo_tier(), None);
423 }
424
425 #[test]
426 fn actions_round_trip() {
427 for r in Right::ALL {
428 assert_eq!(Right::from_action(r.action()), Some(r));
429 }
430 assert_eq!(Right::from_action("vtc.member"), None);
431 let json =
432 serde_json::to_string(&EffectiveRights::from_granted([Right::RepoMaintain])).unwrap();
433 assert_eq!(json, r#"["git.repo.maintain"]"#);
434 }
435
436 #[test]
437 fn default_map_matches_the_org_projection() {
438 let map = RoleMap::default();
439 let r = |x| EffectiveRights::from_granted([x]);
440 assert_eq!(map.requested(r(Right::NsAdmin)), ForgeRole::None);
441 assert_eq!(map.requested(r(Right::RepoCreate)), ForgeRole::None);
442 assert_eq!(map.requested(r(Right::RepoOwn)), ForgeRole::Admin);
443 assert_eq!(map.requested(r(Right::RepoMaintain)), ForgeRole::Maintain);
444 assert_eq!(map.requested(r(Right::CommitSign)), ForgeRole::None);
445 assert_eq!(
446 RoleMap::with_committer_write().requested(r(Right::CommitSign)),
447 ForgeRole::Write
448 );
449 assert_eq!(map.requested(EffectiveRights::NONE), ForgeRole::None);
450 }
451
452 #[test]
453 fn a_namespace_admin_gets_no_forge_role_under_any_map() {
454 let admin = EffectiveRights::from_granted([Right::NsAdmin]);
455 let everything =
456 RoleMap::new(ForgeRole::Admin, ForgeRole::Maintain, ForgeRole::Write).unwrap();
457 for map in [
458 RoleMap::default(),
459 RoleMap::with_committer_write(),
460 everything,
461 ] {
462 assert_eq!(map.requested(admin), ForgeRole::None);
463 }
464 let both = EffectiveRights::from_granted([Right::NsAdmin, Right::RepoMaintain]);
467 assert!(both.holds(Right::RepoOwn));
468 assert_eq!(RoleMap::default().requested(both), ForgeRole::Maintain);
469 }
470
471 #[test]
472 fn serde_round_trips_are_the_identity() {
473 use Right::*;
474 let cases: &[(&[Right], &str)] = &[
475 (&[], "[]"),
476 (&[NsAdmin], r#"["git.ns.admin"]"#),
477 (&[NsAdmin, RepoOwn], r#"["git.ns.admin","git.repo.own"]"#),
478 (
479 &[NsAdmin, RepoMaintain],
480 r#"["git.ns.admin","git.repo.maintain"]"#,
481 ),
482 (&[NsAdmin, RepoCreate], r#"["git.ns.admin"]"#),
483 (
484 &[RepoCreate, CommitSign],
485 r#"["git.repo.create","git.commit.sign"]"#,
486 ),
487 (&[RepoOwn, RepoMaintain, CommitSign], r#"["git.repo.own"]"#),
488 ];
489 for (granted, want) in cases {
490 let x = EffectiveRights::from_granted(granted.iter().copied());
491 let json = serde_json::to_string(&x).unwrap();
492 assert_eq!(json, *want, "{granted:?}");
493 let back: EffectiveRights = serde_json::from_str(&json).unwrap();
494 assert_eq!(back, x, "{granted:?}");
495 assert_eq!(back.forge_tier(), x.forge_tier(), "{granted:?}");
496 assert_eq!(EffectiveRights::from_granted(x.granted()), x);
497 }
498 let admin: EffectiveRights = serde_json::from_str(
500 &serde_json::to_string(&EffectiveRights::from_granted([NsAdmin])).unwrap(),
501 )
502 .unwrap();
503 assert_eq!(RoleMap::default().requested(admin), ForgeRole::None);
504 assert_ne!(
506 EffectiveRights::from_granted([NsAdmin]),
507 EffectiveRights::from_granted([NsAdmin, RepoOwn])
508 );
509 assert_eq!(
510 EffectiveRights::from_granted([RepoOwn]),
511 EffectiveRights::from_granted([RepoOwn, CommitSign])
512 );
513 }
514
515 #[test]
516 fn a_role_map_is_ordered_and_committers_stop_at_write() {
517 use ForgeRole::*;
518 assert!(RoleMap::new(Admin, Maintain, Write).is_ok());
519 assert!(RoleMap::new(Admin, Write, Write).is_ok());
520 assert!(RoleMap::new(Write, Write, None).is_ok());
521 assert!(RoleMap::new(Maintain, Write, None).is_ok());
522 assert!(RoleMap::new(Write, Maintain, None).is_err());
523 assert!(RoleMap::new(Admin, Write, Maintain).is_err());
524 let ok: RoleMap =
525 serde_json::from_str(r#"{"own":"admin","maintain":"write","commit":"write"}"#).unwrap();
526 assert_eq!(ok.maintain(), Write);
527 for bad in [
528 r#"{"own":"write","maintain":"maintain","commit":"none"}"#,
529 r#"{"own":"admin","maintain":"write","commit":"maintain"}"#,
530 r#"{"own":"admin","maintain":"maintain","commit":"none","nsAdmin":"admin"}"#,
531 ] {
532 assert!(serde_json::from_str::<RoleMap>(bad).is_err(), "{bad}");
533 }
534 }
535
536 #[test]
537 fn only_an_owner_may_map_to_admin() {
538 use ForgeRole::*;
539 for (own, maintain, commit) in [
541 (Admin, Admin, None),
542 (Admin, Admin, Write),
543 (Admin, Admin, Admin),
544 (Admin, Maintain, Admin),
545 (Admin, Write, Admin),
546 ] {
547 let err = RoleMap::new(own, maintain, commit).unwrap_err();
548 assert!(
549 err.contains("only an owner"),
550 "{own}/{maintain}/{commit}: {err}"
551 );
552 }
553 for bad in [
555 r#"{"own":"admin","maintain":"admin","commit":"none"}"#,
556 r#"{"own":"admin","maintain":"admin","commit":"write"}"#,
557 r#"{"own":"admin","maintain":"maintain","commit":"admin"}"#,
558 ] {
559 let err = serde_json::from_str::<RoleMap>(bad)
560 .unwrap_err()
561 .to_string();
562 assert!(err.contains("only an owner"), "{bad}: {err}");
563 }
564 for map in [RoleMap::default(), RoleMap::with_committer_write()] {
566 assert!(map.maintain() < Admin && map.commit() < Admin);
567 }
568 assert_eq!(RoleMap::default().own(), Admin);
569 }
570
571 #[test]
572 fn collapsing_rounds_down_never_up() {
573 let forgejo = [ForgeRole::Read, ForgeRole::Write, ForgeRole::Admin];
574 assert_eq!(
575 collapse_to_ladder(ForgeRole::Maintain, &forgejo),
576 ForgeRole::Write
577 );
578 assert_eq!(
579 collapse_to_ladder(ForgeRole::Admin, &forgejo),
580 ForgeRole::Admin
581 );
582 assert_eq!(
583 collapse_to_ladder(ForgeRole::Triage, &forgejo),
584 ForgeRole::Read
585 );
586
587 let personal = [ForgeRole::Write];
588 assert_eq!(
589 collapse_to_ladder(ForgeRole::Admin, &personal),
590 ForgeRole::Write
591 );
592 assert_eq!(
593 collapse_to_ladder(ForgeRole::Read, &personal),
594 ForgeRole::None
595 );
596 assert_eq!(
597 collapse_to_ladder(ForgeRole::None, &personal),
598 ForgeRole::None
599 );
600 }
601}