diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index d99ce4f..6d6a662 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -17,15 +17,23 @@ jobs: - env: NEWLINE: |2+ - name: Generate Deployment + name: k8ify preview run: | jq -r 'try to_entries | map("\(.key)=\(.value|tostring|tojson)") | .[]' > .composeenv <<'EOF' ${{ env.NEWLINE }}${{ toJson(vars) }}${{ env.NEWLINE }}EOF jq -r 'try to_entries | map("\(.key)=\(.value|tostring|tojson)") | .[]' >> .composeenv <<'EOF' ${{ env.NEWLINE }}${{ toJson(secrets) }}${{ env.NEWLINE }}EOF docker run -v "${PWD}:/data" -w /data --env-file <(env) ghcr.io/vshn/k8ify-appcat:latest /bin/k8ify dev ${CI_COMMIT_REF_SLUG} --shell-env-file .composeenv --modified-image '${{ env.NAMESPACE }}/${{ env.CI_PROJECT_NAME }}:${{ env.CI_COMMIT_REF_SLUG }}' rm .composeenv kubectl diff -f manifests/ || true - - name: Deploy - run: kubectl apply -f manifests/ || true + - env: + NEWLINE: |2+ + + name: k8ify deploy + run: | + jq -r 'try to_entries | map("\(.key)=\(.value|tostring|tojson)") | .[]' > .composeenv <<'EOF' ${{ env.NEWLINE }}${{ toJson(vars) }}${{ env.NEWLINE }}EOF + jq -r 'try to_entries | map("\(.key)=\(.value|tostring|tojson)") | .[]' >> .composeenv <<'EOF' ${{ env.NEWLINE }}${{ toJson(secrets) }}${{ env.NEWLINE }}EOF + docker run -v "${PWD}:/data" -w /data --env-file <(env) ghcr.io/vshn/k8ify-appcat:latest /bin/k8ify dev ${CI_COMMIT_REF_SLUG} --shell-env-file .composeenv --modified-image '${{ env.NAMESPACE }}/${{ env.CI_PROJECT_NAME }}:${{ env.CI_COMMIT_REF_SLUG }}' + rm .composeenv + kubectl apply -f manifests/ name: Build and deploy to dev "on": push: diff --git a/.gitignore b/.gitignore index 463623b..4554b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ grafana-organizations-operator env +.idea/ diff --git a/README.md b/README.md index aeee975..b5b3fde 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,49 @@ # Grafana Organizations Operator -Automatically set up Grafana organizations based on information from the [APPUiO Control API](https://kb.vshn.ch/appuio-cloud/references/architecture/control-api.html). +Automatically set up Grafana organizations based on information in Keycloak. + +## Design + +### Data in Keycloak + +Keycloak holds the APPUiO Cloud organization and user data. + +* APPUiO Cloud organizations are represented as groups with the group path `/organizations/[ORGNAME]` +* Teams within organizations are represented as groups with the path `/organizations/[ORGNAME]/[TEAMNAME]`. +* APPUiO Cloud users are represented as normal Keycloak users. All Keycloak users are potential APPUiO Cloud users. +* User permissions are represented as regular Keycloak group memberships. A user can be a member of an organization or of a team, and can have multiple partially overlapping memberships. +* All members of the group configured via `KEYCLOAK_ADMIN_GROUP_PATH` are considered to be admins and have "Admin" permissions on all organizations. + +The Grafana Organizations Operator does not differentiate between organization and team membership, and does not support team-specific permissions. + +This information is translated into Grafana organizations, users and organization users ("permissions" a user has on an organization). + +The Grafana Organizations Operator only cares about organizations present in Keycloak. This allows other Organizations in Grafana to exist without being touched; but it also means that it will not clean up organizations in Grafana which have been deleted in Keycloak. + +### Issues with the Keycloak API + +* The Keycloak LDAP integration is [buggy when it comes to listing members of a group](https://github.com/keycloak/keycloak/issues/10348) (API endpoint `/auth/admin/realms/[REALM]/groups/[ID]/members`). This would not be an issue for the organization users but it is an issue for the `KEYCLOAK_ADMIN_GROUP_PATH` which may come from LDAP. As a workaround we fetch the group memberships of users instead, however this means one HTTP call per user (easily 1000s of HTTP calls). +* The operator assumes that all Grafana organizations are in the root group `/organizations` + +### Issues with Grafana + +* When a new user is created in Grafana (either by logging in via Keycloak or explicitly created via API), Grafana's `auto_assign_org` "feature" automatically gives the user permission to some organization (whichever is configured). This is almost never what we want. To work around this: + * It would be possible to disable `auto_assign_org`, but then Grafana would create a new organization for every new user, which would be even worse. + * We can't let Grafana create users on demand when they first log in (as that would give the user permission to see the `auto_assign_org`), thus we proactively create Grafana users for all known user, thereby avoiding any situations where Grafana would create a user on demand. + * When we create a Grafana user via API we immediately list its group memberships and remove all of them. This is a bit dangerous because it has the potential for race conditions or could leave unwanted permissions behind in case operator fails between the Grafana API calls, but it's the easiest way to handle this. +* Because the `grafana-api-golang-client` implementation is incomplete we are wrapping it in the GrafanaClient type and add some functionality. +* The Grafana API often ignores the OrgID JSON field. The only workaround for this is to set the HTTP header `x-grafana-org-id`. The GrafanaClient wrapper takes care of this. + +## Development Environment Setup + +In order to develop the operator you need: + +* Read access to Keycloak for all users and groups +* A [Grafana test instance](https://operator-dev-grafana.apps.cloudscale-lpg-2.appuio.cloud/) to write to + +You can run the `gen-dev-env.sh` to set up an environment file (`env`) with the required configuration. + +Once that's done you can source the env file (`. ./env`) and run the operator on your local machine using `go run .`. ## License diff --git a/gen-dev-env.sh b/gen-dev-env.sh new file mode 100755 index 0000000..95e001b --- /dev/null +++ b/gen-dev-env.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e -o pipefail +SECRET="$(kubectl --as cluster-admin -n vshn-appuio-grafana get secret grafana-organizations-operator -ojson)" || (>&2 echo "You must be logged in to 'APPUiO Cloud LPG 2' with cluster-admin permissions for this to work" && exit 1) +echo -n "" > env +for VAL in KEYCLOAK_ADMIN_GROUP_PATH KEYCLOAK_CLIENT_ID KEYCLOAK_PASSWORD KEYCLOAK_REALM KEYCLOAK_URL KEYCLOAK_USERNAME; do + echo -n "export ${VAL}=\"" >> env + echo "${SECRET}" | jq -r ".data.${VAL}" | base64 -d >> env + echo "\"" >> env +done +echo "export GRAFANA_URL=\"https://operator-dev-grafana.apps.cloudscale-lpg-2.appuio.cloud\"" >> env +echo "export GRAFANA_USERNAME=\"admin\"" >> env +echo -n "export GRAFANA_PASSWORD=\"" >> env +kubectl --as cluster-admin -n vshn-grafana-organizations-operator-dev get secret grafana-env -ojsonpath='{.data.GF_SECURITY_ADMIN_PASSWORD}' | base64 -d >> env +echo "\"" >> env diff --git a/go.mod b/go.mod index f6d0639..1139e4c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,8 @@ go 1.20 require ( github.com/appuio/control-api v0.26.0 - github.com/grafana/grafana-api-golang-client v0.21.0 + github.com/grafana/grafana-api-golang-client v0.23.0 + github.com/hashicorp/go-cleanhttp v0.5.2 k8s.io/apimachinery v0.26.2 k8s.io/client-go v0.26.2 k8s.io/klog/v2 v2.90.1 @@ -20,7 +21,6 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect diff --git a/go.sum b/go.sum index 382c7a7..561f759 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,7 @@ github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKf github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -47,6 +48,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/grafana-api-golang-client v0.21.0 h1:PQ2Wfo9jMMiftC4VRMlJxbUNvYCXMV1YFDKm7Ny3SaM= github.com/grafana/grafana-api-golang-client v0.21.0/go.mod h1:24W29gPe9yl0/3A9X624TPkAOR8DpHno490cPwnkv8E= +github.com/grafana/grafana-api-golang-client v0.23.0 h1:Uta0dSkxWYf1D83/E7MRLCG69387FiUc+k9U/35nMhY= +github.com/grafana/grafana-api-golang-client v0.23.0/go.mod h1:24W29gPe9yl0/3A9X624TPkAOR8DpHno490cPwnkv8E= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.15.0 h1:1JYBfzqrWPcCclBwxFCPAou9n+q86mfnu7NAeHfte7A= @@ -56,8 +59,10 @@ github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -71,9 +76,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc= github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= diff --git a/main.go b/main.go index 88b1c24..89cecba 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,9 @@ package main import ( "context" - orgs "github.com/appuio/control-api/apis/organization/v1" - controlapi "github.com/appuio/control-api/apis/v1" controller "github.com/appuio/grafana-organizations-operator/pkg" grafana "github.com/grafana/grafana-api-golang-client" - "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/json" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" "k8s.io/klog/v2" "net/http" "net/url" @@ -20,63 +15,67 @@ import ( ) var ( - ControlApiToken string - ControlApiUrl string - GrafanaUrl string - GrafanaUsername string - GrafanaPassword string + GrafanaUrl string + GrafanaUsername string + GrafanaPassword string + KeycloakUrl string + KeycloakRealm string + KeycloakUsername string + KeycloakPassword string + KeycloakClientId string + KeycloakAdminGroupPath string ) func main() { - ControlApiUrl = os.Getenv("CONTROL_API_URL") - ControlApiToken = os.Getenv("CONTROL_API_TOKEN") GrafanaUrl = os.Getenv("GRAFANA_URL") GrafanaUsername = os.Getenv("GRAFANA_USERNAME") if GrafanaUsername == "" { GrafanaUsername = os.Getenv("admin-user") // env variable name used by Grafana Helm chart. And yes using '-' is stupid because of compatibility issues with some shells. } GrafanaPassword = os.Getenv("GRAFANA_PASSWORD") + GrafanaPasswordHidden := "" if GrafanaPassword == "" { GrafanaPassword = os.Getenv("admin-password") // env variable name used by Grafana Helm chart. And yes using '-' is stupid because of compatibility issues with some shells. } + if GrafanaPassword != "" { + GrafanaPasswordHidden = "***hidden***" + } - klog.Infof("CONTROL_API_URL: %s\n", ControlApiUrl) - klog.Infof("GRAFANA_URL: %s\n", GrafanaUrl) - klog.Infof("GRAFANA_USERNAME: %s\n", GrafanaUsername) - - // Because of the strange design of the k8s client we actually need two client objects, which both internally use the same httpClient. - // To make this work we also need three (!) config objects, a common one for the httpClient and one for each k8s client. - commonConfig := &rest.Config{} - commonConfig.BearerToken = ControlApiToken - httpClient, err := rest.HTTPClientFor(commonConfig) - if err != nil { - klog.Errorf("Could not create Control API httpClient: %v\n", err) - os.Exit(1) + KeycloakUrl = os.Getenv("KEYCLOAK_URL") + KeycloakRealm = os.Getenv("KEYCLOAK_REALM") + KeycloakUsername = os.Getenv("KEYCLOAK_USERNAME") + KeycloakPassword = os.Getenv("KEYCLOAK_PASSWORD") + KeycloakClientId = os.Getenv("KEYCLOAK_CLIENT_ID") + KeycloakPasswordHidden := "" + if KeycloakPassword != "" { + KeycloakPasswordHidden = "***hidden***" } + KeycloakAdminGroupPath = os.Getenv("KEYCLOAK_ADMIN_GROUP_PATH") - organizationAppuioIoConfig := &rest.Config{} - organizationAppuioIoConfig.Host = ControlApiUrl - organizationAppuioIoConfig.APIPath = "/apis" - organizationAppuioIoConfig.GroupVersion = &orgs.GroupVersion - organizationAppuioIoConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme.Scheme) - organizationAppuioIoClient, err := rest.RESTClientForConfigAndClient(organizationAppuioIoConfig, httpClient) + klog.Infof("GRAFANA_URL: %s\n", GrafanaUrl) + klog.Infof("GRAFANA_USERNAME: %s\n", GrafanaUsername) + klog.Infof("GRAFANA_PASSWORD: %s\n", GrafanaPasswordHidden) + klog.Infof("KEYCLOAK_URL: %s\n", KeycloakUrl) + klog.Infof("KEYCLOAK_REALM: %s\n", KeycloakRealm) + klog.Infof("KEYCLOAK_USERNAME: %s\n", KeycloakUsername) + klog.Infof("KEYCLOAK_PASSWORD: %s\n", KeycloakPasswordHidden) + klog.Infof("KEYCLOAK_CLIENT_ID: %s\n", KeycloakClientId) + klog.Infof("KEYCLOAK_ADMIN_GROUP_PATH: %s\n", KeycloakAdminGroupPath) + + keycloakClient, err := controller.NewKeycloakClient(KeycloakUrl, KeycloakRealm, KeycloakUsername, KeycloakPassword, KeycloakClientId, KeycloakAdminGroupPath) if err != nil { - klog.Errorf("Could not create Control API client for organization.appuio.io: %v\n", err) + klog.Errorf("Could not create keycloakClient client: %v\n", err) os.Exit(1) } + defer keycloakClient.CloseIdleConnections() - appuioIoConfig := &rest.Config{} - appuioIoConfig.Host = ControlApiUrl - appuioIoConfig.APIPath = "/apis" - appuioIoConfig.GroupVersion = &controlapi.GroupVersion - appuioIoConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme.Scheme) - appuioIoClient, err := rest.RESTClientForConfigAndClient(appuioIoConfig, httpClient) + grafanaConfig := grafana.Config{Client: http.DefaultClient, BasicAuth: url.UserPassword(GrafanaUsername, GrafanaPassword)} + grafanaClient, err := controller.NewGrafanaClient(GrafanaUrl, grafanaConfig) if err != nil { - klog.Errorf("Could not connect Control API client for appuio.io: %v\n", err) + klog.Errorf("Could not create Grafana client: %v\n", err) os.Exit(1) } - - grafanaConfig := grafana.Config{Client: http.DefaultClient, BasicAuth: url.UserPassword(GrafanaUsername, GrafanaPassword)} + defer grafanaClient.CloseIdleConnections() // ctx will be passed to controller to signal termination ctx, cancel := context.WithCancel(context.Background()) @@ -100,19 +99,19 @@ func main() { json.Unmarshal(db, &dashboard) klog.Info("Starting initial sync...") - err = controller.ReconcileAllOrgs(ctx, organizationAppuioIoClient, appuioIoClient, grafanaConfig, GrafanaUrl, dashboard) + err = controller.Reconcile(ctx, keycloakClient, grafanaClient, dashboard) if err != nil { klog.Errorf("Could not do initial reconciliation: %v\n", err) os.Exit(1) } for { + err = controller.Reconcile(ctx, keycloakClient, grafanaClient, dashboard) select { case <-time.After(10 * time.Second): case <-ctx.Done(): os.Exit(0) } - err = controller.ReconcileAllOrgs(ctx, organizationAppuioIoClient, appuioIoClient, grafanaConfig, GrafanaUrl, dashboard) if err != nil { klog.Errorf("Could not reconcile (will retry): %v\n", err) } diff --git a/pkg/controlApi.go b/pkg/controlApi.go index d7e37eb..a8132f1 100644 --- a/pkg/controlApi.go +++ b/pkg/controlApi.go @@ -4,9 +4,7 @@ import ( "context" orgs "github.com/appuio/control-api/apis/organization/v1" controlapi "github.com/appuio/control-api/apis/v1" - grafana "github.com/grafana/grafana-api-golang-client" "k8s.io/client-go/rest" - "k8s.io/klog/v2" ) // Generate list of control API organizations @@ -36,25 +34,3 @@ func getControlApiUsersMap(ctx context.Context, appuioIoClient *rest.RESTClient) } return appuioControlApiUsersMap, nil } - -// Generate map containing all Grafana users grouped by organization. Key is the organization ID, value is an array of users. -func getControlApiOrganizationUsersMap(ctx context.Context, grafanaUsersMap map[string]grafana.User, appuioIoClient *rest.RESTClient) (map[string][]grafana.User, error) { - appuioControlApiOrganizationMembers := controlapi.OrganizationMembersList{} - err := appuioIoClient.Get().Resource("OrganizationMembers").Do(ctx).Into(&appuioControlApiOrganizationMembers) - if err != nil { - return nil, err - } - controlApiOrganizationUsersMap := make(map[string][]grafana.User) - for _, memberlist := range appuioControlApiOrganizationMembers.Items { - users := []grafana.User{} - for _, userRef := range memberlist.Spec.UserRefs { - if grafanaUser, ok := grafanaUsersMap[userRef.Name]; ok { - users = append(users, grafanaUser) - } else { - klog.Warningf("Organization '%s' should have user %s but the user wasn't synced to Grafana, ignoring", memberlist.Namespace, userRef.Name) - } - } - controlApiOrganizationUsersMap[memberlist.Namespace] = users - } - return controlApiOrganizationUsersMap, nil -} diff --git a/pkg/grafanaClient.go b/pkg/grafanaClient.go new file mode 100644 index 0000000..1f8c2e9 --- /dev/null +++ b/pkg/grafanaClient.go @@ -0,0 +1,173 @@ +package controller + +import ( + "encoding/json" + "fmt" + grafana "github.com/grafana/grafana-api-golang-client" + "io" + "net/http" + "net/url" +) + +type UserOrg struct { + OrgID int64 `json:"orgId"` + Name string `json:"name"` + Role string `json:"role"` +} + +type GrafanaClient struct { + config grafana.Config + baseURL url.URL + client *http.Client + grafanaClient *grafana.Client +} + +func NewGrafanaClient(baseURL string, cfg grafana.Config) (*GrafanaClient, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + if cfg.BasicAuth != nil { + u.User = cfg.BasicAuth + } + + tr := &http.Transport{} // Creating the transport explicitly allows for connection pooling and reuse + cli := &http.Client{Transport: tr} + + cfg.Client = cli + grafanaClient, err := grafana.New(baseURL, cfg) + if err != nil { + return nil, err + } + + return &GrafanaClient{ + config: cfg, + baseURL: *u, + client: cli, + grafanaClient: grafanaClient, + }, nil +} + +func (this GrafanaClient) GetUsername() string { + return this.config.BasicAuth.Username() +} + +// This method is missing in the grafana-api-golang-client, that's the reason why we're wrapping that client at all +func (this GrafanaClient) GetUserOrgs(user grafana.User) ([]UserOrg, error) { + url := this.baseURL.String() + fmt.Sprintf("/api/users/%d/orgs", user.ID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + password, _ := this.config.BasicAuth.Password() + req.SetBasicAuth(this.config.BasicAuth.Username(), password) + r, err := this.client.Do(req) + defer r.Body.Close() + if err != nil { + return nil, err + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + userOrgs := make([]UserOrg, 0) + err = json.Unmarshal(body, &userOrgs) + if err != nil { + return nil, err + } + return userOrgs, nil +} + +func (this GrafanaClient) CloseIdleConnections() { + this.client.CloseIdleConnections() +} + +func (this GrafanaClient) OrgUsers(orgID int64) ([]grafana.OrgUser, error) { + return this.grafanaClient.OrgUsers(orgID) +} + +func (this GrafanaClient) UpdateOrgUser(orgID, userID int64, role string) error { + return this.grafanaClient.UpdateOrgUser(orgID, userID, role) +} + +func (this GrafanaClient) AddOrgUser(orgID int64, user, role string) error { + return this.grafanaClient.AddOrgUser(orgID, user, role) +} + +func (this GrafanaClient) RemoveOrgUser(orgID, userID int64) error { + return this.grafanaClient.RemoveOrgUser(orgID, userID) +} + +func (this GrafanaClient) CreateUser(user grafana.User) (int64, error) { + return this.grafanaClient.CreateUser(user) +} + +func (this GrafanaClient) Users() (users []grafana.UserSearch, err error) { + return this.grafanaClient.Users() +} + +func (this GrafanaClient) UserUpdate(u grafana.User) error { + return this.grafanaClient.UserUpdate(u) +} + +func (this GrafanaClient) DeleteUser(id int64) error { + return this.grafanaClient.DeleteUser(id) +} + +func (this GrafanaClient) Orgs() ([]grafana.Org, error) { + return this.grafanaClient.Orgs() +} + +func (this GrafanaClient) UpdateOrg(id int64, name string) error { + return this.grafanaClient.UpdateOrg(id, name) +} + +func (this GrafanaClient) NewOrg(name string) (int64, error) { + return this.grafanaClient.NewOrg(name) +} + +func (this GrafanaClient) Org(id int64) (grafana.Org, error) { + return this.grafanaClient.Org(id) +} + +func (this GrafanaClient) DeleteOrg(id int64) error { + return this.grafanaClient.DeleteOrg(id) +} + +// We don't just wrap this method, we also work around the bad orgID handling of the original library and Grafana API +func (this GrafanaClient) DataSources(org *grafana.Org) ([]*grafana.DataSource, error) { + return this.grafanaClient.WithOrgID(org.ID).DataSources() +} + +// Ditto +func (this GrafanaClient) UpdateDataSource(org *grafana.Org, s *grafana.DataSource) error { + return this.grafanaClient.WithOrgID(org.ID).UpdateDataSource(s) +} + +// Ditto +func (this GrafanaClient) DeleteDataSource(org *grafana.Org, id int64) error { + return this.grafanaClient.WithOrgID(org.ID).DeleteDataSource(id) +} + +// Ditto +func (this GrafanaClient) NewDataSource(org *grafana.Org, s *grafana.DataSource) (int64, error) { + return this.grafanaClient.WithOrgID(org.ID).NewDataSource(s) +} + +// Ditto +func (this GrafanaClient) DataSource(org *grafana.Org, id int64) (*grafana.DataSource, error) { + return this.grafanaClient.WithOrgID(org.ID).DataSource(id) +} + +// Ditto +func (this GrafanaClient) Dashboards(org *grafana.Org) ([]grafana.FolderDashboardSearchResponse, error) { + return this.grafanaClient.WithOrgID(org.ID).Dashboards() +} + +// Ditto +func (this GrafanaClient) NewDashboard(org *grafana.Org, dashboard grafana.Dashboard) (*grafana.DashboardSaveResponse, error) { + return this.grafanaClient.WithOrgID(org.ID).NewDashboard(dashboard) +} diff --git a/pkg/keycloakClient.go b/pkg/keycloakClient.go new file mode 100644 index 0000000..b5dfdce --- /dev/null +++ b/pkg/keycloakClient.go @@ -0,0 +1,313 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "k8s.io/klog/v2" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" +) + +type KeycloakClient struct { + baseURL url.URL + username string + password string + clientId string + realm string + adminGroupPath string + country string + adminGroup *KeycloakGroup + client *http.Client +} + +type KeycloakUser struct { + Id string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email"` + Username string `json:"username"` +} + +type KeycloakGroup struct { + Id string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + SubGroups []*KeycloakGroup `json:"subGroups"` + Attributes *map[string][]string `json:"attributes"` + pathElements []string `json:"-"` // transient +} + +func (this *KeycloakGroup) GetDisplayNameAttribute() string { + if this.Attributes != nil { + displayNames, ok := (*this.Attributes)["displayName"] + if ok && len(displayNames) > 0 { + return displayNames[0] + } + } + return "" +} + +func (this *KeycloakGroup) GetPathElements() []string { + if this.pathElements == nil { + path := this.Path + if strings.HasPrefix(path, "/") { + path = path[1:] + } + this.pathElements = strings.Split(path, "/") + } + return this.pathElements +} + +func (this *KeycloakGroup) IsSameOrganization(other *KeycloakGroup) bool { + if other == nil { + return false + } + return this.GetPathElements()[0] == other.GetPathElements()[0] && this.GetPathElements()[1] == other.GetPathElements()[1] +} + +func (this *KeycloakGroup) GetOrganizationName() string { + if len(this.GetPathElements()) < 2 { + return "" + } + return this.GetPathElements()[1] +} + +func (this *KeycloakUser) GetDisplayName() string { + if this.FirstName == "" && this.LastName == "" { + return this.Email + } + if this.LastName == "" { + return this.FirstName + } + if this.FirstName == "" { + return this.LastName + } + return this.FirstName + " " + this.LastName +} + +func NewKeycloakClient(baseURL string, realm string, username string, password string, clientId string, adminGroupPath string) (*KeycloakClient, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + + tr := &http.Transport{} // Creating the transport explicitly allows for connection pooling and reuse + cli := &http.Client{Transport: tr} + if err != nil { + return nil, err + } + + return &KeycloakClient{ + baseURL: *u, + client: cli, + realm: realm, + username: username, + password: password, + clientId: clientId, + adminGroupPath: adminGroupPath, + }, nil +} + +func (this *KeycloakClient) GetToken() (string, error) { + req, err := http.NewRequest("POST", fmt.Sprintf("%s/auth/realms/%s/protocol/openid-connect/token", this.baseURL.String(), this.realm), nil) + if err != nil { + return "", err + } + + req.Header["Accept"] = []string{"application/json"} + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + req.Header["cache-control"] = []string{"no-cache"} + + data := fmt.Sprintf("grant_type=password&username=%s&password=%s&client_id=%s", url.QueryEscape(this.username), url.QueryEscape(this.password), url.QueryEscape(this.clientId)) + req.Body = io.NopCloser(strings.NewReader(data)) + + r, err := this.client.Do(req) + if err != nil { + return "", err + } + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return "", err + } + + var objmap map[string]interface{} + json.Unmarshal(body, &objmap) + + accessToken, ok := objmap["access_token"] + if !ok { + return "", errors.New("access_token not found in JSON response") + } + return fmt.Sprintf("%s", accessToken), nil +} + +func (this *KeycloakClient) GetUsers(token string) ([]*KeycloakUser, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/auth/admin/realms/%s/users?max=100000", this.baseURL.String(), this.realm), nil) + if err != nil { + return nil, err + } + + req.Header["Authorization"] = []string{"Bearer " + token} + req.Header["cache-control"] = []string{"no-cache"} + + r, err := this.client.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + keycloakUsers := make([]*KeycloakUser, 0) + err = json.Unmarshal(body, &keycloakUsers) + if err != nil { + return nil, err + } + return keycloakUsers, nil +} + +func (this *KeycloakClient) GetGroups(token string) ([]*KeycloakGroup, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/auth/admin/realms/%s/groups?max=100000&briefRepresentation=false", this.baseURL.String(), this.realm), nil) + if err != nil { + return nil, err + } + + req.Header["Authorization"] = []string{"Bearer " + token} + req.Header["cache-control"] = []string{"no-cache"} + + r, err := this.client.Do(req) + if err != nil { + return nil, err + } + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + keycloakGroups := make([]*KeycloakGroup, 0) + err = json.Unmarshal(body, &keycloakGroups) + if err != nil { + return nil, err + } + return keycloakGroups, nil +} + +// This returns all Keycloak groups with two-level path "/organizations/[ORGNAME]", but not "/organizations/[ORGNAME]/[TEAMNAME]" +// The returned groups may have subgroups (teams), but the subgroups themselves are not part of the list. +func (this *KeycloakClient) GetOrganizations(token string) ([]*KeycloakGroup, error) { + allGroups, err := this.GetGroups(token) + if err != nil { + return nil, err + } + + for _, group := range allGroups { + if group.Path == "/organizations" { + return group.SubGroups, nil + } + } + + return []*KeycloakGroup{}, nil +} + +func (this *KeycloakClient) findSubgroup(groups []*KeycloakGroup) { + for _, group := range groups { + if group.Path == this.adminGroupPath { + this.adminGroup = group + break + } + this.findSubgroup(group.SubGroups) + if this.adminGroup != nil { + break + } + } +} + +func (this *KeycloakClient) GetGroupMembership(token string, user *KeycloakUser) ([]*KeycloakGroup, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/auth/admin/realms/%s/users/%s/groups", this.baseURL.String(), this.realm, user.Id), nil) + if err != nil { + return nil, err + } + + req.Header["Authorization"] = []string{"Bearer " + token} + req.Header["cache-control"] = []string{"no-cache"} + + response, err := this.client.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + groups := make([]*KeycloakGroup, 0) + err = json.Unmarshal(body, &groups) + if err != nil { + return nil, err + } + return groups, nil +} + +func (this *KeycloakClient) worker(token string, userChan chan *KeycloakUser, results *sync.Map, errorCount *uint64, wg *sync.WaitGroup) { + defer wg.Done() + + for user := range userChan { + groups, err := this.GetGroupMembership(token, user) + if err != nil { + atomic.AddUint64(errorCount, 1) + klog.Error(err) + } + results.Store(user, groups) + } +} + +func (this *KeycloakClient) GetGroupMemberships(token string, users []*KeycloakUser) (map[*KeycloakUser][]*KeycloakGroup, error) { + results := sync.Map{} + var errorCount uint64 + + userChan := make(chan *KeycloakUser) + wg := new(sync.WaitGroup) + + // creating workers + for i := 0; i < 10; i++ { + wg.Add(1) + go this.worker(token, userChan, &results, &errorCount, wg) + } + + // sending users to workers + for _, user := range users { + userChan <- user + } + + close(userChan) + wg.Wait() + + if errorCount > 0 { + return nil, errors.New("Could not fetch all group memberships") + } + + userGroups := make(map[*KeycloakUser][]*KeycloakGroup) + results.Range(func(k, v interface{}) bool { + userGroups[k.(*KeycloakUser)] = v.([]*KeycloakGroup) + return true + }) + + return userGroups, nil +} + +func (this *KeycloakClient) CloseIdleConnections() { + this.client.CloseIdleConnections() +} diff --git a/pkg/reconcile.go b/pkg/reconcile.go index f928440..6ab026e 100644 --- a/pkg/reconcile.go +++ b/pkg/reconcile.go @@ -2,138 +2,110 @@ package controller import ( "context" - orgs "github.com/appuio/control-api/apis/organization/v1" - grafana "github.com/grafana/grafana-api-golang-client" - "github.com/hashicorp/go-cleanhttp" - "k8s.io/client-go/rest" + "errors" "k8s.io/klog/v2" - "strings" ) -func ReconcileAllOrgs(ctx context.Context, organizationAppuioIoClient *rest.RESTClient, appuioIoClient *rest.RESTClient, grafanaConfig grafana.Config, grafanaUrl string, dashboard map[string]interface{}) error { - // Fetch everything we need from the control API. - // This is racy because data can change while we fetch it, making the result inconsistent. This may lead to sync errors, - // but they should disappear with subsequent syncs. - controlApiOrganizationsList, err := getControlApiOrganizations(ctx, organizationAppuioIoClient) +var ( + interruptedError = errors.New("interrupted") +) + +func Reconcile(ctx context.Context, keycloakClient *KeycloakClient, grafanaClient *GrafanaClient, dashboard map[string]interface{}) error { + klog.Infof("Fetching Keycloak access token...") + keycloakToken, err := keycloakClient.GetToken() if err != nil { return err } - controlApiUsersMap, err := getControlApiUsersMap(ctx, appuioIoClient) + + klog.Infof("Fetching users from Keycloak...") + keycloakUsers, err := keycloakClient.GetUsers(keycloakToken) if err != nil { return err } + klog.Infof("Found %d users", len(keycloakUsers)) - // Generic Grafana client, not specific to an org (deeper down we'll also create an org-specific Grafana client) - grafanaConfig.Client = cleanhttp.DefaultPooledClient() - grafanaClient, err := grafana.New(grafanaUrl, grafanaConfig) + klog.Infof("Fetching group memberships from Keycloak...") + keycloakUserGroups, err := keycloakClient.GetGroupMemberships(keycloakToken, keycloakUsers) if err != nil { return err } - defer grafanaConfig.Client.CloseIdleConnections() + memberships := 0 + for _, groups := range keycloakUserGroups { + memberships += len(groups) + } + klog.Infof("Found %d group memberships", memberships) + + klog.Infof("Fetching organizations from Keycloak...") + keycloakOrganizations, err := keycloakClient.GetOrganizations(keycloakToken) + klog.Infof("Found %d organizations", len(keycloakOrganizations)) + + klog.Infof("Extracting admin users...") + var keycloakAdmins []*KeycloakUser + var keycloakUsersWithoutAdmins []*KeycloakUser +out: + for _, user := range keycloakUsers { + for _, group := range keycloakUserGroups[user] { + if group.Path == keycloakClient.adminGroupPath { + keycloakAdmins = append(keycloakAdmins, user) + continue out + } + } + keycloakUsersWithoutAdmins = append(keycloakUsersWithoutAdmins, user) + } + klog.Infof("Found %d admin users", len(keycloakAdmins)) - // Get all orgs from Grafana - orgs, err := grafanaClient.Orgs() + grafanaOrgsMap, err := reconcileAllOrgs(ctx, keycloakOrganizations, grafanaClient, dashboard) if err != nil { return err } - // Users are a top-level resource, like organizations. Users can exist even if they don't have permissions to do anything. - grafanaUsersMap, err := reconcileUsers(grafanaClient, controlApiUsersMap) + err = reconcileUsers(ctx, keycloakUsers, grafanaClient) if err != nil { return err } - // Lookup table org -> users (editors or viewers) - appuioControlApiOrganizationUsersMap, err := getControlApiOrganizationUsersMap(ctx, grafanaUsersMap, appuioIoClient) + grafanaPermissionsMap := getGrafanaPermissionsMap(keycloakUserGroups, keycloakAdmins, keycloakOrganizations) + err = reconcilePermissions(ctx, grafanaPermissionsMap, grafanaOrgsMap, grafanaClient) if err != nil { return err } - // List of admin users (for now this is equivalent to all users of the "vshn" org). The same for all orgs. - var desiredAdmins []grafana.User - var ok bool - if desiredAdmins, ok = appuioControlApiOrganizationUsersMap["vshn"]; !ok { - desiredAdmins = []grafana.User{} - } - - // Lookup table org ID (the one from the control API, type string) -> Grafana org - grafanaOrgLookup := make(map[string]grafana.Org) - for _, org := range orgs { - nameComponents := strings.Split(org.Name, " - ") - if len(nameComponents) < 2 || strings.Contains(nameComponents[0], " ") { - continue - } - grafanaOrgLookup[nameComponents[0]] = org - } - - // first make sure that all orgs that need to be present are present - for _, o := range controlApiOrganizationsList { - grafanaOrg, err := reconcileOrgBasic(grafanaOrgLookup, grafanaClient, o) - if err != nil { - return err - } - delete(grafanaOrgLookup, o.Name) - - err = reconcileOrgSettings(grafanaOrg, o.Name, grafanaConfig, grafanaUrl, dashboard, appuioControlApiOrganizationUsersMap[o.Name], desiredAdmins) - if err != nil { - return err - } - - // select with a default case is apparently the only way to do a non-blocking read from a channel - select { - case <-ctx.Done(): - return nil - default: - // carry on - } - } - - // then delete the ones that shouldn't be present - for _, grafanaOrgToBeDeleted := range grafanaOrgLookup { - klog.Infof("Organization %d should not exist, deleting: '%s'", grafanaOrgToBeDeleted.ID, grafanaOrgToBeDeleted.Name) - err = grafanaClient.DeleteOrg(grafanaOrgToBeDeleted.ID) - if err != nil { - return err - } - select { - case <-ctx.Done(): - return nil - default: - } - } - - klog.Infof("Reconcile complete") + grafanaClient.CloseIdleConnections() + keycloakClient.CloseIdleConnections() return nil } -// Sync the basic org. Uses the generic Grafana client. -func reconcileOrgBasic(grafanaOrgLookup map[string]grafana.Org, grafanaClient *grafana.Client, o orgs.Organization) (*grafana.Org, error) { - displayName := o.Name - if o.Spec.DisplayName != "" { - displayName = o.Spec.DisplayName - } - grafanaOrgDesiredName := o.Name + " - " + displayName - - if grafanaOrg, ok := grafanaOrgLookup[o.Name]; ok { - if grafanaOrg.Name != grafanaOrgDesiredName { - klog.Infof("Organization %d has wrong name: '%s', should be '%s'", grafanaOrg.ID, grafanaOrg.Name, grafanaOrgDesiredName) - err := grafanaClient.UpdateOrg(grafanaOrg.ID, grafanaOrgDesiredName) - if err != nil { - return nil, err +type GrafanaPermissionSpec struct { + Uid string + PermittedRoles []string +} + +// Convert group memberships found in Keycloak into permissions on organizations in Grafana +func getGrafanaPermissionsMap(keycloakUserGroups map[*KeycloakUser][]*KeycloakGroup, keycloakAdmins []*KeycloakUser, keycloakOrganizations []*KeycloakGroup) map[string][]GrafanaPermissionSpec { + permissionsMap := make(map[string][]GrafanaPermissionSpec) + for _, keycloakOrganization := range keycloakOrganizations { + permissionsMap[keycloakOrganization.Name] = []GrafanaPermissionSpec{} + + userLoop: + for keycloakUser, groups := range keycloakUserGroups { + // If this user is an admin we ignore any specific organization permissions + for _, admin := range keycloakAdmins { + if admin.Username == keycloakUser.Username { + continue userLoop + } + } + for _, group := range groups { + if keycloakOrganization.IsSameOrganization(group) { + permissionsMap[keycloakOrganization.GetOrganizationName()] = append(permissionsMap[keycloakOrganization.GetOrganizationName()], GrafanaPermissionSpec{Uid: keycloakUser.Username, PermittedRoles: []string{"Editor", "Viewer"}}) + continue userLoop // don't try to find further permissions, otherwise we may get more than one permission for the same user on the same org + } } } - return &grafanaOrg, nil - } - klog.Infof("Organization missing, creating: '%s'", grafanaOrgDesiredName) - grafanaOrgId, err := grafanaClient.NewOrg(grafanaOrgDesiredName) - if err != nil { - return nil, err - } - grafanaOrg, err := grafanaClient.Org(grafanaOrgId) - if err != nil { - return nil, err + for _, admin := range keycloakAdmins { + permissionsMap[keycloakOrganization.Name] = append(permissionsMap[keycloakOrganization.Name], GrafanaPermissionSpec{Uid: admin.Username, PermittedRoles: []string{"Admin", "Editor", "Viewer"}}) + } } - return &grafanaOrg, nil + return permissionsMap } diff --git a/pkg/reconcileOrg.go b/pkg/reconcileOrg.go index d99fd61..6a7da3b 100644 --- a/pkg/reconcileOrg.go +++ b/pkg/reconcileOrg.go @@ -3,31 +3,48 @@ package controller import ( "errors" grafana "github.com/grafana/grafana-api-golang-client" - "github.com/hashicorp/go-cleanhttp" "k8s.io/klog/v2" "reflect" "strings" ) -func reconcileOrgSettings(org *grafana.Org, orgName string, config grafana.Config, url string, dashboard map[string]interface{}, desiredUsers []grafana.User, desiredAdmins []grafana.User) error { - // We can't use the grafanaClient from the overarching reconciliation loop because that client doesn't have the X-Grafana-Org-Id header set. - // It appears that the only way to set that header is to create a new client instance. - config.Client = cleanhttp.DefaultPooledClient() - config.OrgID = org.ID - grafanaClient, err := grafana.New(url, config) +// Sync the basic org. Uses the generic Grafana client. +func reconcileOrgBasic(grafanaOrgLookup map[string]grafana.Org, grafanaClient *GrafanaClient, keycloakOrganization *KeycloakGroup) (*grafana.Org, error) { + displayName := keycloakOrganization.Name + if keycloakOrganization.GetDisplayNameAttribute() != "" { + displayName = keycloakOrganization.GetDisplayNameAttribute() + } + grafanaOrgDesiredName := keycloakOrganization.Name + " - " + displayName + + if grafanaOrg, ok := grafanaOrgLookup[keycloakOrganization.Name]; ok { + if grafanaOrg.Name != grafanaOrgDesiredName { + klog.Infof("Organization %d has wrong name: '%s', should be '%s'", grafanaOrg.ID, grafanaOrg.Name, grafanaOrgDesiredName) + err := grafanaClient.UpdateOrg(grafanaOrg.ID, grafanaOrgDesiredName) + if err != nil { + return nil, err + } + } + return &grafanaOrg, nil + } + + klog.Infof("Organization missing, creating: '%s'", grafanaOrgDesiredName) + grafanaOrgId, err := grafanaClient.NewOrg(grafanaOrgDesiredName) if err != nil { - return err + return nil, err } - defer config.Client.CloseIdleConnections() - dataSource, err := reconcileOrgDataSource(org, orgName, grafanaClient) + grafanaOrg, err := grafanaClient.Org(grafanaOrgId) if err != nil { - return err + return nil, err } - err = reconcileOrgDashboard(org, dataSource, grafanaClient, dashboard) + return &grafanaOrg, nil +} + +func reconcileOrgSettings(org *grafana.Org, orgName string, grafanaClient *GrafanaClient, dashboard map[string]interface{}) error { + dataSource, err := reconcileOrgDataSource(org, orgName, grafanaClient) if err != nil { return err } - err = reconcileOrgUsers(org, grafanaClient, desiredUsers, desiredAdmins) + err = reconcileOrgDashboard(org, dataSource, grafanaClient, dashboard) if err != nil { return err } @@ -35,7 +52,7 @@ func reconcileOrgSettings(org *grafana.Org, orgName string, config grafana.Confi return nil } -func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Client) (*grafana.DataSource, error) { +func reconcileOrgDataSource(org *grafana.Org, orgName string, grafanaClient *GrafanaClient) (*grafana.DataSource, error) { // If you add/remove fields here you must also adjust the 'if' statement further down desiredDataSource := &grafana.DataSource{ Name: "Mimir", @@ -56,7 +73,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl var configuredDataSource *grafana.DataSource configuredDataSource = nil - dataSources, err := client.DataSources() + dataSources, err := grafanaClient.DataSources(org) if err != nil { return nil, err } @@ -71,7 +88,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl klog.Infof("Organization %d has misconfigured data source, fixing", org.ID) desiredDataSource.ID = dataSource.ID desiredDataSource.UID = dataSource.UID - err := client.UpdateDataSource(desiredDataSource) + err := grafanaClient.UpdateDataSource(org, desiredDataSource) if err != nil { return nil, err } @@ -81,7 +98,7 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl } } else { klog.Infof("Organization %d has invalid data source %d %s, removing", org.ID, dataSource.ID, dataSource.Name) - client.DeleteDataSource(dataSource.ID) + grafanaClient.DeleteDataSource(org, dataSource.ID) if err != nil { return nil, err } @@ -90,11 +107,11 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl } if configuredDataSource == nil { klog.Infof("Organization %d missing data source, creating", org.ID) - dataSourceId, err := client.NewDataSource(desiredDataSource) + dataSourceId, err := grafanaClient.NewDataSource(org, desiredDataSource) if err != nil { return nil, err } - configuredDataSource, err = client.DataSource(dataSourceId) + configuredDataSource, err = grafanaClient.DataSource(org, dataSourceId) if err != nil { return nil, err } @@ -102,13 +119,13 @@ func reconcileOrgDataSource(org *grafana.Org, orgName string, client *grafana.Cl return configuredDataSource, nil } -func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, client *grafana.Client, dashboardModel map[string]interface{}) error { +func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, grafanaClient *GrafanaClient, dashboardModel map[string]interface{}) error { dashboardTitle, ok := dashboardModel["title"] if !ok { errors.New("Invalid dashboard format: 'title' key not found") } - dashboards, err := client.Dashboards() + dashboards, err := grafanaClient.Dashboards(org) if err != nil { return err } @@ -130,13 +147,14 @@ func reconcileOrgDashboard(org *grafana.Org, dataSource *grafana.DataSource, cli Overwrite: true, } klog.Infof("Creating dashboard '%s' for organization %d", dashboardTitle, org.ID) - _, err = client.NewDashboard(dashboard) + _, err = grafanaClient.NewDashboard(org, dashboard) if err != nil { return err } return nil } +// FIXME obsolete? func reconcileOrgUsers(org *grafana.Org, client *grafana.Client, desiredUsers []grafana.User, desiredAdmins []grafana.User) error { orgUsers, err := client.OrgUsersCurrent() if err != nil { @@ -204,6 +222,7 @@ func reconcileOrgUsers(org *grafana.Org, client *grafana.Client, desiredUsers [] return nil } +// FIXME obsolete? func userListContains(userList []grafana.User, user grafana.User) bool { for _, entry := range userList { if entry.ID == user.ID && entry.Login == user.Login { diff --git a/pkg/reconcileOrgs.go b/pkg/reconcileOrgs.go new file mode 100644 index 0000000..1811662 --- /dev/null +++ b/pkg/reconcileOrgs.go @@ -0,0 +1,68 @@ +package controller + +import ( + "context" + grafana "github.com/grafana/grafana-api-golang-client" + "k8s.io/klog/v2" + "strings" +) + +func reconcileAllOrgs(ctx context.Context, keycloakOrganizations []*KeycloakGroup, grafanaClient *GrafanaClient, dashboard map[string]interface{}) (map[string]*grafana.Org, error) { + grafanaOrgLookupFinal := make(map[string]*grafana.Org) + + // Get all orgs from Grafana + orgs, err := grafanaClient.Orgs() + if err != nil { + return nil, err + } + + // Lookup table org ID (the one from the control API, type string) -> Grafana org + grafanaOrgLookup := make(map[string]grafana.Org) + for _, org := range orgs { + nameComponents := strings.Split(org.Name, " - ") + if len(nameComponents) < 2 || strings.Contains(nameComponents[0], " ") { + continue + } + grafanaOrgLookup[nameComponents[0]] = org + } + + // first make sure that all orgs that need to be present are present + for _, keycloakOrganization := range keycloakOrganizations { + grafanaOrg, err := reconcileOrgBasic(grafanaOrgLookup, grafanaClient, keycloakOrganization) + if err != nil { + return nil, err + } + delete(grafanaOrgLookup, keycloakOrganization.Name) + + err = reconcileOrgSettings(grafanaOrg, keycloakOrganization.Name, grafanaClient, dashboard) + if err != nil { + return nil, err + } + + grafanaOrgLookupFinal[keycloakOrganization.Name] = grafanaOrg + + // select with a default case is apparently the only way to do a non-blocking read from a channel + select { + case <-ctx.Done(): + return nil, interruptedError + default: + // carry on + } + } + + // then delete the ones that shouldn't be present + for _, grafanaOrgToBeDeleted := range grafanaOrgLookup { + klog.Infof("Organization %d should not exist, deleting: '%s'", grafanaOrgToBeDeleted.ID, grafanaOrgToBeDeleted.Name) + err = grafanaClient.DeleteOrg(grafanaOrgToBeDeleted.ID) + if err != nil { + return nil, err + } + select { + case <-ctx.Done(): + return nil, interruptedError + default: + } + } + + return grafanaOrgLookupFinal, nil +} diff --git a/pkg/reconcilePermissions.go b/pkg/reconcilePermissions.go new file mode 100644 index 0000000..d01b6e3 --- /dev/null +++ b/pkg/reconcilePermissions.go @@ -0,0 +1,81 @@ +package controller + +import ( + "context" + "errors" + grafana "github.com/grafana/grafana-api-golang-client" + "k8s.io/klog/v2" + "k8s.io/utils/strings/slices" +) + +func reconcilePermissions(ctx context.Context, grafanaPermissionsMap map[string][]GrafanaPermissionSpec, grafanaOrgsMap map[string]*grafana.Org, grafanaClient *GrafanaClient) error { + for orgName, permissions := range grafanaPermissionsMap { + grafanaOrg, ok := grafanaOrgsMap[orgName] + if !ok { + return errors.New("Internal error: Keycloak organization not present in Grafana. This shouldn't happen.") + } + initialOrgUsers, err := grafanaClient.OrgUsers(grafanaOrg.ID) + if err != nil { + return err + } + + for _, permission := range permissions { + var desiredOrgUser *grafana.OrgUser + + for i, ou := range initialOrgUsers { + if ou.Login == permission.Uid { + desiredOrgUser = &ou + // remove user from initialOrgUsers array + initialOrgUsers[i] = initialOrgUsers[len(initialOrgUsers)-1] + initialOrgUsers = initialOrgUsers[:len(initialOrgUsers)-1] + break + } + } + + if desiredOrgUser == nil { + klog.Infof("User '%s' should have access to org '%s' (%d), adding", permission.Uid, grafanaOrg.Name, grafanaOrg.ID) + err := grafanaClient.AddOrgUser(grafanaOrg.ID, permission.Uid, permission.PermittedRoles[0]) + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + } else { + // orgUser already exists, check if permission is acceptable + if !slices.Contains(permission.PermittedRoles, desiredOrgUser.Role) { + klog.Infof("User '%s' has invalid role on org '%s' (%d), fixing", permission.Uid, grafanaOrg.Name, grafanaOrg.ID) + err := grafanaClient.UpdateOrgUser(grafanaOrg.ID, desiredOrgUser.UserID, permission.PermittedRoles[0]) + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + } + } + + select { + case <-ctx.Done(): + return interruptedError + default: + } + } + + for _, undesiredOrgUser := range initialOrgUsers { + if undesiredOrgUser.Login == "admin" || undesiredOrgUser.Login == grafanaClient.GetUsername() { + continue + } + klog.Infof("User '%s' (%d) must not have access to org '%s' (%d), removing", undesiredOrgUser.Login, undesiredOrgUser.UserID, grafanaOrg.Name, grafanaOrg.ID) + err := grafanaClient.RemoveOrgUser(grafanaOrg.ID, undesiredOrgUser.UserID) + if err != nil { + // This can happen due to race conditions, hence just a warning + klog.Warning(err) + } + + select { + case <-ctx.Done(): + return interruptedError + default: + } + } + } + + return nil +} diff --git a/pkg/reconcileUser.go b/pkg/reconcileUser.go new file mode 100644 index 0000000..149b9fa --- /dev/null +++ b/pkg/reconcileUser.go @@ -0,0 +1,55 @@ +package controller + +import ( + "crypto/rand" + grafana "github.com/grafana/grafana-api-golang-client" + "math/big" +) + +func generatePassword() (string, error) { + const voc string = "abcdfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + len := big.NewInt(int64(len(voc))) + pw := "" + + for i := 0; i < 32; i++ { + index, err := rand.Int(rand.Reader, len) + if err != nil { + return "", err + } + pw = pw + string(voc[index.Uint64()]) + } + return pw, nil +} + +func createUser(client *GrafanaClient, keycloakUser *KeycloakUser) (*grafana.User, error) { + password, err := generatePassword() + if err != nil { + return nil, err + } + grafanaUser := grafana.User{ + Email: keycloakUser.Email, + Login: keycloakUser.Username, + Name: keycloakUser.GetDisplayName(), + Password: password, + } + grafanaUser.ID, err = client.CreateUser(grafanaUser) + if err != nil { + return nil, err + } + + userOrgs, err := client.GetUserOrgs(grafanaUser) + if err != nil { + return nil, err + } + + for _, userOrg := range userOrgs { + // we immediately remove the user from the automatically assigned org because who knows what permissions the user got on that org (can't be controlled when creating the user) + // yes this is stupid but that's how Grafana works + err = client.RemoveOrgUser(userOrg.OrgID, grafanaUser.ID) + if err != nil { + return nil, err + } + } + + return &grafanaUser, nil +} diff --git a/pkg/reconcileUsers.go b/pkg/reconcileUsers.go index 37c6ecc..10f5d80 100644 --- a/pkg/reconcileUsers.go +++ b/pkg/reconcileUsers.go @@ -1,95 +1,67 @@ package controller import ( - "crypto/rand" - controlapi "github.com/appuio/control-api/apis/v1" + "context" grafana "github.com/grafana/grafana-api-golang-client" "k8s.io/klog/v2" - "math/big" ) -func generatePassword() (string, error) { - const voc string = "abcdfghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - len := big.NewInt(int64(len(voc))) - pw := "" - - for i := 0; i < 32; i++ { - index, err := rand.Int(rand.Reader, len) - if err != nil { - return "", err - } - pw = pw + string(voc[index.Uint64()]) - } - return pw, nil -} - -func createUser(client *grafana.Client, user controlapi.User) (*grafana.User, error) { - password, err := generatePassword() - if err != nil { - return nil, err - } - grafanaUser := grafana.User{ - Email: user.Status.Email, - Login: user.Name, - Name: user.Status.DisplayName, - Password: password, - } - grafanaUser.ID, err = client.CreateUser(grafanaUser) - if err != nil { - return nil, err - } - return &grafanaUser, nil -} - -func reconcileUsers(client *grafana.Client, users map[string]controlapi.User) (map[string]grafana.User, error) { - grafanaUsers, err := client.Users() +func reconcileUsers(ctx context.Context, keycloakUsers []*KeycloakUser, grafanaClient *GrafanaClient) error { + grafanaUsers, err := grafanaClient.Users() if err != nil { - return nil, err + return err } - grafanaUsersSet := make(map[string]grafana.UserSearch) + grafanaUsersMap := make(map[string]grafana.UserSearch) for _, grafanaUser := range grafanaUsers { - grafanaUsersSet[grafanaUser.Login] = grafanaUser + if grafanaUser.Login != "admin" && grafanaUser.Login != grafanaClient.GetUsername() { // ignore admin + grafanaUsersMap[grafanaUser.Login] = grafanaUser + } } - finalGrafanaUsersMap := make(map[string]grafana.User) - - for _, user := range users { - if grafanaUserSearch, ok := grafanaUsersSet[user.Name]; ok { - if grafanaUserSearch.Email != user.Status.Email || + for _, keycloakUser := range keycloakUsers { + var grafanaUser *grafana.User + if grafanaUserSearch, ok := grafanaUsersMap[keycloakUser.Username]; ok { + if grafanaUserSearch.Email != keycloakUser.Email || grafanaUserSearch.IsAdmin || - grafanaUserSearch.Login != user.Name || - grafanaUserSearch.Name != user.Status.DisplayName { - klog.Infof("User '%s' differs, fixing", user.Name) - grafanaUser := grafana.User{ + grafanaUserSearch.Login != keycloakUser.Username || + grafanaUserSearch.Name != keycloakUser.GetDisplayName() { + klog.Infof("User '%s' differs, fixing", keycloakUser.Username) + grafanaUser = &grafana.User{ ID: grafanaUserSearch.ID, IsAdmin: false, - Login: user.Name, - Name: user.Status.DisplayName, + Login: keycloakUser.Username, + Name: keycloakUser.GetDisplayName(), } - client.UserUpdate(grafanaUser) + grafanaClient.UserUpdate(*grafanaUser) } - finalGrafanaUsersMap[grafanaUserSearch.Login] = grafana.User{ID: grafanaUserSearch.ID, Login: grafanaUserSearch.Login} } else { - klog.Infof("User '%s' is missing, adding", user.Name) - grafanaUser, err := createUser(client, user) + klog.Infof("User '%s' is missing, adding", keycloakUser.Username) + grafanaUser, err = createUser(grafanaClient, keycloakUser) if err != nil { - //return err // for now just continue in case errors happen klog.Error(err) continue } - klog.Infof("%d", grafanaUser.ID) - finalGrafanaUsersMap[grafanaUser.Login] = grafana.User{ID: grafanaUser.ID, Login: grafanaUser.Login} } - klog.Infof("User '%s' OK", user.Name) - delete(grafanaUsersSet, user.Name) + delete(grafanaUsersMap, keycloakUser.Username) + + select { + case <-ctx.Done(): + return interruptedError + default: + } } - delete(grafanaUsersSet, "admin") // don't delete the admin user... + for _, grafanaUser := range grafanaUsersMap { + klog.Infof("User '%s' (%d) not found in Keycloak, removing", grafanaUser.Login, grafanaUser.ID) + grafanaClient.DeleteUser(grafanaUser.ID) - for _, grafanaUser := range grafanaUsersSet { - klog.Infof("User '%s' (%d) is not in APPUiO Control API, removing", grafanaUser.Login, grafanaUser.ID) - client.DeleteUser(grafanaUser.ID) + select { + case <-ctx.Done(): + return interruptedError + default: + } } - return finalGrafanaUsersMap, nil + + return nil }