diff --git a/examples/gno.land/p/demo/authctx/authctx.gno b/examples/gno.land/p/demo/authctx/authctx.gno new file mode 100644 index 00000000000..dd94c2e6e28 --- /dev/null +++ b/examples/gno.land/p/demo/authctx/authctx.gno @@ -0,0 +1,178 @@ +// Package authctx offers a versatile framework for handling authentication contexts. +// It empowers the management of authentication contexts for users, realms, delegated calls, +// and potentially extends its capabilities to non-address entities and more. +package authctx + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// AuthContext is an interface that represents an authentication context. +type AuthContext interface { + Addr() std.Address + // String returns a unique and human-readable identifier, typically in + // the format ":[/opts]". + String() string + HasRole(role string) bool +} + +// AuthFunc signature should always match AuthContext.HasRole. +type AuthFunc func(role string) bool // TODO: switch to PBAC? +func AuthorizeAll() AuthFunc { return func(_ string) bool { return true } } +func AuthorizeNone() AuthFunc { return func(_ string) bool { return false } } +func AuthorizeRoles(roles ...string) AuthFunc { + whitelist := map[string]bool{} + for _, role := range roles { + whitelist[role] = true + } + return func(check string) bool { + return whitelist[check] + } +} + +// PermWrapper wraps an existing AuthContext to reduce its permissions while +// retaining previous ones. +type scopedAuthContext struct { + parent AuthContext + authOverlay AuthFunc +} + +func (s scopedAuthContext) Addr() std.Address { return s.parent.Addr() } +func (s scopedAuthContext) String() string { return s.parent.String() } // XXX: +"/scoped"? +func (s scopedAuthContext) HasRole(role string) bool { + return s.authOverlay(role) && s.parent.HasRole(role) +} + +func NewScopedAuthContext(ctx AuthContext, authOverlay AuthFunc) AuthContext { + return &scopedAuthContext{ + parent: ctx, + authOverlay: authOverlay, + } +} + +// TODO: Add a helper to create an exposed helper allowing remote contracts to compose their authentication context. + +// origAuthContext represents an authentication context based on `std.GetOrigCaller`. +// It represents the address of the account used to create the transrole. +// This is true even if there are multiple packages and realms called in between. +type origAuthContext struct { + addr std.Address +} + +func NewOrigAuthContext() AuthContext { + return origAuthContext{addr: std.GetOrigCaller()} +} +func (a origAuthContext) Addr() std.Address { return a.addr } +func (a origAuthContext) String() string { return "orig:" + string(a.Addr()) } +func (a origAuthContext) HasRole(role string) bool { return true } + +// prevAuthContext represents an authentication context based on `std.PrevRealm()`. +// It represents the address of the previous realm in the stack. +// This can be another realm or the caller (same as OrigAuthContext) in the case +// of a direct call to the helper without an intermediary realm. +type prevAuthContext struct { + addr std.Address +} + +func NewPrevAuthContext() AuthContext { + return prevAuthContext{std.PrevRealm().Addr()} +} +func (a prevAuthContext) Addr() std.Address { return a.addr } +func (a prevAuthContext) String() string { return "prev:" + string(a.Addr()) } +func (a prevAuthContext) HasRole(role string) bool { return true } + +// delegatedAuthContext represents an authentication context that allows someone to approve other addresses to post on its behalf. +type delegatedAuthContext struct { + owner std.Address + caller std.Address + authFn AuthFunc +} + +func (a delegatedAuthContext) Addr() std.Address { return a.owner } +func (a delegatedAuthContext) String() string { + return "delegated:" + string(a.Addr()) + "/" + string(a.caller) +} +func (a delegatedAuthContext) HasRole(role string) bool { return a.authFn(role) } + +// DelegationDB is a type that represents a database of delegations. +// It maps owner addresses to their respective delegates. +// This structure is intended to be initialized and stored globally by a realm +// that wants to offer delegated authentication context to its users. +type DelegationDB struct{ tree avl.Tree } // owner(std.Address) -> delegates(avl.Tree) + +// delegates is a type that represents a group of delegates. +// It maps delegate addresses to their respective isAuthorizedFn. +type ( + delegates struct{ tree avl.Tree } // delegate(std.Address) -> AuthFunc + delegate struct{ authFn AuthFunc } +) + +func (d *DelegationDB) Approve(delegateAddr std.Address, authFn AuthFunc) { + // std.AssertOriginCall() // TODO: re-enable when compatible with `go test` + owner := std.GetOrigCaller() + delegations, _ := d.delegationsByOwner(owner) + delegations.tree.Set(string(delegateAddr), delegate{authFn}) + d.tree.Set(string(owner), delegations) +} + +func (d DelegationDB) delegationsByOwner(owner std.Address) (delegates, bool) { + delegations, found := d.tree.Get(string(owner)) + if found { + return delegations.(delegates), true + } + return delegates{}, false +} + +// NewOrigDelegatedAuthContext creates a new delegatedAuthContext object. +// It is intended to be called by the pre-approved delegate. +// It returns a delegatedAuthContext object if it was approved; else it panics. +func (d DelegationDB) NewOrigDelegatedAuthContext(owner std.Address) AuthContext { + caller := std.GetOrigCaller() + return d.newDelegatedAuthContext(caller, owner) +} + +func (d DelegationDB) NewPrevDelegatedAuthContext(owner std.Address) AuthContext { + caller := std.PrevRealm().Addr() + return d.newDelegatedAuthContext(caller, owner) +} + +func (d DelegationDB) newDelegatedAuthContext(caller, owner std.Address) AuthContext { + delegations, found := d.delegationsByOwner(owner) + if !found { + return nil + } + delegateRaw, found := delegations.tree.Get(string(caller)) + if !found { + return nil + } + authFn := delegateRaw.(delegate).authFn + + return &delegatedAuthContext{ + owner: owner, + caller: caller, + authFn: authFn, + } +} + +func Must(ctx AuthContext) AuthContext { + if ctx == nil { + panic("unauthorized") + } + return ctx +} + +// IsNative checks if the AuthContext implementation originates from this package. +// It returns true if the AuthContext is native to this package and false if it's from an external source. +func IsNative(ctx AuthContext) bool { + switch typed := ctx.(type) { + case origAuthContext, prevAuthContext, delegatedAuthContext, *origAuthContext, *prevAuthContext, *delegatedAuthContext: + return true + case scopedAuthContext: + return IsNative(typed.parent) + case *scopedAuthContext: + return IsNative(typed.parent) + } + return false +} diff --git a/examples/gno.land/p/demo/authctx/authctx_test.gno b/examples/gno.land/p/demo/authctx/authctx_test.gno new file mode 100644 index 00000000000..fff91a18107 --- /dev/null +++ b/examples/gno.land/p/demo/authctx/authctx_test.gno @@ -0,0 +1,199 @@ +package authctx + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" +) + +func TestNewOrigAuthContext(t *testing.T) { + var test1 std.Address = testutils.TestAddress("test1") + std.TestSetOrigCaller(test1) + + auth := NewOrigAuthContext() + + assertSameAddresses(t, test1, auth.Addr()) + assertSameStrings(t, "orig:"+string(test1), auth.String()) + if !auth.HasRole("foo") { + t.Errorf("should be authorized") + } +} + +func TestNewPrevAuthContext(t *testing.T) { + var test1 std.Address = testutils.TestAddress("test1") + std.TestSetOrigCaller(test1) + + auth := NewPrevAuthContext() + + assertSameAddresses(t, test1, auth.Addr()) + assertSameStrings(t, "prev:"+string(test1), auth.String()) + if !auth.HasRole("foo") { + t.Errorf("should be authorized") + } +} + +func TestPackage(t *testing.T) { + var ( + test1 = testutils.TestAddress("test1") + test2 = testutils.TestAddress("test2") + ) + + tt := []struct { + name string + init func() AuthContext + expNilAddr bool + expAddr std.Address + expAuthorized bool + }{ + { + name: "nil", + init: func() AuthContext { + var ctx AuthContext = nil + return ctx + }, + expNilAddr: true, + }, + { + name: "orig", + init: func() AuthContext { + std.TestSetOrigCaller(test1) + return NewOrigAuthContext() + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "orig-scoped-authorized", + init: func() AuthContext { + std.TestSetOrigCaller(test1) + ctx := NewOrigAuthContext() + scoped := NewScopedAuthContext(ctx, func(role string) bool { return true }) + return scoped + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "orig-scoped-unauthorized", + init: func() AuthContext { + std.TestSetOrigCaller(test1) + ctx := NewOrigAuthContext() + scoped := NewScopedAuthContext(ctx, func(role string) bool { return false }) + return scoped + }, + expAddr: test1, + expAuthorized: false, + }, + { + name: "delegated-orig-authorized", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test1) + db.Approve(test2, AuthorizeAll()) + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "delegated-orig-authorized", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test1) + db.Approve(test2, AuthorizeAll()) + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "delegated-orig-unauthorized", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test1) + db.Approve(test2, AuthorizeNone()) + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expAddr: test1, + expAuthorized: false, + }, + { + name: "delegated-orig-conditional-authorized", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test1) + db.Approve(test2, AuthorizeRoles("foo", "bar", "foobar")) + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "delegated-orig-custom-authfn", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test1) + var customAuthFn AuthFunc = func(role string) bool { return role == "foobar" } + db.Approve(test2, customAuthFn) + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expAddr: test1, + expAuthorized: true, + }, + { + name: "delegated-non-preapproved", + init: func() AuthContext { + var db DelegationDB + std.TestSetOrigCaller(test2) + return db.NewOrigDelegatedAuthContext(test1) + }, + expNilAddr: true, + }, + + // TODO: {name: "prev"} // depends on TestSetPrevRealm + // TODO: {name: "owner-is-a-realm"}, + // TODO: {name: "non-address-owner"}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + ctx := tc.init() + if tc.expAddr == nil && ctx != nil { + t.Errorf("expected AuthContext to be nil, but is %q", ctx) + } + if ctx == nil { + return + } + if tc.expAddr != ctx.Addr() { + t.Errorf("invalid addr, expected %q, got %q", tc.expAddr, ctx.Addr()) + } + isAuthorized := ctx.HasRole("foobar") + if isAuthorized && !tc.expAuthorized { + t.Errorf("expected unauthorized but was authorized") + } + if !isAuthorized && tc.expAuthorized { + t.Errorf("expected authorized but was unauthorized") + } + }) + } +} + +func assertSameStrings(t *testing.T, expected, got string) { + t.Helper() + if expected != got { + t.Errorf("expected %q, got %q.", expected, got) + } +} + +func assertSameAddresses(t *testing.T, expected, got std.Address) { + t.Helper() + if expected != got { + t.Errorf("expected %q, got %q.", expected, got) + } +} diff --git a/examples/gno.land/p/demo/authctx/gno.mod b/examples/gno.land/p/demo/authctx/gno.mod new file mode 100644 index 00000000000..978936f00b6 --- /dev/null +++ b/examples/gno.land/p/demo/authctx/gno.mod @@ -0,0 +1,5 @@ +module gno.land/p/demo/authctx + +require ( + "gno.land/p/demo/avl" v0.0.0-latest +) diff --git a/examples/gno.land/p/demo/authctx/z0_filetest.gno b/examples/gno.land/p/demo/authctx/z0_filetest.gno new file mode 100644 index 00000000000..5fccbdd8fdc --- /dev/null +++ b/examples/gno.land/p/demo/authctx/z0_filetest.gno @@ -0,0 +1,82 @@ +package main + +import ( + "std" + + "gno.land/p/demo/authctx" + "gno.land/p/demo/testutils" +) + +func main() { + test1 := testutils.TestAddress("test1") + test2 := testutils.TestAddress("test2") + test3 := testutils.TestAddress("test3") + test4 := testutils.TestAddress("test4") + test5 := testutils.TestAddress("test5") + + var db authctx.DelegationDB + + std.TestSetOrigCaller(test1) + var customAuthFn authctx.AuthFunc = func(role string) bool { return role == "fooperm" } + db.Approve(test2, customAuthFn) + db.Approve(test3, authctx.AuthorizeAll()) + db.Approve(test4, authctx.AuthorizeNone()) + db.Approve(test5, authctx.AuthorizeRoles("fooperm", "barperm")) + + println("------------- checking various AuthFunc") + { + std.TestSetOrigCaller(test2) + delegation := db.NewOrigDelegatedAuthContext(test1) + println("test2", delegation, delegation.HasRole("bazperm"), authctx.IsNative(delegation)) + } + { + std.TestSetOrigCaller(test3) + delegation := db.NewOrigDelegatedAuthContext(test1) + println("test3", delegation, delegation.HasRole("bazperm"), authctx.IsNative(delegation)) + } + { + std.TestSetOrigCaller(test4) + delegation := db.NewOrigDelegatedAuthContext(test1) + println("test4", delegation, delegation.HasRole("bazperm"), authctx.IsNative(delegation)) + } + { + std.TestSetOrigCaller(test5) + delegation := db.NewOrigDelegatedAuthContext(test1) + println("test5", delegation, delegation.HasRole("bazperm"), authctx.IsNative(delegation)) + } + + println("------------- checking IsNative") + { + myctx := myAuthCtxImpl{} + println("myctx", authctx.IsNative(myctx)) + + scopedMyctx := authctx.NewScopedAuthContext(myctx, authctx.AuthorizeRoles("fooperm")) + println("scopedMyctx", authctx.IsNative(scopedMyctx)) + + orig := authctx.NewOrigAuthContext() + println("orig", authctx.IsNative(orig)) + + scopedOrig := authctx.NewScopedAuthContext(orig, authctx.AuthorizeRoles("fooperm")) + println("scopedOrig", authctx.IsNative(scopedOrig)) + } + + // TODO: test db.NewPrevDelegatedAuthContext +} + +type myAuthCtxImpl struct{} + +func (myAuthCtxImpl) String() string { return "foo" } +func (myAuthCtxImpl) HasRole(role string) bool { return true } +func (myAuthCtxImpl) Addr() std.Address { return std.Address("foobar") } + +// Output: +// ------------- checking various AuthFunc +// test2 delegated:g1w3jhxap3ta047h6lta047h6lta047h6l4mfnm7/g1w3jhxapjta047h6lta047h6lta047h6laqcyu4 false true +// test3 delegated:g1w3jhxap3ta047h6lta047h6lta047h6l4mfnm7/g1w3jhxapnta047h6lta047h6lta047h6lzfhfxt true true +// test4 delegated:g1w3jhxap3ta047h6lta047h6lta047h6l4mfnm7/g1w3jhxap5ta047h6lta047h6lta047h6ldlnrjr false true +// test5 delegated:g1w3jhxap3ta047h6lta047h6lta047h6l4mfnm7/g1w3jhxap4ta047h6lta047h6lta047h6ljkuwga false true +// ------------- checking IsNative +// myctx false +// scopedMyctx false +// orig true +// scopedOrig true