From fbcc82ab174bf996798e094d7ece4389f5b76c16 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 20 Nov 2024 15:49:42 +0300 Subject: [PATCH] POC/Provisioning: Add service for client with authorization (#96755) --- .../apis/provisioning/auth/identity.go | 172 ++++++++++++++++++ pkg/registry/apis/provisioning/client.go | 68 +++++++ pkg/registry/apis/provisioning/hello.go | 31 ++++ pkg/registry/apis/provisioning/register.go | 18 +- pkg/server/wire.go | 2 + pkg/services/apiserver/service.go | 18 ++ 6 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 pkg/registry/apis/provisioning/auth/identity.go create mode 100644 pkg/registry/apis/provisioning/client.go diff --git a/pkg/registry/apis/provisioning/auth/identity.go b/pkg/registry/apis/provisioning/auth/identity.go new file mode 100644 index 00000000000..cba6805d72e --- /dev/null +++ b/pkg/registry/apis/provisioning/auth/identity.go @@ -0,0 +1,172 @@ +package auth + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + "golang.org/x/net/context" + "k8s.io/client-go/rest" + + "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/services/user" +) + +type BackgroundIdentityService interface { + WorkerIdentity(ctx context.Context, namespace string) (identity.Requester, error) + + // This will return a rest.Config with a worker identity attached in the context + RestConfigForNamespace(ctx context.Context, namespace string) (*rest.Config, error) +} + +func ProvideProvisioningIdentityService( + serviceAccounts serviceaccounts.Service, + authn authn.Service, + clientConfigProvider apiserver.DirectRestConfigProvider, + // HACK, for now we will use a global grafana admin user + // service accounts need permissions granted (as far as i can tell) + users user.Service, + orgs org.Service, +) BackgroundIdentityService { + prefix := "provisioning-background-worker" + + return &backgroundIdentities{ + serviceAccountNamePrefix: prefix, + role: org.RoleAdmin, + clientConfigProvider: clientConfigProvider, + + log: slog.Default().With("logger", "background-identities", "prefix", prefix), + accounts: make(map[int64]string), + serviceAccounts: serviceAccounts, + authn: authn, + users: users, + orgs: orgs, + } +} + +type backgroundIdentities struct { + log *slog.Logger + + serviceAccountNamePrefix string + role org.RoleType + clientConfigProvider apiserver.DirectRestConfigProvider + + // typed ids + accounts map[int64]string + mutex sync.Mutex + + users user.Service + serviceAccounts serviceaccounts.Service + authn authn.Service + orgs org.Service +} + +func (o *backgroundIdentities) WorkerIdentity(ctx context.Context, namespace string) (identity.Requester, error) { + info, err := claims.ParseNamespace(namespace) + if err != nil { + return nil, err + } + + o.mutex.Lock() + defer o.mutex.Unlock() + + id, ok := o.accounts[info.OrgID] + if !ok { + if false { // <<<<< HACK + id, err = o.makeAdminUser(ctx, info.OrgID) + } else { + id, err = o.verifyServiceAccount(ctx, info.OrgID) + } + + if err != nil { + return nil, err + } + + o.accounts[info.OrgID] = id + } + + return o.authn.ResolveIdentity(ctx, info.OrgID, id) +} + +func (o *backgroundIdentities) makeAdminUser(ctx context.Context, orgId int64) (string, error) { + found, err := o.users.GetByLogin(ctx, &user.GetUserByLoginQuery{ + LoginOrEmail: o.serviceAccountNamePrefix, + }) + if found == nil { + // Same user across all orgs?? + found, err = o.users.Create(ctx, &user.CreateUserCommand{ + Login: o.serviceAccountNamePrefix, + Name: o.serviceAccountNamePrefix, + Email: o.serviceAccountNamePrefix, + OrgID: orgId, + IsAdmin: true, + DefaultOrgRole: string(org.RoleAdmin), + }) + } + if err != nil { + return "", err + } + + // Make sure the user exists in the org + err = o.orgs.UpdateOrgUser(ctx, &org.UpdateOrgUserCommand{ + Role: identity.RoleAdmin, + OrgID: orgId, + UserID: found.ID, + }) + + return claims.NewTypeID(claims.TypeUser, found.UID), err +} + +func (o *backgroundIdentities) verifyServiceAccount(ctx context.Context, orgId int64) (string, error) { + serviceAccountName := fmt.Sprintf("%s-org-%d", o.serviceAccountNamePrefix, orgId) + saForm := serviceaccounts.CreateServiceAccountForm{ + Name: serviceAccountName, + Role: &o.role, + } + + serviceAccount, err := o.serviceAccounts.CreateServiceAccount(ctx, orgId, &saForm) + if serviceAccount == nil { + accountAlreadyExists := errors.Is(err, serviceaccounts.ErrServiceAccountAlreadyExists) + if accountAlreadyExists { + accountId, err := o.serviceAccounts.RetrieveServiceAccountIdByName(ctx, orgId, serviceAccountName) + if err != nil { + o.log.Error("Failed to retrieve service account", "err", err, "accountName", serviceAccountName) + return "", err + } + // update org_role to make sure everything works properly if someone has changed the role since SA's original creation + dto, err := o.serviceAccounts.UpdateServiceAccount(ctx, orgId, accountId, &serviceaccounts.UpdateServiceAccountForm{ + Name: &serviceAccountName, + Role: &o.role, + }) + if err != nil { + o.log.Error("Failed to update service account", "err", err, "accountName", serviceAccountName) + return "", err + } + + serviceAccount = &serviceaccounts.ServiceAccountDTO{ + Id: dto.Id, + UID: dto.UID, + } + } + } + if serviceAccount == nil { + o.log.Error("Failed to retrieve service account", "err", err, "accountName", serviceAccountName) + return "", err + } + + return claims.NewTypeID(claims.TypeServiceAccount, fmt.Sprintf("%d", serviceAccount.Id)), nil +} + +func (o *backgroundIdentities) RestConfigForNamespace(ctx context.Context, namespace string) (*rest.Config, error) { + id, err := o.WorkerIdentity(ctx, namespace) + if err != nil { + return nil, err + } + return o.clientConfigProvider.GetRestConfigForBackgroundWorker(id), nil +} diff --git a/pkg/registry/apis/provisioning/client.go b/pkg/registry/apis/provisioning/client.go new file mode 100644 index 00000000000..52d29c7c98f --- /dev/null +++ b/pkg/registry/apis/provisioning/client.go @@ -0,0 +1,68 @@ +package provisioning + +import ( + "context" + "sync" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/auth" +) + +type resourceClient struct { + identities auth.BackgroundIdentityService + client map[string]*dynamic.DynamicClient + clientMu sync.Mutex + + kinds map[schema.GroupVersionKind]schema.GroupVersionResource + kindMu sync.Mutex +} + +func newResourceClient(identities auth.BackgroundIdentityService) *resourceClient { + return &resourceClient{ + identities: identities, + client: make(map[string]*dynamic.DynamicClient), + } +} + +func (c *resourceClient) Client(ns string) (*dynamic.DynamicClient, error) { + c.clientMu.Lock() + defer c.clientMu.Unlock() + + client, ok := c.client[ns] + if ok { + return client, nil + } + + config, err := c.identities.RestConfigForNamespace(context.Background(), ns) + if err != nil { + return nil, err + } + + client, err = dynamic.NewForConfig(config) + if err != nil { + return nil, err + } + c.client[ns] = client + return client, nil +} + +func (c *resourceClient) GVR(ns string, gvk schema.GroupVersionKind) (schema.GroupVersionResource, bool) { + c.kindMu.Lock() + defer c.kindMu.Unlock() + + // TODO... this needs to find the right GVR for a GVK + ok := true + var gvr schema.GroupVersionResource + switch gvk.Kind { + case "Dashboard": + gvr = gvk.GroupVersion().WithResource("dashboards") + case "Playlist": + gvr = gvk.GroupVersion().WithResource("playlists") + default: + ok = false + } + + return gvr, ok +} diff --git a/pkg/registry/apis/provisioning/hello.go b/pkg/registry/apis/provisioning/hello.go index d46fe098885..26998e206fd 100644 --- a/pkg/registry/apis/provisioning/hello.go +++ b/pkg/registry/apis/provisioning/hello.go @@ -8,14 +8,18 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1" "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" ) type helloWorldSubresource struct { getter rest.Getter statusUpdater rest.Updater + // for our hacky test access right now + parent *ProvisioningAPIBuilder } func (*helloWorldSubresource) New() runtime.Object { @@ -89,6 +93,33 @@ func (s *helloWorldSubresource) Connect(ctx context.Context, name string, opts r slog.InfoContext(ctx, "the conspicuous boolean", "bool", b) } + write := r.URL.Query().Get("list") + if write != "" { + gvr, ok := s.parent.client.GVR(repo.Namespace, schema.GroupVersionKind{ + Group: v1alpha1.GROUP, // dashboard.grafana.app + Version: v1alpha1.VERSION, // "v0alpha1", + Kind: "Dashboard", + }) + if !ok { + responder.Error(fmt.Errorf("no dashboard gvr")) + return + } + + client, err := s.parent.client.Client(repo.Namespace) + if err != nil { + responder.Error(err) + return + } + + ttt, err := client.Resource(gvr).Namespace(repo.Namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + responder.Error(err) + return + } + responder.Object(200, ttt) // proxy the list + return + } + slog.InfoContext(ctx, "Got a repository", "name", repo.Name, "type", repo.Spec.Type, diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index f535f6b32b5..392c9caaf89 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -30,6 +30,7 @@ import ( provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/auth" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" @@ -42,12 +43,13 @@ var ( // This is used just so wire has something unique to return type ProvisioningAPIBuilder struct { - urlProvider func(namespace string) string - webhookSecreteKey string + urlProvider func(namespace string) string + webhookSecretKey string getter rest.Getter localFileResolver *LocalFolderResolver logger *slog.Logger + client *resourceClient } // This constructor will be called when building a multi-tenant apiserveer @@ -57,21 +59,24 @@ func NewProvisioningAPIBuilder( local *LocalFolderResolver, urlProvider func(namespace string) string, webhookSecreteKey string, + identities auth.BackgroundIdentityService, ) *ProvisioningAPIBuilder { return &ProvisioningAPIBuilder{ urlProvider: urlProvider, localFileResolver: local, logger: slog.Default().With("logger", "provisioning-api-builder"), - webhookSecreteKey: webhookSecreteKey, + webhookSecretKey: webhookSecreteKey, + client: newResourceClient(identities), } } func RegisterAPIService( - // It is OK to use settigns.Cfg here -- this is only used when running single tenant with a full setup + // It is OK to use setting.Cfg here -- this is only used when running single tenant with a full setup cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, reg prometheus.Registerer, + identities auth.BackgroundIdentityService, ) *ProvisioningAPIBuilder { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis @@ -81,7 +86,7 @@ func RegisterAPIService( DevenvPath: filepath.Join(cfg.HomePath, "devenv"), }, func(namespace string) string { return cfg.AppURL - }, cfg.SecretKey) + }, cfg.SecretKey, identities) apiregistration.RegisterAPI(builder) return builder } @@ -136,6 +141,7 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv helloWorld := &helloWorldSubresource{ getter: repositoryStorage, statusUpdater: repositoryStatusStorage, + parent: b, } storage := map[string]rest.Storage{} @@ -508,7 +514,7 @@ spec: // using the configured secret key. The generated secret is consistent. // TODO: this must be replaced by a app platform secrets once we have that. func (b *ProvisioningAPIBuilder) generateWebhookSecret(token string) string { - secretKey := []byte(b.webhookSecreteKey) + secretKey := []byte(b.webhookSecretKey) h := hmac.New(sha256.New, secretKey) h.Write([]byte(token)) diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 05e00a995e7..a2637bcee55 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -36,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/middleware/csrf" "github.com/grafana/grafana/pkg/middleware/loggermw" apiregistry "github.com/grafana/grafana/pkg/registry/apis" + provisioningauth "github.com/grafana/grafana/pkg/registry/apis/provisioning/auth" appregistry "github.com/grafana/grafana/pkg/registry/apps" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -228,6 +229,7 @@ var wireBasicSet = wire.NewSet( authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, + provisioningauth.ProvideProvisioningIdentityService, datasourceproxy.ProvideService, search.ProvideService, searchV2.ProvideService, diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index c0e416f3bc3..0b0334907b4 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -95,6 +95,9 @@ type DirectRestConfigProvider interface { // creating clients that map legacy API handlers to k8s backed services GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config + // Get a rest client with the given identity + GetRestConfigForBackgroundWorker(requester identity.Requester) *clientrest.Config + // This can be used to rewrite incoming requests to path now supported under /apis DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) } @@ -514,6 +517,21 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co } } +func (s *service) GetRestConfigForBackgroundWorker(requester identity.Requester) *clientrest.Config { + return &clientrest.Config{ + Transport: &roundTripperFunc{ + fn: func(req *http.Request) (*http.Response, error) { + if err := s.NamedService.AwaitRunning(req.Context()); err != nil { + return nil, err + } + ctx := identity.WithRequester(context.Background(), requester) + wrapped := grafanaresponsewriter.WrapHandler(s.handler) + return wrapped(req.WithContext(ctx)) + }, + }, + } +} + func (s *service) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) { if err := s.NamedService.AwaitRunning(r.Context()); err != nil { return