go scratch code for atproto

initial auth permission parsing/serialization

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