mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
490 lines
17 KiB
Go
490 lines
17 KiB
Go
package oasimpl
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v3"
|
|
"github.com/ory/fosite"
|
|
"github.com/ory/fosite/compose"
|
|
"github.com/ory/fosite/storage"
|
|
"github.com/ory/fosite/token/jwt"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/grafana/grafana/pkg/api/routing"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"github.com/grafana/grafana/pkg/infra/localcache"
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/infra/slugify"
|
|
"github.com/grafana/grafana/pkg/models/roletype"
|
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/api"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/store"
|
|
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/org"
|
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
|
"github.com/grafana/grafana/pkg/services/signingkeys"
|
|
"github.com/grafana/grafana/pkg/services/team"
|
|
"github.com/grafana/grafana/pkg/services/user"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/util/errutil"
|
|
)
|
|
|
|
const (
|
|
cacheExpirationTime = 5 * time.Minute
|
|
cacheCleanupInterval = 5 * time.Minute
|
|
)
|
|
|
|
type OAuth2ServiceImpl struct {
|
|
cache *localcache.CacheService
|
|
memstore *storage.MemoryStore
|
|
cfg *setting.Cfg
|
|
sqlstore oauthserver.Store
|
|
oauthProvider fosite.OAuth2Provider
|
|
logger log.Logger
|
|
accessControl ac.AccessControl
|
|
acService ac.Service
|
|
saService serviceaccounts.Service
|
|
userService user.Service
|
|
teamService team.Service
|
|
publicKey any
|
|
}
|
|
|
|
func ProvideService(router routing.RouteRegister, db db.DB, cfg *setting.Cfg,
|
|
svcAccSvc serviceaccounts.Service, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service,
|
|
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) {
|
|
if !fmgmt.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
|
|
return nil, nil
|
|
}
|
|
config := &fosite.Config{
|
|
AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan,
|
|
TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL),
|
|
AccessTokenIssuer: cfg.AppURL,
|
|
IDTokenIssuer: cfg.AppURL,
|
|
ScopeStrategy: fosite.WildcardScopeStrategy,
|
|
}
|
|
|
|
s := &OAuth2ServiceImpl{
|
|
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
|
|
cfg: cfg,
|
|
accessControl: accessControl,
|
|
acService: acSvc,
|
|
memstore: storage.NewMemoryStore(),
|
|
sqlstore: store.NewStore(db),
|
|
logger: log.New("oauthserver"),
|
|
userService: userSvc,
|
|
saService: svcAccSvc,
|
|
teamService: teamSvc,
|
|
}
|
|
|
|
api := api.NewAPI(router, s)
|
|
api.RegisterAPIEndpoints()
|
|
|
|
s.oauthProvider = newProvider(config, s, keySvc)
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func newProvider(config *fosite.Config, storage any, signingKeyService signingkeys.Service) fosite.OAuth2Provider {
|
|
keyGetter := func(ctx context.Context) (any, error) {
|
|
_, key, err := signingKeyService.GetOrCreatePrivateKey(ctx, signingkeys.ServerPrivateKeyID, jose.ES256)
|
|
return key, err
|
|
}
|
|
return compose.Compose(
|
|
config,
|
|
storage,
|
|
&compose.CommonStrategy{
|
|
CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config),
|
|
Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter},
|
|
},
|
|
compose.OAuth2ClientCredentialsGrantFactory,
|
|
compose.RFC7523AssertionGrantFactory,
|
|
|
|
compose.OAuth2TokenIntrospectionFactory,
|
|
compose.OAuth2TokenRevocationFactory,
|
|
)
|
|
}
|
|
|
|
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
|
|
// SignedInUser from the associated service account.
|
|
// For performance reason, the service uses caching.
|
|
func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.ExternalService, error) {
|
|
entry, ok := s.cache.Get(id)
|
|
if ok {
|
|
client, ok := entry.(oauthserver.ExternalService)
|
|
if ok {
|
|
s.logger.Debug("GetExternalService: cache hit", "id", id)
|
|
return &client, nil
|
|
}
|
|
}
|
|
|
|
client, err := s.sqlstore.GetExternalService(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Handle the case where the external service has no service account
|
|
if client.ServiceAccountID == oauthserver.NoServiceAccountID {
|
|
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "id", id, "name", client.Name)
|
|
// Create a signed in user with no role and no permissions
|
|
client.SignedInUser = &user.SignedInUser{
|
|
UserID: oauthserver.NoServiceAccountID,
|
|
OrgID: oauthserver.TmpOrgID,
|
|
Name: client.Name,
|
|
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
|
|
}
|
|
s.cache.Set(id, *client, cacheExpirationTime)
|
|
return client, nil
|
|
}
|
|
|
|
// Retrieve self permissions and generate a signed in user
|
|
s.logger.Debug("GetExternalService: fetch permissions", "client id", id)
|
|
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID)
|
|
if err != nil {
|
|
s.logger.Error("GetExternalService: error fetching service account", "id", id, "error", err)
|
|
return nil, err
|
|
}
|
|
client.SignedInUser = &user.SignedInUser{
|
|
UserID: sa.Id,
|
|
OrgID: oauthserver.TmpOrgID,
|
|
OrgRole: org.RoleType(sa.Role), // Need this to compute the permissions in OSS
|
|
Login: sa.Login,
|
|
Name: sa.Name,
|
|
Permissions: map[int64]map[string][]string{},
|
|
}
|
|
client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{})
|
|
if err != nil {
|
|
s.logger.Error("GetExternalService: error fetching permissions", "id", id, "error", err)
|
|
return nil, err
|
|
}
|
|
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions)
|
|
|
|
s.cache.Set(id, *client, cacheExpirationTime)
|
|
return client, nil
|
|
}
|
|
|
|
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
|
|
// it ensures that the associated service account has the correct permissions.
|
|
// Database consistency is not guaranteed, consider changing this in the future.
|
|
func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *oauthserver.ExternalServiceRegistration) (*oauthserver.ExternalServiceDTO, error) {
|
|
if registration == nil {
|
|
s.logger.Warn("RegisterExternalService called without registration")
|
|
return nil, nil
|
|
}
|
|
s.logger.Info("Registering external service", "external service name", registration.Name)
|
|
|
|
// Check if the client already exists in store
|
|
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, registration.Name)
|
|
if errFetchExtSvc != nil {
|
|
var srcError errutil.Error
|
|
if errors.As(errFetchExtSvc, &srcError) {
|
|
if srcError.MessageID != oauthserver.ErrClientNotFoundMessageID {
|
|
s.logger.Error("Error fetching service", "external service", registration.Name, "error", errFetchExtSvc)
|
|
return nil, errFetchExtSvc
|
|
}
|
|
}
|
|
}
|
|
// Otherwise, create a new client
|
|
if client == nil {
|
|
s.logger.Debug("External service does not yet exist", "external service name", registration.Name)
|
|
client = &oauthserver.ExternalService{
|
|
Name: registration.Name,
|
|
ServiceAccountID: oauthserver.NoServiceAccountID,
|
|
Audiences: s.cfg.AppURL,
|
|
}
|
|
}
|
|
|
|
// Parse registration form to compute required permissions for the client
|
|
client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration)
|
|
|
|
if registration.RedirectURI != nil {
|
|
client.RedirectURI = *registration.RedirectURI
|
|
}
|
|
|
|
var errGenCred error
|
|
client.ClientID, client.Secret, errGenCred = s.genCredentials()
|
|
if errGenCred != nil {
|
|
s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred)
|
|
return nil, errGenCred
|
|
}
|
|
|
|
s.logger.Debug("Save service account")
|
|
saID, errSaveServiceAccount := s.saveServiceAccount(ctx, client.Name, client.ServiceAccountID, client.SelfPermissions)
|
|
if errSaveServiceAccount != nil {
|
|
return nil, errSaveServiceAccount
|
|
}
|
|
client.ServiceAccountID = saID
|
|
|
|
grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled)
|
|
client.GrantTypes = strings.Join(grantTypes, ",")
|
|
|
|
// Handle key options
|
|
s.logger.Debug("Handle key options")
|
|
keys, err := s.handleKeyOptions(ctx, registration.Key)
|
|
if err != nil {
|
|
s.logger.Error("Error handling key options", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
if keys != nil {
|
|
client.PublicPem = []byte(keys.PublicPem)
|
|
}
|
|
dto := client.ToDTO()
|
|
dto.KeyResult = keys
|
|
|
|
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
client.Secret = string(hashedSecret)
|
|
|
|
err = s.sqlstore.SaveExternalService(ctx, client)
|
|
if err != nil {
|
|
s.logger.Error("Error saving external service", "client", client.LogID(), "error", err)
|
|
return nil, err
|
|
}
|
|
s.logger.Debug("Registered", "client", client.LogID())
|
|
return dto, nil
|
|
}
|
|
|
|
// randString generates a a cryptographically secure random string of n bytes
|
|
func (s *OAuth2ServiceImpl) randString(n int) (string, error) {
|
|
res := make([]byte, n)
|
|
if _, err := rand.Read(res); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(res), nil
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) {
|
|
id, err := s.randString(20)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
// client_secret must be at least 32 bytes long
|
|
secret, err := s.randString(32)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return id, secret, err
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string {
|
|
grantTypes := []string{}
|
|
|
|
if selfAccessEnabled {
|
|
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
|
|
}
|
|
|
|
if impersonationEnabled {
|
|
grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer))
|
|
}
|
|
|
|
return grantTypes
|
|
}
|
|
|
|
func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *oauthserver.KeyOption) (*oauthserver.KeyResult, error) {
|
|
if keyOption == nil {
|
|
return nil, fmt.Errorf("keyOption is nil")
|
|
}
|
|
|
|
var publicPem, privatePem string
|
|
|
|
if keyOption.Generate {
|
|
switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient {
|
|
case "RSA":
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicPem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PUBLIC KEY",
|
|
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
|
|
}))
|
|
privatePem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
|
}))
|
|
s.logger.Debug("RSA key has been generated")
|
|
default: // default to ECDSA
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
publicPem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Bytes: publicDer,
|
|
}))
|
|
privatePem = string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privateDer,
|
|
}))
|
|
s.logger.Debug("ECDSA key has been generated")
|
|
}
|
|
|
|
return &oauthserver.KeyResult{
|
|
PrivatePem: privatePem,
|
|
PublicPem: publicPem,
|
|
Generated: true,
|
|
}, nil
|
|
}
|
|
|
|
// TODO MVP allow specifying a URL to get the public key
|
|
// if registration.Key.URL != "" {
|
|
// return &oauthserver.KeyResult{
|
|
// URL: registration.Key.URL,
|
|
// }, nil
|
|
// }
|
|
|
|
if keyOption.PublicPEM != "" {
|
|
pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM)
|
|
if err != nil {
|
|
s.logger.Error("Cannot decode base64 encoded PEM string", "error", err)
|
|
}
|
|
_, err = utils.ParsePublicKeyPem(pemEncoded)
|
|
if err != nil {
|
|
s.logger.Error("Cannot parse PEM encoded string", "error", err)
|
|
return nil, err
|
|
}
|
|
return &oauthserver.KeyResult{
|
|
PublicPem: string(pemEncoded),
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("at least one key option must be specified")
|
|
}
|
|
|
|
// saveServiceAccount creates a service account if the service account ID is NoServiceAccountID, otherwise it updates the service account's permissions
|
|
func (s *OAuth2ServiceImpl) saveServiceAccount(ctx context.Context, extSvcName string, saID int64, permissions []ac.Permission) (int64, error) {
|
|
if saID == oauthserver.NoServiceAccountID {
|
|
// Create a service account
|
|
s.logger.Debug("Create service account", "external service name", extSvcName)
|
|
return s.createServiceAccount(ctx, extSvcName, permissions)
|
|
}
|
|
|
|
// check if the service account exists
|
|
s.logger.Debug("Update service account", "external service name", extSvcName)
|
|
sa, err := s.saService.RetrieveServiceAccount(ctx, oauthserver.TmpOrgID, saID)
|
|
if err != nil {
|
|
s.logger.Error("Error retrieving service account", "external service name", extSvcName, "error", err)
|
|
return oauthserver.NoServiceAccountID, err
|
|
}
|
|
|
|
// update the service account's permissions
|
|
if len(permissions) > 0 {
|
|
s.logger.Debug("Update role permissions", "external service name", extSvcName, "saID", saID)
|
|
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
|
|
OrgID: ac.GlobalOrgID,
|
|
Global: true,
|
|
ExternalServiceID: extSvcName,
|
|
ServiceAccountID: sa.Id,
|
|
Permissions: permissions,
|
|
}); err != nil {
|
|
return oauthserver.NoServiceAccountID, err
|
|
}
|
|
return saID, nil
|
|
}
|
|
|
|
// remove the service account
|
|
errDelete := s.deleteServiceAccount(ctx, extSvcName, sa.Id)
|
|
return oauthserver.NoServiceAccountID, errDelete
|
|
}
|
|
|
|
// deleteServiceAccount deletes a service account by ID and removes its associated role
|
|
func (s *OAuth2ServiceImpl) deleteServiceAccount(ctx context.Context, extSvcName string, saID int64) error {
|
|
s.logger.Debug("Delete service account", "external service name", extSvcName, "saID", saID)
|
|
if err := s.saService.DeleteServiceAccount(ctx, oauthserver.TmpOrgID, saID); err != nil {
|
|
return err
|
|
}
|
|
return s.acService.DeleteExternalServiceRole(ctx, extSvcName)
|
|
}
|
|
|
|
// createServiceAccount creates a service account with the given permissions and returns the ID of the service account
|
|
// When no permission is given, the account isn't created and NoServiceAccountID is returned
|
|
// This first design does not use a single transaction for the whole service account creation process => database consistency is not guaranteed.
|
|
// Consider changing this in the future.
|
|
func (s *OAuth2ServiceImpl) createServiceAccount(ctx context.Context, extSvcName string, permissions []ac.Permission) (int64, error) {
|
|
if len(permissions) == 0 {
|
|
// No permission, no service account
|
|
s.logger.Debug("No permission, no service account", "external service name", extSvcName)
|
|
return oauthserver.NoServiceAccountID, nil
|
|
}
|
|
|
|
newRole := func(r roletype.RoleType) *roletype.RoleType {
|
|
return &r
|
|
}
|
|
newBool := func(b bool) *bool {
|
|
return &b
|
|
}
|
|
|
|
slug := slugify.Slugify(extSvcName)
|
|
|
|
s.logger.Debug("Generate service account", "external service name", extSvcName, "orgID", oauthserver.TmpOrgID, "name", slug)
|
|
sa, err := s.saService.CreateServiceAccount(ctx, oauthserver.TmpOrgID, &serviceaccounts.CreateServiceAccountForm{
|
|
Name: slug,
|
|
Role: newRole(roletype.RoleViewer), // FIXME: Use empty role
|
|
IsDisabled: newBool(false),
|
|
})
|
|
if err != nil {
|
|
return oauthserver.NoServiceAccountID, err
|
|
}
|
|
|
|
s.logger.Debug("Create tailored role for service account", "external service name", extSvcName, "name", slug, "service_account_id", sa.Id, "permissions", permissions)
|
|
if err := s.acService.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
|
|
OrgID: ac.GlobalOrgID,
|
|
Global: true,
|
|
ExternalServiceID: slug,
|
|
ServiceAccountID: sa.Id,
|
|
Permissions: permissions,
|
|
}); err != nil {
|
|
return oauthserver.NoServiceAccountID, err
|
|
}
|
|
|
|
return sa.Id, nil
|
|
}
|
|
|
|
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
|
|
// permissions when impersonation is requested
|
|
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *oauthserver.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) {
|
|
selfPermissions := []ac.Permission{}
|
|
impersonatePermissions := []ac.Permission{}
|
|
|
|
if registration.Self.Enabled {
|
|
selfPermissions = append(selfPermissions, registration.Self.Permissions...)
|
|
}
|
|
if registration.Impersonation.Enabled {
|
|
requiredForToken := []ac.Permission{
|
|
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
|
|
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
|
|
}
|
|
if registration.Impersonation.Groups {
|
|
requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
|
|
}
|
|
impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...)
|
|
selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll})
|
|
}
|
|
return selfPermissions, impersonatePermissions
|
|
}
|