diff --git a/rbac2/bytesadapter/adapter.go b/rbac2/bytesadapter/adapter.go new file mode 100644 index 00000000..bddf570b --- /dev/null +++ b/rbac2/bytesadapter/adapter.go @@ -0,0 +1,52 @@ +package bytesadapter + +import ( + "bufio" + "bytes" + "errors" + "strings" + + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/persist" +) + +var ( + errNotImplemented = errors.New("not implemented") +) + +type Adapter struct { + b []byte +} + +var _ persist.Adapter = &Adapter{} + +func NewAdapter(b []byte) *Adapter { + return &Adapter{b} +} + +func (a *Adapter) LoadPolicy(model model.Model) error { + scanner := bufio.NewScanner(bytes.NewReader(a.b)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if err := persist.LoadPolicyLine(line, model); err != nil { + return err + } + } + return scanner.Err() +} + +func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { + return errNotImplemented +} + +func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { + return errNotImplemented +} + +func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { + return errNotImplemented +} + +func (a *Adapter) SavePolicy(model model.Model) error { + return errNotImplemented +} diff --git a/rbac2/rbac2.go b/rbac2/rbac2.go new file mode 100644 index 00000000..4d42717c --- /dev/null +++ b/rbac2/rbac2.go @@ -0,0 +1,139 @@ +package rbac2 + +import ( + "database/sql" + _ "embed" + "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/rbac2/bytesadapter" +) + +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 +} + +//go:embed tangled_policy.csv +var tangledPolicy []byte + +func NewEnforcer(path string) (*Enforcer, error) { + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") + if err != nil { + return nil, err + } + return NewEnforcerWithDB(db) +} + +func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { + m, err := model.NewModelFromString(Model) + if err != nil { + return nil, err + } + + a, err := adapter.NewAdapter(db, "sqlite3", "acl") + if err != nil { + return nil, err + } + + // // PATCH: create unique index to make `AddPoliciesEx` work + // _, err = db.Exec(fmt.Sprintf( + // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, + // tableName, + // )) + // if err != nil { + // return nil, err + // } + + e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error + // e.EnableLog(true) + + // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first + // and then override the adapter to sql-adapter. + // `e.SetModel(m)` after init doesn't work for some reason + if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { + return nil, err + } + + // load dynamic policy from db + e.EnableAutoSave(false) + if err := a.LoadPolicy(e.GetModel()); err != nil { + return nil, err + } + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) + e.BuildRoleLinks() + e.SetAdapter(a) + e.EnableAutoSave(true) + + return &Enforcer{e}, nil +} + +// CaptureModel returns copy of current model. Used for testing +func (e *Enforcer) CaptureModel() model.Model { + return e.e.GetModel().Copy() +} + +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/rbac2/rbac2_test.go b/rbac2/rbac2_test.go new file mode 100644 index 00000000..5feeab02 --- /dev/null +++ b/rbac2/rbac2_test.go @@ -0,0 +1,150 @@ +package rbac2_test + +import ( + "database/sql" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "tangled.org/core/rbac2" +) + +func setup(t *testing.T) *rbac2.Enforcer { + enforcer, err := rbac2.NewEnforcer(":memory:") + assert.NoError(t, err) + + return enforcer +} + +func TestNewEnforcer(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") + assert.NoError(t, err) + + enforcer1, err := rbac2.NewEnforcerWithDB(db) + assert.NoError(t, err) + enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) + model1 := enforcer1.CaptureModel() + + enforcer2, err := rbac2.NewEnforcerWithDB(db) + assert.NoError(t, err) + model2 := enforcer2.CaptureModel() + + // model1.GetLogger().EnableLog(true) + // model1.PrintModel() + // model1.PrintPolicy() + // model1.GetLogger().EnableLog(false) + + model2.GetLogger().EnableLog(true) + model2.PrintModel() + model2.PrintPolicy() + model2.GetLogger().EnableLog(false) + + assert.Equal(t, model1, model2) +} + +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/rbac2/repo.go b/rbac2/repo.go new file mode 100644 index 00000000..6908caea --- /dev/null +++ b/rbac2/repo.go @@ -0,0 +1,91 @@ +package rbac2 + +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/rbac2/spindle.go b/rbac2/spindle.go new file mode 100644 index 00000000..d96eaa0c --- /dev/null +++ b/rbac2/spindle.go @@ -0,0 +1,29 @@ +package rbac2 + +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() +} diff --git a/rbac2/tangled_policy.csv b/rbac2/tangled_policy.csv new file mode 100644 index 00000000..6b5b1402 --- /dev/null +++ b/rbac2/tangled_policy.csv @@ -0,0 +1,19 @@ +#, policies +#, sub, dom, obj, act +p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write +p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write +p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write +p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write + +p, server:owner, /knot/{did}, /member, write +p, server:member, /knot/{did}, /git, write + +p, server:owner, /spindle/{did}, /member, write + + +#, group policies +#, sub, role, dom +g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} + +g, server:owner, server:member, /knot/{did} +g, server:owner, server:member, /spindle/{did}