tangled
alpha
login
or
join now
bnewbold.net
/
cobalt
13
fork
atom
go scratch code for atproto
13
fork
atom
overview
issues
pulls
pipelines
initial auth permission parsing/serialization
bnewbold.net
8 months ago
0ca140bb
e040070a
+341
5 changed files
expand all
collapse all
unified
split
atproto
auth
permission.go
permission_test.go
testdata
permission_scopes_invalid.txt
permission_scopes_other.txt
permission_scopes_valid.txt
+212
atproto/auth/permission.go
···
1
1
+
package auth
2
2
+
3
3
+
import (
4
4
+
"errors"
5
5
+
"fmt"
6
6
+
"net/url"
7
7
+
"strconv"
8
8
+
"strings"
9
9
+
)
10
10
+
11
11
+
var (
12
12
+
ErrInvalidPermissionSyntax = errors.New("invalid permission syntax")
13
13
+
ErrUnknownScope = errors.New("unknown scope type")
14
14
+
)
15
15
+
16
16
+
type Permission struct {
17
17
+
Type string `json:"type,omitempty"`
18
18
+
Resource string `json:"resource"`
19
19
+
20
20
+
// repo
21
21
+
Collections []string `json:"collection,omitempty"`
22
22
+
Action string `json:"action,omitempty"`
23
23
+
24
24
+
// rpc
25
25
+
Endpoints []string `json:"lxm,omitempty"`
26
26
+
Audience string `json:"aud,omitempty"`
27
27
+
28
28
+
// blob
29
29
+
MaxSize *uint64 `json:"maxSize,omitempty"`
30
30
+
Accept []string `json:"accept,omitempty"`
31
31
+
32
32
+
// account
33
33
+
Read []string `json:"read,omitempty"`
34
34
+
Manage []string `json:"manage,omitempty"`
35
35
+
36
36
+
// identity
37
37
+
DID []string `json:"did,omitempty"`
38
38
+
PLC []string `json:"plc,omitempty"`
39
39
+
40
40
+
// include
41
41
+
PermissionSet string `json:"permissionSet,omitempty"`
42
42
+
}
43
43
+
44
44
+
func (p *Permission) Scope() string {
45
45
+
46
46
+
positional := ""
47
47
+
params := make(url.Values)
48
48
+
49
49
+
switch p.Resource {
50
50
+
case "repo":
51
51
+
if len(p.Collections) == 1 {
52
52
+
positional = p.Collections[0]
53
53
+
} else if len(p.Collections) > 1 {
54
54
+
params["collection"] = p.Collections
55
55
+
}
56
56
+
if p.Action != "" {
57
57
+
params.Set("action", p.Action)
58
58
+
}
59
59
+
case "rpc":
60
60
+
if len(p.Endpoints) == 1 {
61
61
+
positional = p.Endpoints[0]
62
62
+
} else if len(p.Endpoints) > 1 {
63
63
+
params["lxm"] = p.Endpoints
64
64
+
}
65
65
+
if p.Audience != "" {
66
66
+
params.Set("aud", p.Audience)
67
67
+
}
68
68
+
case "blob":
69
69
+
if p.MaxSize != nil {
70
70
+
params.Set("maxSize", strconv.Itoa(int(*p.MaxSize)))
71
71
+
}
72
72
+
if len(p.Accept) == 1 {
73
73
+
positional = p.Accept[0]
74
74
+
} else if len(p.Accept) > 1 {
75
75
+
params["accept"] = p.Accept
76
76
+
}
77
77
+
case "account":
78
78
+
if len(p.Read) == 1 {
79
79
+
positional = p.Read[0]
80
80
+
} else if len(p.Read) > 1 {
81
81
+
params["read"] = p.Read
82
82
+
}
83
83
+
if len(p.Manage) > 0 {
84
84
+
params["manage"] = p.Manage
85
85
+
}
86
86
+
case "identity":
87
87
+
if len(p.DID) == 1 {
88
88
+
positional = p.DID[0]
89
89
+
} else if len(p.DID) > 1 {
90
90
+
params["did"] = p.DID
91
91
+
}
92
92
+
if len(p.PLC) > 0 {
93
93
+
params["plc"] = p.PLC
94
94
+
}
95
95
+
case "include":
96
96
+
if p.PermissionSet != "" {
97
97
+
positional = p.PermissionSet
98
98
+
}
99
99
+
// TODO: other params...
100
100
+
if p.Audience != "" {
101
101
+
params.Set("aud", p.Audience)
102
102
+
}
103
103
+
default:
104
104
+
return ""
105
105
+
}
106
106
+
107
107
+
scope := p.Resource
108
108
+
if positional != "" {
109
109
+
scope = scope + ":" + positional
110
110
+
}
111
111
+
if len(params) > 0 {
112
112
+
scope = scope + "?" + params.Encode()
113
113
+
}
114
114
+
return scope
115
115
+
}
116
116
+
117
117
+
func ParseScope(scope string) (*Permission, error) {
118
118
+
119
119
+
front, query, _ := strings.Cut(scope, "?")
120
120
+
resource, positional, _ := strings.Cut(front, ":")
121
121
+
122
122
+
params, err := url.ParseQuery(query)
123
123
+
if err != nil {
124
124
+
return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err)
125
125
+
}
126
126
+
127
127
+
p := Permission{
128
128
+
Type: "permission",
129
129
+
Resource: resource,
130
130
+
}
131
131
+
132
132
+
// TODO: should unknown fields be an error?
133
133
+
// TODO: could pre-parse in all the various fields? and then just positional per type
134
134
+
switch resource {
135
135
+
case "repo":
136
136
+
if params.Has("collection") {
137
137
+
if positional != "" {
138
138
+
return nil, ErrInvalidPermissionSyntax
139
139
+
}
140
140
+
p.Collections = params["collection"]
141
141
+
}
142
142
+
if positional != "" {
143
143
+
p.Collections = []string{positional}
144
144
+
}
145
145
+
p.Action = params.Get("action")
146
146
+
case "rpc":
147
147
+
if params.Has("lxm") {
148
148
+
if positional != "" {
149
149
+
return nil, ErrInvalidPermissionSyntax
150
150
+
}
151
151
+
p.Endpoints = params["lxm"]
152
152
+
}
153
153
+
if positional != "" {
154
154
+
p.Endpoints = []string{positional}
155
155
+
}
156
156
+
p.Audience = params.Get("aud")
157
157
+
case "blob":
158
158
+
if params.Has("accept") {
159
159
+
if positional != "" {
160
160
+
return nil, ErrInvalidPermissionSyntax
161
161
+
}
162
162
+
p.Accept = params["accept"]
163
163
+
}
164
164
+
if positional != "" {
165
165
+
p.Accept = []string{positional}
166
166
+
}
167
167
+
if params.Has("maxSize") {
168
168
+
v, err := strconv.ParseUint(params.Get("maxSize"), 10, 64)
169
169
+
if err != nil {
170
170
+
return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err)
171
171
+
}
172
172
+
p.MaxSize = &v
173
173
+
}
174
174
+
case "account":
175
175
+
if params.Has("read") {
176
176
+
if positional != "" {
177
177
+
return nil, ErrInvalidPermissionSyntax
178
178
+
}
179
179
+
p.Read = params["read"]
180
180
+
}
181
181
+
if positional != "" {
182
182
+
p.Read = []string{positional}
183
183
+
}
184
184
+
p.Manage = params["manage"]
185
185
+
case "identity":
186
186
+
if params.Has("did") {
187
187
+
if positional != "" {
188
188
+
return nil, ErrInvalidPermissionSyntax
189
189
+
}
190
190
+
p.DID = params["did"]
191
191
+
}
192
192
+
if positional != "" {
193
193
+
p.DID = []string{positional}
194
194
+
}
195
195
+
p.PLC = params["plc"]
196
196
+
case "include":
197
197
+
if params.Has("permissionSet") {
198
198
+
if positional != "" {
199
199
+
return nil, ErrInvalidPermissionSyntax
200
200
+
}
201
201
+
p.PermissionSet = params.Get("permissionSet")
202
202
+
}
203
203
+
if positional != "" {
204
204
+
p.PermissionSet = positional
205
205
+
}
206
206
+
// TODO: also parse most other params...
207
207
+
p.Audience = params.Get("aud")
208
208
+
default:
209
209
+
return nil, ErrUnknownScope
210
210
+
}
211
211
+
return &p, nil
212
212
+
}
+99
atproto/auth/permission_test.go
···
1
1
+
package auth
2
2
+
3
3
+
import (
4
4
+
"bufio"
5
5
+
"fmt"
6
6
+
"os"
7
7
+
"testing"
8
8
+
9
9
+
"github.com/stretchr/testify/assert"
10
10
+
)
11
11
+
12
12
+
func TestRoundTrip(t *testing.T) {
13
13
+
assert := assert.New(t)
14
14
+
15
15
+
// NOTE: this escapes colons and slashes, which aren't strictly necessary
16
16
+
testScopes := []string{
17
17
+
"repo:com.example.record?action=all",
18
18
+
"repo?action=all&collection=com.example.record&collection=com.example.other",
19
19
+
"rpc:com.example.query?aud=did%3Aweb%3Aapi.example.com%23frag",
20
20
+
"rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure",
21
21
+
"blob:image/*",
22
22
+
"blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123",
23
23
+
"account:email?manage=deactivate",
24
24
+
"identity:handle?plc=rotation",
25
25
+
"include:app.example.authBasics",
26
26
+
}
27
27
+
28
28
+
for _, scope := range testScopes {
29
29
+
p, err := ParseScope(scope)
30
30
+
assert.NoError(err)
31
31
+
if err != nil {
32
32
+
continue
33
33
+
}
34
34
+
assert.Equal(scope, p.Scope())
35
35
+
}
36
36
+
}
37
37
+
38
38
+
func TestInteropPermissionValid(t *testing.T) {
39
39
+
assert := assert.New(t)
40
40
+
file, err := os.Open("testdata/permission_scopes_valid.txt")
41
41
+
assert.NoError(err)
42
42
+
defer file.Close()
43
43
+
scanner := bufio.NewScanner(file)
44
44
+
for scanner.Scan() {
45
45
+
line := scanner.Text()
46
46
+
if len(line) == 0 || line[0] == '#' {
47
47
+
continue
48
48
+
}
49
49
+
p, err := ParseScope(line)
50
50
+
if err != nil {
51
51
+
fmt.Println("BAD: " + line)
52
52
+
}
53
53
+
assert.NoError(err)
54
54
+
if p != nil {
55
55
+
assert.False(p.Scope() == "")
56
56
+
}
57
57
+
}
58
58
+
assert.NoError(scanner.Err())
59
59
+
}
60
60
+
61
61
+
func TestInteropPermissionInvalid(t *testing.T) {
62
62
+
assert := assert.New(t)
63
63
+
file, err := os.Open("testdata/permission_scopes_invalid.txt")
64
64
+
assert.NoError(err)
65
65
+
defer file.Close()
66
66
+
scanner := bufio.NewScanner(file)
67
67
+
for scanner.Scan() {
68
68
+
line := scanner.Text()
69
69
+
if len(line) == 0 || line[0] == '#' {
70
70
+
continue
71
71
+
}
72
72
+
_, err := ParseScope(line)
73
73
+
if err == nil {
74
74
+
fmt.Println("BAD: " + line)
75
75
+
}
76
76
+
assert.Error(err)
77
77
+
}
78
78
+
assert.NoError(scanner.Err())
79
79
+
}
80
80
+
81
81
+
func TestInteropPermissionOther(t *testing.T) {
82
82
+
assert := assert.New(t)
83
83
+
file, err := os.Open("testdata/permission_scopes_other.txt")
84
84
+
assert.NoError(err)
85
85
+
defer file.Close()
86
86
+
scanner := bufio.NewScanner(file)
87
87
+
for scanner.Scan() {
88
88
+
line := scanner.Text()
89
89
+
if len(line) == 0 || line[0] == '#' {
90
90
+
continue
91
91
+
}
92
92
+
_, err := ParseScope(line)
93
93
+
if err == nil {
94
94
+
fmt.Println("BAD: " + line)
95
95
+
}
96
96
+
assert.Error(err)
97
97
+
}
98
98
+
assert.NoError(scanner.Err())
99
99
+
}
+13
atproto/auth/testdata/permission_scopes_invalid.txt
···
1
1
+
2
2
+
blob:image/png?maxSize=-123
3
3
+
blob:image/png?maxSize=blah
4
4
+
blob:image/png?maxSize
5
5
+
blob:image/png?maxSize=123?maxSize=123
6
6
+
7
7
+
# TODO: these partial strings
8
8
+
#repo:123
9
9
+
#repo
10
10
+
#repo:
11
11
+
#rpc:123
12
12
+
#rpc
13
13
+
#rpc:com.example.query?aud=api.example.com
+3
atproto/auth/testdata/permission_scopes_other.txt
···
1
1
+
atproto
2
2
+
blah
3
3
+
unknown:resource?type=true
+14
atproto/auth/testdata/permission_scopes_valid.txt
···
1
1
+
repo:com.example.record
2
2
+
repo:com.example.record?action=*
3
3
+
repo:*
4
4
+
repo?action=all&collection=com.example.record&collection=com.example.other
5
5
+
6
6
+
rpc:com.example.query?aud=did:web:api.example.com%23api_example
7
7
+
rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure
8
8
+
9
9
+
blob:image/*?maxSize=2000
10
10
+
blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123
11
11
+
12
12
+
account:email?manage=deactivate
13
13
+
identity:handle?plc=rotation
14
14
+
include:app.example.authBasics