diff --git a/coderd/authorize.go b/coderd/authorize.go index b98ad5e20f83c..d09892e7fef0a 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -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(), @@ -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), diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 9b1e83a252ee6..2388cf0aace8c 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -369,6 +369,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { type authCall struct { SubjectID string Roles []string + Scope string Action rbac.Action Object rbac.Object } @@ -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, } diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6acc5d156bdac..50df3824a4385 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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 diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index a36f04d003474..e07a141347100 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1,5 +1,12 @@ -- Code generated by 'make coderd/database/generate'. DO NOT EDIT. +CREATE TYPE api_key_scope AS ENUM ( + 'any', + 'devurls', + 'agent', + 'readonly' +); + CREATE TYPE audit_action AS ENUM ( 'create', 'write', @@ -93,7 +100,8 @@ CREATE TABLE api_keys ( oauth_access_token text DEFAULT ''::text NOT NULL, oauth_refresh_token text DEFAULT ''::text NOT NULL, oauth_id_token text DEFAULT ''::text NOT NULL, - oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL + oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, + scope api_key_scope DEFAULT 'any'::public.api_key_scope NOT NULL ); CREATE TABLE audit_logs ( diff --git a/coderd/database/migrations/000016_api_key_permissions.down.sql b/coderd/database/migrations/000016_api_key_permissions.down.sql new file mode 100644 index 0000000000000..4f50aaff5bdd2 --- /dev/null +++ b/coderd/database/migrations/000016_api_key_permissions.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE api_keys DROP COLUMN scope; + +DROP TYPE api_key_scope; diff --git a/coderd/database/migrations/000016_api_key_permissions.up.sql b/coderd/database/migrations/000016_api_key_permissions.up.sql new file mode 100644 index 0000000000000..e5cfb72202539 --- /dev/null +++ b/coderd/database/migrations/000016_api_key_permissions.up.sql @@ -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'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 900bfa1940acf..1ac62cd2a0a6d 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -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()) @@ -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 +} diff --git a/coderd/database/models.go b/coderd/database/models.go index dae316781354e..c6d8039bafc49 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -14,6 +14,27 @@ import ( "github.com/tabbed/pqtype" ) +type ApiKeyScope string + +const ( + ApiKeyScopeAny ApiKeyScope = "any" + ApiKeyScopeDevurls ApiKeyScope = "devurls" + ApiKeyScopeAgent ApiKeyScope = "agent" + ApiKeyScopeReadonly ApiKeyScope = "readonly" +) + +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 ( @@ -292,18 +313,19 @@ func (e *WorkspaceTransition) Scan(src interface{}) error { } type APIKey struct { - ID string `db:"id" json:"id"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - 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"` - OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` - OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` - OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` - OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + ID string `db:"id" json:"id"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + 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"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + Scope ApiKeyScope `db:"scope" json:"scope"` } type AuditLog struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 137fe36b4456b..5943c2792d66c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -17,7 +17,7 @@ import ( const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, scope FROM api_keys WHERE @@ -42,6 +42,7 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.OAuthRefreshToken, &i.OAuthIDToken, &i.OAuthExpiry, + &i.Scope, ) return i, err } @@ -60,25 +61,27 @@ 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 id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, scope ` type InsertAPIKeyParams struct { - ID string `db:"id" json:"id"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - 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"` - OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` - OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` - OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` - OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + ID string `db:"id" json:"id"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + 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"` + OAuthAccessToken string `db:"oauth_access_token" json:"oauth_access_token"` + OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"` + OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"` + OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"` + Scope ApiKeyScope `db:"scope" json:"scope"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -95,6 +98,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.OAuthRefreshToken, arg.OAuthIDToken, arg.OAuthExpiry, + arg.Scope, ) var i APIKey err := row.Scan( @@ -110,6 +114,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.OAuthRefreshToken, &i.OAuthIDToken, &i.OAuthExpiry, + &i.Scope, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 1af2016f491bf..34e775d530d07 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -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 diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 3be2877426134..2f214e949297c 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -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 ( @@ -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) { @@ -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) @@ -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) @@ -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) @@ -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{ diff --git a/coderd/httpmw/authorize.go b/coderd/httpmw/authorize.go index 84bf7cbfa04b4..48e2da5b74497 100644 --- a/coderd/httpmw/authorize.go +++ b/coderd/httpmw/authorize.go @@ -4,6 +4,8 @@ import ( "context" "net/http" + "github.com/google/uuid" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" ) @@ -11,9 +13,16 @@ import ( // 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") } @@ -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)) }) } diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 0e2466da403cd..3c03286c9ac48 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -124,6 +124,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index ec01d992bd7fe..fe27f1ce21ab4 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -52,6 +52,7 @@ func TestOrganizationParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext())) diff --git a/coderd/httpmw/templateparam_test.go b/coderd/httpmw/templateparam_test.go index 201961ba26c54..682f303d542f6 100644 --- a/coderd/httpmw/templateparam_test.go +++ b/coderd/httpmw/templateparam_test.go @@ -52,6 +52,7 @@ func TestTemplateParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/httpmw/templateversionparam_test.go b/coderd/httpmw/templateversionparam_test.go index d4487b183b788..a18e600502736 100644 --- a/coderd/httpmw/templateversionparam_test.go +++ b/coderd/httpmw/templateversionparam_test.go @@ -52,6 +52,7 @@ func TestTemplateVersionParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index d7a467d65940c..686b7be1b8cc8 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -46,6 +46,7 @@ func TestUserParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go index c985234824458..ae5bbdfd7cbf5 100644 --- a/coderd/httpmw/workspaceagentparam_test.go +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -52,6 +52,7 @@ func TestWorkspaceAgentParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/httpmw/workspacebuildparam_test.go b/coderd/httpmw/workspacebuildparam_test.go index 7ed74e274cc9b..d52ef337af601 100644 --- a/coderd/httpmw/workspacebuildparam_test.go +++ b/coderd/httpmw/workspacebuildparam_test.go @@ -52,6 +52,7 @@ func TestWorkspaceBuildParam(t *testing.T) { HashedSecret: hashed[:], LastUsed: database.Now(), ExpiresAt: database.Now().Add(time.Minute), + Scope: database.ApiKeyScopeAny, }) require.NoError(t, err) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index b9f9e1f825e9e..7c009f720d239 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -10,7 +10,7 @@ import ( ) type Authorizer interface { - ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error + ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope string, action Action, object Object) error } // Filter takes in a list of objects, and will filter the list removing all @@ -18,12 +18,12 @@ type Authorizer interface { // Filter does not allocate a new slice, and will use the existing one // passed in. This can cause memory leaks if the slice is held for a prolonged // period of time. -func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, action Action, objects []O) []O { +func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope string, action Action, objects []O) []O { filtered := make([]O, 0) for i := range objects { object := objects[i] - err := auth.ByRoleName(ctx, subjID, subjRoles, action, object.RBACObject()) + err := auth.ByRoleName(ctx, subjID, subjRoles, scope, action, object.RBACObject()) if err == nil { filtered = append(filtered, object) } @@ -63,7 +63,7 @@ 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 string, action Action, object Object) error { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { r, err := RoleByName(n) @@ -72,7 +72,13 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa } roles = append(roles, r) } - return a.Authorize(ctx, subjectID, roles, action, object) + err := a.Authorize(ctx, subjectID, roles, action, object) + if err != nil { + return err + } + + scopeRole := builtinScopes[scope] + return a.Authorize(ctx, subjectID, []Role{scopeRole}, action, object) } // Authorize allows passing in custom Roles. diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index e1ab601ee30eb..d98d61ca4e7f9 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -99,12 +99,12 @@ func TestFilter(t *testing.T) { t.Run(c.Name, func(t *testing.T) { t.Parallel() authorizer := fakeAuthorizer{ - AuthFunc: func(_ context.Context, _ string, _ []string, _ rbac.Action, object rbac.Object) error { + AuthFunc: func(_ context.Context, _ string, _ []string, _ string, _ rbac.Action, object rbac.Object) error { return c.Auth(object) }, } - filtered := rbac.Filter(context.Background(), authorizer, "me", []string{}, rbac.ActionRead, c.List) + filtered := rbac.Filter(context.Background(), authorizer, "me", []string{}, rbac.ActionRead, rbac.ScopeAny, c.List) require.ElementsMatch(t, c.Expected, filtered, "expect same list") require.Equal(t, len(c.Expected), len(filtered), "same length list") }) diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go index d68f697719acd..527e7204ef773 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -237,7 +237,7 @@ 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) + err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAny, action, c.Resource) if result { assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg)) } else { diff --git a/coderd/rbac/fake_test.go b/coderd/rbac/fake_test.go index e8f6a90394cc7..c74d050f04df5 100644 --- a/coderd/rbac/fake_test.go +++ b/coderd/rbac/fake_test.go @@ -7,9 +7,9 @@ import ( ) type fakeAuthorizer struct { - AuthFunc func(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error + AuthFunc func(ctx context.Context, subjectID string, roleNames []string, scopeName string, action rbac.Action, object rbac.Object) error } -func (f fakeAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error { - return f.AuthFunc(ctx, subjectID, roleNames, action, object) +func (f fakeAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scopeName string, action rbac.Action, object rbac.Object) error { + return f.AuthFunc(ctx, subjectID, roleNames, scopeName, action, object) } diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go new file mode 100644 index 0000000000000..ed1646b4fff46 --- /dev/null +++ b/coderd/rbac/scopes.go @@ -0,0 +1,38 @@ +package rbac + +import "golang.org/x/xerrors" + +const ( + ScopeAny = "any" + ScopeReadonly = "readonly" +) + +var builtinScopes map[string]Role = map[string]Role{ + ScopeAny: { + Name: ScopeAny, + DisplayName: "Any operation", + Site: permissions(map[Object][]Action{ + ResourceWildcard: {WildcardSymbol}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + + ScopeReadonly: { + Name: ScopeReadonly, + DisplayName: "Only read operations", + Site: permissions(map[Object][]Action{ + ResourceWildcard: {ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, +} + +func ScopeByName(name string) (Role, error) { + scope, ok := builtinScopes[name] + if !ok { + return Role{}, xerrors.Errorf("no role named %q", name) + } + return scope, nil +} diff --git a/coderd/roles.go b/coderd/roles.go index 3843124fbc45d..c068a1aa4d05a 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -68,7 +68,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, rbac.ScopeAny, rbac.Action(v.Action), rbac.Object{ ResourceID: v.Object.ResourceID, Owner: v.Object.OwnerID, diff --git a/coderd/userauth.go b/coderd/userauth.go index 5838f933e2571..7c23edef660fd 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -156,6 +156,7 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { OAuthAccessToken: state.Token.AccessToken, OAuthRefreshToken: state.Token.RefreshToken, OAuthExpiry: state.Token.Expiry, + Scope: database.ApiKeyScopeAny, }) if !created { return diff --git a/coderd/users.go b/coderd/users.go index 8e927a9466d7b..d99b64b29b3af 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -637,9 +637,20 @@ func (api *API) postLogin(rw http.ResponseWriter, r *http.Request) { return } + scope := database.ApiKeyScopeAny + if loginWithPassword.Scope != "" { + scope, err = database.MakeApiKeyScope(loginWithPassword.Scope) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: "invalid scope", + }) + } + } + sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypePassword, + Scope: scope, }) if !created { return @@ -661,6 +672,7 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) { sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ UserID: user.ID, LoginType: database.LoginTypePassword, + Scope: database.ApiKeyScopeAny, }) if !created { return @@ -722,6 +734,7 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat OAuthRefreshToken: params.OAuthRefreshToken, OAuthIDToken: params.OAuthIDToken, OAuthExpiry: params.OAuthExpiry, + Scope: params.Scope, }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1b841414cf531..a154414768af9 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -310,6 +310,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req }) return } + if !api.Authorize(rw, r, rbac.ActionCreate, database.Workspace{OrganizationID: organization.ID, OwnerID: apiKey.UserID}) { + return + } _, err = api.Database.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{ OrganizationID: template.OrganizationID, UserID: apiKey.UserID, diff --git a/codersdk/users.go b/codersdk/users.go index 61faa398d0fa6..16a87ab1398e0 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -131,6 +131,7 @@ type UserAuthorizationObject struct { type LoginWithPasswordRequest struct { Email string `json:"email" validate:"required,email"` Password string `json:"password" validate:"required"` + Scope string `json:"scope,omitempty"` } // LoginWithPasswordResponse contains a session token for the newly authenticated user. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 57bc83ebc6dc3..3283a6096dfee 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -12,7 +12,7 @@ export interface AgentGitSSHKey { readonly private_key: string } -// From codersdk/users.go:151:6 +// From codersdk/users.go:152:6 export interface AuthMethods { readonly password: boolean readonly github: boolean @@ -44,7 +44,7 @@ export interface CreateFirstUserResponse { readonly organization_id: string } -// From codersdk/users.go:146:6 +// From codersdk/users.go:147:6 export interface CreateOrganizationRequest { readonly name: string } @@ -100,7 +100,7 @@ export interface CreateWorkspaceRequest { readonly parameter_values?: CreateParameterRequest[] } -// From codersdk/users.go:142:6 +// From codersdk/users.go:143:6 export interface GenerateAPIKeyResponse { readonly key: string } @@ -122,9 +122,10 @@ export interface GoogleInstanceIdentityToken { export interface LoginWithPasswordRequest { readonly email: string readonly password: string + readonly scope?: string } -// From codersdk/users.go:137:6 +// From codersdk/users.go:138:6 export interface LoginWithPasswordResponse { readonly session_token: string }