A set of utilities for working with the AT Protocol in Elixir.

feat: struct and module for new AT Proto permissions

Changed files
+863
lib
atex
test
atex
+1
.gitignore
··· 15 15 secrets 16 16 .DS_Store 17 17 CLAUDE.md 18 + AGENTS.md 18 19 tmp 19 20 temp
+3
CHANGELOG.md
··· 19 19 (Module, Function, Args), denoting a callback function to be invoked by after 20 20 a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a 21 21 simple usage of this. 22 + - `Atex.OAuth.Permission` module for creating 23 + [AT Protocol permission](https://atproto.com/specs/permission) strings for 24 + OAuth. 22 25 23 26 ### Changed 24 27
+809
lib/atex/oauth/permission.ex
··· 1 + defmodule Atex.OAuth.Permission do 2 + use TypedStruct 3 + import Kernel, except: [to_string: 1] 4 + 5 + @type t_tuple() :: { 6 + resource :: String.t(), 7 + positional :: String.t() | nil, 8 + parameters :: list({String.t(), String.t()}) 9 + } 10 + 11 + @typep as_string() :: {:as_string, boolean()} 12 + @type account_attr() :: :email | :repo 13 + @type account_action() :: :read | :manage 14 + @type account_opt() :: 15 + {:attr, account_attr()} | {:action, account_action()} | as_string() 16 + 17 + @type repo_opt() :: 18 + {:create, boolean()} | {:update, boolean()} | {:delete, boolean()} | as_string() 19 + 20 + @type rpc_opt() :: {:aud, String.t()} | {:inherit_aud, boolean()} | as_string() 21 + 22 + @type include_opt() :: {:aud, String.t()} | as_string() 23 + 24 + typedstruct enforce: true do 25 + field :resource, String.t() 26 + field :positional, String.t() | nil 27 + # like a Keyword list but with a string instead of an atom 28 + field :parameters, list({String.t(), String.t()}), enforce: false, default: [] 29 + end 30 + 31 + @doc """ 32 + Creates a new permission struct from a permission scope string. 33 + 34 + Parses an AT Protocol OAuth permission scope string and returns a structured 35 + representation. Permission strings follow the format 36 + `resource:positional?key=value&key2=value2` 37 + 38 + The positional parameter is resource-specific and may be omitted in some cases 39 + (e.g., collection for `repo`, lxm for `rpc`, attr for `account`/`identity`, 40 + accept for `blob`). 41 + 42 + See the [AT Protocol 43 + documentation](https://atproto.com/specs/permission#scope-string-syntax) for 44 + the full syntax and rules for permission scope strings. 45 + 46 + ## Parameters 47 + - `string` - A permission scope string (e.g., "repo:app.example.profile") 48 + 49 + Returns `{:ok, permission}` if a valid scope string was given, otherwise it 50 + will return `{:error, reason}`. 51 + 52 + ## Examples 53 + 54 + # Simple with just a positional 55 + iex> Atex.OAuth.Permission.new("repo:app.example.profile") 56 + {:ok, %Atex.OAuth.Permission{ 57 + resource: "repo", 58 + positional: "app.example.profile", 59 + parameters: [] 60 + }} 61 + 62 + # With parameters 63 + iex> Atex.OAuth.Permission.new("repo?collection=app.example.profile&collection=app.example.post") 64 + {:ok, %Atex.OAuth.Permission{ 65 + resource: "repo", 66 + positional: nil, 67 + parameters: [ 68 + {"collection", "app.example.profile"}, 69 + {"collection", "app.example.post"} 70 + ] 71 + }} 72 + 73 + # Positional with parameters 74 + iex> Atex.OAuth.Permission.new("rpc:app.example.moderation.createReport?aud=*") 75 + {:ok, %Atex.OAuth.Permission{ 76 + resource: "rpc", 77 + positional: "app.example.moderation.createReport", 78 + parameters: [{"aud", "*"}] 79 + }} 80 + 81 + iex> Atex.OAuth.Permission.new("blob:*/*") 82 + {:ok, %Atex.OAuth.Permission{ 83 + resource: "blob", 84 + positional: "*/*", 85 + parameters: [] 86 + }} 87 + 88 + # Invalid: resource without positional or parameters 89 + iex> Atex.OAuth.Permission.new("resource") 90 + {:error, :missing_positional_or_parameters} 91 + 92 + """ 93 + @spec new(String.t()) :: {:ok, t()} | {:error, reason :: atom()} 94 + def new(string) do 95 + case parse(string) do 96 + {:ok, {resource, positional, parameters}} -> 97 + {:ok, %__MODULE__{resource: resource, positional: positional, parameters: parameters}} 98 + 99 + err -> 100 + err 101 + end 102 + end 103 + 104 + @doc """ 105 + Parses an AT Protocol permission scope string into its components. 106 + 107 + Returns a tuple containing the resource name, optional positional parameter, 108 + and a list of key-value parameter pairs. This is a lower-level function 109 + compared to `new/1`, returning the raw components instead of a struct. 110 + 111 + ## Parameters 112 + - `string` - A permission scope string following the format 113 + `resource:positional?key=value&key2=value2` 114 + 115 + Returns `{:ok, {resource, positional, parameters}}` if a valid scope string 116 + was given, otherwise it will return `{:error, reason}`. 117 + 118 + ## Examples 119 + 120 + # Simple with just a positional 121 + iex> Atex.OAuth.Permission.parse("repo:app.example.profile") 122 + {:ok, {"repo", "app.example.profile", []}} 123 + 124 + # With parameters 125 + iex> Atex.OAuth.Permission.parse("repo?collection=app.example.profile&collection=app.example.post") 126 + {:ok, { 127 + "repo", 128 + nil, 129 + [ 130 + {"collection", "app.example.profile"}, 131 + {"collection", "app.example.post"} 132 + ] 133 + }} 134 + 135 + # Positional with parameters 136 + iex> Atex.OAuth.Permission.parse("rpc:app.example.moderation.createReport?aud=*") 137 + {:ok, {"rpc", "app.example.moderation.createReport", [{"aud", "*"}]}} 138 + 139 + iex> Atex.OAuth.Permission.parse("blob:*/*") 140 + {:ok, {"blob", "*/*", []}} 141 + 142 + # Invalid: resource without positional or parameters 143 + iex> Atex.OAuth.Permission.parse("resource") 144 + {:error, :missing_positional_or_parameters} 145 + 146 + """ 147 + @spec parse(String.t()) :: 148 + {:ok, t_tuple()} 149 + | {:error, reason :: atom()} 150 + def parse(string) do 151 + case String.split(string, "?", parts: 2) do 152 + [resource_part] -> 153 + parse_resource_and_positional(resource_part) 154 + 155 + # Empty parameter string is treated as absent 156 + [resource_part, ""] -> 157 + parse_resource_and_positional(resource_part) 158 + 159 + [resource_part, params_part] -> 160 + params_part 161 + |> parse_parameters() 162 + |> then(&parse_resource_and_positional(resource_part, &1)) 163 + end 164 + end 165 + 166 + @spec parse_resource_and_positional(String.t(), list({String.t(), String.t()})) :: 167 + {:ok, t_tuple()} | {:error, reason :: atom()} 168 + defp parse_resource_and_positional(resource_part, parameters \\ []) do 169 + case String.split(resource_part, ":", parts: 2) do 170 + [resource_name, positional] -> 171 + {:ok, {resource_name, positional, parameters}} 172 + 173 + [resource_name] -> 174 + if parameters == [] do 175 + {:error, :missing_positional_or_parameters} 176 + else 177 + {:ok, {resource_name, nil, parameters}} 178 + end 179 + end 180 + end 181 + 182 + @spec parse_parameters(String.t()) :: list({String.t(), String.t()}) 183 + defp parse_parameters(params_string) do 184 + params_string 185 + |> String.split("&") 186 + |> Enum.map(fn param -> 187 + case String.split(param, "=", parts: 2) do 188 + [key, value] -> {key, URI.decode(value)} 189 + [key] -> {key, ""} 190 + end 191 + end) 192 + end 193 + 194 + @doc """ 195 + Converts a permission struct back into its scope string representation. 196 + 197 + This is the inverse operation of `new/1`, converting a structured permission 198 + back into the AT Protocol OAuth scope string format. The resulting string 199 + can be used directly as an OAuth scope parameter. 200 + 201 + Values in `parameters` are automatically URL-encoded as needed (e.g., `#` becomes `%23`). 202 + 203 + ## Parameters 204 + - `struct` - An `%Atex.OAuth.Permission{}` struct 205 + 206 + Returns a permission scope string. 207 + 208 + ## Examples 209 + 210 + # Simple with just a positional 211 + iex> perm = %Atex.OAuth.Permission{ 212 + ...> resource: "repo", 213 + ...> positional: "app.example.profile", 214 + ...> parameters: [] 215 + ...> } 216 + iex> Atex.OAuth.Permission.to_string(perm) 217 + "repo:app.example.profile" 218 + 219 + # With parameters 220 + iex> perm = %Atex.OAuth.Permission{ 221 + ...> resource: "repo", 222 + ...> positional: nil, 223 + ...> parameters: [ 224 + ...> {"collection", "app.example.profile"}, 225 + ...> {"collection", "app.example.post"} 226 + ...> ] 227 + ...> } 228 + iex> Atex.OAuth.Permission.to_string(perm) 229 + "repo?collection=app.example.profile&collection=app.example.post" 230 + 231 + # Positional with parameters 232 + iex> perm = %Atex.OAuth.Permission{ 233 + ...> resource: "rpc", 234 + ...> positional: "app.example.moderation.createReport", 235 + ...> parameters: [{"aud", "*"}] 236 + ...> } 237 + iex> Atex.OAuth.Permission.to_string(perm) 238 + "rpc:app.example.moderation.createReport?aud=*" 239 + 240 + iex> perm = %Atex.OAuth.Permission{ 241 + ...> resource: "blob", 242 + ...> positional: "*/*", 243 + ...> parameters: [] 244 + ...> } 245 + iex> Atex.OAuth.Permission.to_string(perm) 246 + "blob:*/*" 247 + 248 + # Works via String.Chars protocol 249 + iex> perm = %Atex.OAuth.Permission{ 250 + ...> resource: "account", 251 + ...> positional: "email", 252 + ...> parameters: [] 253 + ...> } 254 + iex> to_string(perm) 255 + "account:email" 256 + 257 + """ 258 + @spec to_string(t()) :: String.t() 259 + def to_string(%__MODULE__{} = struct) do 260 + positional_part = if struct.positional, do: ":#{struct.positional}", else: "" 261 + parameters_part = stringify_parameters(struct.parameters) 262 + 263 + struct.resource <> positional_part <> parameters_part 264 + end 265 + 266 + @spec stringify_parameters(list({String.t(), String.t()})) :: String.t() 267 + defp stringify_parameters([]), do: "" 268 + 269 + defp stringify_parameters(params) do 270 + params 271 + |> Enum.map(fn {key, value} -> "#{key}=#{encode_param_value(value)}" end) 272 + |> Enum.join("&") 273 + |> then(&"?#{&1}") 274 + end 275 + 276 + # Encode parameter values for OAuth scope strings 277 + # Preserves unreserved characters (A-Z, a-z, 0-9, -, ., _, ~) and common scope characters (*, :, /) 278 + # Encodes reserved characters like # as %23 279 + @spec encode_param_value(String.t()) :: String.t() 280 + defp encode_param_value(value) do 281 + URI.encode(value, fn char -> 282 + URI.char_unreserved?(char) or char in [?*, ?:, ?/] 283 + end) 284 + end 285 + 286 + @doc """ 287 + Creates an account permission for controlling PDS account hosting details. 288 + 289 + Controls access to private account information such as email address and 290 + repository import capabilities. These permissions cannot be included in 291 + permission sets and must be requested directly by client apps. 292 + 293 + See the [AT Protocol documentation](https://atproto.com/specs/permission#account) 294 + for more information. 295 + 296 + ## Options 297 + - `:attr` (required) - A component of account configuration. Must be `:email` 298 + or `:repo`. 299 + - `:action` (optional) - Degree of control. Can be `:read` or `:manage`. 300 + Defaults to `:read`. 301 + - `:as_string` (optional) - If `true` (default), returns a scope string, 302 + otherwise returns a Permission struct. 303 + 304 + If `:as_string` is true a scope string is returned, otherwise the underlying 305 + Permission struct is returned. 306 + 307 + ## Examples 308 + 309 + # Read account email (default action, as string) 310 + iex> Atex.OAuth.Permission.account(attr: :email) 311 + "account:email" 312 + 313 + # Read account email (as struct) 314 + iex> Atex.OAuth.Permission.account(attr: :email, as_string: false) 315 + %Atex.OAuth.Permission{ 316 + resource: "account", 317 + positional: "email", 318 + parameters: [] 319 + } 320 + 321 + # Read account email (explicit action) 322 + iex> Atex.OAuth.Permission.account(attr: :email, action: :read) 323 + "account:email?action=read" 324 + 325 + # Manage account email 326 + iex> Atex.OAuth.Permission.account(attr: :email, action: :manage) 327 + "account:email?action=manage" 328 + 329 + # Import repo 330 + iex> Atex.OAuth.Permission.account(attr: :repo, action: :manage) 331 + "account:repo?action=manage" 332 + 333 + """ 334 + @spec account(list(account_opt())) :: t() | String.t() 335 + def account(opts \\ []) do 336 + opts = Keyword.validate!(opts, attr: nil, action: nil, as_string: true) 337 + attr = Keyword.get(opts, :attr) 338 + action = Keyword.get(opts, :action) 339 + as_string = Keyword.get(opts, :as_string) 340 + 341 + cond do 342 + is_nil(attr) -> 343 + raise ArgumentError, "option `:attr` must be provided." 344 + 345 + attr not in [:email, :repo] -> 346 + raise ArgumentError, "option `:attr` must be `:email` or `:repo`." 347 + 348 + action not in [nil, :read, :manage] -> 349 + raise ArgumentError, "option `:action` must be `:read`, `:manage`, or `nil`." 350 + 351 + true -> 352 + struct = %__MODULE__{ 353 + resource: "account", 354 + positional: Atom.to_string(attr), 355 + parameters: if(!is_nil(action), do: [{"action", Atom.to_string(action)}], else: []) 356 + } 357 + 358 + if as_string, do: to_string(struct), else: struct 359 + end 360 + end 361 + 362 + @doc """ 363 + Creates a blob permission for uploading media files to PDS. 364 + 365 + Controls the ability to upload blobs (media files) to the PDS. Permissions can 366 + be restricted by MIME type patterns. 367 + 368 + See the [AT Protocol documentation](https://atproto.com/specs/permission#blob) 369 + for more information. 370 + 371 + <!-- TODO: When permission sets are supported, add the note from the docs about this not being allowed in permisison sets. --> 372 + 373 + ## Parameters 374 + - `accept` - A single MIME type string or list of MIME type strings/patterns. 375 + Supports glob patterns like `"*/*"` or `"video/*"`. 376 + - `opts` - Keyword list of options. 377 + 378 + ## Options 379 + - `:as_string` (optional) - If `true` (default), returns a scope string, otherwise 380 + returns a Permission struct. 381 + 382 + If `:as_string` is true a scope string is returned, otherwise the underlying 383 + Permission struct is returned. 384 + 385 + ## Examples 386 + 387 + # Upload any type of blob 388 + iex> Atex.OAuth.Permission.blob("*/*") 389 + "blob:*/*" 390 + 391 + # Only images 392 + iex> Atex.OAuth.Permission.blob("image/*", as_string: false) 393 + %Atex.OAuth.Permission{ 394 + resource: "blob", 395 + positional: "image/*", 396 + parameters: [] 397 + } 398 + 399 + # Multiple mimetypes 400 + iex> Atex.OAuth.Permission.blob(["video/*", "text/html"]) 401 + "blob?accept=video/*&accept=text/html" 402 + 403 + # Multiple more specific mimetypes 404 + iex> Atex.OAuth.Permission.blob(["image/png", "image/jpeg"], as_string: false) 405 + %Atex.OAuth.Permission{ 406 + resource: "blob", 407 + positional: nil, 408 + parameters: [{"accept", "image/png"}, {"accept", "image/jpeg"}] 409 + } 410 + 411 + """ 412 + # TODO: should probably validate that these at least look like mimetypes (~r"^.+/.+$") 413 + @spec blob(String.t() | list(String.t()), list(as_string())) :: t() | String.t() 414 + def blob(accept, opts \\ []) 415 + 416 + def blob(accept, opts) when is_binary(accept) do 417 + opts = Keyword.validate!(opts, as_string: true) 418 + as_string = Keyword.get(opts, :as_string) 419 + struct = %__MODULE__{resource: "blob", positional: accept} 420 + if as_string, do: to_string(struct), else: struct 421 + end 422 + 423 + def blob(accept, opts) when is_list(accept) do 424 + opts = Keyword.validate!(opts, as_string: true) 425 + as_string = Keyword.get(opts, :as_string) 426 + 427 + struct = %__MODULE__{ 428 + resource: "blob", 429 + positional: nil, 430 + parameters: Enum.map(accept, &{"accept", &1}) 431 + } 432 + 433 + if as_string, do: to_string(struct), else: struct 434 + end 435 + 436 + @doc """ 437 + Creates an identity permission for controlling network identity. 438 + 439 + Controls access to the account's DID document and handle. Note that the PDS 440 + might not be able to facilitate identity changes if it does not have control 441 + over the DID document (e.g., when using `did:web`). 442 + 443 + <!-- TODO: same thing about not allowed in permission sets. --> 444 + 445 + See the [AT Protocol 446 + documentation](https://atproto.com/specs/permission#identity) for more 447 + information. 448 + 449 + ## Parameters 450 + - `attr` - An aspect or component of identity. Must be `:handle` or `:*` 451 + (wildcard). 452 + - `opts` - Keyword list of options. 453 + 454 + ## Options 455 + - `:as_string` (optional) - If `true` (default), returns a scope string, 456 + otherwise returns a Permission struct. 457 + 458 + If `:as_string` is true a scope string is returned, otherwise the underlying 459 + Permission struct is returned. 460 + 461 + ## Examples 462 + 463 + # Update account handle (as string) 464 + iex> Atex.OAuth.Permission.identity(:handle) 465 + "identity:handle" 466 + 467 + # Full identity control (as struct) 468 + iex> Atex.OAuth.Permission.identity(:*, as_string: false) 469 + %Atex.OAuth.Permission{ 470 + resource: "identity", 471 + positional: "*", 472 + parameters: [] 473 + } 474 + 475 + """ 476 + @spec identity(:handle | :*, list(as_string())) :: t() | String.t() 477 + def identity(attr, opts \\ []) when attr in [:handle, :*] do 478 + opts = Keyword.validate!(opts, as_string: true) 479 + as_string = Keyword.get(opts, :as_string) 480 + 481 + struct = %__MODULE__{ 482 + resource: "identity", 483 + positional: Atom.to_string(attr) 484 + } 485 + 486 + if as_string, do: to_string(struct), else: struct 487 + end 488 + 489 + @doc """ 490 + Creates a repo permission for write access to records in the account's public 491 + repository. 492 + 493 + Controls write access to specific record types (collections) with optional 494 + restrictions on the types of operations allowed (create, update, delete). 495 + 496 + When no options are provided, all operations are permitted. When any action 497 + option is explicitly set, only the actions set to `true` are enabled. This 498 + allows for precise control over permissions. 499 + 500 + See the [AT Protocol documentation](https://atproto.com/specs/permission#repo) 501 + for more information. 502 + 503 + ## Parameters 504 + - `collection_or_collections` - A single collection NSID string or list of 505 + collection NSIDs. Use `"*"` for wildcard access to all record types (not 506 + allowed in permission sets). 507 + - `options` - Keyword list to restrict operations. If omitted, all operations 508 + are allowed. If any action is specified, only explicitly enabled actions are 509 + permitted. 510 + 511 + ## Options 512 + - `:create` - Allow creating new records. 513 + - `:update` - Allow updating existing records. 514 + - `:delete` - Allow deleting records. 515 + - `:as_string` (optional) - If `true` (default), returns a scope string, 516 + otherwise returns a Permission struct. 517 + 518 + If `:as_string` is true a scope string is returned, otherwise the underlying 519 + Permission struct is returned. 520 + 521 + ## Examples 522 + 523 + # Full permission on a single record type (all actions enabled, actions omitted) 524 + iex> Atex.OAuth.Permission.repo("app.example.profile") 525 + "repo:app.example.profile" 526 + 527 + # Create only permission (other actions implicitly disabled) 528 + iex> Atex.OAuth.Permission.repo("app.example.post", create: true, as_string: false) 529 + %Atex.OAuth.Permission{ 530 + resource: "repo", 531 + positional: "app.example.post", 532 + parameters: [{"action", "create"}] 533 + } 534 + 535 + # Delete only permission 536 + iex> Atex.OAuth.Permission.repo("app.example.like", delete: true) 537 + "repo:app.example.like?action=delete" 538 + 539 + # Create and update only, delete implicitly disabled 540 + iex> Atex.OAuth.Permission.repo("app.example.repost", create: true, update: true) 541 + "repo:app.example.repost?action=update&action=create" 542 + 543 + # Multiple collections with full permissions (no options provided, actions omitted) 544 + iex> Atex.OAuth.Permission.repo(["app.example.profile", "app.example.post"]) 545 + "repo?collection=app.example.profile&collection=app.example.post" 546 + 547 + # Multiple collections with only update permission (as struct) 548 + iex> Atex.OAuth.Permission.repo(["app.example.like", "app.example.repost"], update: true, as_string: false) 549 + %Atex.OAuth.Permission{ 550 + resource: "repo", 551 + positional: nil, 552 + parameters: [ 553 + {"collection", "app.example.like"}, 554 + {"collection", "app.example.repost"}, 555 + {"action", "update"} 556 + ] 557 + } 558 + 559 + # Wildcard permission (all record types, all actions enabled, actions omitted) 560 + iex> Atex.OAuth.Permission.repo("*") 561 + "repo:*" 562 + """ 563 + @spec repo(String.t() | list(String.t()), list(repo_opt())) :: t() | String.t() 564 + def repo(collection_or_collections, actions \\ [create: true, update: true, delete: true]) 565 + 566 + def repo(_collection, []), 567 + do: 568 + raise( 569 + ArgumentError, 570 + ":actions must not be an empty list. If you want to have all actions enabled, either set them explicitly or remove the empty list argument." 571 + ) 572 + 573 + def repo(collection, actions) when is_binary(collection), do: repo([collection], actions) 574 + 575 + def repo(collections, actions) when is_list(collections) do 576 + actions = 577 + Keyword.validate!(actions, [:create, :update, :delete, as_string: true]) 578 + 579 + # Check if any action keys were explicitly provided 580 + has_explicit_actions = 581 + Keyword.has_key?(actions, :create) || 582 + Keyword.has_key?(actions, :update) || 583 + Keyword.has_key?(actions, :delete) 584 + 585 + # If no action keys provided, default all to true; otherwise use explicit values 586 + create = if has_explicit_actions, do: Keyword.get(actions, :create, false), else: true 587 + update = if has_explicit_actions, do: Keyword.get(actions, :update, false), else: true 588 + delete = if has_explicit_actions, do: Keyword.get(actions, :delete, false), else: true 589 + all_actions_true = create && update && delete 590 + 591 + as_string = Keyword.get(actions, :as_string) 592 + singular_collection = length(collections) == 1 593 + collection_parameters = Enum.map(collections, &{"collection", &1}) 594 + 595 + parameters = 596 + [] 597 + |> add_repo_param(:create, create, all_actions_true) 598 + |> add_repo_param(:update, update, all_actions_true) 599 + |> add_repo_param(:delete, delete, all_actions_true) 600 + |> add_repo_param(:collections, collection_parameters) 601 + 602 + struct = %__MODULE__{ 603 + resource: "repo", 604 + positional: if(singular_collection, do: hd(collections)), 605 + parameters: parameters 606 + } 607 + 608 + if as_string, do: to_string(struct), else: struct 609 + end 610 + 611 + # When all actions are true, omit them 612 + defp add_repo_param(list, _type, _value, true), do: list 613 + # Otherwise add them in 614 + defp add_repo_param(list, :create, true, false), do: [{"action", "create"} | list] 615 + defp add_repo_param(list, :update, true, false), do: [{"action", "update"} | list] 616 + defp add_repo_param(list, :delete, true, false), do: [{"action", "delete"} | list] 617 + 618 + # Catch-all for 4-arity version (must be before 3-arity) 619 + defp add_repo_param(list, _type, _value, _all_true), do: list 620 + 621 + defp add_repo_param(list, :collections, [_ | [_ | _]] = collections), 622 + do: Enum.concat(collections, list) 623 + 624 + defp add_repo_param(list, _type, _value), do: list 625 + 626 + @doc """ 627 + Creates an RPC permission for authenticated API requests to remote services. 628 + 629 + The permission is parameterised by the remote endpoint (`lxm`, short for 630 + "Lexicon Method") and the identity of the remote service (the audience, 631 + `aud`). Permissions must be restricted by at least one of these parameters. 632 + 633 + See the [AT Protocol documentation](https://atproto.com/specs/permission#rpc) 634 + for more information. 635 + 636 + ## Parameters 637 + - `lxm` - A single NSID string or list of NSID strings representing API 638 + endpoints. Use `"*"` for wildcard access to all endpoints. 639 + - `opts` - Keyword list of options. 640 + 641 + ## Options 642 + - `:aud` (semi-required) - Audience of API requests as a DID service 643 + reference (e.g., `"did:web:api.example.com#srvtype"`). Supports wildcard 644 + (`"*"`). 645 + - `:inherit_aud` (optional) - If `true`, the `aud` value will be inherited 646 + from permission set invocation context. Only used inside permission sets. 647 + - `:as_string` (optional) - If `true` (default), returns a scope string, 648 + otherwise returns a Permission struct. 649 + 650 + > #### Note {: .info} 651 + > 652 + > `aud` and `lxm` cannot both be wildcard. The permission must be restricted 653 + > by at least one of them. 654 + 655 + If `:as_string` is true a scope string is returned, otherwise the underlying 656 + Permission struct is returned. 657 + 658 + ## Examples 659 + 660 + # Single endpoint with wildcard audience (as string) 661 + iex> Atex.OAuth.Permission.rpc("app.example.moderation.createReport", aud: "*") 662 + "rpc:app.example.moderation.createReport?aud=*" 663 + 664 + # Multiple endpoints with specific service (as struct) 665 + iex> Atex.OAuth.Permission.rpc( 666 + ...> ["app.example.getFeed", "app.example.getProfile"], 667 + ...> aud: "did:web:api.example.com#svc_appview", 668 + ...> as_string: false 669 + ...> ) 670 + %Atex.OAuth.Permission{ 671 + resource: "rpc", 672 + positional: nil, 673 + parameters: [ 674 + {"aud", "did:web:api.example.com#svc_appview"}, 675 + {"lxm", "app.example.getFeed"}, 676 + {"lxm", "app.example.getProfile"} 677 + ] 678 + } 679 + 680 + # Wildcard method with specific service 681 + iex> Atex.OAuth.Permission.rpc("*", aud: "did:web:api.example.com#svc_appview") 682 + "rpc:*?aud=did:web:api.example.com%23svc_appview" 683 + 684 + # Single endpoint with inherited audience (for permission sets) 685 + iex> Atex.OAuth.Permission.rpc("app.example.getPreferences", inherit_aud: true) 686 + "rpc:app.example.getPreferences?inheritAud=true" 687 + 688 + """ 689 + @spec rpc(String.t() | list(String.t()), list(rpc_opt())) :: t() | String.t() 690 + def rpc(lxm_or_lxms, opts \\ []) 691 + def rpc(lxm, opts) when is_binary(lxm), do: rpc([lxm], opts) 692 + 693 + def rpc(lxms, opts) when is_list(lxms) do 694 + opts = Keyword.validate!(opts, aud: nil, inherit_aud: false, as_string: true) 695 + aud = Keyword.get(opts, :aud) 696 + inherit_aud = Keyword.get(opts, :inherit_aud) 697 + as_string = Keyword.get(opts, :as_string) 698 + 699 + # Validation: must have at least one of aud or inherit_aud 700 + cond do 701 + is_nil(aud) && !inherit_aud -> 702 + raise ArgumentError, 703 + "RPC permissions must specify either `:aud` or `:inheritAud` option." 704 + 705 + !is_nil(aud) && inherit_aud -> 706 + raise ArgumentError, 707 + "RPC permissions cannot specify both `:aud` and `:inheritAud` options." 708 + 709 + # Both lxm and aud cannot be wildcard 710 + length(lxms) == 1 && hd(lxms) == "*" && aud == "*" -> 711 + raise ArgumentError, "RPC permissions cannot have both wildcard `lxm` and wildcard `aud`." 712 + 713 + true -> 714 + singular_lxm = length(lxms) == 1 715 + lxm_parameters = Enum.map(lxms, &{"lxm", &1}) 716 + 717 + parameters = 718 + cond do 719 + inherit_aud && singular_lxm -> 720 + [{"inheritAud", "true"}] 721 + 722 + inherit_aud -> 723 + [{"inheritAud", "true"} | lxm_parameters] 724 + 725 + singular_lxm -> 726 + [{"aud", aud}] 727 + 728 + true -> 729 + [{"aud", aud} | lxm_parameters] 730 + end 731 + 732 + struct = %__MODULE__{ 733 + resource: "rpc", 734 + positional: if(singular_lxm, do: hd(lxms)), 735 + parameters: parameters 736 + } 737 + 738 + if as_string, do: to_string(struct), else: struct 739 + end 740 + end 741 + 742 + @doc """ 743 + Creates an include permission for referencing a permission set. 744 + 745 + Permission sets are Lexicon schemas that bundle together multiple permissions 746 + under a single NSID. This allows developers to request a group of related 747 + permissions with a single scope string, improving user experience by reducing 748 + the number of individual permissions that need to be reviewed. 749 + 750 + The `nsid` parameter is required and must be a valid NSID that resolves to a 751 + permission set Lexicon schema. An optional `aud` parameter can be used to specify 752 + the audience for any RPC permissions within the set that have `inheritAud: true`. 753 + 754 + See the [AT Protocol documentation](https://atproto.com/specs/permission#permission-sets) 755 + for more information. 756 + 757 + ## Parameters 758 + - `nsid` - The NSID of the permission set (e.g., "com.example.authBasicFeatures") 759 + - `opts` - Keyword list of options. 760 + 761 + ## Options 762 + - `:aud` (optional) - Audience of API requests as a DID service reference 763 + (e.g., "did:web:api.example.com#srvtype"). Supports wildcard (`"*"`). 764 + - `:as_string` (optional) - If `true` (default), returns a scope string, 765 + otherwise returns a Permission struct. 766 + 767 + If `:as_string` is true a scope string is returned, otherwise the underlying 768 + Permission struct is returned. 769 + 770 + ## Examples 771 + 772 + # Include a permission set (as string) 773 + iex> Atex.OAuth.Permission.include("com.example.authBasicFeatures") 774 + "include:com.example.authBasicFeatures" 775 + 776 + # Include a permission set with audience (as struct) 777 + iex> Atex.OAuth.Permission.include("com.example.authFull", aud: "did:web:api.example.com#svc_chat", as_string: false) 778 + %Atex.OAuth.Permission{ 779 + resource: "include", 780 + positional: "com.example.authFull", 781 + parameters: [{"aud", "did:web:api.example.com#svc_chat"}] 782 + } 783 + 784 + # Include a permission set with wildcard audience 785 + iex> Atex.OAuth.Permission.include("app.example.authFull", aud: "*") 786 + "include:app.example.authFull?aud=*" 787 + 788 + """ 789 + @spec include(String.t(), list(include_opt())) :: t() | String.t() 790 + def include(nsid, opts \\ []) do 791 + opts = Keyword.validate!(opts, aud: nil, as_string: true) 792 + aud = Keyword.get(opts, :aud) 793 + as_string = Keyword.get(opts, :as_string) 794 + 795 + parameters = if !is_nil(aud), do: [{"aud", aud}], else: [] 796 + 797 + struct = %__MODULE__{ 798 + resource: "include", 799 + positional: nsid, 800 + parameters: parameters 801 + } 802 + 803 + if as_string, do: to_string(struct), else: struct 804 + end 805 + end 806 + 807 + defimpl String.Chars, for: Atex.OAuth.Permission do 808 + def to_string(permission), do: Atex.OAuth.Permission.to_string(permission) 809 + end
+50
test/atex/oauth/permission_test.exs
··· 1 + defmodule Atex.OAuth.PermissionTest do 2 + use ExUnit.Case, async: true 3 + alias Atex.OAuth.Permission 4 + doctest Permission 5 + 6 + describe "account/1" do 7 + test "requires `:attr`" do 8 + assert_raise ArgumentError, ~r/`:attr` must be provided/, fn -> 9 + Permission.account() 10 + end 11 + end 12 + 13 + test "requires valid `:attr`" do 14 + assert_raise ArgumentError, ~r/`:attr` must be `:email` or `:repo`/, fn -> 15 + Permission.account(attr: :foobar) 16 + end 17 + 18 + assert Permission.account(attr: :email) 19 + end 20 + 21 + test "requires valid `:action`" do 22 + assert_raise ArgumentError, ~r/`:action` must be `:read`, `:manage`, or `nil`/, fn -> 23 + Permission.account(attr: :email, action: :foobar) 24 + end 25 + 26 + assert Permission.account(attr: :email, action: :manage) 27 + assert Permission.account(attr: :repo, action: nil) 28 + end 29 + end 30 + 31 + describe "rpc/2" do 32 + test "requires at least `:aud` or `:inherit_aud`" do 33 + assert_raise ArgumentError, ~r/must specify either/, fn -> 34 + Permission.rpc("com.example.getProfile") 35 + end 36 + end 37 + 38 + test "disallows `:aud` and `:inherit_aud` at the same time" do 39 + assert_raise ArgumentError, ~r/cannot specify both/, fn -> 40 + Permission.rpc("com.example.getProfile", aud: "example", inherit_aud: true) 41 + end 42 + end 43 + 44 + test "disallows wildcard for `lxm` and `aud` at the same time" do 45 + assert_raise ArgumentError, ~r/wildcard `lxm` and wildcard `aud`/, fn -> 46 + Permission.rpc("*", aud: "*") 47 + end 48 + end 49 + end 50 + end