objective categorical abstract machine language personal data server

Permission sets support

futur.blue 808bc746 a16e0f1b

verified
+1186 -120
+389 -46
frontend/src/templates/OauthAuthorizePage.mlx
··· 10 10 {did: string; handle: string; avatar_data_uri: string option [@default None]} 11 11 [@@deriving json] 12 12 13 + type permission_set_display = 14 + { nsid: string 15 + ; title: string option [@default None] 16 + ; detail: string option [@default None] 17 + ; expanded_scopes: string list } 18 + [@@deriving json] 19 + 13 20 type props = 14 21 { client_url: string * string (* (host, path) *) 15 22 ; client_name: string option [@default None] ··· 17 24 ; current_user: actor 18 25 ; logged_in_users: actor list 19 26 ; scopes: string list 27 + ; permission_sets: permission_set_display list [@default []] 20 28 ; code: string 21 29 ; request_uri: string 22 30 ; csrf_token: string } ··· 38 46 | Bluesky (* transition:generic or app.bsky.* *) 39 47 | Chat (* transition:chat.bsky or chat.bsky.* *) 40 48 | Atproto 49 + | PermissionSet of 50 + { nsid: string 51 + ; title: string option 52 + ; detail: string option 53 + ; expanded_scopes: string list (* raw scope strings for display *) } 41 54 | Unknown of string 42 55 43 56 let parse_scope scope = ··· 45 58 else if scope = "transition:generic" then Bluesky 46 59 else if scope = "transition:chat.bsky" then Chat 47 60 else if scope = "transition:email" then Email `Read 48 - else if String.starts_with ~prefix:"account:email" scope then 49 - if String.exists (fun c -> c = '=') scope then Email `Manage 50 - else Email `Read 51 - else if String.starts_with ~prefix:"identity:" scope then 61 + else if 62 + String.starts_with ~prefix:"account:" scope 63 + || String.starts_with ~prefix:"account?" scope 64 + then 65 + let has_positional = String.starts_with ~prefix:"account:" scope in 66 + let rest = 67 + String.sub scope 8 (String.length scope - 8) 68 + in 69 + let parts = String.split_on_char '?' rest in 70 + let positional_attr = 71 + if has_positional then 72 + match parts with a :: _ when a <> "" -> Some a | _ -> None 73 + else None 74 + in 75 + let query_str = 76 + if has_positional then 77 + if List.length parts > 1 then Some (List.nth parts 1) else None 78 + else if rest <> "" then Some rest 79 + else None 80 + in 81 + let parse_query_params qs = 82 + String.split_on_char '&' qs 83 + |> List.filter_map (fun pair -> 84 + match String.split_on_char '=' pair with 85 + | [k; v] -> 86 + Some (k, v) 87 + | _ -> 88 + None ) 89 + in 90 + let params = 91 + Option.map parse_query_params query_str |> Option.value ~default:[] 92 + in 93 + let attr = 94 + match positional_attr with 95 + | Some a -> 96 + a 97 + | None -> 98 + List.find_map 99 + (fun (k, v) -> if k = "attr" then Some v else None) 100 + params 101 + |> Option.value ~default:"" 102 + in 103 + let action = 104 + List.find_map 105 + (fun (k, v) -> if k = "action" then Some v else None) 106 + params 107 + |> Option.value ~default:"read" 108 + in 109 + if attr = "email" then 110 + if action = "manage" then Email `Manage else Email `Read 111 + else Unknown scope (* repo and other attrs not displayed specially *) 112 + else if 113 + String.starts_with ~prefix:"identity:" scope 114 + || String.starts_with ~prefix:"identity?" scope 115 + then 116 + (* attrs are "handle" or "*" *) 117 + let has_positional = String.starts_with ~prefix:"identity:" scope in 52 118 let rest = String.sub scope 9 (String.length scope - 9) in 53 - if rest = "*" || String.starts_with ~prefix:"*" rest then Identity `Full 54 - else Identity `Handle 55 - else if String.starts_with ~prefix:"repo:" scope then 119 + let parts = String.split_on_char '?' rest in 120 + let positional_attr = 121 + if has_positional then 122 + match parts with a :: _ when a <> "" -> Some a | _ -> None 123 + else None 124 + in 125 + let attr = 126 + match positional_attr with 127 + | Some a -> 128 + a 129 + | None -> 130 + let params = 131 + if has_positional then 132 + if List.length parts > 1 then List.nth parts 1 else "" 133 + else rest 134 + in 135 + String.split_on_char '&' params 136 + |> List.find_map (fun pair -> 137 + match String.split_on_char '=' pair with 138 + | [k; v] when k = "attr" -> 139 + Some v 140 + | _ -> 141 + None ) 142 + |> Option.value ~default:"handle" 143 + in 144 + if attr = "*" then Identity `Full else Identity `Handle 145 + else if 146 + String.starts_with ~prefix:"repo:" scope 147 + || String.starts_with ~prefix:"repo?" scope 148 + then 149 + let has_positional = String.starts_with ~prefix:"repo:" scope in 56 150 let rest = String.sub scope 5 (String.length scope - 5) in 57 151 let parts = String.split_on_char '?' rest in 152 + let positional_coll = 153 + if has_positional then 154 + match parts with coll :: _ when coll <> "" -> Some coll | _ -> None 155 + else None 156 + in 157 + let query_str = 158 + if has_positional then 159 + if List.length parts > 1 then Some (List.nth parts 1) else None 160 + else if 161 + (* for repo?... format, rest starts with the query string *) 162 + rest <> "" 163 + then Some rest 164 + else None 165 + in 166 + let parse_query_params qs = 167 + String.split_on_char '&' qs 168 + |> List.filter_map (fun pair -> 169 + match String.split_on_char '=' pair with 170 + | [k; v] -> 171 + Some (k, v) 172 + | _ -> 173 + None ) 174 + in 175 + let params = 176 + Option.map parse_query_params query_str |> Option.value ~default:[] 177 + in 58 178 let collection = 59 - match parts with coll :: _ when coll <> "" -> [coll] | _ -> ["*"] 179 + match positional_coll with 180 + | Some c -> 181 + [c] 182 + | None -> ( 183 + List.filter_map 184 + (fun (k, v) -> if k = "collection" then Some v else None) 185 + params 186 + |> function [] -> ["*"] | cols -> cols ) 60 187 in 61 188 let actions = 62 - if List.length parts > 1 then 63 - let params = List.nth parts 1 in 64 - if String.contains params '=' then 65 - List.filter_map 66 - (fun a -> 67 - if 68 - String.ends_with ~suffix:a params 69 - || String.contains params ',' 70 - then 71 - match a with 72 - | "create" -> 73 - Some Create 74 - | "update" -> 75 - Some Update 76 - | "delete" -> 77 - Some Delete 78 - | _ -> 79 - None 80 - else None ) 81 - ["create"; "update"; "delete"] 82 - |> function [] -> [Create; Update; Delete] | l -> l 83 - else [Create; Update; Delete] 84 - else [Create; Update; Delete] 189 + let action_strs = 190 + List.filter_map 191 + (fun (k, v) -> if k = "action" then Some v else None) 192 + params 193 + |> List.concat_map (String.split_on_char ',') 194 + in 195 + if action_strs = [] then [Create; Update; Delete] 196 + else 197 + List.filter_map 198 + (fun a -> 199 + match a with 200 + | "create" -> 201 + Some Create 202 + | "update" -> 203 + Some Update 204 + | "delete" -> 205 + Some Delete 206 + | _ -> 207 + None ) 208 + action_strs 209 + |> function [] -> [Create; Update; Delete] | l -> l 85 210 in 86 211 if 87 212 List.exists ··· 97 222 then Chat 98 223 else Bluesky 99 224 else Repo {collections= collection; actions} 100 - else if String.starts_with ~prefix:"rpc:" scope then 225 + else if 226 + String.starts_with ~prefix:"rpc:" scope 227 + || String.starts_with ~prefix:"rpc?" scope 228 + then 229 + let has_positional = String.starts_with ~prefix:"rpc:" scope in 101 230 let rest = String.sub scope 4 (String.length scope - 4) in 102 231 let parts = String.split_on_char '?' rest in 103 - let lxm = match parts with l :: _ -> l | [] -> "*" in 232 + let positional_lxm = 233 + if has_positional then 234 + match parts with l :: _ when l <> "" -> Some l | _ -> None 235 + else None 236 + in 237 + let query_str = 238 + if has_positional then 239 + if List.length parts > 1 then Some (List.nth parts 1) else None 240 + else if rest <> "" then Some rest 241 + else None 242 + in 243 + let parse_query_params qs = 244 + String.split_on_char '&' qs 245 + |> List.filter_map (fun pair -> 246 + match String.split_on_char '=' pair with 247 + | [k; v] -> 248 + Some (k, v) 249 + | _ -> 250 + None ) 251 + in 252 + let params = 253 + Option.map parse_query_params query_str |> Option.value ~default:[] 254 + in 255 + let lxm = 256 + match positional_lxm with 257 + | Some l -> 258 + l 259 + | None -> 260 + List.find_map 261 + (fun (k, v) -> if k = "lxm" then Some v else None) 262 + params 263 + |> Option.value ~default:"*" 264 + in 104 265 let aud = 105 - if List.length parts > 1 then 106 - let params = List.nth parts 1 in 107 - if String.starts_with ~prefix:"aud=" params then 108 - String.sub params 4 (String.length params - 4) 109 - else "*" 110 - else "*" 266 + List.find_map (fun (k, v) -> if k = "aud" then Some v else None) params 267 + |> Option.value ~default:"*" 111 268 in 112 269 if String.starts_with ~prefix:"app.bsky." lxm then Bluesky 113 270 else if String.starts_with ~prefix:"chat.bsky." lxm then Chat 114 271 else Rpc {lxm; aud} 115 - else if String.starts_with ~prefix:"blob:" scope then 272 + else if 273 + String.starts_with ~prefix:"blob:" scope 274 + || String.starts_with ~prefix:"blob?" scope 275 + then 276 + let has_positional = String.starts_with ~prefix:"blob:" scope in 116 277 let rest = String.sub scope 5 (String.length scope - 5) in 117 - Blob [rest] 278 + let mimetypes = 279 + if has_positional then [rest] 280 + else 281 + String.split_on_char '&' rest 282 + |> List.filter_map (fun pair -> 283 + match String.split_on_char '=' pair with 284 + | [k; v] when k = "accept" -> 285 + Some v 286 + | _ -> 287 + None ) 288 + in 289 + Blob (if mimetypes = [] then ["*/*"] else mimetypes) 118 290 else Unknown scope 119 291 292 + (* parse repo scope string without converting app.bsky/chat.bsky to Bluesky/Chat *) 293 + let parse_repo_scope_raw scope = 294 + if 295 + String.starts_with ~prefix:"repo:" scope 296 + || String.starts_with ~prefix:"repo?" scope 297 + then 298 + let has_positional = String.starts_with ~prefix:"repo:" scope in 299 + let rest = String.sub scope 5 (String.length scope - 5) in 300 + let parts = String.split_on_char '?' rest in 301 + let positional_coll = 302 + if has_positional then 303 + match parts with coll :: _ when coll <> "" -> Some coll | _ -> None 304 + else None 305 + in 306 + let query_str = 307 + if has_positional then 308 + if List.length parts > 1 then Some (List.nth parts 1) else None 309 + else if rest <> "" then Some rest 310 + else None 311 + in 312 + let parse_query_params qs = 313 + String.split_on_char '&' qs 314 + |> List.filter_map (fun pair -> 315 + match String.split_on_char '=' pair with 316 + | [k; v] -> 317 + Some (k, v) 318 + | _ -> 319 + None ) 320 + in 321 + let params = 322 + Option.map parse_query_params query_str |> Option.value ~default:[] 323 + in 324 + let collection = 325 + match positional_coll with 326 + | Some c -> 327 + [c] 328 + | None -> ( 329 + List.filter_map 330 + (fun (k, v) -> if k = "collection" then Some v else None) 331 + params 332 + |> function [] -> ["*"] | cols -> cols ) 333 + in 334 + let actions = 335 + let action_strs = 336 + List.filter_map 337 + (fun (k, v) -> if k = "action" then Some v else None) 338 + params 339 + |> List.concat_map (String.split_on_char ',') 340 + in 341 + if action_strs = [] then [Create; Update; Delete] 342 + else 343 + List.filter_map 344 + (fun a -> 345 + match a with 346 + | "create" -> 347 + Some Create 348 + | "update" -> 349 + Some Update 350 + | "delete" -> 351 + Some Delete 352 + | _ -> 353 + None ) 354 + action_strs 355 + |> function [] -> [Create; Update; Delete] | l -> l 356 + in 357 + Some {collections= collection; actions} 358 + else None 359 + 120 360 type collection_actions = {create: bool; update: bool; delete: bool} 121 361 122 362 module StringMap = Map.Make (String) ··· 184 424 has_chat := true 185 425 | Atproto -> 186 426 () 427 + | PermissionSet _ -> 428 + () 187 429 | Unknown s -> 188 430 unknowns := s :: !unknowns ) 189 431 scopes ; ··· 196 438 , !has_chat 197 439 , !unknowns ) 198 440 199 - let[@react.component] make ~scopes () = 441 + let[@react.component] make ~scopes ?(permission_sets = []) () = 200 442 let email, identity, repos, rpcs, blobs, has_bluesky, has_chat, unknowns = 201 443 merge_parsed_scopes scopes 202 444 in 445 + let ps_displays = 446 + List.map 447 + (fun (ps : permission_set_display) -> 448 + PermissionSet 449 + { nsid= ps.nsid 450 + ; title= ps.title 451 + ; detail= ps.detail 452 + ; expanded_scopes= ps.expanded_scopes } ) 453 + permission_sets 454 + in 203 455 <div className="w-full mt-3 space-y-1"> 204 456 ( match email with 205 457 | Some level -> ··· 279 531 </div> 280 532 </div> 281 533 else null ) 282 - ( if List.length repos > 0 && not has_bluesky then 534 + ( if List.length repos > 0 then 283 535 let coll_actions_map = build_collection_actions_map repos in 284 536 let coll_actions_list = 285 537 StringMap.bindings coll_actions_map ··· 482 734 </div> 483 735 </div> 484 736 else null ) 485 - ( if List.length blobs > 0 && not has_bluesky then 737 + ( if List.length blobs > 0 then 486 738 <div className="flex items-start gap-3 p-3 rounded-lg"> 487 739 <div 488 740 className="flex-shrink-0 w-8 h-8 flex items-center \ ··· 523 775 </div> 524 776 </div> 525 777 else null ) 778 + (* permission sets *) 779 + ( List.map 780 + (fun ps -> 781 + match ps with 782 + | PermissionSet {nsid; title; detail; expanded_scopes} -> 783 + let repos = 784 + List.filter_map parse_repo_scope_raw expanded_scopes 785 + in 786 + let coll_actions_map = build_collection_actions_map repos in 787 + let coll_actions_list = 788 + StringMap.bindings coll_actions_map 789 + |> List.sort (fun (a, _) (b, _) -> String.compare a b) 790 + in 791 + <div key=nsid className="flex items-start gap-3 p-3 rounded-lg"> 792 + <div 793 + className="flex-shrink-0 w-8 h-8 flex items-center \ 794 + justify-center rounded-full bg-mist-20/50 \ 795 + text-mist-80"> 796 + <BoxesIcon className="w-4 h-4" /> 797 + </div> 798 + <div className="flex-1 min-w-0"> 799 + <div className="font-serif text-mana-100"> 800 + (string (Option.value title ~default:nsid)) 801 + </div> 802 + ( match detail with 803 + | Some d -> 804 + <div className="text-sm text-mist-100">(string d)</div> 805 + | None -> 806 + null ) 807 + ( if List.length coll_actions_list > 0 then 808 + <table className="w-full mt-2 text-xs"> 809 + <thead> 810 + <tr className="text-mist-80"> 811 + <th className="text-left font-normal pb-1"> 812 + (string "Collection") 813 + </th> 814 + <th className="text-center font-normal pb-1 w-16"> 815 + (string "Create") 816 + </th> 817 + <th className="text-center font-normal pb-1 w-16"> 818 + (string "Update") 819 + </th> 820 + <th className="text-center font-normal pb-1 w-16"> 821 + (string "Delete") 822 + </th> 823 + </tr> 824 + </thead> 825 + <tbody> 826 + ( coll_actions_list 827 + |> List.map (fun (coll, actions) -> 828 + <tr key=coll className="text-mist-100"> 829 + <td className="py-0.5"> 830 + <span className="font-medium"> 831 + (string 832 + ( if coll = "*" then "Any collection" 833 + else coll ) ) 834 + </span> 835 + </td> 836 + <td className="text-center"> 837 + ( if actions.create then 838 + <span className="text-mana-100"> 839 + (string {js|✓|js}) 840 + </span> 841 + else null ) 842 + </td> 843 + <td className="text-center"> 844 + ( if actions.update then 845 + <span className="text-mana-100"> 846 + (string {js|✓|js}) 847 + </span> 848 + else null ) 849 + </td> 850 + <td className="text-center"> 851 + ( if actions.delete then 852 + <span className="text-mana-100"> 853 + (string {js|✓|js}) 854 + </span> 855 + else null ) 856 + </td> 857 + </tr> ) 858 + |> Array.of_list |> array ) 859 + </tbody> 860 + </table> 861 + else null ) 862 + </div> 863 + </div> 864 + | _ -> 865 + null ) 866 + ps_displays 867 + |> Array.of_list |> React.array ) 526 868 </div> 527 869 end 528 870 ··· 534 876 ; current_user 535 877 ; logged_in_users 536 878 ; scopes 879 + ; permission_sets 537 880 ; code 538 881 ; request_uri 539 882 ; csrf_token } : ··· 565 908 useState (fun () -> 566 909 Option.value logo_uri ~default:("https://" ^ host ^ "/favicon.ico") ) 567 910 in 568 - <form className="w-full h-auto max-w-lg px-4 sm:px-0 my-auto"> 911 + <form className="w-full h-auto max-w-lg px-4 sm:px-0 py-16 my-auto"> 569 912 <h1 className="text-2xl font-serif text-mana-200 mb-2"> 570 913 (string ("authorizing " ^ host)) 571 914 </h1> ··· 583 926 /> 584 927 (string " and granting it the following permissions:") 585 928 </span> 586 - <ScopesTable scopes /> 929 + <ScopesTable scopes permission_sets /> 587 930 <div className="w-full flex flex-row items-center justify-between mt-6"> 588 931 <input type_="hidden" name="dream.csrf" value=csrf_token /> 589 932 <input type_="hidden" name="code" value=code />
+32
hermes-cli/lib/codegen.ml
··· 62 62 "Yojson.Safe.t" 63 63 | Query _ | Procedure _ | Subscription _ | Record _ -> 64 64 "unit (* primary type *)" 65 + | PermissionSet _ -> 66 + "unit (* permission-set type *)" 65 67 66 68 (* generate reference to another type *) 67 69 and gen_ref_type nsid out ref_str : string = ··· 698 700 emitln out (Printf.sprintf "let %s = \"%s\"" (Naming.type_name name) full_uri) ; 699 701 emit_newline out 700 702 703 + (* generate permission set module *) 704 + let gen_permission_set_module nsid out name (_spec : permission_set_spec) = 705 + let type_name = Naming.type_name name in 706 + (* generate permission type *) 707 + emitln out (Printf.sprintf "(** %s *)" nsid) ; 708 + emitln out "type permission =" ; 709 + emitln out " { resource: string" ; 710 + emitln out " ; lxm: string list option [@default None]" ; 711 + emitln out " ; aud: string option [@default None]" ; 712 + emitln out 713 + " ; inherit_aud: bool option [@key \"inheritAud\"] [@default None]" ; 714 + emitln out " ; collection: string list option [@default None]" ; 715 + emitln out " ; action: string list option [@default None]" ; 716 + emitln out " ; accept: string list option [@default None] }" ; 717 + emitln out "[@@deriving yojson {strict= false}]" ; 718 + emit_newline out ; 719 + (* generate main type *) 720 + emitln out (Printf.sprintf "type %s =" type_name) ; 721 + emitln out " { title: string option [@default None]" ; 722 + emitln out " ; detail: string option [@default None]" ; 723 + emitln out " ; permissions: permission list }" ; 724 + emitln out "[@@deriving yojson {strict= false}]" ; 725 + emit_newline out 726 + 701 727 (* generate string type alias (for strings with knownValues) *) 702 728 let gen_string_type out name (spec : string_spec) = 703 729 let type_name = Naming.type_name name in ··· 743 769 gen_procedure nsid out def.name spec 744 770 | Record spec -> 745 771 gen_object_type ~first ~last nsid out def.name spec.record 772 + | PermissionSet spec -> 773 + gen_permission_set_module nsid out def.name spec 746 774 | String spec when spec.known_values <> None -> 747 775 gen_string_type out def.name spec 748 776 | String _ ··· 1099 1127 "Yojson.Safe.t" 1100 1128 | Query _ | Procedure _ | Subscription _ | Record _ -> 1101 1129 "unit (* primary type *)" 1130 + | PermissionSet _ -> 1131 + "unit (* permission-set type *)" 1102 1132 and gen_merged_ref_type current_nsid ref_str = 1103 1133 if String.length ref_str > 0 && ref_str.[0] = '#' then begin 1104 1134 (* local ref within same nsid *) ··· 2274 2304 "Yojson.Safe.t" 2275 2305 | Query _ | Procedure _ | Subscription _ | Record _ -> 2276 2306 "unit (* primary type *)" 2307 + | PermissionSet _ -> 2308 + "unit (* permission-set type *)" 2277 2309 and gen_shared_ref_type current_nsid ref_str = 2278 2310 if String.length ref_str > 0 && ref_str.[0] = '#' then begin 2279 2311 (* local ref within same nsid *)
+13
hermes-cli/lib/lexicon_types.ml
··· 88 88 ; record: object_spec 89 89 ; description: string option } 90 90 91 + and lex_permission = 92 + { resource: string 93 + ; extra: (string * Yojson.Safe.t) list } 94 + 95 + and permission_set_spec = 96 + { title: string option 97 + ; title_lang: (string * string) list option 98 + ; detail: string option 99 + ; detail_lang: (string * string) list option 100 + ; permissions: lex_permission list 101 + ; description: string option } 102 + 91 103 and type_def = 92 104 | String of string_spec 93 105 | Integer of integer_spec ··· 105 117 | Procedure of procedure_spec 106 118 | Subscription of subscription_spec 107 119 | Record of record_spec 120 + | PermissionSet of permission_set_spec 108 121 109 122 type def_entry = {name: string; type_def: type_def} 110 123
+54
hermes-cli/lib/parser.ml
··· 151 151 Subscription (parse_subscription_spec json) 152 152 | "record" -> 153 153 Record (parse_record_spec json) 154 + | "permission-set" -> 155 + PermissionSet (parse_permission_set_spec json) 154 156 | t -> 155 157 failwith ("unknown type: " ^ t) 156 158 ··· 317 319 in 318 320 { key 319 321 ; record= parse_object_spec record_json 322 + ; description= get_string_opt "description" json } 323 + 324 + and parse_permission json : lex_permission = 325 + let resource = get_string "resource" json in 326 + let extra = 327 + match json with 328 + | `Assoc pairs -> 329 + List.filter (fun (k, _) -> k <> "resource") pairs 330 + | _ -> 331 + [] 332 + in 333 + {resource; extra} 334 + 335 + and parse_lang_map key json : (string * string) list option = 336 + match json with 337 + | `Assoc pairs -> 338 + let prefix = key ^ ":" in 339 + let lang_pairs = 340 + List.filter_map 341 + (fun (k, v) -> 342 + if String.starts_with ~prefix k then 343 + let lang = 344 + String.sub k (String.length prefix) 345 + (String.length k - String.length prefix) 346 + in 347 + match v with `String s -> Some (lang, s) | _ -> None 348 + else None ) 349 + pairs 350 + in 351 + if lang_pairs = [] then None else Some lang_pairs 352 + | _ -> 353 + None 354 + 355 + and parse_permission_set_spec json : permission_set_spec = 356 + let permissions = 357 + match get_list_opt "permissions" json with 358 + | Some l -> 359 + List.map 360 + (function 361 + | `Assoc _ as j -> 362 + parse_permission j 363 + | _ -> 364 + failwith "invalid permission" ) 365 + l 366 + | None -> 367 + [] 368 + in 369 + { title= get_string_opt "title" json 370 + ; title_lang= parse_lang_map "title" json 371 + ; detail= get_string_opt "detail" json 372 + ; detail_lang= parse_lang_map "detail" json 373 + ; permissions 320 374 ; description= get_string_opt "description" json } 321 375 322 376 (* parse complete lexicon document *)
+41 -1
hermes-cli/test/test_codegen.ml
··· 427 427 (contains code "type status = string") ; 428 428 check bool "contains status_of_yojson" true (contains code "status_of_yojson") 429 429 430 + (* test generating permission-set module *) 431 + let test_gen_permission_set () = 432 + let perm1 : Lexicon_types.lex_permission = 433 + { resource= "rpc" 434 + ; extra= 435 + [("lxm", `List [`String "com.example.foo"]); ("inheritAud", `Bool true)] 436 + } 437 + in 438 + let perm2 : Lexicon_types.lex_permission = 439 + { resource= "repo" 440 + ; extra= [("collection", `List [`String "com.example.data"])] } 441 + in 442 + let ps_spec : Lexicon_types.permission_set_spec = 443 + { title= Some "Test Permissions" 444 + ; title_lang= Some [("de", "Test Berechtigungen")] 445 + ; detail= Some "Access to test features" 446 + ; detail_lang= None 447 + ; permissions= [perm1; perm2] 448 + ; description= None } 449 + in 450 + let doc = 451 + make_lexicon "com.example.perms" 452 + [make_def "main" (Lexicon_types.PermissionSet ps_spec)] 453 + in 454 + let code = Codegen.gen_lexicon_module doc in 455 + check bool "contains type permission" true (contains code "type permission =") ; 456 + check bool "contains resource field" true (contains code "resource: string") ; 457 + check bool "contains lxm field" true (contains code "lxm: string list option") ; 458 + check bool "contains inherit_aud field" true 459 + (contains code "inherit_aud: bool option") ; 460 + check bool "contains type main" true (contains code "type main =") ; 461 + check bool "contains title field" true (contains code "title: string option") ; 462 + check bool "contains permissions field" true 463 + (contains code "permissions: permission list") ; 464 + check bool "contains deriving" true (contains code "[@@deriving yojson") 465 + 430 466 (* test generating query with bytes output (like getBlob) *) 431 467 let test_gen_query_bytes_output () = 432 468 let params_spec = ··· 514 550 let string_tests = 515 551 [("string with known values", `Quick, test_gen_string_known_values)] 516 552 553 + let permission_set_tests = 554 + [("generate permission-set", `Quick, test_gen_permission_set)] 555 + 517 556 let () = 518 557 run "Codegen" 519 558 [ ("objects", object_tests) ··· 521 560 ; ("xrpc", xrpc_tests) 522 561 ; ("ordering", ordering_tests) 523 562 ; ("tokens", token_tests) 524 - ; ("strings", string_tests) ] 563 + ; ("strings", string_tests) 564 + ; ("permission-set", permission_set_tests) ]
+53 -1
hermes-cli/test/test_parser.ml
··· 280 280 | Error e -> 281 281 fail ("parse failed: " ^ e) 282 282 283 + (* parsing permission-set type *) 284 + let test_parse_permission_set () = 285 + let json = 286 + {|{ 287 + "lexicon": 1, 288 + "id": "com.example.auth", 289 + "defs": { 290 + "main": { 291 + "type": "permission-set", 292 + "title": "Example Auth", 293 + "title:de": "Beispiel Auth", 294 + "detail": "Access to authentication features", 295 + "permissions": [ 296 + { 297 + "resource": "rpc", 298 + "lxm": ["com.example.auth.login", "com.example.auth.logout"], 299 + "inheritAud": true 300 + }, 301 + { 302 + "resource": "repo", 303 + "collection": ["com.example.auth.session"], 304 + "action": ["create", "delete"] 305 + } 306 + ] 307 + } 308 + } 309 + }|} 310 + in 311 + match Parser.parse_string json with 312 + | Ok doc -> ( 313 + check test_string "id matches" "com.example.auth" doc.id ; 314 + check int "one definition" 1 (List.length doc.defs) ; 315 + let def = List.hd doc.defs in 316 + match def.type_def with 317 + | Lexicon_types.PermissionSet spec -> 318 + check (option test_string) "title" (Some "Example Auth") spec.title ; 319 + check (option test_string) "detail" 320 + (Some "Access to authentication features") spec.detail ; 321 + check int "two permissions" 2 (List.length spec.permissions) ; 322 + let perm1 = List.hd spec.permissions in 323 + check test_string "first resource" "rpc" perm1.resource ; 324 + (* check extra fields are captured *) 325 + check bool "has lxm in extra" true (List.mem_assoc "lxm" perm1.extra) 326 + | _ -> 327 + fail "expected permission-set type" ) 328 + | Error e -> 329 + fail ("parse failed: " ^ e) 330 + 283 331 (* parsing invalid JSON *) 284 332 let test_parse_invalid_json () = 285 333 let json = {|{ invalid json }|} in ··· 318 366 [ ("invalid json", `Quick, test_parse_invalid_json) 319 367 ; ("missing field", `Quick, test_parse_missing_field) ] 320 368 369 + let permission_set_tests = 370 + [("parse permission-set", `Quick, test_parse_permission_set)] 371 + 321 372 let () = 322 373 run "Parser" 323 374 [ ("objects", object_tests) 324 375 ; ("complex_types", complex_type_tests) 325 - ; ("errors", error_tests) ] 376 + ; ("errors", error_tests) 377 + ; ("permission-set", permission_set_tests) ]
+37 -1
pegasus/lib/api/oauth_/authorize.ml
··· 79 79 | None -> 80 80 login_redirect 81 81 | Some _ -> 82 - let scopes = String.split_on_char ' ' req.scope in 82 + (* parse and resolve permission sets for display *) 83 + let raw_scopes = String.split_on_char ' ' req.scope in 84 + let parsed_scopes = 85 + Oauth.Scopes.parse_scopes req.scope 86 + in 87 + let%lwt permission_sets = 88 + Lwt_list.filter_map_p 89 + (fun scope -> 90 + match scope with 91 + | Oauth.Scopes.Include inc -> ( 92 + match%lwt 93 + Lexicon_resolver.resolve inc.nsid 94 + with 95 + | Error _ -> 96 + Lwt.return_none 97 + | Ok ps -> 98 + let expanded = 99 + Oauth.Scopes.expand_include_scope inc ps 100 + in 101 + Lwt.return_some 102 + { Frontend.OauthAuthorizePage.nsid= 103 + inc.nsid 104 + ; title= ps.title 105 + ; detail= ps.detail 106 + ; expanded_scopes= expanded } ) 107 + | _ -> 108 + Lwt.return_none ) 109 + parsed_scopes 110 + in 111 + (* separate include scopes from regular scopes for display *) 112 + let scopes = 113 + List.filter 114 + (fun s -> 115 + not (String.starts_with ~prefix:"include:" s) ) 116 + raw_scopes 117 + in 83 118 let csrf_token = Dream.csrf_token ctx.req in 84 119 let client_id_uri = 85 120 Option.map Uri.of_string metadata.client_id ··· 110 145 ; logged_in_users 111 146 ; current_user 112 147 ; scopes 148 + ; permission_sets 113 149 ; code 114 150 ; request_uri 115 151 ; csrf_token } ) ) ) )
+9 -3
pegasus/lib/api/oauth_/token.ml
··· 86 86 in 87 87 let exp_sec = now_sec + expires_in in 88 88 let expires_at = exp_sec * 1000 in 89 + (* expand scopes before creating token *) 90 + let%lwt expanded_scopes = 91 + let parsed = Scopes.parse_scopes orig_req.scope in 92 + let%lwt expanded = Scopes.expand_scopes parsed in 93 + Lwt.return (Scopes.scopes_to_string expanded) 94 + in 89 95 let claims = 90 96 `Assoc 91 97 [ ("jti", `String token_id) 92 98 ; ("sub", `String did) 93 99 ; ("iat", `Int now_sec) 94 100 ; ("exp", `Int exp_sec) 95 - ; ("scope", `String orig_req.scope) 101 + ; ("scope", `String expanded_scopes) 96 102 ; ("aud", `String Env.host_endpoint) 97 103 ; ("cnf", `Assoc [("jkt", `String proof.jkt)]) 98 104 ] ··· 117 123 ; client_id= req.client_id 118 124 ; did 119 125 ; dpop_jkt= proof.jkt 120 - ; scope= orig_req.scope 126 + ; scope= expanded_scopes 121 127 ; created_at= now_ms 122 128 ; last_refreshed_at= now_ms 123 129 ; expires_at ··· 135 141 ; ("token_type", `String "DPoP") 136 142 ; ("refresh_token", `String refresh_token) 137 143 ; ("expires_in", `Int expires_in) 138 - ; ("scope", `String orig_req.scope) 144 + ; ("scope", `String expanded_scopes) 139 145 ; ("sub", `String did) ] ) ) ) ) ) 140 146 | "refresh_token" -> ( 141 147 match req.refresh_token with
+2 -21
pegasus/lib/api/server/refreshSession.ml
··· 10 10 failwith "non-refresh auth" 11 11 in 12 12 let%lwt () = Data_store.revoke_token ~did ~jti db in 13 - let%lwt 14 - { handle 15 - ; did 16 - ; email 17 - ; email_auth_factor 18 - ; email_confirmed 19 - ; active 20 - ; status 21 - ; _ } = 22 - Auth.get_session_info did db 23 - in 13 + let%lwt {handle; did; active; status; _} = Auth.get_session_info did db in 24 14 let access_jwt, refresh_jwt = Jwt.generate_jwt did in 25 15 Dream.json @@ Yojson.Safe.to_string 26 16 @@ output_to_yojson 27 - { access_jwt 28 - ; refresh_jwt 29 - ; handle 30 - ; did 31 - ; email 32 - ; email_auth_factor 33 - ; email_confirmed 34 - ; active 35 - ; status 36 - ; did_doc= None } ) 17 + {access_jwt; refresh_jwt; handle; did; active; status; did_doc= None} )
+118
pegasus/lib/lexicon_resolver.ml
··· 1 + type permission = 2 + { resource: string 3 + ; lxm: string list option [@default None] 4 + ; aud: string option [@default None] 5 + ; inherit_aud: bool option [@key "inheritAud"] [@default None] 6 + ; collection: string list option [@default None] 7 + ; action: string list option [@default None] 8 + ; accept: string list option [@default None] } 9 + [@@deriving yojson {strict= false}] 10 + 11 + type permission_set = 12 + { title: string option [@default None] 13 + ; title_lang: (string * string) list option [@default None] 14 + ; detail: string option [@default None] 15 + ; detail_lang: (string * string) list option [@default None] 16 + ; permissions: permission list } 17 + [@@deriving yojson {strict= false}] 18 + 19 + type lexicon_value = 20 + { type_: string [@key "$type"] 21 + ; title: string option [@default None] 22 + ; detail: string option [@default None] 23 + ; permissions: permission list option [@default None] } 24 + [@@deriving yojson {strict= false}] 25 + 26 + let cache : permission_set Ttl_cache.String_cache.t = 27 + Ttl_cache.String_cache.create (3 * Util.hour) () 28 + 29 + (* reuse dns client from id_resolver *) 30 + let dns_client = Id_resolver.Handle.dns_client 31 + 32 + (* resolve did authority for nsid *) 33 + let resolve_did_authority nsid = 34 + let authority = Util.nsid_authority nsid in 35 + try%lwt 36 + let%lwt result = 37 + Dns_client_lwt.getaddrinfo dns_client Dns.Rr_map.Txt 38 + (Domain_name.of_string_exn ("_lexicon." ^ authority)) 39 + in 40 + match result with 41 + | Ok (_, t) -> ( 42 + let txt = Dns.Rr_map.Txt_set.choose t in 43 + match String.split_on_char '=' txt with 44 + | ["did"; did] 45 + when String.starts_with ~prefix:"did:plc:" did 46 + || String.starts_with ~prefix:"did:web:" did -> 47 + Lwt.return_ok (String.trim did) 48 + | _ -> 49 + Lwt.return_error "invalid did in dns record" ) 50 + | Error (`Msg e) -> 51 + Lwt.return_error e 52 + with exn -> Lwt.return_error (Printexc.to_string exn) 53 + 54 + (* fetch lexicon document from authority's repo *) 55 + let fetch_lexicon ~did ~nsid = 56 + try%lwt 57 + match%lwt Id_resolver.Did.resolve did with 58 + | Error e -> 59 + Lwt.return_error ("failed to resolve DID: " ^ e) 60 + | Ok doc -> ( 61 + match Id_resolver.Did.Document.get_service doc "#atproto_pds" with 62 + | None -> 63 + Lwt.return_error "no PDS service in DID document" 64 + | Some pds -> ( 65 + let client = Hermes.make_client ~service:pds () in 66 + try%lwt 67 + let%lwt record = 68 + Lexicons.([%xrpc get "com.atproto.repo.getRecord"]) 69 + ~repo:did ~collection:"com.atproto.lexicon.schema" ~rkey:nsid 70 + client 71 + in 72 + Lwt.return_ok record.value 73 + with _ -> Lwt.return_error ("failed to fetch lexicon record " ^ nsid) 74 + ) ) 75 + with exn -> Lwt.return_error (Printexc.to_string exn) 76 + 77 + (* parse lexicon record into permission_set *) 78 + let parse_permission_set record = 79 + match lexicon_value_of_yojson record with 80 + | Error e -> 81 + Error ("failed to parse lexicon record: " ^ e) 82 + | Ok record -> ( 83 + if record.type_ <> "permission-set" then 84 + Error ("not a permission-set lexicon: " ^ record.type_) 85 + else 86 + match record.permissions with 87 + | None -> 88 + Error "permission-set has no permissions" 89 + | Some permissions -> 90 + Ok 91 + { title= record.title 92 + ; title_lang= None (* skip localized titles for now *) 93 + ; detail= record.detail 94 + ; detail_lang= None (* skip localized details for now *) 95 + ; permissions } ) 96 + 97 + (* resolve and parse permission set from nsid *) 98 + let resolve nsid = 99 + match Ttl_cache.String_cache.get cache nsid with 100 + | Some cached -> 101 + Lwt.return_ok cached 102 + | None -> ( 103 + match%lwt resolve_did_authority nsid with 104 + | Error e -> 105 + Lwt.return_error ("DNS resolution failed: " ^ e) 106 + | Ok did -> ( 107 + match%lwt fetch_lexicon ~did ~nsid with 108 + | Error e -> 109 + Lwt.return_error ("lexicon fetch failed: " ^ e) 110 + | Ok json -> ( 111 + match parse_permission_set json with 112 + | Error e -> 113 + Lwt.return_error e 114 + | Ok ps -> 115 + Ttl_cache.String_cache.set cache nsid ps ; 116 + Lwt.return_ok ps ) ) ) 117 + 118 + let clear_cache nsid = Ttl_cache.String_cache.remove cache nsid
+252 -45
pegasus/lib/oauth/scopes.ml
··· 1 - type account_attr = Email | Repo | Status 1 + type account_attr = Email | Repo 2 2 3 3 type account_action = Read | Manage 4 4 ··· 36 36 37 37 type blob_permission = {accept: accept_pattern list} 38 38 39 + type include_scope = {nsid: string; aud: string option} 40 + 39 41 type static_scope = 40 42 | Atproto 41 43 | TransitionEmail ··· 49 51 | Repo of repo_permission 50 52 | Rpc of rpc_permission 51 53 | Blob of blob_permission 54 + | Include of include_scope 52 55 53 56 let is_valid_nsid s = 54 57 let segments = String.split_on_char '.' s in ··· 64 67 in 65 68 List.length segments >= 3 && List.for_all valid_segment segments 66 69 70 + (* check if permission_nsid is under include_nsid's authority *) 71 + let is_parent_authority_of ~include_nsid ~permission_nsid = 72 + let include_authority = Util.nsid_authority include_nsid in 73 + let permission_authority = Util.nsid_authority permission_nsid in 74 + String.equal include_authority permission_authority 75 + || String.starts_with ~prefix:(include_authority ^ ".") permission_authority 76 + 67 77 let parse_params s = 68 78 if s = "" then [] 69 79 else ··· 124 134 Some Email 125 135 | "repo" -> 126 136 Some Repo 127 - | "status" -> 128 - Some Status 129 137 | _ -> 130 138 None 131 139 ··· 182 190 else None 183 191 184 192 let parse_repo_permission positional params = 185 - let collection_strs = 186 - match positional with 187 - | Some p -> 188 - [p] 189 - | None -> 190 - get_all_params "collection" params 191 - in 192 - if collection_strs = [] then None 193 + (* duplicate positional and query parameters not allowed *) 194 + let has_collection_param = get_all_params "collection" params <> [] in 195 + if positional <> None && has_collection_param then None 193 196 else 194 - let collections = List.filter_map parse_repo_collection collection_strs in 195 - if collections = [] then None 197 + let collection_strs = 198 + match positional with 199 + | Some p -> 200 + [p] 201 + | None -> 202 + get_all_params "collection" params 203 + in 204 + if collection_strs = [] then None 196 205 else 197 - let action_strs = get_all_params "action" params in 198 - let actions = 199 - if action_strs = [] then all_repo_actions 200 - else List.filter_map parse_repo_action action_strs 201 - in 202 - if actions = [] then None else Some {collections; actions} 206 + let collections = List.filter_map parse_repo_collection collection_strs in 207 + if collections = [] then None 208 + else 209 + let action_strs = get_all_params "action" params in 210 + let actions = 211 + if action_strs = [] then all_repo_actions 212 + else List.filter_map parse_repo_action action_strs 213 + in 214 + if actions = [] then None else Some {collections; actions} 203 215 204 216 let parse_rpc_lxm s = 205 217 if s = "*" then Some AnyLxm ··· 224 236 else None 225 237 226 238 let parse_rpc_permission positional params = 227 - let lxm_strs = 228 - match positional with Some p -> [p] | None -> get_all_params "lxm" params 229 - in 230 - if lxm_strs = [] then None 239 + (* duplicate positional and query parameters not allowed *) 240 + let has_lxm_param = get_all_params "lxm" params <> [] in 241 + if positional <> None && has_lxm_param then None 231 242 else 232 - let lxms = List.filter_map parse_rpc_lxm lxm_strs in 233 - if lxms = [] then None 243 + let lxm_strs = 244 + match positional with 245 + | Some p -> 246 + [p] 247 + | None -> 248 + get_all_params "lxm" params 249 + in 250 + if lxm_strs = [] then None 234 251 else 235 - match get_single_param "aud" params with 236 - | None -> 237 - None (* aud is required *) 238 - | Some aud_str -> ( 239 - match parse_rpc_aud aud_str with 252 + let lxms = List.filter_map parse_rpc_lxm lxm_strs in 253 + if lxms = [] then None 254 + else 255 + match get_single_param "aud" params with 240 256 | None -> 241 - None 242 - | Some aud -> 243 - (* rpc:*?aud=* is forbidden *) 244 - if aud = AnyAud && List.mem AnyLxm lxms then None 245 - else Some {lxm= lxms; aud} ) 257 + None (* aud is required *) 258 + | Some aud_str -> ( 259 + match parse_rpc_aud aud_str with 260 + | None -> 261 + None 262 + | Some aud -> 263 + (* rpc:*?aud=* is forbidden *) 264 + if aud = AnyAud && List.mem AnyLxm lxms then None 265 + else Some {lxm= lxms; aud} ) 246 266 247 267 let parse_accept_pattern s = 248 268 if s = "*/*" then Some AnyMime ··· 260 280 None 261 281 262 282 let parse_blob_permission positional params = 263 - let accept_strs = 264 - match positional with 265 - | Some p -> 266 - [p] 267 - | None -> 268 - get_all_params "accept" params 269 - in 270 - if accept_strs = [] then None 283 + (* duplicate positional and query parameters not allowed *) 284 + let has_accept_param = get_all_params "accept" params <> [] in 285 + if positional <> None && has_accept_param then None 271 286 else 272 - let accepts = List.filter_map parse_accept_pattern accept_strs in 273 - if accepts = [] then None else Some {accept= accepts} 287 + let accept_strs = 288 + match positional with 289 + | Some p -> 290 + [p] 291 + | None -> 292 + get_all_params "accept" params 293 + in 294 + if accept_strs = [] then None 295 + else 296 + let accepts = List.filter_map parse_accept_pattern accept_strs in 297 + if accepts = [] then None else Some {accept= accepts} 298 + 299 + let parse_include_scope positional params = 300 + match positional with 301 + | None -> 302 + None 303 + | Some nsid -> ( 304 + if not (is_valid_nsid nsid) then None 305 + else 306 + let aud = get_single_param "aud" params in 307 + (* validate aud if present *) 308 + match aud with 309 + | Some a when not (is_valid_atproto_audience a) -> 310 + None 311 + | _ -> 312 + Some {nsid; aud} ) 274 313 275 314 let parse_static_scope = function 276 315 | "atproto" -> ··· 305 344 Option.map (fun p -> Rpc p) (parse_rpc_permission positional params) 306 345 | "blob" -> 307 346 Option.map (fun p -> Blob p) (parse_blob_permission positional params) 347 + | "include" -> 348 + Option.map 349 + (fun p -> Include p) 350 + (parse_include_scope positional params) 308 351 | _ -> 309 352 None ) 310 353 ··· 457 500 then true 458 501 else allows_rpc scopes opts 459 502 end 503 + 504 + (* convert a permission from permission set to scope string *) 505 + let permission_to_scope ~include_aud (perm : Lexicon_resolver.permission) = 506 + match perm.resource with 507 + | "rpc" -> ( 508 + match perm.lxm with 509 + | None | Some [] -> 510 + None 511 + | Some lxms -> ( 512 + let aud = 513 + match perm.aud with 514 + | Some a -> 515 + Some a 516 + | None -> 517 + if Option.value perm.inherit_aud ~default:false then include_aud 518 + else None 519 + in 520 + match aud with 521 + | None -> 522 + None (* rpc requires aud *) 523 + | Some a -> 524 + Some 525 + (List.map 526 + (fun lxm -> 527 + Printf.sprintf "rpc:%s?aud=%s" lxm (Uri.pct_encode a) ) 528 + lxms ) ) ) 529 + | "repo" -> ( 530 + match perm.collection with 531 + | None | Some [] -> 532 + None 533 + | Some collections -> 534 + let actions = 535 + Option.value perm.action ~default:["create"; "update"; "delete"] 536 + in 537 + let action_str = 538 + let action_set = List.sort String.compare actions in 539 + let default_set = ["create"; "delete"; "update"] in 540 + if action_set = default_set then "" 541 + else "?action=" ^ String.concat "," actions 542 + in 543 + Some 544 + (List.map 545 + (fun coll -> Printf.sprintf "repo:%s%s" coll action_str) 546 + collections ) ) 547 + | "blob" -> ( 548 + match perm.accept with 549 + | None | Some [] -> 550 + None 551 + | Some accepts -> 552 + Some (List.map (fun accept -> Printf.sprintf "blob:%s" accept) accepts) 553 + ) 554 + | "account" | "identity" -> 555 + (* account and identity permissions can't be granted via permission sets *) 556 + None 557 + | _ -> 558 + None 559 + 560 + (* expand include scope to list of granular scopes, 561 + validating authority for each permission nsid & applying inheritAud *) 562 + let expand_include_scope (inc : include_scope) 563 + (ps : Lexicon_resolver.permission_set) = 564 + let allowed_resources = ["rpc"; "repo"] in 565 + ps.permissions 566 + |> List.filter (fun (p : Lexicon_resolver.permission) -> 567 + List.mem p.resource allowed_resources ) 568 + |> List.filter_map (fun (p : Lexicon_resolver.permission) -> 569 + let nsids_to_check = 570 + match p.resource with 571 + | "rpc" -> 572 + Option.value p.lxm ~default:[] 573 + | "repo" -> 574 + (* filter out wildcards from collection validation *) 575 + Option.value p.collection ~default:[] 576 + |> List.filter (fun c -> c <> "*" && is_valid_nsid c) 577 + | _ -> 578 + [] 579 + in 580 + let all_valid = 581 + List.for_all 582 + (fun nsid -> 583 + is_parent_authority_of ~include_nsid:inc.nsid ~permission_nsid:nsid ) 584 + nsids_to_check 585 + in 586 + if all_valid then permission_to_scope ~include_aud:inc.aud p else None ) 587 + |> List.flatten 588 + 589 + (* expand all scopes, resolving includes, to expanded scope string *) 590 + let expand_scopes (scopes : scope list) : string list Lwt.t = 591 + let%lwt expanded = 592 + Lwt_list.map_p 593 + (fun scope -> 594 + match scope with 595 + | Include inc -> ( 596 + match%lwt Lexicon_resolver.resolve inc.nsid with 597 + | Error e -> 598 + Logs.warn (fun l -> 599 + l "failed to resolve permission set %s: %s" inc.nsid e ) ; 600 + Lwt.return [] 601 + | Ok ps -> 602 + Lwt.return (expand_include_scope inc ps) ) 603 + | Static Atproto -> 604 + Lwt.return ["atproto"] 605 + | Static TransitionEmail -> 606 + Lwt.return ["transition:email"] 607 + | Static TransitionGeneric -> 608 + Lwt.return ["transition:generic"] 609 + | Static TransitionChatBsky -> 610 + Lwt.return ["transition:chat.bsky"] 611 + | Account perm -> 612 + let attr_str = 613 + match perm.attr with Email -> "email" | Repo -> "repo" 614 + in 615 + let actions_str = 616 + if List.mem Manage perm.actions then "?action=manage" else "" 617 + in 618 + Lwt.return [Printf.sprintf "account:%s%s" attr_str actions_str] 619 + | Identity perm -> 620 + let attr_str = 621 + match perm.attr with Handle -> "handle" | Any -> "*" 622 + in 623 + Lwt.return [Printf.sprintf "identity:%s" attr_str] 624 + | Repo perm -> 625 + let colls = 626 + List.map 627 + (function All -> "*" | Collection c -> c) 628 + perm.collections 629 + in 630 + let actions = List.map show_repo_action perm.actions in 631 + let action_str = 632 + if actions = ["create"; "update"; "delete"] then "" 633 + else "?action=" ^ String.concat "," actions 634 + in 635 + Lwt.return 636 + (List.map 637 + (fun c -> Printf.sprintf "repo:%s%s" c action_str) 638 + colls ) 639 + | Rpc perm -> 640 + let lxms = 641 + List.map (function AnyLxm -> "*" | Lxm l -> l) perm.lxm 642 + in 643 + let aud_str = match perm.aud with AnyAud -> "*" | Aud a -> a in 644 + Lwt.return 645 + (List.map 646 + (fun l -> 647 + Printf.sprintf "rpc:%s?aud=%s" l (Uri.pct_encode aud_str) ) 648 + lxms ) 649 + | Blob perm -> 650 + let accepts = 651 + List.map 652 + (function 653 + | AnyMime -> 654 + "*/*" 655 + | TypeWildcard t -> 656 + t ^ "/*" 657 + | ExactMime (t, s) -> 658 + t ^ "/" ^ s ) 659 + perm.accept 660 + in 661 + Lwt.return (List.map (fun a -> Printf.sprintf "blob:%s" a) accepts) ) 662 + scopes 663 + in 664 + Lwt.return (List.flatten expanded |> List.sort_uniq String.compare) 665 + 666 + let scopes_to_string scopes = String.concat " " scopes
+7
pegasus/lib/util.ml
··· 551 551 Printf.sprintf "at://%s/%s/%s%s" repo collection rkey 552 552 (Option.value ~default:"" fragment) 553 553 554 + let nsid_authority nsid = 555 + match String.rindex_opt nsid '.' with 556 + | None -> 557 + nsid 558 + | Some idx -> 559 + String.sub nsid 0 idx 560 + 554 561 let send_email_or_log ~(recipients : Letters.recipient list) ~subject 555 562 ~(body : Letters.body) = 556 563 let log_email () =
+2 -2
pegasus/test/dune
··· 1 1 (tests 2 - (names test_sequencer) 2 + (names test_sequencer test_scopes) 3 3 (package pegasus) 4 - (libraries ipld pegasus lwt_ppx alcotest) 4 + (libraries ipld pegasus lwt lwt.unix lwt_ppx alcotest str) 5 5 (preprocess 6 6 (pps lwt_ppx)))
+177
pegasus/test/test_scopes.ml
··· 1 + open Alcotest 2 + open Pegasus.Oauth.Scopes 3 + 4 + let test_string = testable Fmt.string String.equal 5 + 6 + let test_nsid_authority () = 7 + check test_string "three segments" "com.example" 8 + (Pegasus.Util.nsid_authority "com.example.foo") ; 9 + check test_string "four segments" "com.example.app" 10 + (Pegasus.Util.nsid_authority "com.example.app.auth") ; 11 + check test_string "two segments" "com" 12 + (Pegasus.Util.nsid_authority "com.example") 13 + 14 + let test_is_parent_authority () = 15 + check bool "same authority" true 16 + (is_parent_authority_of ~include_nsid:"com.example.app.auth" 17 + ~permission_nsid:"com.example.app.calendar" ) ; 18 + check bool "child authority" true 19 + (is_parent_authority_of ~include_nsid:"com.example.app.auth" 20 + ~permission_nsid:"com.example.app.sub.thing" ) ; 21 + check bool "different authority" false 22 + (is_parent_authority_of ~include_nsid:"com.example.app.auth" 23 + ~permission_nsid:"org.other.thing" ) ; 24 + check bool "partial match not ok" false 25 + (is_parent_authority_of ~include_nsid:"com.example.app.auth" 26 + ~permission_nsid:"com.example.different" ) 27 + 28 + (* test parse_scope for include scopes *) 29 + let test_parse_include_scope () = 30 + (* valid include scope with aud *) 31 + ( match 32 + parse_scope "include:com.example.app.auth?aud=did:web:api.example.com" 33 + with 34 + | Some (Include {nsid; aud}) -> 35 + check test_string "nsid" "com.example.app.auth" nsid ; 36 + check (option test_string) "aud" (Some "did:web:api.example.com") aud 37 + | _ -> 38 + fail "expected Include scope" ) ; 39 + (* valid include scope without aud *) 40 + ( match parse_scope "include:com.example.app.perms" with 41 + | Some (Include {nsid; aud}) -> 42 + check test_string "nsid" "com.example.app.perms" nsid ; 43 + check (option test_string) "aud" None aud 44 + | _ -> 45 + fail "expected Include scope" ) ; 46 + (* bad nsid *) 47 + ( match parse_scope "include:invalid" with 48 + | None -> 49 + () 50 + | Some _ -> 51 + fail "expected None for invalid nsid" ) ; 52 + (* bad aud *) 53 + match parse_scope "include:com.example.foo?aud=notadid" with 54 + | None -> 55 + () 56 + | Some _ -> 57 + fail "expected None for invalid aud" 58 + 59 + let test_permission_to_scope () = 60 + let open Pegasus.Lexicon_resolver in 61 + (* rpc permission with explicit aud *) 62 + let rpc_perm = 63 + { resource= "rpc" 64 + ; lxm= Some ["com.example.foo"; "com.example.bar"] 65 + ; aud= Some "did:web:api.example.com" 66 + ; inherit_aud= None 67 + ; collection= None 68 + ; action= None 69 + ; accept= None } 70 + in 71 + ( match permission_to_scope ~include_aud:None rpc_perm with 72 + | Some scopes -> 73 + check int "two rpc scopes" 2 (List.length scopes) ; 74 + (* check that first scope starts with expected pattern *) 75 + check bool "first scope valid" true 76 + (String.starts_with ~prefix:"rpc:com.example.foo?aud=" 77 + (List.nth scopes 0) ) 78 + | None -> 79 + fail "expected Some scopes" ) ; 80 + (* rpc permission with inheritAud *) 81 + let rpc_inherit = 82 + { resource= "rpc" 83 + ; lxm= Some ["com.example.baz"] 84 + ; aud= None 85 + ; inherit_aud= Some true 86 + ; collection= None 87 + ; action= None 88 + ; accept= None } 89 + in 90 + ( match 91 + permission_to_scope ~include_aud:(Some "did:plc:inherited") rpc_inherit 92 + with 93 + | Some scopes -> 94 + check int "inherited aud single scope" 1 (List.length scopes) ; 95 + check bool "inherited aud scope valid" true 96 + (String.starts_with ~prefix:"rpc:com.example.baz?aud=" 97 + (List.nth scopes 0) ) 98 + | None -> 99 + fail "expected scopes with inherited aud" ) ; 100 + (* repo permission included *) 101 + let repo_perm = 102 + { resource= "repo" 103 + ; lxm= None 104 + ; aud= None 105 + ; inherit_aud= None 106 + ; collection= Some ["com.example.data"] 107 + ; action= Some ["create"; "update"] 108 + ; accept= None } 109 + in 110 + ( match permission_to_scope ~include_aud:None repo_perm with 111 + | Some [scope] -> 112 + check bool "repo scope" true 113 + (String.starts_with ~prefix:"repo:com.example.data" scope) 114 + | _ -> 115 + fail "expected single repo scope" ) ; 116 + (* account permission filtered out *) 117 + let account_perm = 118 + { resource= "account" 119 + ; lxm= None 120 + ; aud= None 121 + ; inherit_aud= None 122 + ; collection= None 123 + ; action= None 124 + ; accept= None } 125 + in 126 + match permission_to_scope ~include_aud:None account_perm with 127 + | None -> 128 + () 129 + | Some _ -> 130 + fail "account should be filtered" 131 + 132 + let test_expand_include_scope_authority () = 133 + let open Pegasus.Lexicon_resolver in 134 + let inc : include_scope = 135 + {nsid= "com.example.app.auth"; aud= Some "did:web:api.example.com"} 136 + in 137 + let ps = 138 + { title= Some "Test" 139 + ; title_lang= None 140 + ; detail= None 141 + ; detail_lang= None 142 + ; permissions= 143 + [ (* valid under com.example.app authority *) 144 + { resource= "rpc" 145 + ; lxm= Some ["com.example.app.login"] 146 + ; aud= None 147 + ; inherit_aud= Some true 148 + ; collection= None 149 + ; action= None 150 + ; accept= None } 151 + ; (* invalid, different authority *) 152 + { resource= "rpc" 153 + ; lxm= Some ["org.other.thing"] 154 + ; aud= None 155 + ; inherit_aud= Some true 156 + ; collection= None 157 + ; action= None 158 + ; accept= None } ] } 159 + in 160 + let expanded = expand_include_scope inc ps in 161 + check int "only valid permission expanded" 1 (List.length expanded) ; 162 + (* check that we have at least one scope starting with rpc: *) 163 + check bool "has rpc scope" true 164 + ( List.length expanded > 0 165 + && String.starts_with ~prefix:"rpc:" (List.hd expanded) ) 166 + 167 + let () = 168 + run "scopes" 169 + [ ( "authority" 170 + , [ ("nsid_authority", `Quick, test_nsid_authority) 171 + ; ("is_parent_authority_of", `Quick, test_is_parent_authority) ] ) 172 + ; ("include", [("parse_include_scope", `Quick, test_parse_include_scope)]) 173 + ; ( "expansion" 174 + , [ ("permission_to_scope", `Quick, test_permission_to_scope) 175 + ; ( "expand_include_scope_authority" 176 + , `Quick 177 + , test_expand_include_scope_authority ) ] ) ]