diff --git a/rbac/v2/rbac.go b/rbac/v2/rbac.go new file mode 100644 index 00000000..e2dc1b49 --- /dev/null +++ b/rbac/v2/rbac.go @@ -0,0 +1,119 @@ +package rbac + +import ( + "database/sql" + "slices" + + adapter "github.com/Blank-Xu/sql-adapter" + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/util" + "tangled.org/core/api/tangled" +) + +const ( + TnglSpindle = "tngl_spindle" + + Model = ` +[request_definition] +r = sub, dom, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub, r.dom) && r.obj == p.obj && r.act == p.act +` +) + +type Enforcer struct { + e *casbin.SyncedEnforcer +} + +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&_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, err + } + + a, err := adapter.NewAdapter(db, "sqlite3", "acl_2") + if err != nil { + return nil, err + } + + e, err := casbin.NewSyncedEnforcer(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.SyncedEnforcer) error { + // policies + _, err := e.AddPoliciesEx([][]string{ + // sub | obj | act + {"repo:collaborator", TnglSpindle, tangled.RepoAddSecretNSID}, + {"repo:collaborator", TnglSpindle, tangled.RepoRemoveSecretNSID}, + {"repo:collaborator", TnglSpindle, tangled.RepoListSecretsNSID}, + {"repo:collaborator", TnglSpindle, tangled.CiCancelPipelineNSID}, + {"repo:collaborator", TnglSpindle, tangled.CiTriggerPipelineNSID}, + }) + if err != nil { + return err + } + + // grouping policies + e.AddNamedDomainMatchingFunc("g", "keyMatch", util.KeyMatch) + _, err = e.AddGroupingPoliciesEx([][]string{ + // sub | role | dom + {"repo:owner", "repo:collaborator", "*"}, // repo owner is collaborator + }) + return err +} + +func (e *Enforcer) EnableAutoSave(autoSave bool) { + e.e.EnableAutoSave(autoSave) +} + +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 + } + return slices.Contains(roles, role), 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 +} diff --git a/rbac/v2/rbac_test.go b/rbac/v2/rbac_test.go new file mode 100644 index 00000000..457bf88a --- /dev/null +++ b/rbac/v2/rbac_test.go @@ -0,0 +1,122 @@ +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.DID("did:example:coolrepo") + fooUser = syntax.DID("did:plc:foo") + ) + + assert.NoError(t, e.SetRepoOwner(fooUser, fooRepo)) + + ok, err = e.IsRepoOwner(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo author should be repo owner") + + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo owner should inherit role role:collaborator") + + ok, err = e.IsRepoSecretsAllowed(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo owner should inherit collaborator permissions") +} + +func TestRepoTeardown(t *testing.T) { + var ( + e = setup(t) + ok bool + err error + fooRepo = syntax.DID("did:example:coolrepo") + fooUser = syntax.DID("did:example:foo") + barUser = syntax.DID("did:example:bar") + ) + + assert.NoError(t, e.SetRepoOwner(fooUser, fooRepo)) + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) + + ok, err = e.IsRepoOwner(fooUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo author should be repo owner") + + ok, err = e.IsRepoOwner(barUser, fooRepo) + assert.NoError(t, err) + assert.False(t, ok, "repo collaborator should not be repo owner") + + ok, err = e.IsRepoCollaborator(barUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo collaborator should be repo collaborator") + + assert.NoError(t, e.DeleteRepo(fooRepo)) + + ok, err = e.IsRepoOwner(barUser, fooRepo) + assert.NoError(t, err) + assert.False(t, ok, "repo owner permission should be deleted after repo deletion") + + ok, err = e.IsRepoCollaborator(barUser, fooRepo) + assert.NoError(t, err) + assert.False(t, ok, "repo collaborator permission should be deleted after repo deletion") +} + +func TestRepoCollaboratorPermissions(t *testing.T) { + var ( + e = setup(t) + ok bool + err error + fooRepo = syntax.DID("did:example:coolrepo") + fooUser = syntax.DID("did:plc:foo") + barUser = syntax.DID("did:plc:bar") + ) + + assert.NoError(t, e.SetRepoOwner(fooUser, 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.IsRepoSecretsAllowed(barUser, fooRepo) + assert.NoError(t, err) + assert.True(t, ok, "repo collaborator should be able to edit repo settings") +} + +func TestGetByRole(t *testing.T) { + var ( + e = setup(t) + err error + fooRepo = syntax.DID("did:example:coolrepo") + owner = syntax.DID("did:plc:foo") + collaborator1 = syntax.DID("did:plc:bar") + collaborator2 = syntax.DID("did:plc:baz") + ) + + assert.NoError(t, e.SetRepoOwner(owner, 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) +} diff --git a/rbac/v2/repo.go b/rbac/v2/repo.go new file mode 100644 index 00000000..e13c0ef9 --- /dev/null +++ b/rbac/v2/repo.go @@ -0,0 +1,71 @@ +package rbac + +import ( + "fmt" + "slices" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" +) + +// SetRepoOwner sets owner to given repo +func (e *Enforcer) SetRepoOwner(owner syntax.DID, repo syntax.DID) error { + return e.setRoleForUser(owner.String(), "repo:owner", repo.String()) +} + +// DeleteRepo deletes all role assignments scoped to the repo. +func (e *Enforcer) DeleteRepo(repo syntax.DID) error { + if repo == "" { + // casbin treats an empty field value as match-anything. so just in case. + return fmt.Errorf("DeleteRepo: empty repo did") + } + _, err := e.e.RemoveFilteredGroupingPolicy(2, repo.String()) + return err +} + +// AddRepoCollaborator adds new collaborator to the repo +func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.DID) error { + _, 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.DID) error { + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) + return err +} + +func (e *Enforcer) GetRepoCollaborators(repo syntax.DID) ([]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.DID) (bool, error) { + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) +} + +func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.DID) (bool, error) { + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) +} + +func (e *Enforcer) IsRepoSecretsAllowed(user syntax.DID, repo syntax.DID) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), TnglSpindle, tangled.RepoAddSecretNSID) +} + +func (e *Enforcer) IsRepoCiTriggerAllowed(user syntax.DID, repo syntax.DID) (bool, error) { + return e.e.Enforce(user.String(), repo.String(), TnglSpindle, tangled.CiTriggerPipelineNSID) +}