Skip to content

Commit

Permalink
feat(boards2): initial permissions support (gnolang#3151)
Browse files Browse the repository at this point in the history
Related to gnolang#3139

Permissioner interface is defined based on Jae's idea to handle permissioned tasks.
---------

Co-authored-by: Jae Kwon <[email protected]>
  • Loading branch information
2 people authored and x1unix committed Jan 6, 2025
1 parent e31be34 commit a2a9113
Show file tree
Hide file tree
Showing 16 changed files with 729 additions and 159 deletions.
46 changes: 46 additions & 0 deletions examples/gno.land/p/demo/boards2/admindao/admindao.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package admindao

import (
"std"

"gno.land/p/demo/avl"
)

// TODO: Add support for proposals
// TODO: Add support for events

// AdminDAO defines a Boards administration DAO.
type AdminDAO struct {
parent *AdminDAO
members *avl.Tree // string(std.Address) -> struct{}
}

// New creates a new admin DAO.
func New(options ...Option) *AdminDAO {
dao := &AdminDAO{members: avl.NewTree()}
for _, apply := range options {
apply(dao)
}
return dao
}

// Parent returns the parent DAO.
// Null can be returned when DAO has no parent assigned.
func (dao AdminDAO) Parent() *AdminDAO {
return dao.parent
}

// Members returns the list of DAO members.
func (dao AdminDAO) Members() []std.Address {
var members []std.Address
dao.members.Iterate("", "", func(key string, _ interface{}) bool {
members = append(members, std.Address(key))
return false
})
return members
}

// IsMember checks if a user is a member of the DAO.
func (dao AdminDAO) IsMember(user std.Address) bool {
return dao.members.Has(user.String())
}
88 changes: 88 additions & 0 deletions examples/gno.land/p/demo/boards2/admindao/admindao_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package admindao

import (
"std"
"testing"

"gno.land/p/demo/uassert"
"gno.land/p/demo/urequire"
)

func TestNew(t *testing.T) {
cases := []struct {
name string
parent *AdminDAO
members []std.Address
}{
{
name: "with parent",
parent: New(),
members: []std.Address{"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"},
},
{
name: "without parent",
members: []std.Address{"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"},
},
{
name: "multiple members",
members: []std.Address{
"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5",
"g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn",
"g1w4ek2u3jta047h6lta047h6lta047h6l9huexc",
},
},
{
name: "no members",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
options := []Option{WithParent(tc.parent)}
for _, m := range tc.members {
options = append(options, WithMember(m))
}

dao := New(options...)

if tc.parent == nil {
uassert.Equal(t, nil, dao.Parent())
} else {
uassert.NotEqual(t, nil, dao.Parent())
}

urequire.Equal(t, len(tc.members), len(dao.Members()), "dao members")
for i, m := range dao.Members() {
uassert.Equal(t, tc.members[i], m)
}
})
}
}

func TestAdminDAOIsMember(t *testing.T) {
cases := []struct {
name string
member std.Address
dao *AdminDAO
want bool
}{
{
name: "member",
member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn",
dao: New(WithMember("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn")),
want: true,
},
{
name: "not a dao member",
member: "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn",
dao: New(WithMember("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc")),
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.dao.IsMember(tc.member)
uassert.Equal(t, got, tc.want)
})
}
}
7 changes: 7 additions & 0 deletions examples/gno.land/p/demo/boards2/admindao/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module gno.land/p/demo/boards2/admindao

require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/uassert v0.0.0-latest
gno.land/p/demo/urequire v0.0.0-latest
)
20 changes: 20 additions & 0 deletions examples/gno.land/p/demo/boards2/admindao/options.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package admindao

import "std"

// Option configures the AdminDAO.
type Option func(*AdminDAO)

// WithParent assigns a parent DAO.
func WithParent(p *AdminDAO) Option {
return func(dao *AdminDAO) {
dao.parent = p
}
}

// WithMember assigns a member to the DAO.
func WithMember(addr std.Address) Option {
return func(dao *AdminDAO) {
dao.members.Set(addr.String(), struct{}{})
}
}
100 changes: 100 additions & 0 deletions examples/gno.land/r/demo/boards2/acl.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package boards2

import (
"std"

"gno.land/p/demo/avl"
"gno.land/p/demo/boards2/admindao"
)

// TODO: Support to deal with permissions for anonymous users?

const (
RoleOwner Role = "owner"
RoleAdmin = "admin"
RoleModerator = "moderator"
)

type (
// Role defines the type for user roles.
Role string

// ACL or access control list manages user roles and permissions.
ACL struct {
superRole Role
dao *admindao.AdminDAO
users *avl.Tree // string(std.Address) -> []Role
roles *avl.Tree // string(role) -> []Permission
}
)

// NewACL create a new access control list.
func NewACL(dao *admindao.AdminDAO, options ...ACLOption) *ACL {
acl := &ACL{
dao: dao,
roles: avl.NewTree(),
users: avl.NewTree(),
}
for _, apply := range options {
apply(acl)
}
return acl
}

// Roles returns the list of roles.
func (acl ACL) Roles() []Role {
var roles []Role
acl.roles.Iterate("", "", func(name string, _ interface{}) bool {
roles = append(roles, Role(name))
return false
})
return roles
}

// GetUserRoles returns the list of roles assigned to a user.
func (acl ACL) GetUserRoles(user std.Address) []Role {
v, found := acl.users.Get(user.String())
if !found {
return nil
}
return v.([]Role)
}

// HasRole checks if a user has a specific role assigned.
func (acl ACL) HasRole(user std.Address, r Role) bool {
for _, role := range acl.GetUserRoles(user) {
if role == r {
return true
}
}
return false
}

// HasPermission checks if a user has a specific permission.
func (acl ACL) HasPermission(user std.Address, perm Permission) bool {
// TODO: Should we check that the user belongs to the DAO?
for _, r := range acl.GetUserRoles(user) {
v, found := acl.roles.Get(string(r))
if !found {
continue
}

for _, p := range v.([]Permission) {
if p == perm {
return true
}
}
}
return false
}

// WithPermission calls a callback when a user has a specific permission.
// It panics on error.
func (acl ACL) WithPermission(user std.Address, perm Permission, a Args, cb func(Args)) {
if !acl.HasPermission(user, perm) || !acl.dao.IsMember(user) {
panic("unauthorized")
}

// TODO: Support DAO proposals that run the callback on proposal execution
cb(a)
}
30 changes: 30 additions & 0 deletions examples/gno.land/r/demo/boards2/acl_options.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package boards2

import "std"

// ACLOption configures an ACL.
type ACLOption func(*ACL)

// WithSuperRole assigns a super role.
// A super role is one that have all ACL permissions.
// These type of role doesn't need to be mapped to any permission.
func WithSuperRole(r Role) ACLOption {
return func(acl *ACL) {
acl.superRole = r
}
}

// WithUser adds a user to the ACL with optional assigned roles.
func WithUser(user std.Address, roles ...Role) ACLOption {
return func(acl *ACL) {
// TODO: Should we enforce that users are members of the DAO? [acl.dao.IsMember(user)]
acl.users.Set(user.String(), append([]Role(nil), roles...))
}
}

// WithRole add a role to the ACL with one or more assigned permissions.
func WithRole(r Role, p Permission, extra ...Permission) ACLOption {
return func(acl *ACL) {
acl.roles.Set(string(r), append([]Permission{p}, extra...))
}
}
Loading

0 comments on commit a2a9113

Please sign in to comment.