diff --git a/rbac/v2/rbac.go b/rbac/v2/rbac.go new file mode 100644 index 00000000..09af6277 --- /dev/null +++ b/rbac/v2/rbac.go @@ -0,0 +1,144 @@ +package rbac + +import ( + "database/sql" + "fmt" + + adapter "github.com/Blank-Xu/sql-adapter" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/util" + "tangled.org/core/api/tangled" +) + +const ( + Model = ` +[request_definition] +r = sub, dom, obj, act + +[policy_definition] +p = sub, dom, obj, act + +[role_definition] +g = _, _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act +` +) + +type Enforcer struct { + e *casbin.Enforcer +} + +func NewEnforcer(path string) (*Enforcer, error) { + m, err := model.NewModelFromString(Model) + if err != nil { + return nil, err + } + + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") + if err != nil { + return nil, err + } + + a, err := adapter.NewAdapter(db, "sqlite3", "acl") + if err != nil { + return nil, err + } + + e, err := casbin.NewEnforcer(m, a) + if err != nil { + return nil, err + } + + if err := seedTangledPolicies(e); err != nil { + return nil, err + } + + return &Enforcer{e}, nil +} + +func seedTangledPolicies(e *casbin.Enforcer) error { + // policies + aturi := func(nsid string) string { + return fmt.Sprintf("at://{did}/%s/{rkey}", nsid) + } + + _, err := e.AddPoliciesEx([][]string{ + // sub | dom | obj | act + {"repo:owner", aturi(tangled.RepoNSID), "/", "write"}, + {"repo:owner", aturi(tangled.RepoNSID), "/collaborator", "write"}, // invite + {"repo:collaborator", aturi(tangled.RepoNSID), "/settings", "write"}, + {"repo:collaborator", aturi(tangled.RepoNSID), "/git", "write"}, // git push + + {"server:owner", "/knot/{did}", "/member", "write"}, // invite + {"server:member", "/knot/{did}", "/git", "write"}, + + {"server:owner", "/spindle/{did}", "/member", "write"}, // invite + }) + if err != nil { + return err + } + + // grouping policies + // TODO(boltless): define our own matcher to replace keyMatch4 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) + _, err = e.AddGroupingPoliciesEx([][]string{ + // sub | role | dom + {"repo:owner", "repo:collaborator", aturi(tangled.RepoNSID)}, + + // using '/knot/' prefix here because knot/spindle identifiers don't + // include the collection type + {"server:owner", "server:member", "/knot/{did}"}, + {"server:owner", "server:member", "/spindle/{did}"}, + }) + return err +} + +func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { + roles, err := e.e.GetImplicitRolesForUser(name, domain...) + if err != nil { + return false, err + } + for _, r := range roles { + if r == role { + return true, nil + } + } + return false, nil +} + +// setRoleForUser sets single user role for specified domain. +// All existing users with that role will be removed. +func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { + currentUsers, err := e.e.GetUsersForRole(role, domain...) + if err != nil { + return err + } + + for _, oldUser := range currentUsers { + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) + if err != nil { + return err + } + } + + _, err = e.e.AddRoleForUser(name, role, domain...) + return err +} + +// validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. +func validateAtUri(uri syntax.ATURI, expected string) error { + if !uri.Authority().IsDID() { + return fmt.Errorf("expected at-uri with did") + } + if expected != "" && uri.Collection().String() != expected { + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) + } + return nil +} diff --git a/rbac/v2/rbac_test.go b/rbac/v2/rbac_test.go new file mode 100644 index 00000000..68ca3dc7 --- /dev/null +++ b/rbac/v2/rbac_test.go @@ -0,0 +1,124 @@ +package rbac_test + +import ( + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "tangled.org/core/rbac/v2" +) + +func setup(t *testing.T) *rbac.Enforcer { + enforcer, err := rbac.NewEnforcer(":memory:") + assert.NoError(t, err) + + return enforcer +} + +func TestRepoOwnerPermissions(t *testing.T) { + var ( + e = setup(t) + ok bool + err error + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") + fooUser = syntax.DID("did:plc:foo") + ) + + assert.NoError(t, e.AddRepo(fooRepo)) + + ok, err = e.IsRepoOwner(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo author should be repo owner") + + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo owner should be able to modify the repo itself") + + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo owner should inherit role role:collaborator") + + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo owner should inherit collaborator permissions") +} + +func TestRepoCollaboratorPermissions(t *testing.T) { + var ( + e = setup(t) + ok bool + err error + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") + barUser = syntax.DID("did:plc:bar") + ) + + assert.NoError(t, e.AddRepo(fooRepo)) + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) + + ok, err = e.IsRepoCollaborator(barUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "should set repo collaborator") + + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo collaborator should be able to edit repo settings") + + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) + assert.NoError(t, err) + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") +} + +func TestGetByRole(t *testing.T) { + var ( + e = setup(t) + err error + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") + owner = syntax.DID("did:plc:foo") + collaborator1 = syntax.DID("did:plc:bar") + collaborator2 = syntax.DID("did:plc:baz") + ) + + assert.NoError(t, e.AddRepo(fooRepo)) + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) + + collaborators, err := e.GetRepoCollaborators(fooRepo) + assert.NoError(t, err) + assert.ElementsMatch(t, []syntax.DID{ + owner, + collaborator1, + collaborator2, + }, collaborators) +} + +func TestSpindleOwnerPermissions(t *testing.T) { + var ( + e = setup(t) + ok bool + err error + spindle = syntax.DID("did:web:spindle.example.com") + owner = syntax.DID("did:plc:foo") + member = syntax.DID("did:plc:bar") + ) + + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) + assert.NoError(t, e.AddSpindleMember(member, spindle)) + + ok, err = e.IsSpindleMember(owner, spindle) + assert.NoError(t, err) + assert.True(t, ok, "spindle owner is spindle member") + + ok, err = e.IsSpindleMember(member, spindle) + assert.NoError(t, err) + assert.True(t, ok, "spindle member is spindle member") + + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) + assert.NoError(t, err) + assert.True(t, ok, "spindle owner can invite members") + + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) + assert.NoError(t, err) + assert.False(t, ok, "spindle member cannot invite members") +} + diff --git a/rbac/v2/repo.go b/rbac/v2/repo.go new file mode 100644 index 00000000..eaac1167 --- /dev/null +++ b/rbac/v2/repo.go @@ -0,0 +1,91 @@ +package rbac + +import ( + "slices" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" +) + +// AddRepo adds new repo with its owner to rbac enforcer +func (e *Enforcer) AddRepo(repo syntax.ATURI) error { + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { + return err + } + user := repo.Authority() + + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) +} + +// DeleteRepo deletes all policies related to the repo +func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { + return err + } + + _, err := e.e.DeleteDomains(repo.String()) + return err +} + +// AddRepoCollaborator adds new collaborator to the repo +func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { + return err + } + + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) + return err +} + +// RemoveRepoCollaborator removes the collaborator from the repo. +// This won't remove inherited roles like repository owner. +func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { + return err + } + + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) + return err +} + +func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { + var collaborators []syntax.DID + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) + if err != nil { + return nil, err + } + for _, m := range members { + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' + continue + } + collaborators = append(collaborators, syntax.DID(m)) + } + + slices.Sort(collaborators) + return slices.Compact(collaborators), nil +} + +func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) +} + +func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) +} + +func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), "/", "write") +} + +func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), "/settings", "write") +} + +func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") +} + +func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), "/git", "write") +} diff --git a/rbac/v2/spindle.go b/rbac/v2/spindle.go new file mode 100644 index 00000000..7f221581 --- /dev/null +++ b/rbac/v2/spindle.go @@ -0,0 +1,29 @@ +package rbac + +import "github.com/bluesky-social/indigo/atproto/syntax" + +func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) +} + +func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { + return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) +} + +func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) + return err +} + +func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) + return err +} + +func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { + return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") +} + +func intoSpindle(did syntax.DID) string { + return "/spindle/" + did.String() +}