Skip to content

Commit

Permalink
Initial controller implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
bastjan committed Nov 22, 2023
1 parent bce657d commit f43e68c
Show file tree
Hide file tree
Showing 8 changed files with 372 additions and 8 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ clean: ## Cleans up the generated resources

.PHONY: run
run: manifests generate fmt vet ## Run a controller from your host.
go run ./main.go
go run ./main.go $(RUN_ARGS)

###
### Assets
Expand Down
205 changes: 202 additions & 3 deletions controllers/cluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@ package controllers

import (
"context"
_ "embed"
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/Nerzal/gocloak/v13"
"github.com/google/go-jsonnet"
lieutenantv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1"
"github.com/wI2L/jsondiff"
"go.uber.org/multierr"
"golang.org/x/exp/slices"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/json"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

"github.com/projectsyn/lieutenant-keycloak-idp-controller/templates"
)

type Clock interface {
Expand All @@ -21,26 +33,151 @@ type Clock interface {
type ClusterReconciler struct {
client.Client
Scheme *runtime.Scheme

KeycloakClient *gocloak.GoCloak
KeycloakRealm string
KeycloakLoginRealm string
KeycloakUser string
KeycloakPassword string
}

//+kubebuilder:rbac:groups=syn.tools,resources=clusters,verbs=get;list;watch
//+kubebuilder:rbac:groups=syn.tools,resources=clusters/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=syn.tools,resources=clusters/finalizers,verbs=update

// Reconcile reconciles the Cluster resource.
func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl.Result, err error) {
l := log.FromContext(ctx).WithName("ClusterReconciler.Reconcile")

instance := &lieutenantv1alpha1.Cluster{}
err := r.Get(ctx, req.NamespacedName, instance)
if err != nil {
if err := r.Get(ctx, req.NamespacedName, instance); err != nil {
if apierrors.IsNotFound(err) {
l.Info("Cluster resource not found. Ignoring since object must be deleted.")
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("unable to get Cluster resource: %w", err)
}

gcl := r.KeycloakClient
token, err := gcl.LoginAdmin(ctx, r.KeycloakUser, r.KeycloakPassword, r.loginRealm())
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to login to keycloak: %w", err)
}
defer func() {
if logoutErr := gcl.LogoutPublicClient(ctx, "admin-cli", r.loginRealm(), token.AccessToken, token.RefreshToken); logoutErr != nil {
multierr.AppendInto(&err, fmt.Errorf("unable to logout from keycloak: %w", logoutErr))
}
}()

jsonnetCtx := map[string]any{
"cluster": instance,
}
jcr, err := json.Marshal(jsonnetCtx)
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to marshal jsonnet context: %w", err)
}
jvm := jsonnet.MakeVM()
jvm.ExtCode("context", string(jcr))

// Create or updated client
cRaw, err := jvm.EvaluateAnonymousSnippet("cluster", templates.ClientDefault)
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to evaluate jsonnet: %w", err)
}
var templatedClient gocloak.Client
if err := json.Unmarshal([]byte(cRaw), &templatedClient); err != nil {
return ctrl.Result{}, fmt.Errorf("unable to unmarshal jsonnet result: %w", err)
}
if templatedClient.ClientID == nil || *templatedClient.ClientID == "" {
return ctrl.Result{}, fmt.Errorf("`clientId` is empty")
}
client, err := r.findClientByClientId(ctx, token.AccessToken, r.KeycloakRealm, *templatedClient.ClientID)
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to get client: %w", err)
}
if client == nil {
l.Info("Client not found, creating", "client", templatedClient)
id, err := gcl.CreateClient(ctx, token.AccessToken, r.KeycloakRealm, templatedClient)
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to create client: %w", err)
}
l.Info("Client created, requeuing", "id", id)
return ctrl.Result{Requeue: true}, nil
}

l.Info("Client found, updating", "client", client.ID)
templatedClient.ID = client.ID
patch, err := jsondiff.Compare(client, templatedClient, jsondiff.Ignores("/secret"))
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to compare existing and templated clients: %w", err)
}
if len(patch) == 0 {
l.Info("No changes to the client detected")
} else {
l.Info("Updating client", "changes", patch)
if err := gcl.UpdateClient(ctx, token.AccessToken, r.KeycloakRealm, templatedClient); err != nil {
return ctrl.Result{}, fmt.Errorf("unable to update client: %w", err)
}
}

// template client roles
rolesRaw, err := jvm.EvaluateAnonymousSnippet("client-roles", templates.ClientRolesDefault)
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to evaluate client-roles jsonnet: %w", err)
}
var templatedRoles []roleMapping
if err := json.Unmarshal([]byte(rolesRaw), &templatedRoles); err != nil {
return ctrl.Result{}, fmt.Errorf("unable to unmarshal client-roles jsonnet result: %w", err)
}
slices.SortFunc(templatedRoles, func(a, b roleMapping) int {
return strings.Compare(a.Role, b.Role)*10 + strings.Compare(a.Group, b.Group)
})
templatedRoles = slices.Compact(templatedRoles)

