This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

rbac/v2: rbac enforcer rewrite

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 object C"
where `A,B,C,D` are `sub,dom,obj,act`.

spindle user ACL and repository collaborator ACL are slightly different
models. For example, from spindle, it should be able to `InviteOnly`
config when unspecificed.

rbac/v2 can be removed one we use repo-did as delegated account
see: <https://tangled.org/tangled.org/core/issues/701>

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

author
Seongmin Lee
date (Jul 29, 2026, 5:25 PM +0900) commit b1443658 parent 9372be7e change-id rwsnluxt
+312
+119
rbac/v2/rbac.go
··· 1 + package rbac 2 + 3 + import ( 4 + "database/sql" 5 + "slices" 6 + 7 + adapter "github.com/Blank-Xu/sql-adapter" 8 + "github.com/casbin/casbin/v2" 9 + "github.com/casbin/casbin/v2/model" 10 + "github.com/casbin/casbin/v2/util" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + const ( 15 + TnglSpindle = "tngl_spindle" 16 + 17 + Model = ` 18 + [request_definition] 19 + r = sub, dom, obj, act 20 + 21 + [policy_definition] 22 + p = sub, 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) && r.obj == p.obj && r.act == p.act 32 + ` 33 + ) 34 + 35 + type Enforcer struct { 36 + e *casbin.SyncedEnforcer 37 + } 38 + 39 + func NewEnforcer(path string) (*Enforcer, error) { 40 + m, err := model.NewModelFromString(Model) 41 + if err != nil { 42 + return nil, err 43 + } 44 + 45 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1&_journal_mode=WAL&_busy_timeout=5000") 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + a, err := adapter.NewAdapter(db, "sqlite3", "acl_2") 51 + if err != nil { 52 + return nil, err 53 + } 54 + 55 + e, err := casbin.NewSyncedEnforcer(m, a) 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + if err := seedTangledPolicies(e); err != nil { 61 + return nil, err 62 + } 63 + 64 + return &Enforcer{e}, nil 65 + } 66 + 67 + func seedTangledPolicies(e *casbin.SyncedEnforcer) error { 68 + // policies 69 + _, err := e.AddPoliciesEx([][]string{ 70 + // sub | obj | act 71 + {"repo:collaborator", TnglSpindle, tangled.RepoAddSecretNSID}, 72 + {"repo:collaborator", TnglSpindle, tangled.RepoRemoveSecretNSID}, 73 + {"repo:collaborator", TnglSpindle, tangled.RepoListSecretsNSID}, 74 + {"repo:collaborator", TnglSpindle, tangled.CiCancelPipelineNSID}, 75 + {"repo:collaborator", TnglSpindle, tangled.CiTriggerPipelineNSID}, 76 + }) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + // grouping policies 82 + e.AddNamedDomainMatchingFunc("g", "keyMatch", util.KeyMatch) 83 + _, err = e.AddGroupingPoliciesEx([][]string{ 84 + // sub | role | dom 85 + {"repo:owner", "repo:collaborator", "*"}, // repo owner is collaborator 86 + }) 87 + return err 88 + } 89 + 90 + func (e *Enforcer) EnableAutoSave(autoSave bool) { 91 + e.e.EnableAutoSave(autoSave) 92 + } 93 + 94 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 95 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 96 + if err != nil { 97 + return false, err 98 + } 99 + return slices.Contains(roles, role), nil 100 + } 101 + 102 + // setRoleForUser sets single user role for specified domain. 103 + // All existing users with that role will be removed. 104 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 105 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 106 + if err != nil { 107 + return err 108 + } 109 + 110 + for _, oldUser := range currentUsers { 111 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 112 + if err != nil { 113 + return err 114 + } 115 + } 116 + 117 + _, err = e.e.AddRoleForUser(name, role, domain...) 118 + return err 119 + }
+122
rbac/v2/rbac_test.go
··· 1 + package rbac_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + _ "github.com/mattn/go-sqlite3" 8 + "github.com/stretchr/testify/assert" 9 + "tangled.org/core/rbac/v2" 10 + ) 11 + 12 + func setup(t *testing.T) *rbac.Enforcer { 13 + enforcer, err := rbac.NewEnforcer(":memory:") 14 + assert.NoError(t, err) 15 + 16 + return enforcer 17 + } 18 + 19 + func TestRepoOwnerPermissions(t *testing.T) { 20 + var ( 21 + e = setup(t) 22 + ok bool 23 + err error 24 + fooRepo = syntax.DID("did:example:coolrepo") 25 + fooUser = syntax.DID("did:plc:foo") 26 + ) 27 + 28 + assert.NoError(t, e.SetRepoOwner(fooUser, fooRepo)) 29 + 30 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 31 + assert.NoError(t, err) 32 + assert.True(t, ok, "repo author should be repo owner") 33 + 34 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 35 + assert.NoError(t, err) 36 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 37 + 38 + ok, err = e.IsRepoSecretsAllowed(fooUser, fooRepo) 39 + assert.NoError(t, err) 40 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 41 + } 42 + 43 + func TestRepoTeardown(t *testing.T) { 44 + var ( 45 + e = setup(t) 46 + ok bool 47 + err error 48 + fooRepo = syntax.DID("did:example:coolrepo") 49 + fooUser = syntax.DID("did:example:foo") 50 + barUser = syntax.DID("did:example:bar") 51 + ) 52 + 53 + assert.NoError(t, e.SetRepoOwner(fooUser, fooRepo)) 54 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 55 + 56 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 57 + assert.NoError(t, err) 58 + assert.True(t, ok, "repo author should be repo owner") 59 + 60 + ok, err = e.IsRepoOwner(barUser, fooRepo) 61 + assert.NoError(t, err) 62 + assert.False(t, ok, "repo collaborator should not be repo owner") 63 + 64 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 65 + assert.NoError(t, err) 66 + assert.True(t, ok, "repo collaborator should be repo collaborator") 67 + 68 + assert.NoError(t, e.DeleteRepo(fooRepo)) 69 + 70 + ok, err = e.IsRepoOwner(barUser, fooRepo) 71 + assert.NoError(t, err) 72 + assert.False(t, ok, "repo owner permission should be deleted after repo deletion") 73 + 74 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 75 + assert.NoError(t, err) 76 + assert.False(t, ok, "repo collaborator permission should be deleted after repo deletion") 77 + } 78 + 79 + func TestRepoCollaboratorPermissions(t *testing.T) { 80 + var ( 81 + e = setup(t) 82 + ok bool 83 + err error 84 + fooRepo = syntax.DID("did:example:coolrepo") 85 + fooUser = syntax.DID("did:plc:foo") 86 + barUser = syntax.DID("did:plc:bar") 87 + ) 88 + 89 + assert.NoError(t, e.SetRepoOwner(fooUser, fooRepo)) 90 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 91 + 92 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 93 + assert.NoError(t, err) 94 + assert.True(t, ok, "should set repo collaborator") 95 + 96 + ok, err = e.IsRepoSecretsAllowed(barUser, fooRepo) 97 + assert.NoError(t, err) 98 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 99 + } 100 + 101 + func TestGetByRole(t *testing.T) { 102 + var ( 103 + e = setup(t) 104 + err error 105 + fooRepo = syntax.DID("did:example:coolrepo") 106 + owner = syntax.DID("did:plc:foo") 107 + collaborator1 = syntax.DID("did:plc:bar") 108 + collaborator2 = syntax.DID("did:plc:baz") 109 + ) 110 + 111 + assert.NoError(t, e.SetRepoOwner(owner, fooRepo)) 112 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 113 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 114 + 115 + collaborators, err := e.GetRepoCollaborators(fooRepo) 116 + assert.NoError(t, err) 117 + assert.ElementsMatch(t, []syntax.DID{ 118 + owner, 119 + collaborator1, 120 + collaborator2, 121 + }, collaborators) 122 + }
+71
rbac/v2/repo.go
··· 1 + package rbac 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + // SetRepoOwner sets owner to given repo 13 + func (e *Enforcer) SetRepoOwner(owner syntax.DID, repo syntax.DID) error { 14 + return e.setRoleForUser(owner.String(), "repo:owner", repo.String()) 15 + } 16 + 17 + // DeleteRepo deletes all role assignments scoped to the repo. 18 + func (e *Enforcer) DeleteRepo(repo syntax.DID) error { 19 + if repo == "" { 20 + // casbin treats an empty field value as match-anything. so just in case. 21 + return fmt.Errorf("DeleteRepo: empty repo did") 22 + } 23 + _, err := e.e.RemoveFilteredGroupingPolicy(2, repo.String()) 24 + return err 25 + } 26 + 27 + // AddRepoCollaborator adds new collaborator to the repo 28 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.DID) error { 29 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 30 + return err 31 + } 32 + 33 + // RemoveRepoCollaborator removes the collaborator from the repo. 34 + // This won't remove inherited roles like repository owner. 35 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.DID) error { 36 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 37 + return err 38 + } 39 + 40 + func (e *Enforcer) GetRepoCollaborators(repo syntax.DID) ([]syntax.DID, error) { 41 + var collaborators []syntax.DID 42 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 43 + if err != nil { 44 + return nil, err 45 + } 46 + for _, m := range members { 47 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 48 + continue 49 + } 50 + collaborators = append(collaborators, syntax.DID(m)) 51 + } 52 + 53 + slices.Sort(collaborators) 54 + return slices.Compact(collaborators), nil 55 + } 56 + 57 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.DID) (bool, error) { 58 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 59 + } 60 + 61 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.DID) (bool, error) { 62 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 63 + } 64 + 65 + func (e *Enforcer) IsRepoSecretsAllowed(user syntax.DID, repo syntax.DID) (bool, error) { 66 + return e.e.Enforce(user.String(), repo.String(), TnglSpindle, tangled.RepoAddSecretNSID) 67 + } 68 + 69 + func (e *Enforcer) IsRepoCiTriggerAllowed(user syntax.DID, repo syntax.DID) (bool, error) { 70 + return e.e.Enforce(user.String(), repo.String(), TnglSpindle, tangled.CiTriggerPipelineNSID) 71 + }