1 import Base 2 import ./type.bend as Az 3 import ../../plan/type.bend as P 4 import ../../../environment/agreement/type.bend as G 5 import ../../../environment/agreement/ops.bend as GO 6 7 def keep_if(c: Bool, x: String, rest: List<&2, String>) -> List<&2, String>: 8 match c: 9 case True{}: 10 x <> rest 11 case False{}: 12 rest 13 14 # ---- resources: what Azure has against what the system declares ---- 15 def same(a: Az.Resource, b: Az.Resource) -> Bool: 16 match a b: 17 case Az.Resource{an, ak, ag} Az.Resource{bn, bk, bg}: 18 String.eq(an, bn) && String.eq(ak, bk) && String.eq(ag, bg) 19 20 def listed(+r: Az.Resource, rs: List<&2, Az.Resource>) -> Bool: 21 match rs: 22 case []: 23 False{} 24 case x <> rest: 25 same(r, x) || listed(r, rest) 26 27 def keep_resource(c: Bool, r: Az.Resource, rest: List<&2, Az.Resource>) -> List<&2, Az.Resource>: 28 match c: 29 case True{}: 30 r <> rest 31 case False{}: 32 rest 33 34 # The resources in `xs` that are not in `ys` (same name, type and resource group). 35 def absent(xs: List<&2, Az.Resource>, +ys: List<&2, Az.Resource>) -> List<&2, Az.Resource>: 36 match xs: 37 case []: 38 [] 39 case +r <> rest: 40 keep_resource(Bool.not(listed(r, ys)), r, absent(rest, ys)) 41 42 # What Azure has that the system does not declare. 43 def undeclared(observed: List<&2, Az.Resource>, +declared: List<&2, Az.Resource>) -> List<&2, Az.Resource>: 44 absent(observed, declared) 45 46 # What the system declares that Azure does not have. 47 def missing(declared: List<&2, Az.Resource>, +observed: List<&2, Az.Resource>) -> List<&2, Az.Resource>: 48 absent(declared, observed) 49 50 def show(r: Az.Resource) -> String: 51 match r: 52 case Az.Resource{n, k, g}: 53 n ++ " (" ++ k ++ " in " ++ g ++ ")" 54 55 def shown(rs: List<&2, Az.Resource>) -> List<&2, String>: 56 match rs: 57 case []: 58 [] 59 case r <> rest: 60 show(r) <> shown(rest) 61 62 # ---- permissions ---- 63 # ARM patterns use '*' for zero or more characters; matching is case insensitive. 64 def glob_chars(+ps: List<&2, Char>, +vs: List<&2, Char>) -> Bool: 65 match ps: 66 case []: 67 match vs: 68 case []: 69 True{} 70 case _ <> _: 71 False{} 72 case p <> rest: 73 match p: 74 case '*': 75 match vs: 76 case []: 77 glob_chars(rest, []) 78 case v <> tail: 79 glob_chars(rest, v <> tail) || glob_chars('*' <> rest, tail) 80 case _: 81 match vs: 82 case []: 83 False{} 84 case v <> tail: 85 Char.is_eq(p, v) && glob_chars(rest, tail) 86 87 def glob(pattern: String, value: String) -> Bool: 88 glob_chars(String.to_list(String.to_lower(pattern)), String.to_list(String.to_lower(value))) 89 90 # A wildcard pattern is covered only by the same pattern or '*'. This conservative rule avoids claiming 91 # that overlapping wildcards grant exactly equal permissions. 92 def covers(+want: String, +actual: String) -> Bool: 93 String.eq(String.to_lower(want), String.to_lower(actual)) || 94 String.eq(want, "*") || 95 (Bool.not(String.contains(actual, "*")) && glob(want, actual)) 96 97 def covered(+action: String, patterns: List<&2, String>) -> Bool: 98 match patterns: 99 case []: 100 False{} 101 case p <> rest: 102 covers(p, action) || covered(action, rest) 103 104 def every_covered(xs: List<&2, String>, +by: List<&2, String>) -> Bool: 105 match xs: 106 case []: 107 True{} 108 case x <> rest: 109 covered(x, by) && every_covered(rest, by) 110 111 def actions_if(hit: Bool, acts: List<&2, String>, rest: List<&2, String>) -> List<&2, String>: 112 match hit: 113 case True{}: 114 List.append(&2, String, acts, rest) 115 case False{}: 116 rest 117 118 def scope_actions(+scope: String, xs: List<&2, Az.Permission>) -> List<&2, String>: 119 match xs: 120 case []: 121 [] 122 case Az.Permission{+s, acts, _} <> rest: 123 actions_if(String.eq(scope, s), acts, scope_actions(scope, rest)) 124 125 def no_exclusions(+scope: String, xs: List<&2, Az.Permission>) -> Bool: 126 match xs: 127 case []: 128 True{} 129 case Az.Permission{+s, _, exclusions} <> rest: 130 (Bool.not(String.eq(scope, s)) || List.is_empty(&2, String, exclusions)) && no_exclusions(scope, rest) 131 132 # Whether the permissions on `scope` are exactly `want`, with nothing excluded. 133 def exact(+scope: String, +want: List<&2, String>, +actual: List<&2, Az.Permission>) -> Bool: 134 +got = scope_actions(scope, actual) 135 no_exclusions(scope, actual) && every_covered(got, want) && every_covered(want, got) 136 137 # The resource groups where `person` has other than exactly what the agreement grants there (nothing 138 # granted means no access at all). 139 def not_minimal(groups: List<&2, String>, +a: G.Agreement, +person: String, +actual: List<&2, Az.Permission>) -> List<&2, String>: 140 match groups: 141 case []: 142 [] 143 case +g <> rest: 144 keep_if(Bool.not(exact(g, GO.granted(a, person, g), actual)), g, not_minimal(rest, a, person, actual)) 145 146 # Permission minimality: on every resource group, `person` has exactly what the agreement grants there. 147 def access_minimal(groups: List<&2, String>, +a: G.Agreement, +person: String, +actual: List<&2, Az.Permission>) -> Bool: 148 List.is_empty(&2, String, not_minimal(groups, a, person, actual)) 149 150 # ---- identities ---- 151 def identity_names(xs: List<&2, Az.Identity>) -> List<&2, String>: 152 match xs: 153 case []: 154 [] 155 case Az.Identity{_, name} <> rest: 156 name <> identity_names(rest) 157 158 # Who is signed in with this profile, or "" when no one is. 159 def signed_in(+profile: String, xs: List<&2, Az.Identity>) -> String: 160 match xs: 161 case []: 162 "" 163 case Az.Identity{+p, name} <> rest: 164 Bool.pick(String, String.eq(profile, p), name, signed_in(profile, rest)) 165 166 # ---- the steps ---- 167 def blocked_if(+what: String, +xs: List<&2, String>, why: String) -> List<&2, P.Step>: 168 Bool.pick(List<&2, P.Step>, List.is_empty(&2, String, xs), [], [P.Blocked{what, why ++ String.join(xs, ", ")}]) 169 170 def verdict(+what: String, +bs: List<&2, P.Step>) -> List<&2, P.Step>: 171 Bool.pick(List<&2, P.Step>, List.is_empty(&2, P.Step, bs), [P.Current{what}], bs) 172 173 def domain(a: G.Agreement) -> String: 174 match a: 175 case G.Agreement{d, _, _, _}: 176 d 177 178 def judged(+what: String, +a: G.Agreement, +declared: List<&2, Az.Resource>, o: Az.Observed) -> List<&2, P.Step>: 179 match o: 180 case Az.Observed{+ids, +rs, gs, ps, +unread}: 181 +user = signed_in("azure", ids) 182 Bool.pick(List<&2, P.Step>, List.is_empty(&2, String, unread), 183 verdict(what, List.concat(&2, P.Step, [ 184 blocked_if(what, shown(undeclared(rs, declared)), "undeclared resources: "), 185 blocked_if(what, shown(missing(declared, rs)), "declared resources missing: "), 186 blocked_if(what, GO.unknown_identities(identity_names(ids), a), "identities not in the agreement: "), 187 blocked_if(what, GO.outside_domain(a), "people outside the directory domain " ++ domain(a) ++ ": "), 188 blocked_if(what, not_minimal(gs, a, GO.person_of(a, user), ps), user ++ " has other access than the agreement grants on: ")])), 189 [P.Blocked{what, "cannot read " ++ String.join(unread, ", ") ++ " (is az installed and signed in?)"}]) 190 191 # What Azure as observed means for the system: Current when every resource is declared and every 192 # declared resource exists, every identity in use is one of the agreement's people, every person is in 193 # its directory domain, and the signed-in user has exactly what the agreement grants; else Blocked. 194 def steps(az: Az.Azure, +a: G.Agreement, o: Az.Observed) -> List<&2, P.Step>: 195 match az: 196 case Az.Azure{org, project, declared}: 197 judged("azure " ++ org ++ " " ++ project, a, declared, o)