Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: RBAC scopes per API key (WIP) #1846

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 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions coderd/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import (

func AuthorizeFilter[O rbac.Objecter](api *API, r *http.Request, action rbac.Action, objects []O) []O {
roles := httpmw.UserRoles(r)
return rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, action, objects)
return rbac.Filter(r.Context(), api.Authorizer, roles.ID.String(), roles.Roles, string(roles.Scope), action, objects)
}

func (api *API) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Objecter) bool {
roles := httpmw.UserRoles(r)
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, string(roles.Scope), action, object.RBACObject())
if err != nil {
httpapi.Write(rw, http.StatusForbidden, httpapi.Response{
Message: err.Error(),
Expand All @@ -35,6 +35,7 @@ func (api *API) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.A
// in the early days
logger.Warn(r.Context(), "unauthorized",
slog.F("roles", roles.Roles),
slog.F("scope", roles.Scope),
slog.F("user_id", roles.ID),
slog.F("username", roles.Username),
slog.F("route", r.URL.Path),
Expand Down
4 changes: 3 additions & 1 deletion coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
type authCall struct {
SubjectID string
Roles []string
Scope string
Action rbac.Action
Object rbac.Object
}
Expand All @@ -378,10 +379,11 @@ type fakeAuthorizer struct {
AlwaysReturn error
}

func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scopeName string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Scope: scopeName,
Action: action,
Object: object,
}
Expand Down
1 change: 1 addition & 0 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
OAuthRefreshToken: arg.OAuthRefreshToken,
OAuthIDToken: arg.OAuthIDToken,
OAuthExpiry: arg.OAuthExpiry,
Scope: arg.Scope,
}
q.apiKeys = append(q.apiKeys, key)
return key, nil
Expand Down
10 changes: 9 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE api_keys DROP COLUMN scope;

DROP TYPE api_key_scope;
8 changes: 8 additions & 0 deletions coderd/database/migrations/000016_api_key_permissions.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TYPE api_key_scope AS ENUM (
'any',
'devurls',
'agent',
'readonly'
);

ALTER TABLE api_keys ADD COLUMN scope api_key_scope NOT NULL DEFAULT 'any';
23 changes: 22 additions & 1 deletion coderd/database/modelmethods.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package database

import "github.com/coder/coder/coderd/rbac"
import (
"golang.org/x/xerrors"

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

func (t Template) RBACObject() rbac.Object {
return rbac.ResourceTemplate.InOrg(t.OrganizationID).WithID(t.ID.String())
Expand All @@ -26,3 +30,20 @@ func (o Organization) RBACObject() rbac.Object {
func (d ProvisionerDaemon) RBACObject() rbac.Object {
return rbac.ResourceProvisionerDaemon.WithID(d.ID.String())
}

var validApiKeyScopes map[string]ApiKeyScope

func init() {
validApiKeyScopes = make(map[string]ApiKeyScope)
for _, scope := range []ApiKeyScope{ApiKeyScopeAny, ApiKeyScopeAgent, ApiKeyScopeDevurls, ApiKeyScopeReadonly} {
validApiKeyScopes[string(scope)] = scope
}
}

func MakeApiKeyScope(v string) (ApiKeyScope, error) {
scope, ok := validApiKeyScopes[v]
if !ok {
return ApiKeyScope(""), xerrors.Errorf("invalid token scope: %s", v)
}
return scope, nil
}
46 changes: 34 additions & 12 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 20 additions & 15 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions coderd/database/queries/apikeys.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ INSERT INTO
oauth_access_token,
oauth_refresh_token,
oauth_id_token,
oauth_expiry
oauth_expiry,
scope
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *;

-- name: UpdateAPIKeyByID :exec
UPDATE
Expand Down
43 changes: 43 additions & 0 deletions coderd/httpmw/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,44 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})

t.Run("ValidWithScope", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
hashed = sha256.Sum256([]byte(secret))
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

sentAPIKey, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
HashedSecret: hashed[:],
ExpiresAt: database.Now().AddDate(0, 0, 1),
Scope: "agent",
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
_ = httpmw.APIKey(r)
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "it worked!",
})
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)

gotAPIKey, err := db.GetAPIKeyByID(r.Context(), id)
require.NoError(t, err)

require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})

t.Run("QueryParameter", func(t *testing.T) {
t.Parallel()
var (
Expand All @@ -226,6 +264,7 @@ func TestAPIKey(t *testing.T) {
ID: id,
HashedSecret: hashed[:],
ExpiresAt: database.Now().AddDate(0, 0, 1),
Scope: database.ApiKeyScopeAny,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -259,6 +298,7 @@ func TestAPIKey(t *testing.T) {
HashedSecret: hashed[:],
LastUsed: database.Now().AddDate(0, 0, -1),
ExpiresAt: database.Now().AddDate(0, 0, 1),
Scope: database.ApiKeyScopeAny,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r)
Expand Down Expand Up @@ -292,6 +332,7 @@ func TestAPIKey(t *testing.T) {
HashedSecret: hashed[:],
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
Scope: database.ApiKeyScopeAny,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r)
Expand Down Expand Up @@ -326,6 +367,7 @@ func TestAPIKey(t *testing.T) {
LoginType: database.LoginTypeGithub,
LastUsed: database.Now(),
ExpiresAt: database.Now().AddDate(0, 0, 1),
Scope: database.ApiKeyScopeAny,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(successHandler).ServeHTTP(rw, r)
Expand Down Expand Up @@ -360,6 +402,7 @@ func TestAPIKey(t *testing.T) {
LoginType: database.LoginTypeGithub,
LastUsed: database.Now(),
OAuthExpiry: database.Now().AddDate(0, 0, -1),
Scope: database.ApiKeyScopeAny,
})
require.NoError(t, err)
token := &oauth2.Token{
Expand Down
22 changes: 19 additions & 3 deletions coderd/httpmw/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@ import (
"context"
"net/http"

"github.com/google/uuid"

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

// User roles are the 'subject' field of Authorize()
type userRolesKey struct{}

type AuthorizationScope struct {
ID uuid.UUID
Username string
Roles []string
Scope database.ApiKeyScope
}

// UserRoles returns the API key from the ExtractUserRoles handler.
func UserRoles(r *http.Request) database.GetAllUserRolesRow {
apiKey, ok := r.Context().Value(userRolesKey{}).(database.GetAllUserRolesRow)
func UserRoles(r *http.Request) AuthorizationScope {
apiKey, ok := r.Context().Value(userRolesKey{}).(AuthorizationScope)
if !ok {
panic("developer error: user roles middleware not provided")
}
Expand All @@ -33,7 +42,14 @@ func ExtractUserRoles(db database.Store) func(http.Handler) http.Handler {
return
}

ctx := context.WithValue(r.Context(), userRolesKey{}, role)
authScope := AuthorizationScope{
ID: role.ID,
Username: role.Username,
Roles: role.Roles,
Scope: apiKey.Scope,
}

ctx := context.WithValue(r.Context(), userRolesKey{}, authScope)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
Expand Down
Loading