+3
CHANGELOG.md
+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
+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
+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