diff --git a/atproto/auth/permission.go b/atproto/auth/permission.go new file mode 100644 index 0000000..f14a552 --- /dev/null +++ b/atproto/auth/permission.go @@ -0,0 +1,212 @@ +package auth + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +var ( + ErrInvalidPermissionSyntax = errors.New("invalid permission syntax") + ErrUnknownScope = errors.New("unknown scope type") +) + +type Permission struct { + Type string `json:"type,omitempty"` + Resource string `json:"resource"` + + // repo + Collections []string `json:"collection,omitempty"` + Action string `json:"action,omitempty"` + + // rpc + Endpoints []string `json:"lxm,omitempty"` + Audience string `json:"aud,omitempty"` + + // blob + MaxSize *uint64 `json:"maxSize,omitempty"` + Accept []string `json:"accept,omitempty"` + + // account + Read []string `json:"read,omitempty"` + Manage []string `json:"manage,omitempty"` + + // identity + DID []string `json:"did,omitempty"` + PLC []string `json:"plc,omitempty"` + + // include + PermissionSet string `json:"permissionSet,omitempty"` +} + +func (p *Permission) Scope() string { + + positional := "" + params := make(url.Values) + + switch p.Resource { + case "repo": + if len(p.Collections) == 1 { + positional = p.Collections[0] + } else if len(p.Collections) > 1 { + params["collection"] = p.Collections + } + if p.Action != "" { + params.Set("action", p.Action) + } + case "rpc": + if len(p.Endpoints) == 1 { + positional = p.Endpoints[0] + } else if len(p.Endpoints) > 1 { + params["lxm"] = p.Endpoints + } + if p.Audience != "" { + params.Set("aud", p.Audience) + } + case "blob": + if p.MaxSize != nil { + params.Set("maxSize", strconv.Itoa(int(*p.MaxSize))) + } + if len(p.Accept) == 1 { + positional = p.Accept[0] + } else if len(p.Accept) > 1 { + params["accept"] = p.Accept + } + case "account": + if len(p.Read) == 1 { + positional = p.Read[0] + } else if len(p.Read) > 1 { + params["read"] = p.Read + } + if len(p.Manage) > 0 { + params["manage"] = p.Manage + } + case "identity": + if len(p.DID) == 1 { + positional = p.DID[0] + } else if len(p.DID) > 1 { + params["did"] = p.DID + } + if len(p.PLC) > 0 { + params["plc"] = p.PLC + } + case "include": + if p.PermissionSet != "" { + positional = p.PermissionSet + } + // TODO: other params... + if p.Audience != "" { + params.Set("aud", p.Audience) + } + default: + return "" + } + + scope := p.Resource + if positional != "" { + scope = scope + ":" + positional + } + if len(params) > 0 { + scope = scope + "?" + params.Encode() + } + return scope +} + +func ParseScope(scope string) (*Permission, error) { + + front, query, _ := strings.Cut(scope, "?") + resource, positional, _ := strings.Cut(front, ":") + + params, err := url.ParseQuery(query) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) + } + + p := Permission{ + Type: "permission", + Resource: resource, + } + + // TODO: should unknown fields be an error? + // TODO: could pre-parse in all the various fields? and then just positional per type + switch resource { + case "repo": + if params.Has("collection") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.Collections = params["collection"] + } + if positional != "" { + p.Collections = []string{positional} + } + p.Action = params.Get("action") + case "rpc": + if params.Has("lxm") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.Endpoints = params["lxm"] + } + if positional != "" { + p.Endpoints = []string{positional} + } + p.Audience = params.Get("aud") + case "blob": + if params.Has("accept") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.Accept = params["accept"] + } + if positional != "" { + p.Accept = []string{positional} + } + if params.Has("maxSize") { + v, err := strconv.ParseUint(params.Get("maxSize"), 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) + } + p.MaxSize = &v + } + case "account": + if params.Has("read") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.Read = params["read"] + } + if positional != "" { + p.Read = []string{positional} + } + p.Manage = params["manage"] + case "identity": + if params.Has("did") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.DID = params["did"] + } + if positional != "" { + p.DID = []string{positional} + } + p.PLC = params["plc"] + case "include": + if params.Has("permissionSet") { + if positional != "" { + return nil, ErrInvalidPermissionSyntax + } + p.PermissionSet = params.Get("permissionSet") + } + if positional != "" { + p.PermissionSet = positional + } + // TODO: also parse most other params... + p.Audience = params.Get("aud") + default: + return nil, ErrUnknownScope + } + return &p, nil +} diff --git a/atproto/auth/permission_test.go b/atproto/auth/permission_test.go new file mode 100644 index 0000000..e9c953a --- /dev/null +++ b/atproto/auth/permission_test.go @@ -0,0 +1,99 @@ +package auth + +import ( + "bufio" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRoundTrip(t *testing.T) { + assert := assert.New(t) + + // NOTE: this escapes colons and slashes, which aren't strictly necessary + testScopes := []string{ + "repo:com.example.record?action=all", + "repo?action=all&collection=com.example.record&collection=com.example.other", + "rpc:com.example.query?aud=did%3Aweb%3Aapi.example.com%23frag", + "rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure", + "blob:image/*", + "blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123", + "account:email?manage=deactivate", + "identity:handle?plc=rotation", + "include:app.example.authBasics", + } + + for _, scope := range testScopes { + p, err := ParseScope(scope) + assert.NoError(err) + if err != nil { + continue + } + assert.Equal(scope, p.Scope()) + } +} + +func TestInteropPermissionValid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/permission_scopes_valid.txt") + assert.NoError(err) + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + p, err := ParseScope(line) + if err != nil { + fmt.Println("BAD: " + line) + } + assert.NoError(err) + if p != nil { + assert.False(p.Scope() == "") + } + } + assert.NoError(scanner.Err()) +} + +func TestInteropPermissionInvalid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/permission_scopes_invalid.txt") + assert.NoError(err) + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + _, err := ParseScope(line) + if err == nil { + fmt.Println("BAD: " + line) + } + assert.Error(err) + } + assert.NoError(scanner.Err()) +} + +func TestInteropPermissionOther(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/permission_scopes_other.txt") + assert.NoError(err) + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + _, err := ParseScope(line) + if err == nil { + fmt.Println("BAD: " + line) + } + assert.Error(err) + } + assert.NoError(scanner.Err()) +} diff --git a/atproto/auth/testdata/permission_scopes_invalid.txt b/atproto/auth/testdata/permission_scopes_invalid.txt new file mode 100644 index 0000000..dc7a2ba --- /dev/null +++ b/atproto/auth/testdata/permission_scopes_invalid.txt @@ -0,0 +1,13 @@ + +blob:image/png?maxSize=-123 +blob:image/png?maxSize=blah +blob:image/png?maxSize +blob:image/png?maxSize=123?maxSize=123 + +# TODO: these partial strings +#repo:123 +#repo +#repo: +#rpc:123 +#rpc +#rpc:com.example.query?aud=api.example.com diff --git a/atproto/auth/testdata/permission_scopes_other.txt b/atproto/auth/testdata/permission_scopes_other.txt new file mode 100644 index 0000000..24a90e2 --- /dev/null +++ b/atproto/auth/testdata/permission_scopes_other.txt @@ -0,0 +1,3 @@ +atproto +blah +unknown:resource?type=true diff --git a/atproto/auth/testdata/permission_scopes_valid.txt b/atproto/auth/testdata/permission_scopes_valid.txt new file mode 100644 index 0000000..e714fd0 --- /dev/null +++ b/atproto/auth/testdata/permission_scopes_valid.txt @@ -0,0 +1,14 @@ +repo:com.example.record +repo:com.example.record?action=* +repo:* +repo?action=all&collection=com.example.record&collection=com.example.other + +rpc:com.example.query?aud=did:web:api.example.com%23api_example +rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure + +blob:image/*?maxSize=2000 +blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123 + +account:email?manage=deactivate +identity:handle?plc=rotation +include:app.example.authBasics