feat: add API key scopes and application_connect scope (#4067)

This commit is contained in:
Dean Sheather
2022-09-20 03:39:02 +10:00
committed by GitHub
parent adad347902
commit 29d804e692
42 changed files with 476 additions and 88 deletions

View File

@ -11,14 +11,15 @@ import (
)
func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) {
roles := httpmw.AuthorizationUserRoles(r)
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, action, objects)
roles := httpmw.UserAuthorization(r)
objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objects)
if err != nil {
// Log the error as Filter should not be erroring.
h.Logger.Error(r.Context(), "filter failed",
slog.Error(err),
slog.F("user_id", roles.ID),
slog.F("username", roles.Username),
slog.F("scope", roles.Scope),
slog.F("route", r.URL.Path),
slog.F("action", action),
)
@ -55,8 +56,8 @@ func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objec
// return
// }
func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool {
roles := httpmw.AuthorizationUserRoles(r)
err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object.RBACObject())
roles := httpmw.UserAuthorization(r)
err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, object.RBACObject())
if err != nil {
// Log the errors for debugging
internalError := new(rbac.UnauthorizedError)
@ -70,6 +71,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r
slog.F("roles", roles.Roles),
slog.F("user_id", roles.ID),
slog.F("username", roles.Username),
slog.F("scope", roles.Scope),
slog.F("route", r.URL.Path),
slog.F("action", action),
slog.F("object", object),

View File

@ -163,6 +163,8 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
// Some quick reused objects
workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
applicationConnectObj := rbac.ResourceWorkspaceApplicationConnect.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String())
// skipRoutes allows skipping routes from being checked.
skipRoutes := map[string]string{
"POST:/api/v2/users/logout": "Logging out deletes the API Key for other routes",
@ -408,11 +410,11 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
assertAllHTTPMethods("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
AssertObject: applicationConnectObj,
})
assertAllHTTPMethods("/@{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
AssertObject: applicationConnectObj,
})
return skipRoutes, assertRoute
@ -518,6 +520,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck
type authCall struct {
SubjectID string
Roles []string
Scope rbac.Scope
Action rbac.Action
Object rbac.Object
}
@ -527,21 +530,25 @@ type recordingAuthorizer struct {
AlwaysReturn error
}
func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
var _ rbac.Authorizer = (*recordingAuthorizer)(nil)
func (r *recordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error {
r.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Scope: scope,
Action: action,
Object: object,
}
return r.AlwaysReturn
}
func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
func (r *recordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) {
return &fakePreparedAuthorizer{
Original: r,
SubjectID: subjectID,
Roles: roles,
Scope: scope,
Action: action,
}, nil
}
@ -554,9 +561,10 @@ type fakePreparedAuthorizer struct {
Original *recordingAuthorizer
SubjectID string
Roles []string
Scope rbac.Scope
Action rbac.Action
}
func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error {
return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Action, object)
return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Scope, f.Action, object)
}

View File

@ -1588,6 +1588,7 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
UpdatedAt: arg.UpdatedAt,
LastUsed: arg.LastUsed,
LoginType: arg.LoginType,
Scope: arg.Scope,
}
q.apiKeys = append(q.apiKeys, key)
return key, nil

View File

