Skip to content

feat: add session actor middleware #897

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

Closed
wants to merge 11 commits into from
42 changes: 42 additions & 0 deletions coderd/access/session/actor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package session

import (
"github.com/coder/coder/coderd/database"
)

// ActorType is an enum of all types of Actors.
type ActorType string

// ActorTypes.
const (
ActorTypeAnonymous ActorType = "anonymous"
ActorTypeUser ActorType = "user"
)

// Actor represents an unauthenticated or authenticated client accessing the
// API. To check authorization, callers should call pass the Actor into the
// authz package to assert access.
type Actor interface {
Type() ActorType
// ID is the unique ID of the actor for logging purposes.
ID() string
// Name is a friendly, but consistent, name for the actor for logging
// purposes. E.g. "deansheather"
Name() string
Comment on lines +25 to +29
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't this either be system or the user's name? Seems like certain routes should never be system, so I'm confused when the logging would come into play.

Copy link
Member Author

Choose a reason for hiding this comment

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

On each request I want to log the actor name at least, so it's easy to see which user made a request in the logs. I am removing the SystemActor though.

Copy link
Member Author

Choose a reason for hiding this comment

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

is this ok @kylecarbs? The goal of this is so we can log the username without having to do a type cast because of the same reason as above (type switching just to read the username is very slow).

Copy link
Member

Choose a reason for hiding this comment

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

If we're just going to have the UserActor do we need to generalize the actor type at all? Could we just use database.User directly? That way we wouldn't have to type-cast at all.

Copy link
Member Author

Choose a reason for hiding this comment

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

We're not just going to have the UserActor type. We will also have a WorkspaceActor and eventually a SatelliteActor.

Copy link
Member

Choose a reason for hiding this comment

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

If we just have two or three though, could we just log the username / workspace / satellite name separately? If the actor type is primarily going to be used for that, I'm hesitant to say it's worth the grouping.

I'd think we'd have separate routes for users / workspaces / satellites anyways, similar to how agents can only access specific routes right now. In that pattern, there's no opportunity to have a user call an agent route, because they'll never need to do so.

Copy link
Member

Choose a reason for hiding this comment

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

That is one way to separate things. But that makes each actor type more distinct.

RBAC means an agent token is just a UserActor with a reduced scope. I see the benefit in having 1 type and letting rbac decide what they can and can't do.

I see value in the separating routes approach too.

Both options require their own care. One thing I wonder about is will a provisionerd ever need to query coderd for anything? Like is there a case some provisionerd will want a system_user with some RBAC role/perms to query some extra endpoints? I have no idea if there is, but having 1 system is nice in the sense all actors are equivalent and just defer to rbac for drawing lines.


// TODO: Steven - RBAC methods
}

// AnonymousActor represents an unauthenticated API client.
type AnonymousActor interface {
Actor
Anonymous()
}

// UserActor represents an authenticated user actor. Any consumers that wish to
// check if the actor is a user (and access user fields such as User.ID) can
// do a checked type cast from Actor to UserActor.
type UserActor interface {
Actor
User() *database.User
}
22 changes: 22 additions & 0 deletions coderd/access/session/anonymous.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package session

const AnonymousUserID = "anonymous"

type anonymousActor struct{}

// Anon is a static AnonymousActor implementation.
var Anon AnonymousActor = anonymousActor{}

func (anonymousActor) Type() ActorType {
return ActorTypeAnonymous
}

func (anonymousActor) ID() string {
return AnonymousUserID
}

func (anonymousActor) Name() string {
return AnonymousUserID
}

func (anonymousActor) Anonymous() {}
18 changes: 18 additions & 0 deletions coderd/access/session/anonymous_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package session_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/access/session"
)

func TestAnonymousActor(t *testing.T) {
t.Parallel()

require.Equal(t, session.ActorTypeAnonymous, session.Anon.Type())
require.Equal(t, session.AnonymousUserID, session.Anon.ID())
require.Equal(t, session.AnonymousUserID, session.Anon.Name())
session.Anon.Anonymous()
}
4 changes: 4 additions & 0 deletions coderd/access/session/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package session provides session authentication via middleware for the Coder
// HTTP API. It also exposes the Actor type, which is the intermediary layer
// between identity and RBAC authorization.
package session
51 changes: 51 additions & 0 deletions coderd/access/session/mw.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package session

import (
"context"
"net/http"

"github.com/coder/coder/coderd/database"
)

type actorContextKey struct{}

// APIKey returns the API key from the ExtractAPIKey handler.
func RequestActor(r *http.Request) Actor {
actor, ok := r.Context().Value(actorContextKey{}).(Actor)
if !ok {
panic("developer error: ExtractActor middleware not provided")
}
return actor
}

// ExtractActor determines the Actor from the request. It will try to get the
// following actors in order:
// 1. UserActor
// 2. AnonymousActor
func ExtractActor(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
act Actor
)

// Try to get a UserActor.
act, ok := UserActorFromRequest(ctx, db, rw, r)
if !ok {
return
}

// TODO: Dean - WorkspaceActor, SatelliteActor etc.

// Fallback to an AnonymousActor.
if act == nil {
act = Anon
}

ctx = context.WithValue(ctx, actorContextKey{}, act)
next.ServeHTTP(rw, r.WithContext(ctx))
return
})
}
}
141 changes: 141 additions & 0 deletions coderd/access/session/mw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package session_test

import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/access/session"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/httpapi"
)

func TestMiddleware(t *testing.T) {
t.Parallel()

successHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Only called if the API key passes through the handler.
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "it worked!",
})
})

t.Run("NoMiddleware", func(t *testing.T) {
t.Parallel()

require.Panics(t, func() {
r := httptest.NewRequest("GET", "/", nil)
_ = session.RequestActor(r)
})
})

t.Run("UserActor", func(t *testing.T) {
t.Parallel()

t.Run("Error", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: session.AuthCookie,
Value: "invalid-api-key",
})

session.ExtractActor(db)(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})

t.Run("OK", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
u = newUser(t, db)
_, token = newAPIKey(t, db, u, time.Time{}, time.Time{})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: session.AuthCookie,
Value: token,
})

var (
called int64
handler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)

// Double check the UserActor.
act := session.RequestActor(r)
require.NotNil(t, act)
require.Equal(t, session.ActorTypeUser, act.Type())
require.Equal(t, u.ID.String(), act.ID())
require.Equal(t, u.Username, act.Name())

userActor, ok := act.(session.UserActor)
require.True(t, ok)
require.Equal(t, u, *userActor.User())

httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "success",
})
})
)

session.ExtractActor(db)(handler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)

require.EqualValues(t, 1, called)
})
})

t.Run("Fallthrough", func(t *testing.T) {
t.Parallel()

var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)

var (
called int64
handler = http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)

// Double check the UserActor.
act := session.RequestActor(r)
require.NotNil(t, act)
require.Equal(t, session.ActorTypeAnonymous, act.Type())
require.Equal(t, session.AnonymousUserID, act.ID())
require.Equal(t, session.AnonymousUserID, act.Name())

anonActor, ok := act.(session.AnonymousActor)
require.True(t, ok)
anonActor.Anonymous()

httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "success",
})
})
)

// No auth provided.
session.ExtractActor(db)(handler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)

require.EqualValues(t, 1, called)
})
}
Loading