diff --git a/examples/gno.land/p/demo/boards2/admindao/admindao.gno b/examples/gno.land/p/demo/boards2/admindao/admindao.gno new file mode 100644 index 00000000000..4e1d49e435e --- /dev/null +++ b/examples/gno.land/p/demo/boards2/admindao/admindao.gno @@ -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()) +} diff --git a/examples/gno.land/p/demo/boards2/admindao/admindao_test.gno b/examples/gno.land/p/demo/boards2/admindao/admindao_test.gno new file mode 100644 index 00000000000..171f5618892 --- /dev/null +++ b/examples/gno.land/p/demo/boards2/admindao/admindao_test.gno @@ -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) + }) + } +} diff --git a/examples/gno.land/p/demo/boards2/admindao/gno.mod b/examples/gno.land/p/demo/boards2/admindao/gno.mod new file mode 100644 index 00000000000..5067fdbcbcc --- /dev/null +++ b/examples/gno.land/p/demo/boards2/admindao/gno.mod @@ -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 +) diff --git a/examples/gno.land/p/demo/boards2/admindao/options.gno b/examples/gno.land/p/demo/boards2/admindao/options.gno new file mode 100644 index 00000000000..66ee99f1cba --- /dev/null +++ b/examples/gno.land/p/demo/boards2/admindao/options.gno @@ -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{}{}) + } +} diff --git a/examples/gno.land/r/demo/boards2/acl.gno b/examples/gno.land/r/demo/boards2/acl.gno new file mode 100644 index 00000000000..631591e1214 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/acl.gno @@ -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) +} diff --git a/examples/gno.land/r/demo/boards2/acl_options.gno b/examples/gno.land/r/demo/boards2/acl_options.gno new file mode 100644 index 00000000000..6a481470766 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/acl_options.gno @@ -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...)) + } +} diff --git a/examples/gno.land/r/demo/boards2/acl_test.gno b/examples/gno.land/r/demo/boards2/acl_test.gno new file mode 100644 index 00000000000..2b765920a45 --- /dev/null +++ b/examples/gno.land/r/demo/boards2/acl_test.gno @@ -0,0 +1,271 @@ +package boards2 + +import ( + "std" + "testing" + + "gno.land/p/demo/boards2/admindao" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestNewACL(t *testing.T) { + roles := []string{"a", "b"} + dao := admindao.New() + + acl := NewACL(dao, WithRole("a", "permission1"), WithRole("b", "permission2")) + + urequire.Equal(t, len(roles), len(acl.Roles()), "roles") + for i, r := range acl.Roles() { + uassert.Equal(t, roles[i], string(r)) + } +} + +func TestACLWithPermission(t *testing.T) { + cases := []struct { + name string + user std.Address + permission Permission + args Args + acl *ACL + err string + called bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + acl: NewACL( + admindao.New(admindao.WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithRole("foo", "bar"), + ), + called: true, + }, + { + name: "ok with arguments", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + args: Args{"a", "b"}, + acl: NewACL( + admindao.New(admindao.WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithRole("foo", "bar"), + ), + called: true, + }, + { + name: "no permission", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + acl: NewACL( + admindao.New(admindao.WithMember("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), + WithRole("foo", "bar"), + ), + err: "unauthorized", + }, + { + name: "is not a DAO member", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithRole("foo", "bar"), + ), + err: "unauthorized", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var ( + called bool + args Args + ) + + callback := func(a Args) { + args = a + called = true + } + + testCaseFn := func() { + tc.acl.WithPermission(tc.user, tc.permission, tc.args, callback) + } + + if tc.err != "" { + urequire.PanicsWithMessage(t, tc.err, testCaseFn, "panic") + return + } else { + urequire.NotPanics(t, testCaseFn, "no panic") + } + + urequire.Equal(t, tc.called, called, "callback called") + urequire.Equal(t, len(tc.args), len(args), "args count") + for i, a := range args { + uassert.Equal(t, tc.args[i].(string), a.(string)) + } + }) + } +} + +func TestACLGetUserRoles(t *testing.T) { + cases := []struct { + name string + user std.Address + roles []string + acl *ACL + }{ + { + name: "single role", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin"}, + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin")), + }, + { + name: "multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin", "foo", "bar"}, + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo", "bar")), + }, + { + name: "without roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + }, + { + name: "not a user", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + acl: NewACL(admindao.New()), + }, + { + name: "multiple users", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + roles: []string{"admin"}, + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin"), + WithUser("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "admin"), + WithUser("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", "admin", "bar"), + ), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + roles := tc.acl.GetUserRoles(tc.user) + + urequire.Equal(t, len(tc.roles), len(roles), "user role count") + for i, r := range roles { + uassert.Equal(t, tc.roles[i], string(r)) + } + }) + } +} + +func TestACLHasRole(t *testing.T) { + cases := []struct { + name string + user std.Address + role Role + acl *ACL + want bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "admin", + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin")), + want: true, + }, + { + name: "ok with multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "foo", + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "admin", "foo")), + want: true, + }, + { + name: "user without roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5")), + }, + { + name: "has no role", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + role: "bar", + acl: NewACL(admindao.New(), WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo")), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.acl.HasRole(tc.user, tc.role) + uassert.Equal(t, got, tc.want) + }) + } +} + +func TestACLHasPermission(t *testing.T) { + cases := []struct { + name string + user std.Address + permission Permission + acl *ACL + want bool + }{ + { + name: "ok", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithRole("foo", "bar"), + ), + want: true, + }, + { + name: "ok with multiple users", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "bar", + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithUser("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", "foo"), + WithRole("foo", "bar"), + ), + want: true, + }, + { + name: "ok with multiple roles", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "other", + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo", "baz"), + WithRole("foo", "bar"), + WithRole("baz", "other"), + ), + want: true, + }, + { + name: "no permission", + user: "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + permission: "other", + acl: NewACL( + admindao.New(), + WithUser("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", "foo"), + WithRole("foo", "bar"), + ), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.acl.HasPermission(tc.user, tc.permission) + uassert.Equal(t, got, tc.want) + }) + } +} diff --git a/examples/gno.land/r/demo/boards2/board.gno b/examples/gno.land/r/demo/boards2/board.gno index 8942ad8f2df..64cc8730d6b 100644 --- a/examples/gno.land/r/demo/boards2/board.gno +++ b/examples/gno.land/r/demo/boards2/board.gno @@ -1,7 +1,6 @@ package boards2 import ( - "regexp" "std" "strconv" "strings" @@ -11,8 +10,6 @@ import ( "gno.land/p/moul/txlink" ) -var reBoardName = regexp.MustCompile(`^[a-z]{3}[_a-z0-9]{0,23}[0-9]{3}$`) - type BoardID uint64 func (id BoardID) String() string { @@ -34,8 +31,6 @@ type Board struct { } func newBoard(id BoardID, name string, creator std.Address) *Board { - assertIsBoardName(name) - if gBoardsByName.Has(name) { panic("board already exists") } @@ -97,21 +92,6 @@ func (board *Board) DeleteThread(pid PostID) { } } -// TODO: Change HasPermission to use a new authorization interface's `CanDo()` -func (board *Board) HasPermission(addr std.Address, perm Permission) bool { - if board.creator == addr { - switch perm { - case PermissionEdit: - return true - case PermissionDelete: - return true - default: - return false - } - } - return false -} - func (board *Board) Render() string { s := "\\[" + newLink("post", board.GetPostFormURL()) + "]\n\n" if board.threads.Size() > 0 { @@ -140,9 +120,3 @@ func (board *Board) GetURLFromReplyID(threadID, replyID PostID) string { func (board *Board) GetPostFormURL() string { return txlink.URL("CreateThread", "bid", board.id.String()) } - -func assertIsBoardName(name string) { - if !reBoardName.MatchString(name) { - panic("invalid board name: " + name) - } -} diff --git a/examples/gno.land/r/demo/boards2/board_test.gno b/examples/gno.land/r/demo/boards2/board_test.gno index d4fcc212ed5..88996928fc3 100644 --- a/examples/gno.land/r/demo/boards2/board_test.gno +++ b/examples/gno.land/r/demo/boards2/board_test.gno @@ -77,58 +77,6 @@ func TestBoard_DeleteThread(t *testing.T) { uassert.False(t, ok) } -func TestBoard_HasPermission(t *testing.T) { - var ( - alice std.Address = "012345" - bob std.Address = "cafebabe" - ) - - cases := []struct { - label string - creator std.Address - actor std.Address - perm Permission - expect bool - }{ - { - label: "creator should be able to edit board", - expect: true, - creator: alice, - actor: alice, - perm: PermissionEdit, - }, - { - label: "creator should be able to delete board", - expect: true, - creator: alice, - actor: alice, - perm: PermissionDelete, - }, - { - label: "guest shouldn't be able to edit boards", - expect: false, - creator: alice, - actor: bob, - perm: PermissionEdit, - }, - { - label: "guest shouldn't be able to delete boards", - expect: false, - creator: alice, - actor: bob, - perm: PermissionDelete, - }, - } - - for i, c := range cases { - t.Run(c.label, func(t *testing.T) { - b := newBoard(BoardID(i), "test12345", c.creator) - got := b.HasPermission(c.actor, c.perm) - uassert.Equal(t, c.expect, got) - }) - } -} - var boardUrlPrefix = strings.TrimPrefix(std.CurrentRealm().PkgPath(), "gno.land") func TestBoard_GetURLFromThreadID(t *testing.T) { diff --git a/examples/gno.land/r/demo/boards2/boards.gno b/examples/gno.land/r/demo/boards2/boards.gno index f50d779426f..752f63bc438 100644 --- a/examples/gno.land/r/demo/boards2/boards.gno +++ b/examples/gno.land/r/demo/boards2/boards.gno @@ -1,16 +1,37 @@ package boards2 -import "gno.land/p/demo/avl" +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/boards2/admindao" +) // Default minimum fee in ugnot required for anonymous users const defaultAnonymousFee = 100_000_000 var ( + gAuth Permissioner gLastBoardID BoardID gBoardsByID avl.Tree // string(id) -> *Board gBoardsByName avl.Tree // string(name) -> *Board ) +func init() { + // TODO: Decide how to initialize realm owner (DAO owner member) + admin := std.Address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") // @test1 + // TODO: Implement support for assigning a new realm DAO + dao := admindao.New(admindao.WithMember(admin)) + gAuth = NewACL( + dao, + WithSuperRole(RoleOwner), + // TODO: Assign roles and permissions + // WithRole(RoleAdmin, permissions...), + // WithRole(RoleModerator, permissions...), + WithUser(admin, RoleOwner), + ) +} + // incGetBoardID returns a new board ID. func incGetBoardID() BoardID { gLastBoardID++ diff --git a/examples/gno.land/r/demo/boards2/gno.mod b/examples/gno.land/r/demo/boards2/gno.mod index 3872857d2b7..6c8431f592c 100644 --- a/examples/gno.land/r/demo/boards2/gno.mod +++ b/examples/gno.land/r/demo/boards2/gno.mod @@ -2,10 +2,12 @@ module gno.land/r/demo/boards2 require ( gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/boards2/admindao v0.0.0-latest gno.land/p/demo/mux v0.0.0-latest gno.land/p/demo/testutils v0.0.0-latest gno.land/p/demo/uassert v0.0.0-latest gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/demo/urequire v0.0.0-latest gno.land/p/moul/txlink v0.0.0-latest gno.land/r/demo/users v0.0.0-latest ) diff --git a/examples/gno.land/r/demo/boards2/permission.gno b/examples/gno.land/r/demo/boards2/permission.gno new file mode 100644 index 00000000000..70ce929c38d --- /dev/null +++ b/examples/gno.land/r/demo/boards2/permission.gno @@ -0,0 +1,36 @@ +package boards2 + +import ( + "errors" + "std" +) + +const ( + PermissionBoardCreate Permission = "board:create" + PermissionThreadCreate = "thread:create" + PermissionThreadEdit = "thread:edit" + PermissionThreadDelete = "thread:delete" + PermissionThreadRepost = "thread:repost" + PermissionReplyDelete = "reply:delete" +) + +// ErrUnauzorized indicates that user doesn't have a required permission. +var ErrUnauzorized = errors.New("unauthorized") + +type ( + // Permission defines the type for permissions. + Permission string + + // Args is a list of generic arguments. + Args []interface{} + + // Permissioner define an interface to for permissioned execution. + Permissioner interface { + // HasPermission checks if a user has a specific permission. + HasPermission(std.Address, Permission) bool + + // WithPermission calls a callback when a user has a specific permission. + // It panics on error. + WithPermission(std.Address, Permission, Args, func(Args)) + } +) diff --git a/examples/gno.land/r/demo/boards2/post.gno b/examples/gno.land/r/demo/boards2/post.gno index 38035ddaec3..655e7259512 100644 --- a/examples/gno.land/r/demo/boards2/post.gno +++ b/examples/gno.land/r/demo/boards2/post.gno @@ -157,6 +157,7 @@ func (post *Post) DeleteReply(replyID PostID) error { return errors.New("reply not found in thread") } + // TODO: Remove child replies too! reply := v.(*Post) if reply.parentID != post.id { parent, _ := post.GetReply(reply.parentID) @@ -167,22 +168,6 @@ func (post *Post) DeleteReply(replyID PostID) error { return nil } -// TODO: Change HasPermission to use a new authorization interface's `CanDo()` -func (post *Post) HasPermission(addr std.Address, perm Permission) bool { - if post.creator == addr { - switch perm { - case PermissionEdit: - return true - case PermissionDelete: - return true - default: - return false - } - } - // post notes inherit permissions of the board. - return post.board.HasPermission(addr, perm) -} - func (post *Post) GetSummary() string { return summaryOf(post.body, 80) } diff --git a/examples/gno.land/r/demo/boards2/post_test.gno b/examples/gno.land/r/demo/boards2/post_test.gno index 42529da8483..05888f9e297 100644 --- a/examples/gno.land/r/demo/boards2/post_test.gno +++ b/examples/gno.land/r/demo/boards2/post_test.gno @@ -117,12 +117,6 @@ func TestNewThread(t *testing.T) { uassert.Equal(t, replyURL, thread.GetReplyFormURL()) uassert.Equal(t, repostURL, thread.GetRepostFormURL()) uassert.Equal(t, deleteURL, thread.GetDeleteFormURL()) - uassert.True(t, thread.HasPermission(creator, PermissionEdit)) - uassert.True(t, thread.HasPermission(creator, PermissionDelete)) - uassert.False(t, thread.HasPermission(creator, Permission("unknown"))) - uassert.False(t, thread.HasPermission(member, PermissionEdit)) - uassert.False(t, thread.HasPermission(member, PermissionDelete)) - uassert.False(t, thread.HasPermission(member, Permission("unknown"))) } func TestThreadAddReply(t *testing.T) { @@ -280,12 +274,6 @@ func TestNewReply(t *testing.T) { uassert.Equal(t, url, reply.GetURL()) uassert.Equal(t, replyURL, reply.GetReplyFormURL()) uassert.Equal(t, deleteURL, reply.GetDeleteFormURL()) - uassert.True(t, reply.HasPermission(creator, PermissionEdit)) - uassert.True(t, reply.HasPermission(creator, PermissionDelete)) - uassert.False(t, reply.HasPermission(creator, Permission("unknown"))) - uassert.False(t, reply.HasPermission(member, PermissionEdit)) - uassert.False(t, reply.HasPermission(member, PermissionDelete)) - uassert.False(t, reply.HasPermission(member, Permission("unknown"))) } func TestReplyAddReply(t *testing.T) { diff --git a/examples/gno.land/r/demo/boards2/public.gno b/examples/gno.land/r/demo/boards2/public.gno index 5cf1f1ae08d..e3cb3066f3d 100644 --- a/examples/gno.land/r/demo/boards2/public.gno +++ b/examples/gno.land/r/demo/boards2/public.gno @@ -2,6 +2,7 @@ package boards2 import ( "std" + "strings" "gno.land/r/demo/users" ) @@ -17,21 +18,38 @@ func GetBoardIDFromName(name string) (BoardID, bool) { func CreateBoard(name string) BoardID { assertIsUserCall() - caller := std.GetOrigCaller() - assertIsNotAnonymousCaller(caller) + name = strings.TrimSpace(name) + if name == "" { + panic("board name is empty") + } + + // TODO: Now that registered user requirement is removed must define a way to avoid + // increasing the IDs. Require a fee? + // Or we have to change the way boards are created, which could be async. + caller := std.GetOrigCaller() id := incGetBoardID() - board := newBoard(id, name, caller) - gBoardsByID.Set(id.Key(), board) - gBoardsByName.Set(name, board) - return board.id + args := Args{name, id} + gAuth.WithPermission(caller, PermissionBoardCreate, args, func(a Args) { + // TODO: Do the callback really need the args or we could have the same result directly referencing? + name := a[0].(string) + id := a[1].(BoardID) + board := newBoard(id, name, caller) + gBoardsByID.Set(id.Key(), board) + gBoardsByName.Set(name, board) + }) + return id } -func CreateThread(bid BoardID, title string, body string) PostID { +func CreateThread(bid BoardID, title, body string) PostID { assertIsUserCall() caller := std.GetOrigCaller() - assertAnonymousCallerFeeReceived(caller) + assertHasPermission(caller, PermissionThreadCreate) // TODO: Who can create threads? + assertAnonymousCallerFeeReceived(caller) // TODO: Do we require a fee to anonymous users? + assertBoardExists(bid) + + // TODO: Assert that caller is a board member (when board type is invite only) board := mustGetBoard(bid) thread := board.AddThread(caller, title, body) @@ -42,75 +60,109 @@ func CreateReply(bid BoardID, threadID, replyID PostID, body string) PostID { assertIsUserCall() caller := std.GetOrigCaller() - assertAnonymousCallerFeeReceived(caller) + assertAnonymousCallerFeeReceived(caller) // TODO: Do we require a fee to anonymous users? + + board := mustGetBoard(bid) + thread := mustGetThread(board, threadID) - var ( - reply *Post - board = mustGetBoard(bid) - thread = mustGetThread(board, threadID) - ) + // TODO: Assert thread is not locked + // TODO: Assert that caller is a board member (when board type is invite only) + var reply *Post if replyID == threadID { + // When the parent reply is the thread just add reply to thread reply = thread.AddReply(caller, body) } else { + // Try to get parent reply and add a new child reply post := mustGetReply(thread, replyID) reply = post.AddReply(caller, body) } return reply.id } -func CreateRepost(bid BoardID, threadID PostID, title string, body string, dstBoardID BoardID) PostID { +func CreateRepost(bid BoardID, threadID PostID, title, body string, dstBoardID BoardID) PostID { assertIsUserCall() caller := std.GetOrigCaller() assertAnonymousCallerFeeReceived(caller) + assertBoardExists(dstBoardID) board := mustGetBoard(bid) if board.IsPrivate() { panic("cannot repost from a private board") } + // TODO: Assert that board allows reposts? + // TODO: Assert that caller is member of both boards (when board types are invite only) + + assertThreadExists(board, threadID) + dst := mustGetBoard(dstBoardID) thread := mustGetThread(board, threadID) repost := thread.AddRepostTo(caller, title, body, dst) return repost.id } -func DeleteThread(bid BoardID, threadID PostID, reason string) { +func DeleteThread(bid BoardID, threadID PostID) { assertIsUserCall() board := mustGetBoard(bid) - thread := mustGetThread(board, threadID) + assertThreadExists(board, threadID) caller := std.GetOrigCaller() - assertUserHasPermission(thread, caller, PermissionDelete) - - board.DeleteThread(threadID) + args := Args{bid, threadID} + gAuth.WithPermission(caller, PermissionThreadDelete, args, func(a Args) { + bid := a[0].(BoardID) + board := mustGetBoard(bid) + + threadID := a[1].(PostID) + board.DeleteThread(threadID) + }) } -func DeleteReply(bid BoardID, threadID, replyID PostID, reason string) { +func DeleteReply(bid BoardID, threadID, replyID PostID) { assertIsUserCall() board := mustGetBoard(bid) thread := mustGetThread(board, threadID) - reply := mustGetReply(thread, replyID) + assertReplyExists(thread, replyID) + + // TODO: Hide reply when the caller is the owner of the reply (remove WithPermission call for now) + // TODO: Support removing reply and children though proposals? (WithPermission) caller := std.GetOrigCaller() - assertUserHasPermission(reply, caller, PermissionDelete) + args := Args{bid, threadID, replyID} + gAuth.WithPermission(caller, PermissionReplyDelete, args, func(a Args) { + bid := a[0].(BoardID) + board := mustGetBoard(bid) - thread.DeleteReply(replyID) + threadID := a[1].(PostID) + thread := mustGetThread(board, threadID) + + replyID := a[2].(PostID) + thread.DeleteReply(replyID) + }) } func EditThread(bid BoardID, threadID PostID, title, body string) { assertIsUserCall() board := mustGetBoard(bid) - thread := mustGetThread(board, threadID) + assertThreadExists(board, threadID) caller := std.GetOrigCaller() - assertUserHasPermission(thread, caller, PermissionEdit) - - thread.Update(title, body) + args := Args{bid, threadID, title, body} + gAuth.WithPermission(caller, PermissionThreadEdit, args, func(a Args) { + bid := a[0].(BoardID) + board := mustGetBoard(bid) + + threadID := a[1].(PostID) + thread := mustGetThread(board, threadID) + + title := a[2].(string) + body := a[3].(string) + thread.Update(title, body) + }) } func EditReply(bid BoardID, threadID, replyID PostID, title, body string) { @@ -118,12 +170,14 @@ func EditReply(bid BoardID, threadID, replyID PostID, title, body string) { board := mustGetBoard(bid) thread := mustGetThread(board, threadID) - post := mustGetReply(thread, replyID) - + reply := mustGetReply(thread, replyID) caller := std.GetOrigCaller() - assertUserHasPermission(post, caller, PermissionEdit) + if caller != reply.GetCreator() { + panic("only the reply creator is allowed to edit it") + } - post.Update(title, body) + // TODO: Should we have a history of previous reply contents? + reply.Update(title, body) } func assertIsUserCall() { @@ -132,20 +186,12 @@ func assertIsUserCall() { } } -func assertIsNotAnonymousCaller(caller std.Address) { - // Caller is anonymous if doesn't have a registered user name - if users.GetUserByAddress(caller) == nil { - panic("unauthorized") - } -} - func assertAnonymousFeeReceived() { sent := std.GetOrigSend() fee := std.NewCoin("ugnot", int64(defaultAnonymousFee)) if len(sent) == 0 || sent[0].IsLT(fee) { panic("please register a user, otherwise a minimum fee of " + fee.String() + " is required") } - return } func assertAnonymousCallerFeeReceived(caller std.Address) { @@ -154,8 +200,26 @@ func assertAnonymousCallerFeeReceived(caller std.Address) { } } -func assertUserHasPermission(post *Post, user std.Address, p Permission) { - if !post.HasPermission(user, p) { +func assertHasPermission(user std.Address, p Permission) { + if !gAuth.HasPermission(user, p) { panic("unauthorized") } } + +func assertBoardExists(id BoardID) { + if _, found := getBoard(id); !found { + panic("board not found: " + id.String()) + } +} + +func assertThreadExists(b *Board, threadID PostID) { + if _, found := b.GetThread(threadID); !found { + panic("thread not found: " + threadID.String()) + } +} + +func assertReplyExists(thread *Post, replyID PostID) { + if _, found := thread.GetReply(replyID); !found { + panic("reply not found: " + replyID.String()) + } +} diff --git a/examples/gno.land/r/demo/boards2/role.gno b/examples/gno.land/r/demo/boards2/role.gno deleted file mode 100644 index b33f46c6472..00000000000 --- a/examples/gno.land/r/demo/boards2/role.gno +++ /dev/null @@ -1,10 +0,0 @@ -package boards2 - -// TODO: Rename file to "auth.gno" and define a new interface - -type Permission string - -const ( - PermissionEdit Permission = "edit" - PermissionDelete Permission = "delete" -)