go scratch code for atproto

updates to permisison string support

+201 -83
+78 -67
permissions/permission.go
··· 5 5 "fmt" 6 6 "net/url" 7 7 "strings" 8 + "unicode" 8 9 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 10 11 ) 11 12 12 13 var ( 13 14 ErrInvalidPermissionSyntax = errors.New("invalid permission syntax") 14 - ErrUnknownScope = errors.New("unknown scope type") 15 + ErrInvalidPermissionParams = errors.New("invalid permission parameters") 16 + ErrUnknownResource = errors.New("unknown permission resource") 15 17 ) 16 18 17 19 type GenericPermission struct { ··· 25 27 Resource string `json:"resource"` 26 28 27 29 // common params (eg, identity, account) 28 - Attribute string `json:"attr,omitempty"` 29 - Action []string `json:"action,omitempty"` 30 - Audience string `json:"aud,omitempty"` 31 - 32 - // repo 33 - Collections []string `json:"collection,omitempty"` 34 - 35 - // rpc 36 - Endpoints []string `json:"lxm,omitempty"` 37 - 38 - // blob 39 - Accept []string `json:"accept,omitempty"` 40 - 41 - // include 42 - NSID string `json:"nsid,omitempty"` 30 + Accept []string `json:"accept,omitempty"` 31 + Action []string `json:"action,omitempty"` 32 + Attribute string `json:"attr,omitempty"` 33 + Audience string `json:"aud,omitempty"` 34 + InheritAud bool `json:"inheritAud,omitempty"` 35 + Collection []string `json:"collection,omitempty"` 36 + Endpoint []string `json:"lxm,omitempty"` 37 + NSID string `json:"nsid,omitempty"` 43 38 } 44 39 45 - func (p *Permission) Scope() string { 40 + func (p *Permission) ScopeString() string { 46 41 47 42 positional := "" 48 43 params := make(url.Values) ··· 73 68 params.Set("aud", p.Audience) 74 69 } 75 70 case "repo": 76 - if len(p.Collections) == 1 { 77 - positional = p.Collections[0] 78 - } else if len(p.Collections) > 1 { 79 - params["collection"] = p.Collections 71 + if len(p.Collection) == 1 { 72 + positional = p.Collection[0] 73 + } else if len(p.Collection) > 1 { 74 + params["collection"] = p.Collection 80 75 } 81 76 if len(p.Action) != 0 { 82 77 params["action"] = p.Action 83 78 } 84 79 case "rpc": 85 - if len(p.Endpoints) == 1 { 86 - positional = p.Endpoints[0] 87 - } else if len(p.Endpoints) > 1 { 88 - params["lxm"] = p.Endpoints 80 + if len(p.Endpoint) == 1 { 81 + positional = p.Endpoint[0] 82 + } else if len(p.Endpoint) > 1 { 83 + params["lxm"] = p.Endpoint 89 84 } 90 85 if p.Audience != "" { 91 86 params.Set("aud", p.Audience) ··· 106 101 107 102 func ParseGenericScope(scope string) (*GenericPermission, error) { 108 103 104 + if !isASCII(scope) { 105 + return nil, ErrInvalidPermissionSyntax 106 + } 107 + 109 108 front, query, _ := strings.Cut(scope, "?") 110 109 resource, positional, _ := strings.Cut(front, ":") 110 + 111 + // XXX: more charset restrictions 111 112 112 113 params, err := url.ParseQuery(query) 113 114 if err != nil { ··· 122 123 return &p, nil 123 124 } 124 125 125 - // TODO: improve this function 126 + // TODO: improve this function, add tests 126 127 func validBlobAccept(accept string) bool { 127 128 if accept == "*/*" { 128 129 return true ··· 140 141 return true 141 142 } 142 143 143 - // TODO: improve this function 144 + // TODO: improve this function, add tests 144 145 func validServiceRef(accept string) bool { 145 146 parts := strings.SplitN(accept, "#", 3) 146 147 if len(parts) != 2 { ··· 156 157 return true 157 158 } 158 159 159 - func ParseScope(scope string) (*Permission, error) { 160 + func ParsePermissionString(scope string) (*Permission, error) { 160 161 g, err := ParseGenericScope(scope) 161 162 if err != nil { 162 163 return nil, err ··· 171 172 case "account": 172 173 for k, _ := range g.Params { 173 174 if !(k == "attr" || k == "action") { 174 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 175 + return nil, fmt.Errorf("%w: unsupported 'account' param: %s", ErrInvalidPermissionParams, k) 175 176 } 176 177 } 177 178 if g.Params.Has("attr") { 178 179 if g.Positional != "" || len(g.Params["attr"]) != 1 { 179 - return nil, ErrInvalidPermissionSyntax 180 + return nil, ErrInvalidPermissionParams 180 181 } 181 182 p.Attribute = g.Params.Get("attr") 182 183 } ··· 184 185 p.Attribute = g.Positional 185 186 } 186 187 if p.Attribute == "" { 187 - return nil, ErrInvalidPermissionSyntax 188 + return nil, ErrInvalidPermissionParams 188 189 } 189 190 if p.Attribute != "" && p.Attribute != "email" && p.Attribute != "repo" { 190 - return nil, ErrInvalidPermissionSyntax 191 + return nil, ErrInvalidPermissionParams 191 192 } 193 + // TODO: maybe this should not be limited to a single "action" string? 192 194 if len(g.Params["action"]) > 1 { 193 - return nil, ErrInvalidPermissionSyntax 195 + return nil, ErrInvalidPermissionParams 194 196 } 195 197 p.Action = g.Params["action"] 196 198 for _, act := range p.Action { 197 199 if act != "read" && act != "manage" { 198 - return nil, ErrInvalidPermissionSyntax 200 + return nil, ErrInvalidPermissionParams 199 201 } 200 202 } 201 203 case "blob": 202 204 for k, _ := range g.Params { 203 205 if !(k == "accept") { 204 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 206 + return nil, fmt.Errorf("%w: unsupported 'blob' param: %s", ErrInvalidPermissionParams, k) 205 207 } 206 208 } 207 209 if g.Params.Has("accept") { 208 210 if g.Positional != "" { 209 - return nil, ErrInvalidPermissionSyntax 211 + return nil, ErrInvalidPermissionParams 210 212 } 211 213 p.Accept = g.Params["accept"] 212 214 } ··· 214 216 p.Accept = []string{g.Positional} 215 217 } 216 218 if len(p.Accept) == 0 { 217 - return nil, ErrInvalidPermissionSyntax 219 + return nil, ErrInvalidPermissionParams 218 220 } 219 221 for _, acc := range p.Accept { 220 222 if !validBlobAccept(acc) { 221 - return nil, ErrInvalidPermissionSyntax 223 + return nil, ErrInvalidPermissionParams 222 224 } 223 225 } 224 226 case "identity": 225 227 for k, _ := range g.Params { 226 228 if !(k == "attr") { 227 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 229 + return nil, fmt.Errorf("%w: unsupported 'identity' param: %s", ErrInvalidPermissionParams, k) 228 230 } 229 231 } 230 232 if g.Params.Has("attr") { 231 233 if g.Positional != "" || len(g.Params["attr"]) != 1 { 232 - return nil, ErrInvalidPermissionSyntax 234 + return nil, ErrInvalidPermissionParams 233 235 } 234 236 p.Attribute = g.Params.Get("attr") 235 237 } ··· 237 239 p.Attribute = g.Positional 238 240 } 239 241 if p.Attribute != "*" && p.Attribute != "handle" { 240 - return nil, ErrInvalidPermissionSyntax 242 + return nil, ErrInvalidPermissionParams 241 243 } 242 244 case "include": 243 245 for k, _ := range g.Params { 244 246 if !(k == "nsid" || k == "aud") { 245 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 247 + return nil, fmt.Errorf("%w: unsupported 'include' param: %s", ErrInvalidPermissionParams, k) 246 248 } 247 249 } 248 250 if g.Params.Has("nsid") { 249 251 if g.Positional != "" || len(g.Params["nsid"]) != 1 { 250 - return nil, ErrInvalidPermissionSyntax 252 + return nil, ErrInvalidPermissionParams 251 253 } 252 254 p.NSID = g.Params.Get("nsid") 253 255 } ··· 256 258 } 257 259 _, err := syntax.ParseNSID(p.NSID) 258 260 if err != nil { 259 - return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 261 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 260 262 } 261 263 if g.Params.Has("aud") && (len(g.Params["aud"]) != 1 || g.Params.Get("aud") == "") { 262 - return nil, ErrInvalidPermissionSyntax 264 + return nil, ErrInvalidPermissionParams 263 265 } 264 266 p.Audience = g.Params.Get("aud") 265 267 if p.Audience != "" && p.Audience != "*" && !validServiceRef(p.Audience) { 266 - return nil, ErrInvalidPermissionSyntax 268 + return nil, ErrInvalidPermissionParams 267 269 } 268 270 // possibly other params in the future... 269 271 case "repo": 270 272 for k, _ := range g.Params { 271 273 if !(k == "collection" || k == "action") { 272 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 274 + return nil, fmt.Errorf("%w: unsupported 'repo' param: %s", ErrInvalidPermissionParams, k) 273 275 } 274 276 } 275 277 if g.Params.Has("collection") { 276 278 if g.Positional != "" { 277 - return nil, ErrInvalidPermissionSyntax 279 + return nil, ErrInvalidPermissionParams 278 280 } 279 - p.Collections = g.Params["collection"] 281 + p.Collection = g.Params["collection"] 280 282 } 281 283 if g.Positional != "" { 282 - p.Collections = []string{g.Positional} 284 + p.Collection = []string{g.Positional} 283 285 } 284 - if len(p.Collections) == 0 { 285 - return nil, ErrInvalidPermissionSyntax 286 + if len(p.Collection) == 0 { 287 + return nil, ErrInvalidPermissionParams 286 288 } 287 - for _, coll := range p.Collections { 289 + for _, coll := range p.Collection { 288 290 if coll == "*" { 289 291 continue 290 292 } 291 293 _, err := syntax.ParseNSID(coll) 292 294 if err != nil { 293 - return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 295 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 294 296 } 295 297 } 296 298 p.Action = g.Params["action"] 297 299 for _, act := range p.Action { 298 300 if act != "create" && act != "update" && act != "delete" { 299 - return nil, ErrInvalidPermissionSyntax 301 + return nil, ErrInvalidPermissionParams 300 302 } 301 303 } 302 304 case "rpc": 303 305 for k, _ := range g.Params { 304 306 if !(k == "lxm" || k == "aud") { 305 - return nil, fmt.Errorf("%w: unexpected param: %s", ErrInvalidPermissionSyntax, k) 307 + return nil, fmt.Errorf("%w: unsupported 'rpc' param: %s", ErrInvalidPermissionParams, k) 306 308 } 307 309 } 308 310 if g.Params.Has("lxm") { 309 311 if g.Positional != "" { 310 - return nil, ErrInvalidPermissionSyntax 312 + return nil, ErrInvalidPermissionParams 311 313 } 312 - p.Endpoints = g.Params["lxm"] 314 + p.Endpoint = g.Params["lxm"] 313 315 } 314 316 if g.Positional != "" { 315 - p.Endpoints = []string{g.Positional} 317 + p.Endpoint = []string{g.Positional} 316 318 } 317 - if len(p.Endpoints) == 0 { 318 - return nil, ErrInvalidPermissionSyntax 319 + if len(p.Endpoint) == 0 { 320 + return nil, ErrInvalidPermissionParams 319 321 } 320 322 if len(g.Params["aud"]) != 1 { 321 - return nil, ErrInvalidPermissionSyntax 323 + return nil, ErrInvalidPermissionParams 322 324 } 323 325 p.Audience = g.Params.Get("aud") 324 - for _, nsid := range p.Endpoints { 326 + for _, nsid := range p.Endpoint { 325 327 if nsid == "*" { 326 328 if p.Audience == "*" { 327 - return nil, ErrInvalidPermissionSyntax 329 + return nil, ErrInvalidPermissionParams 328 330 } 329 331 continue 330 332 } 331 333 _, err := syntax.ParseNSID(nsid) 332 334 if err != nil { 333 - return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 335 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) 334 336 } 335 337 } 336 338 if p.Audience != "*" && !validServiceRef(p.Audience) { 337 - return nil, ErrInvalidPermissionSyntax 339 + return nil, ErrInvalidPermissionParams 338 340 } 339 341 default: 340 - return nil, ErrUnknownScope 342 + return nil, fmt.Errorf("%w: %s", ErrUnknownResource, g.Resource) 341 343 } 342 344 return &p, nil 343 345 } 346 + 347 + func isASCII(s string) bool { 348 + for i := 0; i < len(s); i++ { 349 + if s[i] > unicode.MaxASCII { 350 + return false 351 + } 352 + } 353 + return true 354 + }
+29 -6
permissions/permission_test.go
··· 27 27 } 28 28 29 29 for _, scope := range testScopes { 30 - p, err := ParseScope(scope) 30 + p, err := ParsePermissionString(scope) 31 31 assert.NoError(err) 32 32 if err != nil { 33 33 fmt.Println("BAD: " + scope) 34 34 continue 35 35 } 36 - assert.Equal(scope, p.Scope()) 36 + assert.Equal(scope, p.ScopeString()) 37 37 } 38 38 } 39 39 ··· 42 42 Generic GenericPermission `json:"generic"` 43 43 } 44 44 45 - func TestGenericPermissions(t *testing.T) { 45 + func TestGenericGenericScopesValid(t *testing.T) { 46 46 assert := assert.New(t) 47 47 file, err := os.Open("testdata/generic_scopes.json") 48 48 if err != nil { ··· 68 68 } 69 69 } 70 70 71 + func TestGenericScopesInvalid(t *testing.T) { 72 + assert := assert.New(t) 73 + file, err := os.Open("testdata/generic_scopes_invalid.txt") 74 + if err != nil { 75 + assert.NoError(err) 76 + t.Fail() 77 + } 78 + defer file.Close() 79 + scanner := bufio.NewScanner(file) 80 + for scanner.Scan() { 81 + line := scanner.Text() 82 + if len(line) == 0 || line[0] == '#' { 83 + continue 84 + } 85 + _, err := ParseGenericScope(line) 86 + if err != nil { 87 + fmt.Println("BAD: " + line) 88 + } 89 + assert.Error(err) 90 + } 91 + assert.NoError(scanner.Err()) 92 + } 93 + 71 94 func TestInteropPermissionValid(t *testing.T) { 72 95 assert := assert.New(t) 73 96 file, err := os.Open("testdata/permission_scopes_valid.txt") ··· 87 110 fmt.Println("BAD: " + line) 88 111 } 89 112 assert.NoError(err) 90 - p, err := ParseScope(line) 113 + p, err := ParsePermissionString(line) 91 114 if err != nil { 92 115 fmt.Println("BAD: " + line) 93 116 } 94 117 assert.NoError(err) 95 118 if p != nil { 96 - assert.False(p.Scope() == "") 119 + assert.False(p.ScopeString() == "") 97 120 } 98 121 } 99 122 assert.NoError(scanner.Err()) ··· 113 136 if len(line) == 0 || line[0] == '#' { 114 137 continue 115 138 } 116 - _, err := ParseScope(line) 139 + _, err := ParsePermissionString(line) 117 140 if err == nil { 118 141 fmt.Println("BAD: " + line) 119 142 }
+71
permissions/testdata/generic_scopes.json
··· 1 1 [ 2 2 { 3 + "scope": "resource", 4 + "generic": { 5 + "resource": "resource", 6 + "positional": "", 7 + "params": {} 8 + } 9 + }, 10 + { 11 + "scope": "resource:positional?key=val", 12 + "generic": { 13 + "resource": "resource", 14 + "positional": "positional", 15 + "params": { 16 + "key": ["val"] 17 + } 18 + } 19 + }, 20 + { 21 + "scope": "resource:positional?thing&key=val", 22 + "generic": { 23 + "resource": "resource", 24 + "positional": "positional", 25 + "params": { 26 + "thing": [""], 27 + "key": ["val"] 28 + } 29 + } 30 + }, 31 + { 32 + "scope": "service:did:web:com.example#type?key=val", 33 + "generic": { 34 + "resource": "service", 35 + "positional": "did:web:com.example#type", 36 + "params": { 37 + "key": ["val"] 38 + } 39 + } 40 + }, 41 + { 42 + "scope": "resource:", 43 + "generic": { 44 + "resource": "resource", 45 + "positional": "", 46 + "params": {} 47 + } 48 + }, 49 + { 50 + "scope": "resource:?", 51 + "generic": { 52 + "resource": "resource", 53 + "positional": "", 54 + "params": {} 55 + } 56 + }, 57 + { 58 + "scope": "resource:&", 59 + "generic": { 60 + "resource": "resource", 61 + "positional": "&", 62 + "params": {} 63 + } 64 + }, 65 + { 66 + "scope": "resource?", 67 + "generic": { 68 + "resource": "resource", 69 + "positional": "", 70 + "params": {} 71 + } 72 + }, 73 + { 3 74 "scope": "res:pos?p=true", 4 75 "generic": { 5 76 "resource": "res",
-10
permissions/testdata/generic_scopes_SKIP.json
··· 1 - [ 2 - { 3 - "scope": "my-res:my%20pos", 4 - "generic": { 5 - "resource": "my-res", 6 - "positional": "my pos", 7 - "params": {} 8 - } 9 - } 10 - ]
+2
permissions/testdata/generic_scopes_invalid.txt
··· 1 + resource:positional?key=québec 2 + emoji:☺️
+21
permissions/testdata/permission_scopes_valid.txt
··· 37 37 rpc?aud=*&lxm=com.example.method1&lxm=com.example.method2 38 38 rpc:com.example.query?aud=did:web:api.example.com%23api_example 39 39 rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure 40 + 41 + 42 + # examples from specification text 43 + repo:app.example.profile 44 + repo:app.example.profile?action=create&action=update&action=delete 45 + repo?collection=app.example.profile&collection=app.example.post 46 + repo:* 47 + repo:*?action=delete 48 + rpc:app.example.moderation.createReport?aud=* 49 + rpc?lxm=*&aud=did:web:api.example.com%23svc_appview 50 + blob:*/* 51 + blob?accept=video/*&accept=text/html 52 + account:email 53 + account:repo?action=manage 54 + identity:handle 55 + identity:* 56 + identity:*? 57 + rpc?lxm=*&aud=did:web:api.example.com%23svc_appview 58 + blob?accept=video/*&accept=text/html 59 + repo:app.example.profile?action=create&action=update&action=delete 60 + include:app.example.authFull?aud=did:web:api.example.com%23svc_chat