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 "/api/blobs" => Self::new(Resource::Blobs, None, Action::Deploy),
286 "/api/certs" => Self::new(Resource::Certs, None, Action::Read),
287 "/api/cache/invalidate" => Self::new(Resource::Cache, None, Action::Write),
288 "/api/metrics" => Self::new(Resource::System, None, Action::Read),
289 "/api/prune" | "/api/scrub" => Self::new(Resource::System, None, Action::Admin),
290 p if p == "/api/tokens" || p.starts_with("/api/tokens/") => {
291 Self::new(Resource::Tokens, None, Action::Admin)
292 }
293 p if p == "/api/authz/policy" || p.starts_with("/api/authz/") => {
294 Self::new(Resource::System, None, Action::Admin)
295 }
296 _ => Self::new(Resource::System, None, Action::Admin),
298 };
299 Some(right)
300 }
301}
302
303fn site_subpath_action(method: &str, get: bool, sub: &[&str]) -> Option<Action> {
305 match sub.first().copied() {
306 Some("deployments") => {
308 let activate = sub.last() == Some(&"activate");
309 if activate || method == "POST" {
310 Some(Action::Deploy) } else if get {
312 Some(Action::Read)
313 } else {
314 None
315 }
316 }
317 Some("current") if get => Some(Action::Read),
318 Some("config") => {
319 if get {
320 Some(Action::Read)
321 } else if method == "PUT" {
322 Some(Action::Write)
323 } else {
324 None
325 }
326 }
327 Some("domains") => {
329 let check = sub.last() == Some(&"check"); if get || check {
331 Some(Action::Read)
332 } else if method == "POST" || method == "DELETE" {
333 Some(Action::Write)
334 } else {
335 None
336 }
337 }
338 Some("domain-verifications") if get => Some(Action::Read),
339 Some("aliases") => {
340 if get {
341 Some(Action::Read)
342 } else if method == "PUT" || method == "DELETE" {
343 Some(Action::Write)
344 } else {
345 None
346 }
347 }
348 Some("_boatramp") => {
351 if get {
352 Some(Action::Read)
353 } else if method == "POST" && sub.get(1) == Some(&"dlq") {
354 Some(Action::Write)
355 } else {
356 None
357 }
358 }
359 _ => None,
360 }
361}
362
363pub fn project_of(target: &str) -> &str {
366 target.split_once('/').map_or(target, |(p, _)| p)
367}
368
369pub fn project_api_path(path: &str) -> Option<(&str, &str)> {
383 let rest = path.strip_prefix("/api/projects/")?;
384 Some(rest.split_once('/').unwrap_or((rest, "")))
385}
386
387fn target_matches(granted: Option<&str>, required: Option<&str>) -> bool {
399 match granted {
400 None => true,
401 Some(g) => match g.strip_suffix("/*") {
402 Some(project) => required.is_some_and(|r| project_of(r) == project),
403 None => required == Some(g),
404 },
405 }
406}
407
408#[derive(Debug, Clone, Default, PartialEq, Eq)]
413pub struct RightSet {
414 rights: Vec<Right>,
415}
416
417impl RightSet {
418 pub fn new() -> Self {
420 Self::default()
421 }
422
423 pub fn insert(&mut self, right: Right) {
425 if !self.rights.contains(&right) {
426 self.rights.push(right);
427 }
428 }
429
430 pub fn allows(&self, required: &Right) -> bool {
432 self.rights.iter().any(|g| g.satisfies(required))
433 }
434
435 pub fn is_empty(&self) -> bool {
437 self.rights.is_empty()
438 }
439
440 pub fn rights(&self) -> &[Right] {
442 &self.rights
443 }
444}
445
446impl FromIterator<Right> for RightSet {
447 fn from_iter<I: IntoIterator<Item = Right>>(iter: I) -> Self {
448 let mut set = Self::new();
449 for r in iter {
450 set.insert(r);
451 }
452 set
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TargetKind {
459 Site,
461 Project,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct GrantedRole {
470 pub name: String,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub target: Option<String>,
475}
476
477impl GrantedRole {
478 pub fn global(name: impl Into<String>) -> Self {
480 Self {
481 name: name.into(),
482 target: None,
483 }
484 }
485
486 pub fn scoped(name: impl Into<String>, target: impl Into<String>) -> Self {
488 Self {
489 name: name.into(),
490 target: Some(target.into()),
491 }
492 }
493
494 pub fn parse(spec: &str) -> Self {
498 match spec.split_once(':') {
499 Some((name, target)) if !target.trim().is_empty() => {
500 Self::scoped(name.trim(), target.trim())
501 }
502 _ => Self::global(spec.trim()),
503 }
504 }
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510pub enum TargetScope {
511 AnyTarget,
513 RoleTarget,
516 ProjectWildcard,
521}
522
523impl TargetScope {
524 pub fn is_targeted(self) -> bool {
526 matches!(self, Self::RoleTarget | Self::ProjectWildcard)
527 }
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
532pub struct RightTemplate {
533 pub resource: Resource,
535 pub action: Action,
537 pub scope: TargetScope,
539}
540
541impl RightTemplate {
542 pub fn any(resource: Resource, action: Action) -> Self {
544 Self {
545 resource,
546 action,
547 scope: TargetScope::AnyTarget,
548 }
549 }
550
551 pub fn scoped(resource: Resource, action: Action) -> Self {
553 Self {
554 resource,
555 action,
556 scope: TargetScope::RoleTarget,
557 }
558 }
559
560 pub fn project_wildcard(resource: Resource, action: Action) -> Self {
563 Self {
564 resource,
565 action,
566 scope: TargetScope::ProjectWildcard,
567 }
568 }
569}
570
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(deny_unknown_fields)]
575pub struct AuthzPolicy {
576 #[serde(default = "crate::schema_version")]
578 pub version: u32,
579 pub roles: BTreeMap<String, Vec<RightTemplate>>,
581}
582
583impl Default for AuthzPolicy {
584 fn default() -> Self {
585 Self::default_policy()
586 }
587}
588
589impl AuthzPolicy {
590 pub fn default_policy() -> Self {
593 let mut roles: BTreeMap<String, Vec<RightTemplate>> = BTreeMap::new();
594
595 roles.insert(
598 "admin".to_string(),
599 Resource::ALL
600 .iter()
601 .map(|&r| RightTemplate::any(r, Action::Admin))
602 .collect(),
603 );
604
605 roles.insert(
607 "publisher".to_string(),
608 vec![
609 RightTemplate::scoped(Resource::Site, Action::Read),
610 RightTemplate::scoped(Resource::Site, Action::Write),
611 RightTemplate::scoped(Resource::Site, Action::Deploy),
612 RightTemplate::any(Resource::Blobs, Action::Deploy),
613 ],
614 );
615
616 roles.insert(
618 "deployer".to_string(),
619 vec![
620 RightTemplate::scoped(Resource::Site, Action::Read),
621 RightTemplate::scoped(Resource::Site, Action::Deploy),
622 RightTemplate::any(Resource::Blobs, Action::Deploy),
623 ],
624 );
625
626 roles.insert(
628 "viewer".to_string(),
629 vec![RightTemplate::scoped(Resource::Site, Action::Read)],
630 );
631
632 roles.insert(
634 "operator".to_string(),
635 vec![
636 RightTemplate::any(Resource::System, Action::Read),
637 RightTemplate::any(Resource::Certs, Action::Read),
638 RightTemplate::any(Resource::Cache, Action::Write),
639 ],
640 );
641
642 roles.insert(
646 "project_admin".to_string(),
647 vec![
648 RightTemplate::scoped(Resource::Project, Action::Admin),
649 RightTemplate::project_wildcard(Resource::Site, Action::Admin),
650 RightTemplate::any(Resource::Blobs, Action::Deploy),
651 ],
652 );
653
654 roles.insert(
658 "project_publisher".to_string(),
659 vec![
660 RightTemplate::scoped(Resource::Project, Action::Read),
661 RightTemplate::scoped(Resource::Project, Action::Write),
662 RightTemplate::scoped(Resource::Project, Action::Deploy),
663 RightTemplate::project_wildcard(Resource::Site, Action::Read),
664 RightTemplate::project_wildcard(Resource::Site, Action::Write),
665 RightTemplate::project_wildcard(Resource::Site, Action::Deploy),
666 RightTemplate::any(Resource::Blobs, Action::Deploy),
667 ],
668 );
669
670 roles.insert(
672 "project_viewer".to_string(),
673 vec![
674 RightTemplate::scoped(Resource::Project, Action::Read),
675 RightTemplate::project_wildcard(Resource::Site, Action::Read),
676 ],
677 );
678
679 Self {
680 version: crate::SCHEMA_VERSION,
681 roles,
682 }
683 }
684
685 pub fn role_takes_target(&self, role: &str) -> bool {
687 self.roles
688 .get(role)
689 .is_some_and(|ts| ts.iter().any(|t| t.scope.is_targeted()))
690 }
691
692 pub fn role_target_kind(&self, role: &str) -> Option<TargetKind> {
700 let templates = self.roles.get(role)?;
701 let mut site = false;
702 let mut project = false;
703 for t in templates {
704 match t.scope {
705 TargetScope::RoleTarget if t.resource == Resource::Site => site = true,
706 TargetScope::RoleTarget => project = true,
707 TargetScope::ProjectWildcard => project = true,
708 TargetScope::AnyTarget => {}
709 }
710 }
711 if site {
714 Some(TargetKind::Site)
715 } else if project {
716 Some(TargetKind::Project)
717 } else {
718 None
719 }
720 }
721
722 pub fn normalize_grants(&self, roles: &[GrantedRole]) -> Vec<GrantedRole> {
729 roles
730 .iter()
731 .map(|g| match (&g.target, self.role_target_kind(&g.name)) {
732 (Some(t), Some(TargetKind::Site)) if !t.contains('/') => {
733 GrantedRole::scoped(&g.name, format!("{}/{t}", crate::project::DEFAULT_PROJECT))
734 }
735 _ => g.clone(),
736 })
737 .collect()
738 }
739
740 pub fn rights_for(&self, roles: &[GrantedRole]) -> RightSet {
746 let mut set = RightSet::new();
747 for granted in roles {
748 let Some(templates) = self.roles.get(&granted.name) else {
749 continue;
750 };
751 for t in templates {
752 let target = match t.scope {
753 TargetScope::AnyTarget => None,
754 TargetScope::RoleTarget => match &granted.target {
755 Some(x) => Some(x.clone()),
756 None => continue,
757 },
758 TargetScope::ProjectWildcard => match &granted.target {
761 Some(x) => Some(format!("{x}/*")),
762 None => continue,
763 },
764 };
765 set.insert(Right::new(t.resource, target, t.action));
766 }
767 }
768 set
769 }
770}
771
772pub const POLICY_KEY: &str = "authz/policy";
775
776pub const REVOKED_PREFIX: &str = "authz/revoked/";
780
781pub const TOKEN_META_PREFIX: &str = "authz/tokens/";
784
785pub fn revoked_key(revocation_id: &str) -> String {
787 format!("{REVOKED_PREFIX}{revocation_id}")
788}
789
790pub const ROOT_ANCHOR_PREFIX: &str = "auth/root/";
794
795pub fn root_anchor_key(pubkey: &str) -> String {
797 format!("{ROOT_ANCHOR_PREFIX}{pubkey}")
798}
799
800pub fn token_meta_key(id: &str) -> String {
802 format!("{TOKEN_META_PREFIX}{id}")
803}
804
805pub fn bootstrap_key(secret_hash: &str) -> String {
808 format!("authz/bootstrap/{secret_hash}")
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
815pub struct TokenMeta {
816 #[serde(default = "crate::schema_version")]
818 pub version: u32,
819 pub label: String,
821 pub roles: Vec<GrantedRole>,
823 pub created_at: u64,
825 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub expires_at: Option<u64>,
828 pub revocation_id: String,
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn admin_satisfies_every_action_on_its_resource() {
839 let admin_site = Right::new(Resource::Site, None, Action::Admin);
840 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
841 let required = Right::new(Resource::Site, Some("blog".into()), action);
842 assert!(
843 admin_site.satisfies(&required),
844 "admin must satisfy {action:?}"
845 );
846 }
847 assert!(!admin_site.satisfies(&Right::new(Resource::Tokens, None, Action::Read)));
849 }
850
851 #[test]
852 fn target_scoping_is_exact_unless_wildcard() {
853 let blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
854 assert!(blog.satisfies(&Right::new(
855 Resource::Site,
856 Some("blog".into()),
857 Action::Write
858 )));
859 assert!(!blog.satisfies(&Right::new(
860 Resource::Site,
861 Some("api".into()),
862 Action::Write
863 )));
864 let any = Right::new(Resource::Site, None, Action::Write);
866 assert!(any.satisfies(&Right::new(
867 Resource::Site,
868 Some("api".into()),
869 Action::Write
870 )));
871 }
872
873 #[test]
874 fn distinct_actions_do_not_imply_each_other() {
875 let write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
876 let deploy_req = Right::new(Resource::Site, Some("blog".into()), Action::Deploy);
877 assert!(
878 !write.satisfies(&deploy_req),
879 "write must not imply deploy (only admin does)"
880 );
881 }
882
883 #[test]
885 fn required_right_table() {
886 let cases: &[(&str, &str, Option<Right>)] = &[
887 ("POST", "/api/auth/exchange", None),
888 ("GET", "/api/auth/whoami", None),
889 (
892 "POST",
893 "/api/cluster/join-token",
894 Some(Right::new(Resource::System, None, Action::Admin)),
895 ),
896 ("POST", "/api/cluster/join", None),
899 (
901 "POST",
902 "/api/cluster/rotate-key",
903 Some(Right::new(Resource::System, None, Action::Admin)),
904 ),
905 (
907 "POST",
908 "/api/cluster/revoke",
909 Some(Right::new(Resource::System, None, Action::Admin)),
910 ),
911 (
912 "PUT",
913 "/api/blobs/abc123",
914 Some(Right::new(Resource::Blobs, None, Action::Deploy)),
915 ),
916 (
917 "GET",
918 "/api/sites",
919 Some(Right::new(Resource::System, None, Action::Read)),
920 ),
921 (
922 "POST",
923 "/api/sites/blog/deployments",
924 Some(Right::new(
925 Resource::Site,
926 Some("default/blog".into()),
927 Action::Deploy,
928 )),
929 ),
930 (
931 "GET",
932 "/api/sites/blog/deployments",
933 Some(Right::new(
934 Resource::Site,
935 Some("default/blog".into()),
936 Action::Read,
937 )),
938 ),
939 (
940 "GET",
941 "/api/sites/blog/deployments/d1",
942 Some(Right::new(
943 Resource::Site,
944 Some("default/blog".into()),
945 Action::Read,
946 )),
947 ),
948 (
949 "POST",
950 "/api/sites/blog/deployments/d1/activate",
951 Some(Right::new(
952 Resource::Site,
953 Some("default/blog".into()),
954 Action::Deploy,
955 )),
956 ),
957 (
958 "GET",
959 "/api/sites/blog/current",
960 Some(Right::new(
961 Resource::Site,
962 Some("default/blog".into()),
963 Action::Read,
964 )),
965 ),
966 (
967 "GET",
968 "/api/sites/blog/config",
969 Some(Right::new(
970 Resource::Site,
971 Some("default/blog".into()),
972 Action::Read,
973 )),
974 ),
975 (
976 "PUT",
977 "/api/sites/blog/config",
978 Some(Right::new(
979 Resource::Site,
980 Some("default/blog".into()),
981 Action::Write,
982 )),
983 ),
984 (
985 "GET",
986 "/api/sites/blog/domains/x.example.com/verification",
987 Some(Right::new(
988 Resource::Site,
989 Some("default/blog".into()),
990 Action::Read,
991 )),
992 ),
993 (
994 "POST",
995 "/api/sites/blog/domains/x.example.com/verification",
996 Some(Right::new(
997 Resource::Site,
998 Some("default/blog".into()),
999 Action::Write,
1000 )),
1001 ),
1002 (
1003 "DELETE",
1004 "/api/sites/blog/domains/x.example.com/verification",
1005 Some(Right::new(
1006 Resource::Site,
1007 Some("default/blog".into()),
1008 Action::Write,
1009 )),
1010 ),
1011 (
1012 "POST",
1013 "/api/sites/blog/domains/x.example.com/verification/check",
1014 Some(Right::new(
1015 Resource::Site,
1016 Some("default/blog".into()),
1017 Action::Read,
1018 )),
1019 ),
1020 (
1021 "GET",
1022 "/api/sites/blog/domain-verifications",
1023 Some(Right::new(
1024 Resource::Site,
1025 Some("default/blog".into()),
1026 Action::Read,
1027 )),
1028 ),
1029 (
1030 "PUT",
1031 "/api/sites/blog/aliases/www",
1032 Some(Right::new(
1033 Resource::Site,
1034 Some("default/blog".into()),
1035 Action::Write,
1036 )),
1037 ),
1038 (
1039 "GET",
1040 "/api/sites/blog/aliases",
1041 Some(Right::new(
1042 Resource::Site,
1043 Some("default/blog".into()),
1044 Action::Read,
1045 )),
1046 ),
1047 (
1048 "GET",
1049 "/api/sites/blog/_boatramp/handlers",
1050 Some(Right::new(
1051 Resource::Site,
1052 Some("default/blog".into()),
1053 Action::Read,
1054 )),
1055 ),
1056 (
1057 "POST",
1058 "/api/tokens",
1059 Some(Right::new(Resource::Tokens, None, Action::Admin)),
1060 ),
1061 (
1062 "DELETE",
1063 "/api/tokens/t1",
1064 Some(Right::new(Resource::Tokens, None, Action::Admin)),
1065 ),
1066 (
1067 "GET",
1068 "/api/prune",
1069 Some(Right::new(Resource::System, None, Action::Admin)),
1070 ),
1071 (
1072 "POST",
1073 "/api/scrub",
1074 Some(Right::new(Resource::System, None, Action::Admin)),
1075 ),
1076 (
1077 "GET",
1078 "/api/certs",
1079 Some(Right::new(Resource::Certs, None, Action::Read)),
1080 ),
1081 (
1082 "POST",
1083 "/api/cache/invalidate",
1084 Some(Right::new(Resource::Cache, None, Action::Write)),
1085 ),
1086 (
1087 "GET",
1088 "/api/metrics",
1089 Some(Right::new(Resource::System, None, Action::Read)),
1090 ),
1091 ];
1092 for (method, path, expected) in cases {
1093 assert_eq!(
1094 &Right::required(method, path),
1095 expected,
1096 "required({method}, {path})"
1097 );
1098 }
1099 }
1100
1101 #[test]
1102 fn unknown_site_subpath_is_deny_safe() {
1103 assert_eq!(
1105 Right::required("PATCH", "/api/sites/blog/frobnicate"),
1106 Some(Right::new(Resource::System, None, Action::Admin))
1107 );
1108 }
1109
1110 #[test]
1111 fn attach_unverified_is_admin_only() {
1112 let required = Right::required(
1116 "POST",
1117 "/api/sites/blog/domains/evil.example.com/attach-unverified",
1118 )
1119 .expect("route is gated");
1120 assert_eq!(required, Right::new(Resource::System, None, Action::Admin));
1121 let site_write = Right::new(Resource::Site, Some("blog".into()), Action::Write);
1123 assert!(!site_write.satisfies(&required));
1124 assert!(Right::new(Resource::System, None, Action::Admin).satisfies(&required));
1126 }
1127
1128 #[test]
1129 fn default_policy_publisher_can_deploy_and_write_its_site_only() {
1130 let policy = AuthzPolicy::default_policy();
1131 let rights = policy.rights_for(&[GrantedRole::scoped("publisher", "blog")]);
1132 assert!(rights.allows(&Right::new(
1134 Resource::Site,
1135 Some("blog".into()),
1136 Action::Deploy
1137 )));
1138 assert!(rights.allows(&Right::new(
1139 Resource::Site,
1140 Some("blog".into()),
1141 Action::Write
1142 )));
1143 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1144 assert!(!rights.allows(&Right::new(
1146 Resource::Site,
1147 Some("api".into()),
1148 Action::Read
1149 )));
1150 assert!(!rights.allows(&Right::new(Resource::Tokens, None, Action::Admin)));
1151 }
1152
1153 #[test]
1154 fn default_policy_deployer_cannot_edit_config() {
1155 let policy = AuthzPolicy::default_policy();
1156 let rights = policy.rights_for(&[GrantedRole::scoped("deployer", "blog")]);
1157 assert!(rights.allows(&Right::new(
1158 Resource::Site,
1159 Some("blog".into()),
1160 Action::Deploy
1161 )));
1162 assert!(rights.allows(&Right::new(
1163 Resource::Site,
1164 Some("blog".into()),
1165 Action::Read
1166 )));
1167 assert!(
1168 !rights.allows(&Right::new(
1169 Resource::Site,
1170 Some("blog".into()),
1171 Action::Write
1172 )),
1173 "deployer must not edit config"
1174 );
1175 }
1176
1177 #[test]
1178 fn default_policy_admin_can_do_anything() {
1179 let policy = AuthzPolicy::default_policy();
1180 let rights = policy.rights_for(&[GrantedRole::global("admin")]);
1181 for resource in Resource::ALL {
1182 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1183 let target = matches!(resource, Resource::Site).then(|| "any".to_string());
1184 assert!(
1185 rights.allows(&Right::new(resource, target, action)),
1186 "admin must allow {resource:?}·{action:?}"
1187 );
1188 }
1189 }
1190 }
1191
1192 #[test]
1193 fn site_role_without_target_grants_nothing_site_scoped() {
1194 let policy = AuthzPolicy::default_policy();
1195 let rights = policy.rights_for(&[GrantedRole::global("publisher")]);
1198 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1199 assert!(!rights.allows(&Right::new(
1200 Resource::Site,
1201 Some("blog".into()),
1202 Action::Read
1203 )));
1204 }
1205
1206 #[test]
1207 fn role_takes_target_classifies_roles() {
1208 let policy = AuthzPolicy::default_policy();
1209 assert!(policy.role_takes_target("publisher"));
1210 assert!(policy.role_takes_target("viewer"));
1211 assert!(!policy.role_takes_target("admin"));
1212 assert!(!policy.role_takes_target("operator"));
1213 }
1214
1215 #[test]
1216 fn policy_round_trips_through_json() {
1217 let policy = AuthzPolicy::default_policy();
1218 let json = serde_json::to_string(&policy).unwrap();
1219 let back: AuthzPolicy = serde_json::from_str(&json).unwrap();
1220 assert_eq!(policy, back);
1221 assert_eq!(back.version, crate::SCHEMA_VERSION);
1222 }
1223
1224 #[test]
1225 fn unknown_role_is_ignored() {
1226 let policy = AuthzPolicy::default_policy();
1227 let rights = policy.rights_for(&[GrantedRole::global("nonesuch")]);
1228 assert!(rights.is_empty());
1229 }
1230
1231 #[test]
1234 fn legacy_site_grant_normalizes_to_default_project() {
1235 let policy = AuthzPolicy::default_policy();
1236 let n = policy.normalize_grants(&[GrantedRole::scoped("publisher", "blog")]);
1238 assert_eq!(n, vec![GrantedRole::scoped("publisher", "default/blog")]);
1239 let untouched = [
1242 GrantedRole::scoped("publisher", "acme/blog"),
1243 GrantedRole::scoped("project_admin", "acme"),
1244 GrantedRole::global("admin"),
1245 ];
1246 assert_eq!(policy.normalize_grants(&untouched), untouched);
1247 }
1248
1249 #[test]
1250 fn project_admin_covers_its_project_but_not_another() {
1251 let policy = AuthzPolicy::default_policy();
1252 let rights = policy.rights_for(&[GrantedRole::scoped("project_admin", "acme")]);
1253 for action in [Action::Read, Action::Write, Action::Deploy, Action::Admin] {
1255 assert!(
1256 rights.allows(&Right::new(
1257 Resource::Site,
1258 Some("acme/blog".into()),
1259 action
1260 )),
1261 "project-admin:acme covers acme/blog·{action:?}"
1262 );
1263 }
1264 assert!(rights.allows(&Right::new(
1266 Resource::Project,
1267 Some("acme".into()),
1268 Action::Deploy
1269 )));
1270 assert!(rights.allows(&Right::new(
1271 Resource::Project,
1272 Some("acme".into()),
1273 Action::Admin
1274 )));
1275 assert!(!rights.allows(&Right::new(
1277 Resource::Site,
1278 Some("shop/blog".into()),
1279 Action::Read
1280 )));
1281 assert!(!rights.allows(&Right::new(
1282 Resource::Project,
1283 Some("shop".into()),
1284 Action::Read
1285 )));
1286 assert!(!rights.allows(&Right::new(
1288 Resource::Site,
1289 Some("blog".into()),
1290 Action::Read
1291 )));
1292 }
1293
1294 #[test]
1295 fn project_viewer_is_read_only_across_the_project() {
1296 let policy = AuthzPolicy::default_policy();
1297 let rights = policy.rights_for(&[GrantedRole::scoped("project_viewer", "acme")]);
1298 assert!(rights.allows(&Right::new(
1299 Resource::Site,
1300 Some("acme/blog".into()),
1301 Action::Read
1302 )));
1303 assert!(rights.allows(&Right::new(
1304 Resource::Project,
1305 Some("acme".into()),
1306 Action::Read
1307 )));
1308 assert!(!rights.allows(&Right::new(
1310 Resource::Site,
1311 Some("acme/blog".into()),
1312 Action::Write
1313 )));
1314 assert!(!rights.allows(&Right::new(
1315 Resource::Project,
1316 Some("acme".into()),
1317 Action::Deploy
1318 )));
1319 }
1320
1321 #[test]
1322 fn project_publisher_ships_but_cannot_admin_the_project() {
1323 let policy = AuthzPolicy::default_policy();
1324 let rights = policy.rights_for(&[GrantedRole::scoped("project_publisher", "acme")]);
1325 assert!(rights.allows(&Right::new(
1326 Resource::Site,
1327 Some("acme/blog".into()),
1328 Action::Deploy
1329 )));
1330 assert!(rights.allows(&Right::new(
1331 Resource::Project,
1332 Some("acme".into()),
1333 Action::Deploy
1334 )));
1335 assert!(rights.allows(&Right::new(Resource::Blobs, None, Action::Deploy)));
1336 assert!(!rights.allows(&Right::new(
1338 Resource::Project,
1339 Some("acme".into()),
1340 Action::Admin
1341 )));
1342 }
1343
1344 #[test]
1345 fn required_maps_project_paths() {
1346 assert_eq!(
1348 Right::required("POST", "/api/projects/acme/sites/blog/deployments"),
1349 Some(Right::new(
1350 Resource::Site,
1351 Some("acme/blog".into()),
1352 Action::Deploy
1353 ))
1354 );
1355 assert_eq!(
1357 Right::required("GET", "/api/projects/acme/functions/resize"),
1358 Some(Right::new(
1359 Resource::Project,
1360 Some("acme".into()),
1361 Action::Read
1362 ))
1363 );
1364 assert_eq!(
1365 Right::required("POST", "/api/projects/acme/functions/resize/versions"),
1366 Some(Right::new(
1367 Resource::Project,
1368 Some("acme".into()),
1369 Action::Deploy
1370 ))
1371 );
1372 assert_eq!(
1374 Right::required("DELETE", "/api/projects/acme"),
1375 Some(Right::new(
1376 Resource::Project,
1377 Some("acme".into()),
1378 Action::Admin
1379 ))
1380 );
1381 assert_eq!(
1383 Right::required("GET", "/api/projects"),
1384 Some(Right::new(Resource::System, None, Action::Read))
1385 );
1386 assert_eq!(
1387 Right::required("POST", "/api/projects"),
1388 Some(Right::new(Resource::System, None, Action::Admin))
1389 );
1390 assert_eq!(
1392 Right::required("GET", "/api/functions"),
1393 Some(Right::new(
1394 Resource::Project,
1395 Some("default".into()),
1396 Action::Read
1397 ))
1398 );
1399 }
1400}