if err := r.createClientRoles(ctx, gcl, token.AccessToken, *client.ID, templatedRoles); err != nil {
return ctrl.Result{}, fmt.Errorf("unable to create client roles: %w", err)
}

actualRoles, err := gcl.GetClientRoles(ctx, token.AccessToken, r.KeycloakRealm, *client.ID, gocloak.GetRoleParams{})
if err != nil {
return ctrl.Result{}, fmt.Errorf("unable to get client roles: %w", err)
}

groups := make(map[string][]gocloak.Role)
for _, role := range templatedRoles {
if role.Group == "" {
continue
}
ri := slices.IndexFunc(actualRoles, func(r *gocloak.Role) bool {
return *r.Name == role.Role
})
if ri == -1 {
return ctrl.Result{}, fmt.Errorf("unable to find role %q", role.Role)
}
groups[role.Group] = append(groups[role.Group], *actualRoles[ri])
}
for groupPath, roles := range groups {
if len(roles) == 0 {
l.Info("No roles to map, skipping", "group", groupPath)
continue
}

g, err := gcl.GetGroupByPath(ctx, token.AccessToken, r.KeycloakRealm, groupPath)
if err != nil {
var kcErr *gocloak.APIError
if errors.As(err, &kcErr) && kcErr.Code == http.StatusNotFound {
l.Info("Group not found, skipping mapping", "group", groupPath)
continue
}
return ctrl.Result{}, fmt.Errorf("unable to get group: %w", err)
}

l.Info("Syncing client role group mapping", "group", groupPath, "roles", roles)
if err := gcl.AddClientRolesToGroup(ctx, token.AccessToken, r.KeycloakRealm, *client.ID, *g.ID, roles); err != nil {
return ctrl.Result{}, fmt.Errorf("unable to add client roles to group: %w", err)
}
}

return ctrl.Result{}, nil
}

Expand All @@ -50,3 +187,65 @@ func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&lieutenantv1alpha1.Cluster{}).
Complete(r)
}

// createClientRoles creates the given client roles if they do not exist yet
func (r *ClusterReconciler) createClientRoles(ctx context.Context, gcl *gocloak.GoCloak, token string, clientId string, roles []roleMapping) error {
l := log.FromContext(ctx).WithName("ClusterReconciler.createClientRoles")

var clientRoles []string
for _, role := range roles {
clientRoles = append(clientRoles, role.Role)
}
slices.Sort(clientRoles)
clientRoles = slices.Compact(clientRoles)
for _, role := range clientRoles {
id, err := gcl.CreateClientRole(ctx, token, r.KeycloakRealm, clientId, gocloak.Role{
Name: &role,
})
if err != nil {
var kcErr *gocloak.APIError
if errors.As(err, &kcErr) && kcErr.Code == http.StatusConflict {
l.Info("Client role already exists", "role", role)
continue
}
l.Error(err, "unable to create client role", "role", role)
return fmt.Errorf("keycloak error: %w", err)
}
l.Info("Client role created", "role", role, "id", id)
}

return nil
}

// findClientByClientId returns the client with the given client id or nil if no client was found
func (r *ClusterReconciler) findClientByClientId(ctx context.Context, token string, realm string, clientId string) (*gocloak.Client, error) {
clients, err := r.KeycloakClient.GetClients(
ctx,
token,
realm,
gocloak.GetClientsParams{
ClientID: &clientId,
},
)
if err != nil {
return nil, fmt.Errorf("unable to get clients: %w", err)
}
// Since we are filtering by client id, which is unique, there should only be one client
for _, client := range clients {
return client, nil
}

return nil, nil
}

func (r *ClusterReconciler) loginRealm() string {
if r.KeycloakLoginRealm != "" {
return r.KeycloakLoginRealm
}
return r.KeycloakRealm
}

type roleMapping struct {
Role string `json:"role"`
Group string `json:"group"`
}
13 changes: 12 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ go 1.21
toolchain go1.21.4

require (
github.com/Nerzal/gocloak/v13 v13.8.0
github.com/go-logr/logr v1.3.0
github.com/google/go-jsonnet v0.20.0
github.com/projectsyn/lieutenant-operator v1.5.0
github.com/stretchr/testify v1.8.4
github.com/wI2L/jsondiff v0.5.0
go.uber.org/multierr v1.11.0
k8s.io/apimachinery v0.28.4
k8s.io/client-go v0.28.4
sigs.k8s.io/controller-runtime v0.16.3
Expand All @@ -29,8 +33,10 @@ require (
github.com/go-openapi/jsonpointer v0.20.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/gobuffalo/flect v1.0.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
Expand All @@ -50,17 +56,22 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.starlark.net v0.0.0-20231016134836-22325403fcb3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/mod v0.13.0 // indirect
Expand Down
Loading

0 comments on commit f43e68c

Please sign in to comment.