1use std::collections::BTreeMap;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Action {
23 Read,
25 Write,
27 Deploy,
29 Admin,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Resource {
41 Site,
43 Project,
46 Blobs,
48 Tokens,
50 Certs,
52 Cache,
54 System,
56}
57
58impl Resource {
59 pub const ALL: [Self; 7] = [
61 Self::Site,
62 Self::Project,
63 Self::Blobs,
64 Self::Tokens,
65 Self::Certs,
66 Self::Cache,
67 Self::System,
68 ];
69
70 pub fn as_str(self) -> &'static str {
72 match self {
73 Self::Site => "site",
74 Self::Project => "project",
75 Self::Blobs => "blobs",
76 Self::Tokens => "tokens",
77 Self::Certs => "certs",
78 Self::Cache => "cache",
79 Self::System => "system",
80 }
81 }
82}
83
84impl Action {
85 pub fn as_str(self) -> &'static str {
87 match self {
88 Self::Read => "read",
89 Self::Write => "write",
90 Self::Deploy => "deploy",
91 Self::Admin => "admin",
92 }
93 }
94}
95
96impl std::fmt::Display for Resource {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(self.as_str())
99 }
100}
101
102impl std::fmt::Display for Action {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(self.as_str())
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
113pub struct Right {
114 pub resource: Resource,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub target: Option<String>,
119 pub action: Action,
121}
122
123impl Right {
124 pub fn new(resource: Resource, target: Option<String>, action: Action) -> Self {
126 Self {
127 resource,
128 target,
129 action,
130 }
131 }
132
133 pub fn target_term(&self) -> &str {
135 self.target.as_deref().unwrap_or("*")
136 }
137
138 pub fn satisfies(&self, required: &Self) -> bool {
142 self.resource == required.resource
143 && (self.action == required.action || self.action == Action::Admin)
144 && target_matches(self.target.as_deref(), required.target.as_deref())
145 }
146
147 pub fn required(method: &str, path: &str) -> Option<Self> {
154 let m = method.to_ascii_uppercase();
155 let get = m == "GET";
156
157 if path == "/api/auth/exchange" || path == "/api/auth/whoami" {
161 return None;
162 }
163
164 if path == "/api/cluster/join" {
169 return None;
170 }
171
172 if path == "/api/tokens/bootstrap" {
177 return None;
178 }
179
180 if path.starts_with("/api/blobs/") {
183 return Some(Self::new(Resource::Blobs, None, Action::Deploy));
184 }
185
186 if m == "POST" && path.contains("/domains/") && path.ends_with("/attach-unverified") {
193 return Some(Self::new(Resource::System, None, Action::Admin));
194 }
195
196 if let Some((proj, sub)) = project_api_path(path) {
200 if proj.is_empty() {
201 return Some(Self::new(Resource::System, None, Action::Read));
203 }
204 let sub: Vec<&str> = sub.split('/').filter(|s| !s.is_empty()).collect();
205 return Some(match sub.split_first() {
206 None => Self::new(
208 Resource::Project,
209 Some(proj.to_string()),
210 if get { Action::Read } else { Action::Admin },
211 ),
212 Some((&"sites", tail)) => {
214 let site = tail.first().copied().unwrap_or("");
215 if site.is_empty() {
216 return Some(Self::new(
217 Resource::Project,
218 Some(proj.to_string()),
219 Action::Read,
220 ));
221 }
222 let site_sub: Vec<&str> = tail.iter().skip(1).copied().collect();
223 match site_subpath_action(&m, get, &site_sub) {
224 Some(a) => Self::new(Resource::Site, Some(format!("{proj}/{site}")), a),
225 None => Self::new(Resource::System, None, Action::Admin),
227 }
228 }
229 Some(_) => Self::new(
232 Resource::Project,
233 Some(proj.to_string()),
234 if get { Action::Read } else { Action::Deploy },
235 ),
236 });
237 }
238
239 if let Some(rest) = path.strip_prefix("/api/sites/") {
242 let mut segs = rest.split('/');
243 let site = segs.next().unwrap_or("");
244 if site.is_empty() {
245 return Some(Self::new(Resource::System, None, Action::Read));
247 }
248 let target = Some(format!("{}/{site}", crate::project::DEFAULT_PROJECT));
249 let sub: Vec<&str> = segs.filter(|s| !s.is_empty()).collect();
250 let action = site_subpath_action(&m, get, &sub);
251 return Some(match action {
252 Some(a) => Self::new(Resource::Site, target, a),
253 None => Self::new(Resource::System, None, Action::Admin),
255 });
256 }
257
258 let default_project = crate::project::DEFAULT_PROJECT.to_string();
260 let right = match path {
261 "/api/sites" => Self::new(Resource::System, None, Action::Read),
262 "/api/projects" => {
265 let action = if get { Action::Read } else { Action::Admin };
266 Self::new(Resource::System, None, action)
267 }
268 p if p == "/api/functions" || p.starts_with("/api/functions/") => {
272 let action = if get { Action::Read } else { Action::Deploy };
273 Self::new(Resource::Project, Some(default_project.clone()), action)
274 }
275 p if p == "/api/workflows" || p.starts_with("/api/workflows/") => {
277 let action = if get { Action::Read } else { Action::Deploy };
278 Self::new(Resource::Project, Some(default_project.clone()), action)
279 }
280 p if p == "/api/compute" || p.starts_with("/api/compute/") => {
282 let action = if get { Action::Read } else { Action::Deploy };
283 Self::new(Resource::Project, Some(default_project.clone()), action)
284 }
285 p if p == "/api/graphql" || p.starts_with("/api/graphql/") => {
292 let action = if get { Action::Read } else { Action::Deploy };
293 Self::new(Resource::Project, Some(default_project.clone()), action)
294 }
295 "/api/blobs" => Self::new(Resource::Blobs, None, Action::Deploy),
296 "/api/certs" => Self::new(Resource::Certs, None, Action::Read),
297 "/api/cache/invalidate" => Self::new(Resource::Cache, None, Action::Write),
298 "/api/metrics" => Self::new(Resource::System, None, Action::Read),
299 "/api/prune" | "/api/scrub" => Self::new(Resource::System, None, Action::Admin),
300 p if p == "/api/tokens" || p.starts_with("/api/tokens/") => {
301 Self::new(Resource::Tokens, None, Action::Admin)
302 }
303 p if p == "/api/authz/policy" || p.starts_with("/api/authz/") => {
304 Self::new(Resource::System, None, Action::Admin)
305 }
306 _ => Self::new(Resource::System, None, Action::Admin),
308 };
309 Some(right)
310 }
311}
312
313fn site_subpath_action(method: &str, get: bool, sub: &[&str]) -> Option<Action> {
315 match sub.first().copied() {
316 Some("deployments") => {
318 let activate = sub.last() == Some(&"activate");
319 if activate || method == "POST" {
320 Some(Action::Deploy) } else if get {
322 Some(Action::Read)
323 } else {
324 None
325 }
326 }
327 Some("current") if get => Some(Action::Read),
328 Some("config") => {
329 if get {
330 Some(Action::Read)
331 } else if method == "PUT" {
332 Some(Action::Write)
333 } else {
334 None
335 }
336 }
337 Some("domains") => {
339 let check = sub.last() == Some(&"check"); if get || check {
341 Some(Action::Read)
342 } else if method == "POST" || method == "DELETE" {
343 Some(Action::Write)
344 } else {
345 None
346 }
347 }
348 Some("domain-verifications") if get => Some(Action::Read),
349 Some("aliases") => {
350 if get {
351 Some(Action::Read)
352 } else if method == "PUT" || method == "DELETE" {
353 Some(Action::Write)
354 } else {
355 None
356 }
357 }
358 Some("_boatramp") => {
361 if get {
362 Some(Action::Read)
363 } else if method == "POST" && sub.get(1) == Some(&"dlq") {
364 Some(Action::Write)
365 } else {
366 None
367 }
368 }
369 _ => None,
370 }
371}
372
373pub fn project_of(target: &str) -> &str {
376 target.split_once('/').map_or(target, |(p, _)| p)
377}
378
379pub fn project_api_path(path: &str) -> Option<(&str, &str)> {
393 let rest = path.strip_prefix("/api/projects/")?;
394 Some(rest.split_once('/').unwrap_or((rest, "")))
395}
396
397fn target_matches(granted: Option<&str>, required: Option<&str>) -> bool {
409 match granted {
410 None => true,
411 Some(g) => match g.strip_suffix("/*") {
412 Some(project) => required.is_some_and(|r| project_of(r) == project),
413 None => required == Some(g),
414 },
415 }
416}
417
418#[derive(Debug, Clone, Default, PartialEq, Eq)]
423pub struct RightSet {
424 rights: Vec<Right>,
425}
426
427impl RightSet {
428 pub fn new() -> Self {
430 Self::default()
431 }
432
433 pub fn insert(&mut self, right: Right) {
435 if !self.rights.contains(&right) {
436 self.rights.push(right);
437 }
438 }
439
440 pub fn allows(&self, required: &Right) -> bool {
442 self.rights.iter().any(|g| g.satisfies(required))
443 }
444
445 pub fn is_empty(&self) -> bool {
447 self.rights.is_empty()
448 }
449
450 pub fn rights(&self) -> &[Right] {
452 &self.rights
453 }
454}
455
456impl FromIterator<Right> for RightSet {
457 fn from_iter<I: IntoIterator<Item = Right>>(iter: I) -> Self {
458 let mut set = Self::new();
459 for r in iter {
460 set.insert(r);
461 }
462 set
463 }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum TargetKind {
469 Site,
471 Project,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct GrantedRole {
480 pub name: String,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub target: Option<String>,
485}
486
487impl GrantedRole {
488 pub fn global(name: impl Into<String>) -> Self {
490 Self {
491 name: name.into(),
492 target: None,
493 }
494 }
495
496 pub fn scoped(name: impl Into<String>, target: impl Into<String>) -> Self {
498 Self {
499 name: name.into(),
500 target: Some(target.into()),
501 }
502 }
503
504 pub fn parse(spec: &str) -> Self {
508 match spec.split_once(':') {
509 Some((name, target)) if !target.trim().is_empty() => {
510 Self::scoped(name.trim(), target.trim())
511 }
512 _ => Self::global(spec.trim()),
513 }
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
519#[serde(rename_all = "snake_case")]
520pub enum TargetScope {
521 AnyTarget,
523 RoleTarget,
526 ProjectWildcard,
531}
532
533impl TargetScope {
534 pub fn is_targeted(self) -> bool {
536 matches!(self, Self::RoleTarget | Self::ProjectWildcard)
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542pub struct RightTemplate {
543 pub resource: Resource,
545 pub action: Action,
547 pub scope: TargetScope,
549}
550
551impl RightTemplate {
552 pub fn any(resource: Resource, action: Action) -> Self {
554 Self {
555 resource,
556 action,
557 scope: TargetScope::AnyTarget,
558 }
559 }
560
561 pub fn scoped(resource: Resource, action: Action) -> Self {
563 Self {
564 resource,
565 action,
566 scope: TargetScope::RoleTarget,
567 }
568 }
569
570 pub fn project_wildcard(resource: Resource, action: Action) -> Self {
573 Self {
574 resource,
575 action,
576 scope: TargetScope::ProjectWildcard,
577 }
578 }
579}
580
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct AuthzPolicy {
586 #[serde(default = "crate::schema_version")]
588 pub version: u32,
589 pub roles: BTreeMap<String, Vec<RightTemplate>>,
591}
592
593impl Default for AuthzPolicy {
594 fn default() -> Self {
595 Self::default_policy()
596 }
597}
598
599impl AuthzPolicy {
600 pub fn default_policy() -> Self {
603 let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
604
605 roles.insert(
608 "admin".to_string(),
609 Resource::ALL
610 .iter()
611 .map(|&r| RightTemplate::any(r, Action::Admin))
612 .collect(),
613 );
614
615 roles.insert(
617 "publisher".to_string(),
618 vec![
619 RightTemplate::scoped(Resource::Site, Action::Read),
620 RightTemplate::scoped(Resource::Site, Action::Write),
621 RightTemplate::scoped(Resource::Site, Action::Deploy),
622 RightTemplate::any(Resource::Blobs, Action::Deploy),
623 ],
624 );
625
626 roles.insert(
628 "deployer".to_string(),
629 vec![
630 RightTemplate::scoped(Resource::Site, Action::Read),
631 RightTemplate::scoped(Resource::Site, Action::Deploy),
632 RightTemplate::any(Resource::Blobs, Action::Deploy),
633 ],
634 );
635
636 roles.insert(
638 "viewer".to_string(),
639 vec![RightTemplate::scoped(Resource::Site, Action::Read)],
640 );
641
642 roles.insert(
644 "operator".to_string(),
645 vec![
646 RightTemplate::any(Resource::System, Action::Read),
647 RightTemplate::any(Resource::Certs, Action::Read),
648 RightTemplate::any(Resource::Cache, Action::Write),
649 ],
650 );
651
652 roles.insert(
656 "project_admin".to_string(),
657 vec![
658 RightTemplate::scoped(Resource::Project, Action::Admin),
659 RightTemplate::project_wildcard(Resource::Site, Action::Admin),
660 RightTemplate::any(Resource::Blobs, Action::Deploy),
661 ],
662 );
663
664 roles.insert(
668 "project_publisher".to_string(),
669 vec![
670 RightTemplate::scoped(Resource::Project, Action::Read),
671 RightTemplate::scoped(Resource::Project, Action::Write),
672 RightTemplate::scoped(Resource::Project, Action::Deploy),
673 RightTemplate::project_wildcard(Resource::Site, Action::Read),
674 RightTemplate::project_wildcard(Resource::Site, Action::Write),
675 RightTemplate::project_wildcard(Resource::Site, Action::Deploy),
676 RightTemplate::any(Resource::Blobs, Action::Deploy),
677 ],
678 );
679
680 roles.insert(
682 "project_viewer".to_string(),
683 vec![
684 RightTemplate::scoped(Resource::Project, Action::Read),
685 RightTemplate::project_wildcard(Resource::Site, Action::Read),
686 ],
687 );
688
689 Self {
690 version: crate::SCHEMA_VERSION,
691 roles,
692 }
693 }
694
695 pub fn role_takes_target(&self, role: &str) -> bool {
697 self.roles
698 .get(role)
699 .is_some_and(|ts| ts.iter().any(|t| t.scope.is_targeted()))
700 }
701
702 pub fn role_target_kind(&self, role: &str) -> Option<TargetKind> {
710 let templates = self.roles.get(role)?;
711 let mut site = false;
712 let mut project = false;
713 for t in templates {
714 match t.scope {
715 TargetScope::RoleTarget if t.resource == Resource::Site => site = true,
716 TargetScope::RoleTarget => project = true,
717 TargetScope::ProjectWildcard => project = true,
718 TargetScope::AnyTarget => {}
719 }
720 }
721 if site {
724 Some(TargetKind::Site)
725 } else if project {
726 Some(TargetKind::Project)
727 } else {
728 None
729 }
730 }
731
732 pub fn normalize_grants(&self, roles: &[GrantedRole]) -> Vec<GrantedRole> {
739 roles
740 .iter()
741 .map(|g| match (&g.target, self.role_target_kind(&g.name)) {
742 (Some(t), Some(TargetKind::Site)) if !t.contains('/') => {
743 GrantedRole::scoped(&g.name, format!("{}/{t}", crate::project::DEFAULT_PROJECT))
744 }
745 _ => g.clone(),
746 })
747 .collect()
748 }
749
750 pub fn rights_for(&self, roles: &[GrantedRole]) -> RightSet {
756 let mut set = RightSet::new();
757 for granted in roles {
758 let Some(templates) = self.roles.get(&granted.name) else {
759 continue;
760 };
761 for t in templates {
762 let target = match t.scope {
763 TargetScope::AnyTarget => None,
764 TargetScope::RoleTarget => match &granted.target {
765 Some(x) => Some(x.clone()),
766 None => continue,
767 },
768 TargetScope::ProjectWildcard => match &granted.target {
771 Some(x) => Some(format!("{x}/*")),
772 None => continue,
773 },
774 };
775 set.insert(Right::new(t.resource, target, t.action));
776 }
777 }
778 set
779 }
780}
781
782pub const POLICY_KEY: &str = "authz/policy";
785
786pub const REVOKED_PREFIX: &str = "authz/revoked/";
790
791pub const TOKEN_META_PREFIX: &str = "authz/tokens/";
794
795pub fn revoked_key(revocation_id: &str) -> String {
797 format!("{REVOKED_PREFIX}{revocation_id}")
798}
799
800pub const ROOT_ANCHOR_PREFIX: &str = "auth/root/";
804
805pub fn root_anchor_key(pubkey: &str) -> String {
807 format!("{ROOT_ANCHOR_PREFIX}{pubkey}")
808}
809
810pub fn token_meta_key(id: &str) -> String {
812 format!("{TOKEN_META_PREFIX}{id}")
813}
814
815pub fn bootstrap_key(secret_hash: &str) -> String {
818 format!("authz/bootstrap/{secret_hash}")
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
825pub struct TokenMeta {
826 #[serde(default = "crate::schema_version")]
828 pub version: u32,
829 pub label: String,
831 pub roles: Vec<GrantedRole>,
833 pub created_at: u64,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
837 pub expires_at: Option<u64>,
838 pub revocation_id: String,
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn admin_satisfies_every_action_on_its_resource() {
849 let admin_site = Right::new(Resource::Site, None, Action::Admin);
850 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
851 let required = Right::new(Resource::Site, Some("blog".into()), action);
852 assert!(
853 admin_site.satisfies(&required),
854 "admin must satisfy {action:?}"
855 );
856 }
857 assert!(!admin_site.satisfies(&Right::new(Resource::Tokens, None, Action::Read)));
859 }
860
861 #[test]
862 fn target_scoping_is_exact_unless_wildcard() {
863 let blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
864 assert!(blog.satisfies(&Right::new(
865 Resource::Site,
866 Some("blog".into()),
867 Action::Write
868 )));
869 assert!(!blog.satisfies(&Right::new(
870 Resource::Site,
871 Some("api".into()),
872 Action::Write
873 )));
874 let any = Right::new(Resource::Site, None, Action::Write);
876 assert!(any.satisfies(&Right::new(
877 Resource::Site,
878 Some("api".into()),
879 Action::Write
880 )));
881 }
882
883 #[test]
884 fn distinct_actions_do_not_imply_each_other() {
885 let write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
886 let deploy_req = Right::new(Resource::Site, Some("blog".into()), Action::Deploy);
887 assert!(
888 !write.satisfies(&deploy_req),
889 "write must not imply deploy (only admin does)"
890 );
891 }
892
893 #[test]
895 fn required_right_table() {
896 let cases: &[(&str, &str, Option<Right>)] = &[
897 ("POST", "/api/auth/exchange", None),
898 ("GET", "/api/auth/whoami", None),
899 (
902 "POST",
903 "/api/cluster/join-token",
904 Some(Right::new(Resource::System, None, Action::Admin)),
905 ),
906 ("POST", "/api/cluster/join", None),
909 (
911 "POST",
912 "/api/cluster/rotate-key",
913 Some(Right::new(Resource::System, None, Action::Admin)),
914 ),
915 (
917 "POST",
918 "/api/cluster/revoke",
919 Some(Right::new(Resource::System, None, Action::Admin)),
920 ),
921 (
922 "PUT",
923 "/api/blobs/abc123",
924 Some(Right::new(Resource::Blobs, None, Action::Deploy)),
925 ),
926 (
927 "GET",
928 "/api/sites",
929 Some(Right::new(Resource::System, None, Action::Read)),
930 ),
931 (
932 "POST",
933 "/api/sites/blog/deployments",
934 Some(Right::new(
935 Resource::Site,
936 Some("default/blog".into()),
937 Action::Deploy,
938 )),
939 ),
940 (
941 "GET",
942 "/api/sites/blog/deployments",
943 Some(Right::new(
944 Resource::Site,
945 Some("default/blog".into()),
946 Action::Read,
947 )),
948 ),
949 (
950 "GET",
951 "/api/sites/blog/deployments/d1",
952 Some(Right::new(
953 Resource::Site,
954 Some("default/blog".into()),
955 Action::Read,
956 )),
957 ),
958 (
959 "POST",
960 "/api/sites/blog/deployments/d1/activate",
961 Some(Right::new(
962 Resource::Site,
963 Some("default/blog".into()),
964 Action::Deploy,
965 )),
966 ),
967 (
968 "GET",
969 "/api/sites/blog/current",
970 Some(Right::new(
971 Resource::Site,
972 Some("default/blog".into()),
973 Action::Read,
974 )),
975 ),
976 (
977 "GET",
978 "/api/sites/blog/config",
979 Some(Right::new(
980 Resource::Site,
981 Some("default/blog".into()),
982 Action::Read,
983 )),
984 ),
985 (
986 "PUT",
987 "/api/sites/blog/config",
988 Some(Right::new(
989 Resource::Site,
990 Some("default/blog".into()),
991 Action::Write,
992 )),
993 ),
994 (
995 "GET",
996 "/api/sites/blog/domains/x.example.com/verification",
997 Some(Right::new(
998 Resource::Site,
999 Some("default/blog".into()),
1000 Action::Read,
1001 )),
1002 ),
1003 (
1004 "POST",
1005 "/api/sites/blog/domains/x.example.com/verification",
1006 Some(Right::new(
1007 Resource::Site,
1008 Some("default/blog".into()),
1009 Action::Write,
1010 )),
1011 ),
1012 (
1013 "DELETE",
1014 "/api/sites/blog/domains/x.example.com/verification",
1015 Some(Right::new(
1016 Resource::Site,
1017 Some("default/blog".into()),
1018 Action::Write,
1019 )),
1020 ),
1021 (
1022 "POST",
1023 "/api/sites/blog/domains/x.example.com/verification/check",
1024 Some(Right::new(
1025 Resource::Site,
1026 Some("default/blog".into()),
1027 Action::Read,
1028 )),
1029 ),
1030 (
1031 "GET",
1032 "/api/sites/blog/domain-verifications",
1033 Some(Right::new(
1034 Resource::Site,
1035 Some("default/blog".into()),
1036 Action::Read,
1037 )),
1038 ),
1039 (
1040 "PUT",
1041 "/api/sites/blog/aliases/www",
1042 Some(Right::new(
1043 Resource::Site,
1044 Some("default/blog".into()),
1045 Action::Write,
1046 )),
1047 ),
1048 (
1049 "GET",
1050 "/api/sites/blog/aliases",
1051 Some(Right::new(
1052 Resource::Site,
1053 Some("default/blog".into()),
1054 Action::Read,
1055 )),
1056 ),
1057 (
1058 "GET",
1059 "/api/sites/blog/_boatramp/handlers",
1060 Some(Right::new(
1061 Resource::Site,
1062 Some("default/blog".into()),
1063 Action::Read,
1064 )),
1065 ),
1066 (
1067 "POST",
1068 "/api/tokens",
1069 Some(Right::new(Resource::Tokens, None, Action::Admin)),
1070 ),
1071 (
1072 "DELETE",
1073 "/api/tokens/t1",
1074 Some(Right::new(Resource::Tokens, None, Action::Admin)),
1075 ),
1076 (
1077 "GET",
1078 "/api/prune",
1079 Some(Right::new(Resource::System, None, Action::Admin)),
1080 ),
1081 (
1082 "POST",
1083 "/api/scrub",
1084 Some(Right::new(Resource::System, None, Action::Admin)),
1085 ),
1086 (
1087 "GET",
1088 "/api/certs",
1089 Some(Right::new(Resource::Certs, None, Action::Read)),
1090 ),
1091 (
1092 "POST",
1093 "/api/cache/invalidate",
1094 Some(Right::new(Resource::Cache, None, Action::Write)),
1095 ),
1096 (
1097 "GET",
1098 "/api/metrics",
1099 Some(Right::new(Resource::System, None, Action::Read)),
1100 ),
1101 (
1105 "GET",
1106 "/api/graphql/supergraph",
1107 Some(Right::new(
1108 Resource::Project,
1109 Some("default".into()),
1110 Action::Read,
1111 )),
1112 ),
1113 (
1114 "PUT",
1115 "/api/graphql/subgraphs/catalog",
1116 Some(Right::new(
1117 Resource::Project,
1118 Some("default".into()),
1119 Action::Deploy,
1120 )),
1121 ),
1122 (
1123 "POST",
1124 "/api/graphql/safelist",
1125 Some(Right::new(
1126 Resource::Project,
1127 Some("default".into()),
1128 Action::Deploy,
1129 )),
1130 ),
1131 ];
1132 for (method, path, expected) in cases {
1133 assert_eq!(
1134 &Right::required(method, path),
1135 expected,
1136 "required({method}, {path})"
1137 );
1138 }
1139 }
1140
1141 #[test]
1142 fn unknown_site_subpath_is_deny_safe() {
1143 assert_eq!(
1145 Right::required("PATCH", "/api/sites/blog/frobnicate"),
1146 Some(Right::new(Resource::System, None, Action::Admin))
1147 );
1148 }
1149
1150 #[test]
1151 fn attach_unverified_is_admin_only() {
1152 let required = Right::required(
1156 "POST",
1157 "/api/sites/blog/domains/evil.example.com/attach-unverified",
1158 )
1159 .expect("route is gated");
1160 assert_eq!(required, Right::new(Resource::System, None, Action::Admin));
1161 let site_write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
1163 assert!(!site_write.satisfies(&required));
1164 assert!(Right::new(Resource::System, None, Action::Admin).satisfies(&required));
1166 }
1167
1168 #[test]
1169 fn default_policy_publisher_can_deploy_and_write_its_site_only() {
1170 let policy = AuthzPolicy::default_policy();
1171 let rights = policy.rights_for(&[GrantedRole::scoped("publisher", "blog")]);
1172 assert!(rights.allows(&Right::new(
1174 Resource::Site,
1175 Some("blog".into()),
1176 Action::Deploy
1177 )));
1178 assert!(rights.allows(&Right::new(
1179 Resource::Site,
1180 Some("blog".into()),
1181 Action::Write
1182 )));
1183 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1184 assert!(!rights.allows(&Right::new(
1186 Resource::Site,
1187 Some("api".into()),
1188 Action::Read
1189 )));
1190 assert!(!rights.allows(&Right::new(Resource::Tokens, None, Action::Admin)));
1191 }
1192
1193 #[test]
1194 fn default_policy_deployer_cannot_edit_config() {
1195 let policy = AuthzPolicy::default_policy();
1196 let rights = policy.rights_for(&[GrantedRole::scoped("deployer", "blog")]);
1197 assert!(rights.allows(&Right::new(
1198 Resource::Site,
1199 Some("blog".into()),
1200 Action::Deploy
1201 )));
1202 assert!(rights.allows(&Right::new(
1203 Resource::Site,
1204 Some("blog".into()),
1205 Action::Read
1206 )));
1207 assert!(
1208 !rights.allows(&Right::new(
1209 Resource::Site,
1210 Some("blog".into()),
1211 Action::Write
1212 )),
1213 "deployer must not edit config"
1214 );
1215 }
1216
1217 #[test]
1218 fn default_policy_admin_can_do_anything() {
1219 let policy = AuthzPolicy::default_policy();
1220 let rights = policy.rights_for(&[GrantedRole::global("admin")]);
1221 for resource in Resource::ALL {
1222 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1223 let target = matches!(resource, Resource::Site).then(|| "any".to_string());
1224 assert!(
1225 rights.allows(&Right::new(resource, target, action)),
1226 "admin must allow {resource:?}·{action:?}"
1227 );
1228 }
1229 }
1230 }
1231
1232 #[test]
1233 fn site_role_without_target_grants_nothing_site_scoped() {
1234 let policy = AuthzPolicy::default_policy();
1235 let rights = policy.rights_for(&[GrantedRole::global("publisher")]);
1238 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1239 assert!(!rights.allows(&Right::new(
1240 Resource::Site,
1241 Some("blog".into()),
1242 Action::Read
1243 )));
1244 }
1245
1246 #[test]
1247 fn role_takes_target_classifies_roles() {
1248 let policy = AuthzPolicy::default_policy();
1249 assert!(policy.role_takes_target("publisher"));
1250 assert!(policy.role_takes_target("viewer"));
1251 assert!(!policy.role_takes_target("admin"));
1252 assert!(!policy.role_takes_target("operator"));
1253 }
1254
1255 #[test]
1256 fn policy_round_trips_through_json() {
1257 let policy = AuthzPolicy::default_policy();
1258 let json = serde_json::to_string(&policy).unwrap();
1259 let back: AuthzPolicy = serde_json::from_str(&json).unwrap();
1260 assert_eq!(policy, back);
1261 assert_eq!(back.version, crate::SCHEMA_VERSION);
1262 }
1263
1264 #[test]
1265 fn unknown_role_is_ignored() {
1266 let policy = AuthzPolicy::default_policy();
1267 let rights = policy.rights_for(&[GrantedRole::global("nonesuch")]);
1268 assert!(rights.is_empty());
1269 }
1270
1271 #[test]
1274 fn legacy_site_grant_normalizes_to_default_project() {
1275 let policy = AuthzPolicy::default_policy();
1276 let n = policy.normalize_grants(&[GrantedRole::scoped("publisher", "blog")]);
1278 assert_eq!(n, vec![GrantedRole::scoped("publisher", "default/blog")]);
1279 let untouched = [
1282 GrantedRole::scoped("publisher", "acme/blog"),
1283 GrantedRole::scoped("project_admin", "acme"),
1284 GrantedRole::global("admin"),
1285 ];
1286 assert_eq!(policy.normalize_grants(&untouched), untouched);
1287 }
1288
1289 #[test]
1290 fn project_admin_covers_its_project_but_not_another() {
1291 let policy = AuthzPolicy::default_policy();
1292 let rights = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
1293 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1295 assert!(
1296 rights.allows(&Right::new(
1297 Resource::Site,
1298 Some("acme/blog".into()),
1299 action
1300 )),
1301 "project-admin:acme covers acme/blog·{action:?}"
1302 );
1303 }
1304 assert!(rights.allows(&Right::new(
1306 Resource::Project,
1307 Some("acme".into()),
1308 Action::Deploy
1309 )));
1310 assert!(rights.allows(&Right::new(
1311 Resource::Project,
1312 Some("acme".into()),
1313 Action::Admin
1314 )));
1315 assert!(!rights.allows(&Right::new(
1317 Resource::Site,
1318 Some("shop/blog".into()),
1319 Action::Read
1320 )));
1321 assert!(!rights.allows(&Right::new(
1322 Resource::Project,
1323 Some("shop".into()),
1324 Action::Read
1325 )));
1326 assert!(!rights.allows(&Right::new(
1328 Resource::Site,
1329 Some("blog".into()),
1330 Action::Read
1331 )));
1332 }
1333
1334 #[test]
1335 fn project_viewer_is_read_only_across_the_project() {
1336 let policy = AuthzPolicy::default_policy();
1337 let rights = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1338 assert!(rights.allows(&Right::new(
1339 Resource::Site,
1340 Some("acme/blog".into()),
1341 Action::Read
1342 )));
1343 assert!(rights.allows(&Right::new(
1344 Resource::Project,
1345 Some("acme".into()),
1346 Action::Read
1347 )));
1348 assert!(!rights.allows(&Right::new(
1350 Resource::Site,
1351 Some("acme/blog".into()),
1352 Action::Write
1353 )));
1354 assert!(!rights.allows(&Right::new(
1355 Resource::Project,
1356 Some("acme".into()),
1357 Action::Deploy
1358 )));
1359 }
1360
1361 #[test]
1362 fn project_publisher_ships_but_cannot_admin_the_project() {
1363 let policy = AuthzPolicy::default_policy();
1364 let rights = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
1365 assert!(rights.allows(&Right::new(
1366 Resource::Site,
1367 Some("acme/blog".into()),
1368 Action::Deploy
1369 )));
1370 assert!(rights.allows(&Right::new(
1371 Resource::Project,
1372 Some("acme".into()),
1373 Action::Deploy
1374 )));
1375 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1376 assert!(!rights.allows(&Right::new(
1378 Resource::Project,
1379 Some("acme".into()),
1380 Action::Admin
1381 )));
1382 }
1383
1384 #[test]
1385 fn required_maps_project_paths() {
1386 assert_eq!(
1388 Right::required("POST", "/api/projects/acme/sites/blog/deployments"),
1389 Some(Right::new(
1390 Resource::Site,
1391 Some("acme/blog".into()),
1392 Action::Deploy
1393 ))
1394 );
1395 assert_eq!(
1397 Right::required("GET", "/api/projects/acme/functions/resize"),
1398 Some(Right::new(
1399 Resource::Project,
1400 Some("acme".into()),
1401 Action::Read
1402 ))
1403 );
1404 assert_eq!(
1405 Right::required("POST", "/api/projects/acme/functions/resize/versions"),
1406 Some(Right::new(
1407 Resource::Project,
1408 Some("acme".into()),
1409 Action::Deploy
1410 ))
1411 );
1412 assert_eq!(
1414 Right::required("DELETE", "/api/projects/acme"),
1415 Some(Right::new(
1416 Resource::Project,
1417 Some("acme".into()),
1418 Action::Admin
1419 ))
1420 );
1421 assert_eq!(
1423 Right::required("GET", "/api/projects"),
1424 Some(Right::new(Resource::System, None, Action::Read))
1425 );
1426 assert_eq!(
1427 Right::required("POST", "/api/projects"),
1428 Some(Right::new(Resource::System, None, Action::Admin))
1429 );
1430 assert_eq!(
1432 Right::required("GET", "/api/functions"),
1433 Some(Right::new(
1434 Resource::Project,
1435 Some("default".into()),
1436 Action::Read
1437 ))
1438 );
1439 }
1440}