From 11dd6b5b53b9babeebdb4a85fd16684715372a9a Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Thu, 18 Dec 2025 21:14:09 +0900 Subject: [PATCH] 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: Signed-off-by: Seongmin Lee --- rbac2/bytesadapter/adapter.go | 52 ++++++++++++ rbac2/rbac2.go | 139 +++++++++++++++++++++++++++++++ rbac2/rbac2_test.go | 150 ++++++++++++++++++++++++++++++++++ rbac2/repo.go | 91 +++++++++++++++++++++ rbac2/spindle.go | 29 +++++++ rbac2/tangled_policy.csv | 19 +++++ 6 files changed, 480 insertions(+) create mode 100644 rbac2/bytesadapter/adapter.go create mode 100644 rbac2/rbac2.go create mode 100644 rbac2/rbac2_test.go create mode 100644 rbac2/repo.go create mode 100644 rbac2/spindle.go create mode 100644 rbac2/tangled_policy.csv 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} -- 2.51.2