Monorepo for Tangled tangled.org

rbac2: rbac enforcer rewrite

1. Use repo AT-URI as identifier.
2. Use `dom` field rather than `obj` to filter by repository. So now
it's "user with role A in repo B can do action D to field C" where
`A,B,C,D` are `sub,dom,obj,act`.
3. Manage app-logic rules in embedded csv file which won't be saved in
db and load to memory on start. This makes app's global rbac rule
change easier as we just need to edit the csv file.

Many permission check methods are missing, but should be enough to test
this new RBAC enforcer package in spindle.

Related issue: <https://tangled.org/tangled.org/core/issues/282>

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me 34e120e7 6188b80a

verified
+52
rbac2/bytesadapter/adapter.go
··· 1 + package bytesadapter 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "errors" 7 + "strings" 8 + 9 + "github.com/casbin/casbin/v2/model" 10 + "github.com/casbin/casbin/v2/persist" 11 + ) 12 + 13 + var ( 14 + errNotImplemented = errors.New("not implemented") 15 + ) 16 + 17 + type Adapter struct { 18 + b []byte 19 + } 20 + 21 + var _ persist.Adapter = &Adapter{} 22 + 23 + func NewAdapter(b []byte) *Adapter { 24 + return &Adapter{b} 25 + } 26 + 27 + func (a *Adapter) LoadPolicy(model model.Model) error { 28 + scanner := bufio.NewScanner(bytes.NewReader(a.b)) 29 + for scanner.Scan() { 30 + line := strings.TrimSpace(scanner.Text()) 31 + if err := persist.LoadPolicyLine(line, model); err != nil { 32 + return err 33 + } 34 + } 35 + return scanner.Err() 36 + } 37 + 38 + func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { 39 + return errNotImplemented 40 + } 41 + 42 + func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { 43 + return errNotImplemented 44 + } 45 + 46 + func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { 47 + return errNotImplemented 48 + } 49 + 50 + func (a *Adapter) SavePolicy(model model.Model) error { 51 + return errNotImplemented 52 + }
+139
rbac2/rbac2.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + _ "embed" 6 + "fmt" 7 + 8 + adapter "github.com/Blank-Xu/sql-adapter" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/casbin/casbin/v2" 11 + "github.com/casbin/casbin/v2/model" 12 + "github.com/casbin/casbin/v2/util" 13 + "tangled.org/core/rbac2/bytesadapter" 14 + ) 15 + 16 + const ( 17 + Model = ` 18 + [request_definition] 19 + r = sub, dom, obj, act 20 + 21 + [policy_definition] 22 + p = sub, dom, obj, act 23 + 24 + [role_definition] 25 + g = _, _, _ 26 + 27 + [policy_effect] 28 + e = some(where (p.eft == allow)) 29 + 30 + [matchers] 31 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 32 + ` 33 + ) 34 + 35 + type Enforcer struct { 36 + e *casbin.Enforcer 37 + } 38 + 39 + //go:embed tangled_policy.csv 40 + var tangledPolicy []byte 41 + 42 + func NewEnforcer(path string) (*Enforcer, error) { 43 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 44 + if err != nil { 45 + return nil, err 46 + } 47 + return NewEnforcerWithDB(db) 48 + } 49 + 50 + func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { 51 + m, err := model.NewModelFromString(Model) 52 + if err != nil { 53 + return nil, err 54 + } 55 + 56 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // // PATCH: create unique index to make `AddPoliciesEx` work 62 + // _, err = db.Exec(fmt.Sprintf( 63 + // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, 64 + // tableName, 65 + // )) 66 + // if err != nil { 67 + // return nil, err 68 + // } 69 + 70 + e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error 71 + // e.EnableLog(true) 72 + 73 + // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first 74 + // and then override the adapter to sql-adapter. 75 + // `e.SetModel(m)` after init doesn't work for some reason 76 + if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { 77 + return nil, err 78 + } 79 + 80 + // load dynamic policy from db 81 + e.EnableAutoSave(false) 82 + if err := a.LoadPolicy(e.GetModel()); err != nil { 83 + return nil, err 84 + } 85 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 86 + e.BuildRoleLinks() 87 + e.SetAdapter(a) 88 + e.EnableAutoSave(true) 89 + 90 + return &Enforcer{e}, nil 91 + } 92 + 93 + // CaptureModel returns copy of current model. Used for testing 94 + func (e *Enforcer) CaptureModel() model.Model { 95 + return e.e.GetModel().Copy() 96 + } 97 + 98 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 99 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 100 + if err != nil { 101 + return false, err 102 + } 103 + for _, r := range roles { 104 + if r == role { 105 + return true, nil 106 + } 107 + } 108 + return false, nil 109 + } 110 + 111 + // setRoleForUser sets single user role for specified domain. 112 + // All existing users with that role will be removed. 113 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 114 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 115 + if err != nil { 116 + return err 117 + } 118 + 119 + for _, oldUser := range currentUsers { 120 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 121 + if err != nil { 122 + return err 123 + } 124 + } 125 + 126 + _, err = e.e.AddRoleForUser(name, role, domain...) 127 + return err 128 + } 129 + 130 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 131 + func validateAtUri(uri syntax.ATURI, expected string) error { 132 + if !uri.Authority().IsDID() { 133 + return fmt.Errorf("expected at-uri with did") 134 + } 135 + if expected != "" && uri.Collection().String() != expected { 136 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 137 + } 138 + return nil 139 + }
+150
rbac2/rbac2_test.go
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "database/sql" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + _ "github.com/mattn/go-sqlite3" 9 + "github.com/stretchr/testify/assert" 10 + "tangled.org/core/rbac2" 11 + ) 12 + 13 + func setup(t *testing.T) *rbac2.Enforcer { 14 + enforcer, err := rbac2.NewEnforcer(":memory:") 15 + assert.NoError(t, err) 16 + 17 + return enforcer 18 + } 19 + 20 + func TestNewEnforcer(t *testing.T) { 21 + db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1") 22 + assert.NoError(t, err) 23 + 24 + enforcer1, err := rbac2.NewEnforcerWithDB(db) 25 + assert.NoError(t, err) 26 + enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) 27 + model1 := enforcer1.CaptureModel() 28 + 29 + enforcer2, err := rbac2.NewEnforcerWithDB(db) 30 + assert.NoError(t, err) 31 + model2 := enforcer2.CaptureModel() 32 + 33 + // model1.GetLogger().EnableLog(true) 34 + // model1.PrintModel() 35 + // model1.PrintPolicy() 36 + // model1.GetLogger().EnableLog(false) 37 + 38 + model2.GetLogger().EnableLog(true) 39 + model2.PrintModel() 40 + model2.PrintPolicy() 41 + model2.GetLogger().EnableLog(false) 42 + 43 + assert.Equal(t, model1, model2) 44 + } 45 + 46 + func TestRepoOwnerPermissions(t *testing.T) { 47 + var ( 48 + e = setup(t) 49 + ok bool 50 + err error 51 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 52 + fooUser = syntax.DID("did:plc:foo") 53 + ) 54 + 55 + assert.NoError(t, e.AddRepo(fooRepo)) 56 + 57 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 58 + assert.NoError(t, err) 59 + assert.True(t, ok, "repo author should be repo owner") 60 + 61 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 62 + assert.NoError(t, err) 63 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 64 + 65 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 66 + assert.NoError(t, err) 67 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 68 + 69 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 70 + assert.NoError(t, err) 71 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 72 + } 73 + 74 + func TestRepoCollaboratorPermissions(t *testing.T) { 75 + var ( 76 + e = setup(t) 77 + ok bool 78 + err error 79 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 80 + barUser = syntax.DID("did:plc:bar") 81 + ) 82 + 83 + assert.NoError(t, e.AddRepo(fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 85 + 86 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 87 + assert.NoError(t, err) 88 + assert.True(t, ok, "should set repo collaborator") 89 + 90 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 91 + assert.NoError(t, err) 92 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 93 + 94 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 95 + assert.NoError(t, err) 96 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 97 + } 98 + 99 + func TestGetByRole(t *testing.T) { 100 + var ( 101 + e = setup(t) 102 + err error 103 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 104 + owner = syntax.DID("did:plc:foo") 105 + collaborator1 = syntax.DID("did:plc:bar") 106 + collaborator2 = syntax.DID("did:plc:baz") 107 + ) 108 + 109 + assert.NoError(t, e.AddRepo(fooRepo)) 110 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 111 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 112 + 113 + collaborators, err := e.GetRepoCollaborators(fooRepo) 114 + assert.NoError(t, err) 115 + assert.ElementsMatch(t, []syntax.DID{ 116 + owner, 117 + collaborator1, 118 + collaborator2, 119 + }, collaborators) 120 + } 121 + 122 + func TestSpindleOwnerPermissions(t *testing.T) { 123 + var ( 124 + e = setup(t) 125 + ok bool 126 + err error 127 + spindle = syntax.DID("did:web:spindle.example.com") 128 + owner = syntax.DID("did:plc:foo") 129 + member = syntax.DID("did:plc:bar") 130 + ) 131 + 132 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 133 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 134 + 135 + ok, err = e.IsSpindleMember(owner, spindle) 136 + assert.NoError(t, err) 137 + assert.True(t, ok, "spindle owner is spindle member") 138 + 139 + ok, err = e.IsSpindleMember(member, spindle) 140 + assert.NoError(t, err) 141 + assert.True(t, ok, "spindle member is spindle member") 142 + 143 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 144 + assert.NoError(t, err) 145 + assert.True(t, ok, "spindle owner can invite members") 146 + 147 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 148 + assert.NoError(t, err) 149 + assert.False(t, ok, "spindle member cannot invite members") 150 + }
+91
rbac2/repo.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "/git", "write") 91 + }
+29
rbac2/spindle.go
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+19
rbac2/tangled_policy.csv
··· 1 + #, policies 2 + #, sub, dom, obj, act 3 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write 4 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write 5 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write 6 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write 7 + 8 + p, server:owner, /knot/{did}, /member, write 9 + p, server:member, /knot/{did}, /git, write 10 + 11 + p, server:owner, /spindle/{did}, /member, write 12 + 13 + 14 + #, group policies 15 + #, sub, role, dom 16 + g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} 17 + 18 + g, server:owner, server:member, /knot/{did} 19 + g, server:owner, server:member, /spindle/{did}