@ -4,12 +4,15 @@ package database_test
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/database/postgres"
)
func TestNestedInTx(t *testing.T) {
@ -20,7 +23,7 @@ func TestNestedInTx(t *testing.T) {
uid := uuid.New()
sqlDB := testSQLDB(t)
err := database.MigrateUp(sqlDB)
err := migrations.Up(sqlDB)
require.NoError(t, err, "migrations")
db := database.New(sqlDB)
@ -48,3 +51,17 @@ func TestNestedInTx(t *testing.T) {
require.NoError(t, err, "user exists")
require.Equal(t, uid, user.ID, "user id expected")
}
func testSQLDB(t testing.TB) *sql.DB {
t.Helper()
connection, closeFn, err := postgres.Open()
require.NoError(t, err)
t.Cleanup(closeFn)
db, err := sql.Open("postgres", connection)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return db
}

View File

@ -1,5 +1,10 @@
-- Code generated by 'make coderd/database/generate'. DO NOT EDIT.
CREATE TYPE api_key_scope AS ENUM (
'all',
'application_connect'
);
CREATE TYPE audit_action AS ENUM (
'create',
'write',
@ -109,7 +114,8 @@ CREATE TABLE api_keys (
updated_at timestamp with time zone NOT NULL,
login_type login_type NOT NULL,
lifetime_seconds bigint DEFAULT 86400 NOT NULL,
ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL
ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL,
scope api_key_scope DEFAULT 'all'::public.api_key_scope NOT NULL
);
CREATE TABLE audit_logs (

View File

@ -9,7 +9,7 @@ import (
"path/filepath"
"runtime"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/database/postgres"
)
@ -25,7 +25,7 @@ func main() {
panic(err)
}
err = database.MigrateUp(db)
err = migrations.Up(db)
if err != nil {
panic(err)
}

View File

@ -0,0 +1,6 @@
-- Avoid "upgrading" devurl keys to fully fledged API keys.
DELETE FROM api_keys WHERE scope != 'all';
ALTER TABLE api_keys DROP COLUMN scope;
DROP TYPE api_key_scope;

View File

@ -0,0 +1,6 @@
CREATE TYPE api_key_scope AS ENUM (
'all',
'application_connect'
);
ALTER TABLE api_keys ADD COLUMN scope api_key_scope NOT NULL DEFAULT 'all';

View File

@ -1,4 +1,4 @@
package database
package migrations
import (
"context"
@ -14,12 +14,12 @@ import (
"golang.org/x/xerrors"
)
//go:embed migrations/*.sql
//go:embed *.sql
var migrations embed.FS
func migrateSetup(db *sql.DB) (source.Driver, *migrate.Migrate, error) {
func setup(db *sql.DB) (source.Driver, *migrate.Migrate, error) {
ctx := context.Background()
sourceDriver, err := iofs.New(migrations, "migrations")
sourceDriver, err := iofs.New(migrations, ".")
if err != nil {
return nil, nil, xerrors.Errorf("create iofs: %w", err)
}
@ -45,9 +45,9 @@ func migrateSetup(db *sql.DB) (source.Driver, *migrate.Migrate, error) {
return sourceDriver, m, nil
}
// MigrateUp runs SQL migrations to ensure the database schema is up-to-date.
func MigrateUp(db *sql.DB) (retErr error) {
_, m, err := migrateSetup(db)
// Up runs SQL migrations to ensure the database schema is up-to-date.
func Up(db *sql.DB) (retErr error) {
_, m, err := setup(db)
if err != nil {
return xerrors.Errorf("migrate setup: %w", err)
}
@ -76,9 +76,9 @@ func MigrateUp(db *sql.DB) (retErr error) {
return nil
}
// MigrateDown runs all down SQL migrations.
func MigrateDown(db *sql.DB) error {
_, m, err := migrateSetup(db)
// Down runs all down SQL migrations.
func Down(db *sql.DB) error {
_, m, err := setup(db)
if err != nil {
return xerrors.Errorf("migrate setup: %w", err)
}
@ -100,7 +100,7 @@ func MigrateDown(db *sql.DB) error {
// applied, without making any changes to the database. If not, returns a
// non-nil error.
func EnsureClean(db *sql.DB) error {
sourceDriver, m, err := migrateSetup(db)
sourceDriver, m, err := setup(db)
if err != nil {
return xerrors.Errorf("migrate setup: %w", err)
}

View File

@ -1,6 +1,6 @@
//go:build linux
package database_test
package migrations_test
import (
"database/sql"
@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/database/postgres"
)
@ -33,7 +33,7 @@ func TestMigrate(t *testing.T) {
db := testSQLDB(t)
err := database.MigrateUp(db)
err := migrations.Up(db)
require.NoError(t, err)
})
@ -42,10 +42,10 @@ func TestMigrate(t *testing.T) {
db := testSQLDB(t)
err := database.MigrateUp(db)
err := migrations.Up(db)
require.NoError(t, err)
err = database.MigrateUp(db)
err = migrations.Up(db)
require.NoError(t, err)
})
@ -54,13 +54,13 @@ func TestMigrate(t *testing.T) {
db := testSQLDB(t)
err := database.MigrateUp(db)
err := migrations.Up(db)
require.NoError(t, err)
err = database.MigrateDown(db)
err = migrations.Down(db)
require.NoError(t, err)
err = database.MigrateUp(db)
err = migrations.Up(db)
require.NoError(t, err)
})
}
@ -120,7 +120,7 @@ func TestCheckLatestVersion(t *testing.T) {
})
}
err := database.CheckLatestVersion(driver, tc.currentVersion)
err := migrations.CheckLatestVersion(driver, tc.currentVersion)
var errMessage string
if err != nil {
errMessage = err.Error()

View File

@ -4,6 +4,17 @@ import (
"github.com/coder/coder/coderd/rbac"
)
func (s APIKeyScope) ToRBAC() rbac.Scope {
switch s {
case APIKeyScopeAll:
return rbac.ScopeAll
case APIKeyScopeApplicationConnect:
return rbac.ScopeApplicationConnect
default:
panic("developer error: unknown scope type " + string(s))
}
}
func (t Template) RBACObject() rbac.Object {
return rbac.ResourceTemplate.InOrg(t.OrganizationID)
}
@ -21,6 +32,10 @@ func (w Workspace) ExecutionRBAC() rbac.Object {
return rbac.ResourceWorkspaceExecution.InOrg(w.OrganizationID).WithOwner(w.OwnerID.String())
}
func (w Workspace) ApplicationConnectRBAC() rbac.Object {
return rbac.ResourceWorkspaceApplicationConnect.InOrg(w.OrganizationID).WithOwner(w.OwnerID.String())
}
func (m OrganizationMember) RBACObject() rbac.Object {
return rbac.ResourceOrganizationMember.InOrg(m.OrganizationID)
}

View File

@ -14,6 +14,25 @@ import (
"github.com/tabbed/pqtype"
)
type APIKeyScope string
const (
APIKeyScopeAll APIKeyScope = "all"
APIKeyScopeApplicationConnect APIKeyScope = "application_connect"
)
func (e *APIKeyScope) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = APIKeyScope(s)
case string:
*e = APIKeyScope(s)
default:
return fmt.Errorf("unsupported scan type for APIKeyScope: %T", src)
}
return nil
}
type AuditAction string
const (
@ -324,6 +343,7 @@ type APIKey struct {
LoginType LoginType `db:"login_type" json:"login_type"`
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
Scope APIKeyScope `db:"scope" json:"scope"`
}
type AgentStat struct {

View File

@ -14,7 +14,7 @@ import (
"github.com/ory/dockertest/v3/docker"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/cryptorand"
)
@ -143,7 +143,7 @@ func Open() (string, func(), error) {
return retryErr
}
err = database.MigrateUp(db)
err = migrations.Up(db)
if err != nil {
retryErr = xerrors.Errorf("migrate db: %w", err)
// Only try to migrate once.

View File

@ -128,7 +128,7 @@ func (q *sqlQuerier) DeleteAPIKeyByID(ctx context.Context, id string) error {
const getAPIKeyByID = `-- name: GetAPIKeyByID :one
SELECT
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope
FROM
api_keys
WHERE
@ -151,12 +151,13 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro
&i.LoginType,
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
)
return i, err
}
const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address FROM api_keys WHERE last_used > $1
SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope FROM api_keys WHERE last_used > $1
`
func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) {
@ -179,6 +180,7 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.
&i.LoginType,
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
); err != nil {
return nil, err
}
@ -205,7 +207,8 @@ INSERT INTO
expires_at,
created_at,
updated_at,
login_type
login_type,
scope
)
VALUES
($1,
@ -214,7 +217,7 @@ VALUES
WHEN 0 THEN 86400
ELSE $2::bigint
END
, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address
, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope
`
type InsertAPIKeyParams struct {
@ -228,6 +231,7 @@ type InsertAPIKeyParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
LoginType LoginType `db:"login_type" json:"login_type"`
Scope APIKeyScope `db:"scope" json:"scope"`
}
func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) {
@ -242,6 +246,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
arg.CreatedAt,
arg.UpdatedAt,
arg.LoginType,
arg.Scope,
)
var i APIKey
err := row.Scan(
@ -255,6 +260,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
&i.LoginType,
&i.LifetimeSeconds,
&i.IPAddress,
&i.Scope,
)
return i, err
}

View File

@ -23,7 +23,8 @@ INSERT INTO
expires_at,
created_at,
updated_at,
login_type
login_type,
scope
)
VALUES
(@id,
@ -32,7 +33,7 @@ VALUES
WHEN 0 THEN 86400
ELSE @lifetime_seconds::bigint
END
, @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type) RETURNING *;
, @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope) RETURNING *;
-- name: UpdateAPIKeyByID :exec
UPDATE

View File

@ -18,6 +18,9 @@ packages:
rename:
api_key: APIKey
api_key_scope: APIKeyScope
api_key_scope_all: APIKeyScopeAll
api_key_scope_application_connect: APIKeyScopeApplicationConnect
avatar_url: AvatarURL
login_type_oidc: LoginTypeOIDC
oauth_access_token: OAuthAccessToken

View File

@ -35,16 +35,23 @@ func APIKey(r *http.Request) database.APIKey {
}
// User roles are the 'subject' field of Authorize()
type userRolesKey struct{}
type userAuthKey struct{}
// AuthorizationUserRoles returns the roles used for authorization.
// Comes from the ExtractAPIKey handler.
func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesRow {
userRoles, ok := r.Context().Value(userRolesKey{}).(database.GetAuthorizationUserRolesRow)
type Authorization struct {
ID uuid.UUID
Username string
Roles []string
Scope database.APIKeyScope
}
// UserAuthorization returns the roles and scope used for authorization. Depends
// on the ExtractAPIKey handler.
func UserAuthorization(r *http.Request) Authorization {
auth, ok := r.Context().Value(userAuthKey{}).(Authorization)
if !ok {
panic("developer error: user roles middleware not provided")
panic("developer error: ExtractAPIKey middleware not provided")
}
return userRoles
return auth
}
// OAuth2Configs is a collection of configurations for OAuth-based authentication.
@ -324,7 +331,13 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool
ctx := r.Context()
ctx = context.WithValue(ctx, apiKeyContextKey{}, key)
ctx = context.WithValue(ctx, userRolesKey{}, roles)
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
ID: key.UserID,
Username: roles.Username,
Roles: roles.Roles,
Scope: key.Scope,
})
next.ServeHTTP(rw, r.WithContext(ctx))
})
}

View File

@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
@ -145,6 +146,7 @@ func TestAPIKey(t *testing.T) {
ID: id,
HashedSecret: hashed[:],
UserID: user.ID,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(successHandler).ServeHTTP(rw, r)
@ -170,6 +172,7 @@ func TestAPIKey(t *testing.T) {
HashedSecret: hashed[:],
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(successHandler).ServeHTTP(rw, r)
@ -196,6 +199,7 @@ func TestAPIKey(t *testing.T) {
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@ -215,6 +219,46 @@ 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()
user = createUser(r.Context(), t, db)
)
r.AddCookie(&http.Cookie{
Name: codersdk.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
_, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
UserID: user.ID,
HashedSecret: hashed[:],
ExpiresAt: database.Now().AddDate(0, 0, 1),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeApplicationConnect,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
apiKey := httpmw.APIKey(r)
assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scope)
httpapi.Write(rw, http.StatusOK, codersdk.Response{
Message: "it worked!",
})
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("QueryParameter", func(t *testing.T) {
t.Parallel()
var (
@ -235,6 +279,7 @@ func TestAPIKey(t *testing.T) {
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@ -268,6 +313,7 @@ func TestAPIKey(t *testing.T) {
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(successHandler).ServeHTTP(rw, r)
@ -301,6 +347,7 @@ func TestAPIKey(t *testing.T) {
ExpiresAt: database.Now().Add(time.Minute),
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(successHandler).ServeHTTP(rw, r)
@ -334,6 +381,7 @@ func TestAPIKey(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
@ -373,6 +421,7 @@ func TestAPIKey(t *testing.T) {
LoginType: database.LoginTypeGithub,
LastUsed: database.Now(),
UserID: user.ID,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
_, err = db.InsertUserLink(r.Context(), database.InsertUserLinkParams{
@ -425,6 +474,7 @@ func TestAPIKey(t *testing.T) {
ExpiresAt: database.Now().AddDate(0, 0, 1),
UserID: user.ID,
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil, false)(successHandler).ServeHTTP(rw, r)

View File

@ -87,7 +87,7 @@ func TestExtractUserRoles(t *testing.T) {
httpmw.ExtractAPIKey(db, &httpmw.OAuth2Configs{}, false),
)
rtr.Get("/", func(_ http.ResponseWriter, r *http.Request) {
roles := httpmw.AuthorizationUserRoles(r)
roles := httpmw.UserAuthorization(r)
require.ElementsMatch(t, user.ID, roles.ID)
require.ElementsMatch(t, expRoles, roles.Roles)
})
@ -124,6 +124,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -51,6 +51,7 @@ func TestOrganizationParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext()))

View File

@ -51,6 +51,7 @@ func TestTemplateParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -51,6 +51,7 @@ func TestTemplateVersionParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -45,6 +45,7 @@ func TestUserParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -51,6 +51,7 @@ func TestWorkspaceAgentParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -51,6 +51,7 @@ func TestWorkspaceBuildParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -54,6 +54,7 @@ func TestWorkspaceParam(t *testing.T) {
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
@ -359,6 +360,7 @@ func setupWorkspaceWithAgents(t testing.TB, cfg setupConfig) (database.Store, *h
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)

View File

@ -21,7 +21,7 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
member := httpmw.OrganizationMemberParam(r)
apiKey := httpmw.APIKey(r)
actorRoles := httpmw.AuthorizationUserRoles(r)
actorRoles := httpmw.UserAuthorization(r)
if apiKey.UserID == member.UserID {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{

View File

@ -38,6 +38,7 @@ func TestActiveUsers(t *testing.T) {
_, _ = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
UserID: uuid.New(),
LastUsed: database.Now(),
Scope: database.APIKeyScopeAll,
})
return db
},
@ -49,12 +50,14 @@ func TestActiveUsers(t *testing.T) {
_, _ = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
UserID: uuid.New(),
LastUsed: database.Now(),
Scope: database.APIKeyScopeAll,
})
// Because this API key hasn't been used in the past hour, this shouldn't
// add to the user count.
_, _ = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
UserID: uuid.New(),
LastUsed: database.Now().Add(-2 * time.Hour),
Scope: database.APIKeyScopeAll,
})
return db
},
@ -66,10 +69,12 @@ func TestActiveUsers(t *testing.T) {
_, _ = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
UserID: uuid.New(),
LastUsed: database.Now(),
Scope: database.APIKeyScopeAll,
})
_, _ = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
UserID: uuid.New(),
LastUsed: database.Now(),
Scope: database.APIKeyScopeAll,
})
return db
},

View File

@ -77,6 +77,7 @@ func TestProvisionerJobLogs_Unit(t *testing.T) {
UserID: userID,
ExpiresAt: time.Now().Add(5 * time.Hour),
LoginType: database.LoginTypePassword,
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
_, err = fDB.InsertUser(ctx, database.InsertUserParams{

View File

@ -13,8 +13,8 @@ import (
)
type Authorizer interface {
ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error)
ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error)
}
type PreparedAuthorized interface {
@ -24,7 +24,7 @@ type PreparedAuthorized interface {
// Filter takes in a list of objects, and will filter the list removing all
// the elements the subject does not have permission for. All objects must be
// of the same type.
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) ([]O, error) {
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, action Action, objects []O) ([]O, error) {
ctx, span := tracing.StartSpan(ctx, trace.WithAttributes(
attribute.String("subject_id", subjID),
attribute.StringSlice("subject_roles", subjRoles),
@ -39,7 +39,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub
objectType := objects[0].RBACObject().Type
filtered := make([]O, 0)
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, action, objectType)
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
@ -63,6 +63,8 @@ type RegoAuthorizer struct {
query rego.PreparedEvalQuery
}
var _ Authorizer = (*RegoAuthorizer)(nil)
// Load the policy from policy.rego in this directory.
//
//go:embed policy.rego
@ -91,13 +93,31 @@ type authSubject struct {
// ByRoleName will expand all roleNames into roles before calling Authorize().
// This is the function intended to be used outside this package.
// The role is fetched from the builtin map located in memory.
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error {
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error {
roles, err := RolesByNames(roleNames)
if err != nil {
return err
}
return a.Authorize(ctx, subjectID, roles, action, object)
err = a.Authorize(ctx, subjectID, roles, action, object)
if err != nil {
return err
}
// If the scope isn't "any", we need to check with the scope's role as well.
if scope != ScopeAll {
scopeRole, err := ScopeRole(scope)
if err != nil {
return err
}
err = a.Authorize(ctx, subjectID, []Role{scopeRole}, action, object)
if err != nil {
return err
}
}
return nil
}
// Authorize allows passing in custom Roles.
@ -129,11 +149,11 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*PartialAuthorizer, error) {
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
auth, err := newPartialAuthorizer(ctx, subjectID, roles, action, objectType)
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, action, objectType)
if err != nil {
return nil, xerrors.Errorf("new partial authorizer: %w", err)
}
@ -141,7 +161,7 @@ func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Rol
return auth, nil
}
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error) {
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@ -150,5 +170,5 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
return nil, err
}
return a.Prepare(ctx, subjectID, roles, action, objectType)
return a.Prepare(ctx, subjectID, roles, scope, action, objectType)
}

View File

@ -3,6 +3,7 @@ package rbac
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/google/uuid"
@ -13,7 +14,6 @@ import (
"github.com/coder/coder/testutil"
)
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
// For the unit test we want to pass in the roles directly, instead of just
@ -42,7 +42,7 @@ func TestFilterError(t *testing.T) {
auth, err := NewAuthorizer()
require.NoError(t, err)
_, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace})
_, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, ActionRead, []Object{ResourceUser, ResourceWorkspace})
require.ErrorContains(t, err, "object types must be uniform")
}
@ -75,6 +75,7 @@ func TestFilter(t *testing.T) {
SubjectID string
Roles []string
Action Action
Scope Scope
ObjectType string
}{
{
@ -139,6 +140,13 @@ func TestFilter(t *testing.T) {
ObjectType: ResourceOrganization.Type,
Action: ActionRead,
},
{
Name: "ScopeApplicationConnect",
SubjectID: userIDs[0].String(),
Roles: []string{RoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
}
for _, tc := range testCases {
@ -154,11 +162,16 @@ func TestFilter(t *testing.T) {
auth, err := NewAuthorizer()
require.NoError(t, err, "new auth")
scope := ScopeAll
if tc.Scope != "" {
scope = tc.Scope
}
// Run auth 1 by 1
var allowedCount int
for i, obj := range localObjects {
obj.Type = tc.ObjectType
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, ActionRead, obj.RBACObject())
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, ActionRead, obj.RBACObject())
obj.Allowed = err == nil
if err == nil {
allowedCount++
@ -167,7 +180,7 @@ func TestFilter(t *testing.T) {
}
// Run by filter
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, tc.Action, localObjects)
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, tc.Action, localObjects)
require.NoError(t, err)
require.Equal(t, allowedCount, len(list), "expected number of allowed")
for _, obj := range list {
@ -614,6 +627,36 @@ func TestAuthorizeLevels(t *testing.T) {
}))
}
func TestAuthorizeScope(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unusedID := uuid.New()
user := subject{
UserID: "me",
Roles: []Role{},
}
user.Roles = []Role{must(ScopeRole(ScopeApplicationConnect))}
testAuthorize(t, "Admin_ScopeApplicationConnect", user, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
// Allowed by scope:
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID), actions: []Action{ActionCreate}, allow: true},
})
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
@ -636,13 +679,20 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
authorizer, err := NewAuthorizer()
require.NoError(t, err)
for _, cases := range sets {
for _, c := range cases {
t.Run(name, func(t *testing.T) {
for i, c := range cases {
c := c
if c.resource.Type != "application_connect" {
continue
}
caseName := fmt.Sprintf("%s/%d", name, i)
t.Run(caseName, func(t *testing.T) {
t.Parallel()
for _, a := range c.actions {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, a, c.resource)
// Logging only
if authError != nil {
var uerr *UnauthorizedError
@ -666,23 +716,35 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
assert.Error(t, authError, "expected unauthorized")
}
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, a, c.resource.Type)
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, ScopeAll, a, c.resource.Type)
require.NoError(t, err, "make prepared authorizer")
// Also check the rego policy can form a valid partial query result.
// This ensures we can convert the queries into SQL WHERE clauses in the future.
// If this function returns 'Support' sections, then we cannot convert the query into SQL.
if len(partialAuthz.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.input)
if len(partialAuthz.mainAuthorizer.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.mainAuthorizer.input)
t.Logf("input: %s", string(d))
for _, q := range partialAuthz.partialQueries.Queries {
for _, q := range partialAuthz.mainAuthorizer.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
for _, s := range partialAuthz.partialQueries.Support {
for _, s := range partialAuthz.mainAuthorizer.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
}
require.Equal(t, 0, len(partialAuthz.partialQueries.Support), "expected 0 support rules")
if partialAuthz.scopeAuthorizer != nil {
if len(partialAuthz.scopeAuthorizer.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.scopeAuthorizer.input)
t.Logf("scope input: %s", string(d))
for _, q := range partialAuthz.scopeAuthorizer.partialQueries.Queries {
t.Logf("scope query: %+v", q.String())
}
for _, s := range partialAuthz.scopeAuthorizer.partialQueries.Support {
t.Logf("scope support: %+v", s.String())
}
}
require.Equal(t, 0, len(partialAuthz.scopeAuthorizer.partialQueries.Support), "expected 0 support rules in scope authorizer")
}
partialErr := partialAuthz.Authorize(ctx, c.resource)
if authError != nil {

View File

@ -33,28 +33,33 @@ func BenchmarkRBACFilter(b *testing.B) {
Name string
Roles []string
UserID uuid.UUID
Scope rbac.Scope
}{
{
Name: "NoRoles",
Roles: []string{},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "Admin",
// Give some extra roles that an admin might have
Roles: []string{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "OrgAdmin",
Roles: []string{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "OrgMember",
// Member of 2 orgs
Roles: []string{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgMember(orgs[1]), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "ManyRoles",
@ -66,6 +71,14 @@ func BenchmarkRBACFilter(b *testing.B) {
rbac.RoleMember(),
},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "AdminWithScope",
// Give some extra roles that an admin might have
Roles: []string{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeApplicationConnect,
},
}
@ -77,7 +90,7 @@ func BenchmarkRBACFilter(b *testing.B) {
b.Run(c.Name, func(b *testing.B) {
objects := benchmarkSetup(orgs, users, b.N)
b.ResetTimer()
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, rbac.ActionRead, objects)
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, rbac.ActionRead, objects)
require.NoError(b, err)
var _ = allowed
})
@ -184,6 +197,16 @@ func TestRolePermissions(t *testing.T) {
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin},
},
},
{
Name: "MyWorkspaceInOrgAppConnect",
// When creating the WithID won't be set, but it does not change the result.
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
Resource: rbac.ResourceWorkspaceApplicationConnect.InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]authSubject{
true: {owner, orgAdmin, orgMemberMe},
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin},
},
},
{
Name: "Templates",
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
@ -335,7 +358,8 @@ func TestRolePermissions(t *testing.T) {
for _, subj := range subjs {
delete(remainingSubjs, subj.Name)
msg := fmt.Sprintf("%s as %q doing %q on %q", c.Name, subj.Name, action, c.Resource.Type)
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, action, c.Resource)
// TODO: scopey
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, action, c.Resource)
if result {
assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg))
} else {

View File

@ -31,6 +31,15 @@ var (
Type: "workspace_execution",
}
// ResourceWorkspaceApplicationConnect CRUD. Org + User owner
// create = connect to an application
// read = ?
// update = ?
// delete = ?
ResourceWorkspaceApplicationConnect = Object{
Type: "application_connect",
}
// ResourceAuditLog
// read = access audit log
ResourceAuditLog = Object{

View File

@ -11,6 +11,59 @@ import (
)
type PartialAuthorizer struct {
// mainAuthorizer is used for the user's roles. It is always not-nil.
mainAuthorizer *subPartialAuthorizer
// scopeAuthorizer is used for the API key scope. It may be nil.
scopeAuthorizer *subPartialAuthorizer
}
var _ PreparedAuthorized = (*PartialAuthorizer)(nil)
func (pa *PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
err := pa.mainAuthorizer.Authorize(ctx, object)
if err != nil {
return err
}
if pa.scopeAuthorizer != nil {
return pa.scopeAuthorizer.Authorize(ctx, object)
}
return nil
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
pAuth, err := newSubPartialAuthorizer(ctx, subjectID, roles, action, objectType)
if err != nil {
return nil, err
}
var scopeAuth *subPartialAuthorizer
if scope != ScopeAll {
scopeRole, err := ScopeRole(scope)
if err != nil {
return nil, xerrors.Errorf("unknown scope %q", scope)
}
scopeAuth, err = newSubPartialAuthorizer(ctx, subjectID, []Role{scopeRole}, action, objectType)
if err != nil {
return nil, err
}
}
return &PartialAuthorizer{
mainAuthorizer: pAuth,
scopeAuthorizer: scopeAuth,
}, nil
}
type subPartialAuthorizer struct {
// partialQueries is mainly used for unit testing to assert our rego policy
// can always be compressed into a set of queries.
partialQueries *rego.PartialQueries
@ -25,7 +78,7 @@ type PartialAuthorizer struct {
alwaysTrue bool
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*PartialAuthorizer, error) {
func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*subPartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@ -55,7 +108,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a
return nil, xerrors.Errorf("prepare: %w", err)
}
pAuth := &PartialAuthorizer{
pAuth := &subPartialAuthorizer{
partialQueries: partialQueries,
preparedQueries: []rego.PreparedEvalQuery{},
input: input,
@ -87,7 +140,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a
}
// Authorize authorizes a single object using the partially prepared queries.
func (a PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
func (a subPartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()

46
coderd/rbac/scopes.go Normal file
View File

@ -0,0 +1,46 @@
package rbac
import (
"fmt"
"golang.org/x/xerrors"
)
type Scope string
const (
ScopeAll Scope = "all"
ScopeApplicationConnect Scope = "application_connect"
)
var builtinScopes map[Scope]Role = map[Scope]Role{
// ScopeAll is a special scope that allows access to all resources. During
// authorize checks it is usually not used directly and skips scope checks.
ScopeAll: {
Name: fmt.Sprintf("Scope_%s", ScopeAll),
DisplayName: "All operations",
Site: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
Org: map[string][]Permission{},
User: []Permission{},
},
ScopeApplicationConnect: {
Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect),
DisplayName: "Ability to connect to applications",
Site: permissions(map[Object][]Action{
ResourceWorkspaceApplicationConnect: {ActionCreate},
}),
Org: map[string][]Permission{},
User: []Permission{},
},
}
func ScopeRole(scope Scope) (Role, error) {
role, ok := builtinScopes[scope]
if !ok {
return Role{}, xerrors.Errorf("no scope named %q", scope)
}
return role, nil
}

View File

@ -13,7 +13,7 @@ import (
// assignableSiteRoles returns all site wide roles that can be assigned.
func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
actorRoles := httpmw.AuthorizationUserRoles(r)
actorRoles := httpmw.UserAuthorization(r)
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceRoleAssignment) {
httpapi.Forbidden(rw)
return
@ -26,7 +26,7 @@ func (api *API) assignableSiteRoles(rw http.ResponseWriter, r *http.Request) {
// assignableSiteRoles returns all site wide roles that can be assigned.
func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
organization := httpmw.OrganizationParam(r)
actorRoles := httpmw.AuthorizationUserRoles(r)
actorRoles := httpmw.UserAuthorization(r)
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceOrgRoleAssignment.InOrg(organization.ID)) {
httpapi.Forbidden(rw)
@ -39,6 +39,7 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
func (api *API) checkPermissions(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) {
httpapi.ResourceNotFound(rw)
@ -69,7 +70,7 @@ func (api *API) checkPermissions(rw http.ResponseWriter, r *http.Request) {
if v.Object.OwnerID == "me" {
v.Object.OwnerID = roles.ID.String()
}
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action),
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, apiKey.Scope.ToRBAC(), rbac.Action(v.Action),
rbac.Object{
Owner: v.Object.OwnerID,
OrgID: v.Object.OrganizationID,

View File

@ -34,6 +34,7 @@ func TestTelemetry(t *testing.T) {
_, err := db.InsertAPIKey(ctx, database.InsertAPIKeyParams{
ID: uuid.NewString(),
LastUsed: database.Now(),
Scope: database.APIKeyScopeAll,
})
require.NoError(t, err)
_, err = db.InsertParameterSchema(ctx, database.InsertParameterSchemaParams{

View File

@ -696,7 +696,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
var (
// User is the user to modify.
user = httpmw.UserParam(r)
actorRoles = httpmw.AuthorizationUserRoles(r)
actorRoles = httpmw.UserAuthorization(r)
apiKey = httpmw.APIKey(r)
aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{
Features: api.FeaturesService,
@ -1073,6 +1073,7 @@ func (api *API) createAPIKey(r *http.Request, params createAPIKeyParams) (*http.
UpdatedAt: database.Now(),
HashedSecret: hashed[:],
LoginType: params.LoginType,
Scope: database.APIKeyScopeAll,
})
if err != nil {
return nil, xerrors.Errorf("insert API key: %w", err)

View File

@ -26,7 +26,7 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) {
if !api.Authorize(r, rbac.ActionCreate, workspace.ApplicationConnectRBAC()) {
httpapi.ResourceNotFound(rw)
return
}
@ -127,7 +127,7 @@ type proxyApplication struct {
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ExecutionRBAC()) {
if !api.Authorize(r, rbac.ActionCreate, proxyApp.Workspace.ApplicationConnectRBAC()) {
httpapi.ResourceNotFound(rw)
return
}