-
Notifications
You must be signed in to change notification settings - Fork 894
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
Changes from 6 commits
f868929
ada9c1a
999c197
6f7b7d4
1cf0c58
175aa8c
cb9ec1e
efcee3d
4c9dd06
5bea2cb
dadef23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
deansheather marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't this either be There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're just going to have the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I see value in the separating routes approach too. Both options require their own care. One thing I wonder about is will a |
||
|
||
// TODO: Steven - RBAC methods | ||
deansheather marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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 | ||
} |
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() {} |
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() | ||
} |
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. | ||
deansheather marked this conversation as resolved.
Show resolved
Hide resolved
|
||
package session |
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 | ||
} | ||
deansheather marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
ctx = context.WithValue(ctx, actorContextKey{}, act) | ||
next.ServeHTTP(rw, r.WithContext(ctx)) | ||
return | ||
}) | ||
} | ||
} |
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) | ||
}) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.