From 6dbb6408d44257f690c160b15ae43fa960fc96ff Mon Sep 17 00:00:00 2001 From: Jeremy Price Date: Wed, 20 Oct 2021 14:36:11 +0200 Subject: [PATCH] Access Control: Add service accounts (#38994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add extra fields to OSS types to support enterprise * Create a service account at the same time as the API key * Use service account credentials when accessing API with APIkey * Add GetRole to service, merge RoleDTO and Role structs This patch merges the identical OSS and Enterprise data structures, which improves the code for two reasons: 1. Makes switching between OSS and Enterprise easier 2. Reduces the chance of incompatibilities developing between the same functions in OSS and Enterprise * If API key is not linked to a service account, continue login as usual * Fallback to old auth if no service account linked to key * Add CloneUserToServiceAccount * Adding LinkAPIKeyToServiceAccount * Handle api key link error * Better error messages for OSS accesscontrol * Set an invalid user id as default * Re-arrange field names * ServiceAccountId is integer * Better error messages Co-authored-by: Hugo Häggmark Co-authored-by: Eric Leijonmarck Co-authored-by: Emil Tullstedt Co-authored-by: Ieva Co-authored-by: Gabriel MABILLE --- pkg/api/apikey.go | 11 ++++ pkg/models/apikey.go | 29 +++++---- pkg/models/user.go | 25 ++++---- pkg/services/accesscontrol/accesscontrol.go | 9 +++ pkg/services/accesscontrol/mock/mock.go | 64 +++++++++++++++---- .../ossaccesscontrol/ossaccesscontrol.go | 16 +++++ pkg/services/contexthandler/contexthandler.go | 30 +++++++-- pkg/services/sqlstore/apikey.go | 16 +++-- .../sqlstore/migrations/apikey_mig.go | 4 ++ 9 files changed, 156 insertions(+), 48 deletions(-) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index 2336af4d34b..5c42b5e5574 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -72,6 +72,17 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext, cmd models.AddApiKeyComman } } cmd.OrgId = c.OrgId + var err error + var serviceAccount *models.User = &models.User{Id: -1} + if hs.Cfg.FeatureToggles["service-accounts"] { + if cmd.CreateNewServiceAccount { + serviceAccount, err = hs.AccessControl.CloneUserToServiceAccount(c.Req.Context(), c.SignedInUser) + if err != nil { + return response.Error(500, "Unable to clone user to service account", err) + } + cmd.ServiceAccountId = serviceAccount.Id + } + } newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name) if err != nil { diff --git a/pkg/models/apikey.go b/pkg/models/apikey.go index ddde171735b..9c05105d10d 100644 --- a/pkg/models/apikey.go +++ b/pkg/models/apikey.go @@ -13,24 +13,27 @@ var ( ) type ApiKey struct { - Id int64 - OrgId int64 - Name string - Key string - Role RoleType - Created time.Time - Updated time.Time - Expires *int64 + Id int64 + OrgId int64 + Name string + Key string + Role RoleType + Created time.Time + Updated time.Time + Expires *int64 + ServiceAccountId int64 } // --------------------- // COMMANDS type AddApiKeyCommand struct { - Name string `json:"name" binding:"Required"` - Role RoleType `json:"role" binding:"Required"` - OrgId int64 `json:"-"` - Key string `json:"-"` - SecondsToLive int64 `json:"secondsToLive"` + Name string `json:"name" binding:"Required"` + Role RoleType `json:"role" binding:"Required"` + OrgId int64 `json:"-"` + Key string `json:"-"` + SecondsToLive int64 `json:"secondsToLive"` + ServiceAccountId int64 `json:"serviceAccount"` + CreateNewServiceAccount bool `json:"createServiceAccount"` Result *ApiKey `json:"-"` } diff --git a/pkg/models/user.go b/pkg/models/user.go index f987a76f95b..0a19bce31a6 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -56,18 +56,19 @@ func (u *User) NameOrFallback() string { // COMMANDS type CreateUserCommand struct { - Email string - Login string - Name string - Company string - OrgId int64 - OrgName string - Password string - EmailVerified bool - IsAdmin bool - IsDisabled bool - SkipOrgSetup bool - DefaultOrgRole string + Email string + Login string + Name string + Company string + OrgId int64 + OrgName string + Password string + EmailVerified bool + IsAdmin bool + IsDisabled bool + SkipOrgSetup bool + DefaultOrgRole string + IsServiceAccount bool Result User } diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 7514a102617..5961caae77d 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -14,6 +14,15 @@ type AccessControl interface { // GetUserPermissions returns user permissions. GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*Permission, error) + // GetUserRoles returns user roles. + GetUserRoles(ctx context.Context, user *models.SignedInUser) ([]*RoleDTO, error) + + // CloneUserToServiceAccount Creates a new service account and assigns it the same roles as the user has + CloneUserToServiceAccount(ctx context.Context, user *models.SignedInUser) (*models.User, error) + + // LinkAPIKeyToServiceAccount Connects an APIkey to a service account. Multiple API keys may be linked to one account. + LinkAPIKeyToServiceAccount(ctx context.Context, ApiKey *models.ApiKey, serviceAccount *models.User) error + //IsDisabled returns if access control is enabled or not IsDisabled() bool diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 1cf6ebcf856..af90dd3dfaa 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -14,17 +14,24 @@ type fullAccessControl interface { } type Calls struct { - Evaluate []interface{} - GetUserPermissions []interface{} - IsDisabled []interface{} - DeclareFixedRoles []interface{} - GetUserBuiltInRoles []interface{} - RegisterFixedRoles []interface{} + CloneUserToServiceAccount []interface{} + Evaluate []interface{} + GetUserPermissions []interface{} + GetUserRoles []interface{} + IsDisabled []interface{} + DeclareFixedRoles []interface{} + GetUserBuiltInRoles []interface{} + RegisterFixedRoles []interface{} + LinkAPIKeyToServiceAccount []interface{} } type Mock struct { + // Unless an override is provided, user will be returned by CloneUserToServiceAccount + createduser *models.User // Unless an override is provided, permissions will be returned by GetUserPermissions permissions []*accesscontrol.Permission + // Unless an override is provided, roles will be returned by GetUserRoles + roles []*accesscontrol.RoleDTO // Unless an override is provided, disabled will be returned by IsDisabled disabled bool // Unless an override is provided, builtInRoles will be returned by GetUserBuiltInRoles @@ -34,12 +41,15 @@ type Mock struct { Calls Calls // Override functions - EvaluateFunc func(context.Context, *models.SignedInUser, accesscontrol.Evaluator) (bool, error) - GetUserPermissionsFunc func(context.Context, *models.SignedInUser) ([]*accesscontrol.Permission, error) - IsDisabledFunc func() bool - DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error - GetUserBuiltInRolesFunc func(user *models.SignedInUser) []string - RegisterFixedRolesFunc func() error + CloneUserToServiceAccountFunc func(context.Context, *models.SignedInUser) (*models.User, error) + LinkAPIKeyToServiceAccountFunc func(context.Context, *models.ApiKey, *models.User) error + EvaluateFunc func(context.Context, *models.SignedInUser, accesscontrol.Evaluator) (bool, error) + GetUserPermissionsFunc func(context.Context, *models.SignedInUser) ([]*accesscontrol.Permission, error) + GetUserRolesFunc func(context.Context, *models.SignedInUser) ([]*accesscontrol.RoleDTO, error) + IsDisabledFunc func() bool + DeclareFixedRolesFunc func(...accesscontrol.RoleRegistration) error + GetUserBuiltInRolesFunc func(user *models.SignedInUser) []string + RegisterFixedRolesFunc func() error } // Ensure the mock stays in line with the interface @@ -99,6 +109,36 @@ func (m *Mock) GetUserPermissions(ctx context.Context, user *models.SignedInUser return m.permissions, nil } +func (m *Mock) GetUserRoles(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.RoleDTO, error) { + m.Calls.GetUserRoles = append(m.Calls.GetUserRoles, []interface{}{ctx, user}) + // Use override if provided + if m.GetUserRolesFunc != nil { + return m.GetUserRolesFunc(ctx, user) + } + // Otherwise return the Roles list + return m.roles, nil +} + +func (m *Mock) CloneUserToServiceAccount(ctx context.Context, user *models.SignedInUser) (*models.User, error) { + m.Calls.CloneUserToServiceAccount = append(m.Calls.CloneUserToServiceAccount, []interface{}{ctx, user}) + // Use override if provided + if m.CloneUserToServiceAccountFunc != nil { + return m.CloneUserToServiceAccountFunc(ctx, user) + } + // Otherwise return the user + return m.createduser, nil +} + +func (m *Mock) LinkAPIKeyToServiceAccount(ctx context.Context, apikey *models.ApiKey, service_account *models.User) error { + m.Calls.LinkAPIKeyToServiceAccount = append(m.Calls.LinkAPIKeyToServiceAccount, []interface{}{ctx, apikey, service_account}) + // Use override if provided + if m.LinkAPIKeyToServiceAccountFunc != nil { + return m.LinkAPIKeyToServiceAccountFunc(ctx, apikey, service_account) + } + // Otherwise return the default + return nil +} + // Middleware checks if service disabled or not to switch to fallback authorization. // This mock return m.disabled unless an override is provided. func (m *Mock) IsDisabled() bool { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 18eade800d6..9ad49c05313 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -2,6 +2,7 @@ package ossaccesscontrol import ( "context" + "errors" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -71,6 +72,21 @@ func (ac *OSSAccessControlService) Evaluate(ctx context.Context, user *models.Si return evaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)) } +// GetUserRoles returns user permissions based on built-in roles +func (ac *OSSAccessControlService) GetUserRoles(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.RoleDTO, error) { + return nil, errors.New("unsupported function") //OSS users will continue to use builtin roles via GetUserPermissions +} + +// CloneUserToServiceAccount creates a service account with permissions based on a user +func (ac *OSSAccessControlService) CloneUserToServiceAccount(ctx context.Context, user *models.SignedInUser) (*models.User, error) { + return nil, errors.New("clone user not implemented yet in service accounts") //Please switch on Enterprise to test this +} + +// Link creates a service account with permissions based on a user +func (ac *OSSAccessControlService) LinkAPIKeyToServiceAccount(context.Context, *models.ApiKey, *models.User) error { + return errors.New("link SA not implemented yet in service accounts") //Please switch on Enterprise to test this +} + // GetUserPermissions returns user permissions based on built-in roles func (ac *OSSAccessControlService) GetUserPermissions(ctx context.Context, user *models.SignedInUser) ([]*accesscontrol.Permission, error) { timer := prometheus.NewTimer(metrics.MAccessPermissionsSummary) diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index cec307e6a58..cf673755d17 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -218,11 +218,33 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo return true } + if apikey.ServiceAccountId < 1 { //There is no service account attached to the apikey + //Use the old APIkey method. This provides backwards compatibility. + reqContext.SignedInUser = &models.SignedInUser{} + reqContext.OrgRole = apikey.Role + reqContext.ApiKeyId = apikey.Id + reqContext.OrgId = apikey.OrgId + reqContext.IsSignedIn = true + return true + } + + //There is a service account attached to the API key + + //Use service account linked to API key as the signed in user + query := models.GetSignedInUserQuery{UserId: apikey.ServiceAccountId, OrgId: apikey.OrgId} + if err := bus.Dispatch(&query); err != nil { + reqContext.Logger.Error( + "Failed to link API key to service account in", + "id", query.UserId, + "org", query.OrgId, + "err", err, + ) + reqContext.JsonApiErr(500, "Unable to link API key to service account", err) + return true + } + reqContext.IsSignedIn = true - reqContext.SignedInUser = &models.SignedInUser{} - reqContext.OrgRole = apikey.Role - reqContext.ApiKeyId = apikey.Id - reqContext.OrgId = apikey.OrgId + reqContext.SignedInUser = query.Result return true } diff --git a/pkg/services/sqlstore/apikey.go b/pkg/services/sqlstore/apikey.go index fa578ae1302..04dab446b16 100644 --- a/pkg/services/sqlstore/apikey.go +++ b/pkg/services/sqlstore/apikey.go @@ -76,14 +76,16 @@ func AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) error { } else if cmd.SecondsToLive < 0 { return models.ErrInvalidApiKeyExpiration } + t := models.ApiKey{ - OrgId: cmd.OrgId, - Name: cmd.Name, - Role: cmd.Role, - Key: cmd.Key, - Created: updated, - Updated: updated, - Expires: expires, + OrgId: cmd.OrgId, + Name: cmd.Name, + Role: cmd.Role, + Key: cmd.Key, + Created: updated, + Updated: updated, + Expires: expires, + ServiceAccountId: cmd.ServiceAccountId, } if _, err := sess.Insert(&t); err != nil { diff --git a/pkg/services/sqlstore/migrations/apikey_mig.go b/pkg/services/sqlstore/migrations/apikey_mig.go index bc3de8ef4c4..f68bae4c7ac 100644 --- a/pkg/services/sqlstore/migrations/apikey_mig.go +++ b/pkg/services/sqlstore/migrations/apikey_mig.go @@ -82,4 +82,8 @@ func addApiKeyMigrations(mg *Migrator) { mg.AddMigration("Add expires to api_key table", NewAddColumnMigration(apiKeyV2, &Column{ Name: "expires", Type: DB_BigInt, Nullable: true, })) + + mg.AddMigration("Add service account foreign key", NewAddColumnMigration(apiKeyV2, &Column{ + Name: "service_account_id", Type: DB_BigInt, Nullable: true, + })) }