Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add p/demo/authctx #1244

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
162 changes: 162 additions & 0 deletions examples/gno.land/p/demo/authctx/authctx.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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() string
moul marked this conversation as resolved.
Show resolved Hide resolved
IsAuthorized(action string) bool
}

// AuthFunc signature should always match AuthContext.IsAuthorized.
type AuthFunc func(action 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 AuthorizeActions(actions ...string) AuthFunc {
whitelist := map[string]bool{}
for _, action := range actions {
whitelist[action] = 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) IsAuthorized(action string) bool {
return s.authOverlay(action) && s.parent.IsAuthorized(action)
}

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 transaction.
// 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) IsAuthorized(action string) bool { return true }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can an AuthContext authorize an action without knowing from whom the action comes from?
Is this supposed to return true all the time or is it a stub?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"correct auth is expected to be passed in as context"
So I guess that makes sense, but something seems off.
An authorization should be of both the object/caller and the subject.
You can assume the caller (more on that below) but still there is no subject.
Upon what is the action authorized?
It could be a naming issue, s/action/request/g for example would work, with request.action and request.subject.

Something about this general approach feels weird though.
Here's an analogy:
In a secure facility such as a building with access controls,
you have an identity card, which you swipe onto sensors, to open doors.

  • You don't put the ACL data in the card, because this increases surface area a lot.
  • There would be all kinds of ways to attack the card itself.
  • Instead, usually the card merely holds an identity, and the ACL data is elsewhere in a central place.
  • But AuthCtx is like an access control card that has the logic of permissioning inside locally.
  • And the interfaces say that the way to use it is to implement your own access control logic card.

Wouldn't it make more sense to just pass around a Context which includes self identity, and to have a black box AuthControl.Authenticate(caller/subject, object, more action data)?

  • The user has less freedom, which is good -- less ways to mess up.
  • The security is contained within the implementation of AuthControl, easier to audit.

I don't see the benefit of the assume-the-caller AuthCtx that is exposed to the user.

Copy link
Member Author

@moul moul Oct 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, let me explain my initial idea for this package:

Think of AuthContext like JWT but simpler. It's a way to prove that something came from a trusted source and can be used for authentication. It allows you to pass around authentication information securely and preserve authenticity.

In a JWT, you have scopes or roles, but it's mainly used to manage permissions, not all the access control rules of an app.

Another analogy I like is sudo. When you're a regular user, you have limited permissions. But when you use sudo, you temporarily get more power. However, you can configure sudo to only give you specific powers for certain tasks (scopes). The final app still checks if you have the right permissions, and sudo is responsible to create the AuthContext.

AuthContext also has the advantage of being storable. Instead of just storing an address to represent an owner, you can store more context. This is useful when you want your system (like a book/boards) to be usable by different accounts, contracts, and delegates. No need for boilerplate code on your realm.

Orig/Prev AuthContexts have a default scope of "all," but when you delegate rights, you can limit them. There's also a helper to change (drop privileges) on any AuthContext, including Orig/Prev ones.

In short, AuthContext isn't just for access control lists (ACLs). Use tools like p/acl for that. For delegated contexts, scope is as important as ACLs. While ACLs verify an owner's actions, AuthContext.IsAuthorized checks the caller's rights in the current situation.

Now, I see two main directions:

  1. Keep AuthContext as it is, making it better at context management, but not a complete ACL solution. It's more of a flexible authentication context for writing apps with less boilerplate.
  2. Make AuthContext a complete ACL solution, removing the role system, and using an interface that can be implemented by different ACL mechanisms.

Possible next steps (things I plan to consider/hack on):

  • 1. Introduce authctx.IsNative(ctx) to determine where the AuthContext comes from.
  • 2. Remove ACLs, making AuthContext just a container, and add checks during object creation.
  • 3. Expand AuthContext with features like expiration.
  • 4. Allow custom IsAuthorized checks through closures.
  • 5. Improve the current p/acl library and make it interface-based.
  • 6. Rename IsAuthorized to HasScope.
  • 7. Move the ACL system in authctx to an interface implementation, so it's still there but as a default option.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try to condense my general understanding here.

If it's a restrictor, then why not just fail early instead of wrapping contexts? For example, if Bob orig caller calls some realm logic (scoped to realm permissions) which calls performs some action on a Book, why not just fail early when the action can't be performed by Bob? And if Bob is allowed, then why not fail early when the realm logic is not allowed?

It seems to me this is merely delaying logic that could be performed immediately into a chain of callbacks, and what's the point of that? I don't see the benefit.


// 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) IsAuthorized(action 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) IsAuthorized(action string) bool { return a.authFn(action) }

// 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
}
199 changes: 199 additions & 0 deletions examples/gno.land/p/demo/authctx/authctx_test.gno
Original file line number Diff line number Diff line change
@@ -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.IsAuthorized("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.IsAuthorized("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(action 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(action 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, AuthorizeActions("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(action string) bool { return action == "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.IsAuthorized("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)
}
}
5 changes: 5 additions & 0 deletions examples/gno.land/p/demo/authctx/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module gno.land/p/demo/authctx

require (
"gno.land/p/demo/avl" v0.0.0-latest
)
Loading
Loading