From a4eb26f9c4d956cff53e92bfdff742021aa092b4 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 16 Dec 2024 09:19:53 +0100 Subject: [PATCH 01/26] Add expose LDAP groups Without this patch, we are filtering LDAP groups and take a decision on what to expose. This is a problem, as it removes the flexibility of rolebindings later. We intend to expose custom role bindings for extra services (for example Kong), which requires teams to be entering a different model. In that case, the platform team creates a new cluster role, but the grantee of the role might come from a deployment tool, hence outside the operator. I fixed it by first exposing the groups directly in the token provider. This means that further commits are required to filter properly + generate the right token groups directly. --- internal/services/token-provider.go | 28 ++- internal/services/token-provider_test.go | 167 +++++++++++++++++- .../services/webhook-tokenauthenticator.go | 2 + pkg/types/types.go | 1 + 4 files changed, 186 insertions(+), 12 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 43efce75..25ecf2cd 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -75,16 +75,30 @@ func (issuer *TokenIssuer) GenerateExtraToken(username string, email string, has func (issuer *TokenIssuer) GenerateUserToken(groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool) (*string, error) { var auths = GetUserNamespaces(groups) + claims, err := generateUserClaims(auths, groups, username, email, hasAdminAccess, hasApplicationAccess, hasOpsAccess, hasViewerAccess, hasServiceAccess, issuer) + if err != nil { + return nil, err + } + token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) + signedToken, err := token.SignedString(issuer.EcdsaPrivate) + if err != nil { + return nil, err + } + return &signedToken, err +} +func generateUserClaims(auths []*types.Project, groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool, issuer *TokenIssuer) (types.AuthJWTClaims, error) { + + var emptyClaims types.AuthJWTClaims duration, err := time.ParseDuration(issuer.TokenDuration) if err != nil { - return nil, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) + return emptyClaims, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) } expirationTime := time.Now().Add(duration) url, err := url.Parse(issuer.PublicApiServerURL) if err != nil { - return nil, fmt.Errorf("unable to parse url %s", issuer.PublicApiServerURL) + return emptyClaims, fmt.Errorf("unable to parse url %s", issuer.PublicApiServerURL) } if hasAdminAccess || hasApplicationAccess || hasOpsAccess { @@ -97,7 +111,7 @@ func (issuer *TokenIssuer) GenerateUserToken(groups []string, username string, e auths = []*types.Project{} duration, err = time.ParseDuration(issuer.ExtraTokenDuration) if err != nil { - return nil, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) + return emptyClaims, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) } expirationTime = time.Now().Add(duration) utils.Log.Info().Msgf("A specific token with duration %v would be issued.", duration.String()) @@ -107,6 +121,7 @@ func (issuer *TokenIssuer) GenerateUserToken(groups []string, username string, e claims := types.AuthJWTClaims{ Auths: auths, User: username, + Groups: groups, Contact: email, AdminAccess: hasAdminAccess, ApplicationAccess: hasApplicationAccess, @@ -123,12 +138,7 @@ func (issuer *TokenIssuer) GenerateUserToken(groups []string, username string, e }, } - token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) - signedToken, err := token.SignedString(issuer.EcdsaPrivate) - if err != nil { - return nil, err - } - return &signedToken, err + return claims, err } func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*string, error) { diff --git a/internal/services/token-provider_test.go b/internal/services/token-provider_test.go index 3b32e34e..bcf95477 100644 --- a/internal/services/token-provider_test.go +++ b/internal/services/token-provider_test.go @@ -1,13 +1,16 @@ -package services_test +package services import ( "crypto/ecdsa" "os" + "reflect" + "slices" + "sort" "strings" "testing" - "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" + "github.com/ca-gip/kubi/pkg/types" "github.com/dgrijalva/jwt-go" "github.com/stretchr/testify/assert" ) @@ -30,7 +33,7 @@ func TestECDSA(t *testing.T) { utils.Log.Fatal().Msgf("Unable to parse ECDSA public key: %v", err) } - issuer := services.TokenIssuer{ + issuer := TokenIssuer{ EcdsaPrivate: ecdsaKey, EcdsaPublic: ecdsaPub, TokenDuration: "4h", @@ -52,3 +55,161 @@ func TestECDSA(t *testing.T) { assert.Nil(t, err) }) } + +func Test_generateUserClaims(t *testing.T) { + type args struct { + auths []*types.Project + groups []string + username string + email string + hasAdminAccess bool + hasApplicationAccess bool + hasOpsAccess bool + hasViewerAccess bool + hasServiceAccess bool + issuer *TokenIssuer + } + type want struct { + err error + auths []string + groups []string + user string + } + var tests = []struct { + name string + args args + expected want + }{ + { + name: "Regular user token contains project and all its groups", + args: args{ + auths: []*types.Project{ + &types.Project{ + Project: "ns-development", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + &types.Project{ + Project: "ns-devops-automation-integration", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + }, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + username: "foo", + email: "foo@bar.baz", + hasAdminAccess: false, + hasApplicationAccess: false, + hasOpsAccess: false, + hasViewerAccess: false, + hasServiceAccess: false, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + }, + expected: want{ + err: nil, + auths: []string{"ns-development", "ns-devops-automation-integration"}, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + user: "foo", + }, + }, + { + name: "Admin user does not have projects", + args: args{ + auths: []*types.Project{ + &types.Project{ + Project: "ns-development", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + &types.Project{ + Project: "ns-devops-automation-integration", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + }, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + username: "foo", + email: "foo@bar.baz", + hasAdminAccess: true, + hasApplicationAccess: false, + hasOpsAccess: false, + hasViewerAccess: false, + hasServiceAccess: false, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + }, + expected: want{ + err: nil, + auths: []string{}, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + user: "foo", + }, + }, + { + name: "Appops user does not have projects", + args: args{ + auths: []*types.Project{ + &types.Project{ + Project: "ns-development", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + &types.Project{ + Project: "ns-devops-automation-integration", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + }, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + username: "foo", + email: "foo@bar.baz", + hasAdminAccess: false, + hasApplicationAccess: false, + hasOpsAccess: true, + hasViewerAccess: false, + hasServiceAccess: false, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + }, + expected: want{ + err: nil, + auths: []string{}, + groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, + user: "foo", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotToken, gotErr := generateUserClaims(tt.args.auths, tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess, tt.args.issuer) + if gotErr != nil { + assert.Equal(t, gotErr, tt.expected.err) + } + assert.Equalf(t, gotToken.User, tt.expected.user, "generateUserClaims(%v, %v, %v, %v, %v, %v, %v, %v, %v, %v)", tt.args.auths, tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess, tt.args.issuer) + if !reflect.DeepEqual(gotToken.Groups, tt.expected.groups) { + t.Errorf("generateUserClaims() got = %v, want %v", gotToken.Groups, tt.expected.groups) + } + + var listAuths []string + for _, projectAuthName := range gotToken.Auths { + listAuths = append(listAuths, projectAuthName.Project) + } + + sort.Strings(listAuths) + sort.Strings(tt.expected.auths) + if !slices.Equal(listAuths, tt.expected.auths) { + t.Errorf("generateUserClaims() got = %v, want %v", gotToken.Auths, tt.expected.auths) + } + }) + } +} diff --git a/internal/services/webhook-tokenauthenticator.go b/internal/services/webhook-tokenauthenticator.go index 9a033900..dcdfdf6e 100644 --- a/internal/services/webhook-tokenauthenticator.go +++ b/internal/services/webhook-tokenauthenticator.go @@ -55,6 +55,8 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { groups = append(groups, fmt.Sprintf("%s-%s", auth.Namespace(), auth.Role)) } + groups = append(groups, token.Groups...) + if token.AdminAccess { groups = append(groups, utils.AdminGroup) } diff --git a/pkg/types/types.go b/pkg/types/types.go index e016e049..3873e34e 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -98,6 +98,7 @@ type KubeConfigUserToken struct { type AuthJWTClaims struct { Auths []*Project `json:"auths"` User string `json:"user"` + Groups []string `json:"groups"` Contact string `json:"email"` AdminAccess bool `json:"adminAccess"` ApplicationAccess bool `json:"appAccess"` From 947b1a549ad3267ebf0603dca3c722f924a299dd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Wed, 18 Dec 2024 13:02:10 +0100 Subject: [PATCH 02/26] Refactor tokenProvider Without this, the code was duplicated and the generation of claims was not very readable. For example, it contained steps that are part of the issuer initialisation. This is a problem, as it leads to difficult reviews and difficulty to iterate on the topic. This fixes it by creating a new constructor for the token issuer, which clarifies all what's necessary for it, and fatals if the requirements are not met. This makes sure the code does not error forever when the requirements are not met. On top of that, the signature of the token was separated, allowing easier testing. The testing has shown a lack of handling the errors in the JWT signature checks, which should be fixed in a later commit. --- cmd/api/main.go | 21 +-- cmd/authorization-webhook/main.go | 28 ++-- internal/services/token-provider.go | 167 ++++++++++++---------- internal/services/token-provider_test.go | 174 +++++++++++------------ 4 files changed, 190 insertions(+), 200 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 33dd3e00..95ef3916 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,13 +1,11 @@ package main import ( - "crypto/ecdsa" "net/http" "os" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" - "github.com/dgrijalva/jwt-go" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog/log" @@ -31,23 +29,10 @@ func main() { if err != nil { utils.Log.Fatal().Msgf("Unable to read ECDSA public key: %v", err) } - var ecdsaKey *ecdsa.PrivateKey - var ecdsaPub *ecdsa.PublicKey - if ecdsaKey, err = jwt.ParseECPrivateKeyFromPEM(ecdsaPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA private key: %v", err) - } - if ecdsaPub, err = jwt.ParseECPublicKeyFromPEM(ecdsaPubPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA public key: %v", err) - } - tokenIssuer := &services.TokenIssuer{ - EcdsaPrivate: ecdsaKey, - EcdsaPublic: ecdsaPub, - TokenDuration: utils.Config.TokenLifeTime, - ExtraTokenDuration: utils.Config.ExtraTokenLifeTime, - Locator: utils.Config.Locator, - PublicApiServerURL: utils.Config.PublicApiServerURL, - Tenant: utils.Config.Tenant, + tokenIssuer, err := services.NewTokenIssuer(ecdsaPem, ecdsaPubPem, utils.Config.TokenLifeTime, utils.Config.ExtraTokenLifeTime, utils.Config.Locator, utils.Config.PublicApiServerURL, utils.Config.Tenant) + if err != nil { + utils.Log.Fatal().Msgf("Unable to create token issuer: %v", err) } router := mux.NewRouter() diff --git a/cmd/authorization-webhook/main.go b/cmd/authorization-webhook/main.go index f55ed24b..b6c9d821 100644 --- a/cmd/authorization-webhook/main.go +++ b/cmd/authorization-webhook/main.go @@ -1,13 +1,11 @@ package main import ( - "crypto/ecdsa" "net/http" "os" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" - "github.com/dgrijalva/jwt-go" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog/log" @@ -31,22 +29,18 @@ func main() { if err != nil { utils.Log.Fatal().Msgf("Unable to read ECDSA public key: %v", err) } - var ecdsaKey *ecdsa.PrivateKey - var ecdsaPub *ecdsa.PublicKey - if ecdsaKey, err = jwt.ParseECPrivateKeyFromPEM(ecdsaPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA private key: %v", err) - } - if ecdsaPub, err = jwt.ParseECPublicKeyFromPEM(ecdsaPubPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA public key: %v", err) - } - tokenIssuer := &services.TokenIssuer{ - EcdsaPrivate: ecdsaKey, - EcdsaPublic: ecdsaPub, - TokenDuration: utils.Config.TokenLifeTime, - Locator: utils.Config.Locator, - PublicApiServerURL: utils.Config.PublicApiServerURL, - Tenant: utils.Config.Tenant, + tokenIssuer, err := services.NewTokenIssuer( + ecdsaPem, + ecdsaPubPem, + utils.Config.TokenLifeTime, + utils.Config.ExtraTokenLifeTime, // This had to be included in refactor. TODO: Check side effects + utils.Config.Locator, + utils.Config.PublicApiServerURL, + utils.Config.Tenant, + ) + if err != nil { + utils.Log.Fatal().Msgf("Unable to create token issuer: %v", err) } router := mux.NewRouter() diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 25ecf2cd..d7b275c0 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -20,34 +20,55 @@ import ( type TokenIssuer struct { EcdsaPrivate *ecdsa.PrivateKey EcdsaPublic *ecdsa.PublicKey - TokenDuration string - ExtraTokenDuration string + TokenDuration time.Duration + ExtraTokenDuration time.Duration Locator string - PublicApiServerURL string + PublicApiServerURL *url.URL Tenant string } -// Generate an service token from a user account -// The semantic of this token should be hold by the target backend, ex: service api, promotion api... -// Only user with transversal access can generate extra tokens -func (issuer *TokenIssuer) GenerateExtraToken(username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, scopes string) (*string, error) { +func NewTokenIssuer(privateKey []byte, publicKey []byte, tokenDuration string, extraTokenDuration string, locator string, publicApiServerURL string, tenant string) (*TokenIssuer, error) { + duration, err := time.ParseDuration(tokenDuration) + if err != nil { + return nil, fmt.Errorf("unable to parse duration %s", tokenDuration) + } - duration, err := time.ParseDuration(issuer.ExtraTokenDuration) + extraDuration, err := time.ParseDuration(extraTokenDuration) if err != nil { - return nil, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) + return nil, fmt.Errorf("unable to parse extra Token duration %s", extraTokenDuration) } - expiration := time.Now().Add(duration) - url, err := url.Parse(issuer.PublicApiServerURL) + apiURL, err := url.Parse(publicApiServerURL) if err != nil { - return nil, fmt.Errorf("unable to parse url %s", issuer.PublicApiServerURL) + return nil, fmt.Errorf("unable to parse url %s", publicApiServerURL) } - if !(hasAdminAccess || hasApplicationAccess || hasOpsAccess) { - utils.Log.Info().Msgf("The user %s don't have transversal access ( admin: %v, application: %v, ops: %v ).", username, hasAdminAccess, hasApplicationAccess, hasOpsAccess) - return nil, nil - } else { - utils.Log.Info().Msgf("Generating extra token with scope %s ", scopes) + var ecdsaKey *ecdsa.PrivateKey + var ecdsaPub *ecdsa.PublicKey + if ecdsaKey, err = jwt.ParseECPrivateKeyFromPEM(privateKey); err != nil { + return nil, fmt.Errorf("unable to parse ECDSA private key: %v", err) } + if ecdsaPub, err = jwt.ParseECPublicKeyFromPEM(publicKey); err != nil { + return nil, fmt.Errorf("unable to parse ECDSA public key: %v", err) + } + + return &TokenIssuer{ + EcdsaPrivate: ecdsaKey, + EcdsaPublic: ecdsaPub, + TokenDuration: duration, + ExtraTokenDuration: extraDuration, + Locator: locator, + PublicApiServerURL: apiURL, + Tenant: tenant, + }, nil +} + +// Generate an service token from a user account +// The semantic of this token is held by the target backend, ex: service api, promotion api... +// Only users with "transverse" access can generate extra tokens +func (issuer *TokenIssuer) generateServiceJWTClaims(username string, email string, scopes string) (types.AuthJWTClaims, error) { + + expiration := time.Now().Add(issuer.ExtraTokenDuration) + utils.Log.Info().Msgf("Generating extra token with scope %s ", scopes) // Create the Claims claims := types.AuthJWTClaims{ @@ -55,7 +76,7 @@ func (issuer *TokenIssuer) GenerateExtraToken(username string, email string, has User: username, Contact: email, Locator: issuer.Locator, - Endpoint: url.Host, + Endpoint: issuer.PublicApiServerURL.Host, Tenant: issuer.Tenant, Scopes: scopes, StandardClaims: jwt.StandardClaims{ @@ -64,57 +85,27 @@ func (issuer *TokenIssuer) GenerateExtraToken(username string, email string, has }, } - token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) - signedToken, err := token.SignedString(issuer.EcdsaPrivate) - if err != nil { - return nil, err - } - return &signedToken, err -} - -func (issuer *TokenIssuer) GenerateUserToken(groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool) (*string, error) { - - var auths = GetUserNamespaces(groups) - claims, err := generateUserClaims(auths, groups, username, email, hasAdminAccess, hasApplicationAccess, hasOpsAccess, hasViewerAccess, hasServiceAccess, issuer) - if err != nil { - return nil, err - } - token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) - signedToken, err := token.SignedString(issuer.EcdsaPrivate) - if err != nil { - return nil, err - } - return &signedToken, err + return claims, nil } -func generateUserClaims(auths []*types.Project, groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool, issuer *TokenIssuer) (types.AuthJWTClaims, error) { +// Generate a user token from a user account +func (issuer *TokenIssuer) generateUserJWTClaims(groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool) (types.AuthJWTClaims, error) { - var emptyClaims types.AuthJWTClaims - duration, err := time.ParseDuration(issuer.TokenDuration) - if err != nil { - return emptyClaims, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) - } - expirationTime := time.Now().Add(duration) - - url, err := url.Parse(issuer.PublicApiServerURL) - if err != nil { - return emptyClaims, fmt.Errorf("unable to parse url %s", issuer.PublicApiServerURL) + var auths = []*types.Project{} + if hasAdminAccess || hasApplicationAccess || hasOpsAccess || hasServiceAccess { + utils.Log.Info().Msgf("The user %s will have transversal access, removing all the projects (admin: %v, application: %v, ops: %v, service: %v)", username, hasAdminAccess, hasApplicationAccess, hasOpsAccess, hasServiceAccess) + } else { + auths = GetUserNamespaces(groups) + utils.Log.Info().Msgf("The user %s will have access to the projects %v", username, auths) } - if hasAdminAccess || hasApplicationAccess || hasOpsAccess { - utils.Log.Info().Msgf("The user %s will have transversal access ( admin: %v, application: %v, ops: %v )", username, hasAdminAccess, hasApplicationAccess, hasOpsAccess) - auths = []*types.Project{} - } + var expirationTime time.Time - if hasServiceAccess { - utils.Log.Info().Msgf("The user %s will have transversal service access ( service: %v )", username, hasServiceAccess) - auths = []*types.Project{} - duration, err = time.ParseDuration(issuer.ExtraTokenDuration) - if err != nil { - return emptyClaims, fmt.Errorf("unable to parse duration %s", issuer.ExtraTokenDuration) - } - expirationTime = time.Now().Add(duration) - utils.Log.Info().Msgf("A specific token with duration %v would be issued.", duration.String()) + switch hasServiceAccess { + case true: + expirationTime = time.Now().Add(issuer.ExtraTokenDuration) + default: + expirationTime = time.Now().Add(issuer.TokenDuration) } // Create the Claims @@ -129,7 +120,7 @@ func generateUserClaims(auths []*types.Project, groups []string, username string ServiceAccess: hasServiceAccess, ViewerAccess: hasViewerAccess, Locator: issuer.Locator, - Endpoint: url.Host, + Endpoint: issuer.PublicApiServerURL.Host, Tenant: issuer.Tenant, StandardClaims: jwt.StandardClaims{ @@ -138,7 +129,16 @@ func generateUserClaims(auths []*types.Project, groups []string, username string }, } - return claims, err + return claims, nil +} + +func signJWTClaims(claims types.AuthJWTClaims, issuer *TokenIssuer) (*string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) + signedToken, err := token.SignedString(issuer.EcdsaPrivate) + if err != nil { + return nil, err + } + return &signedToken, err } func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*string, error) { @@ -149,21 +149,44 @@ func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*s } groups, err := ldap.GetUserGroups(*userDN) + utils.Log.Info().Msgf("The user %s is part of the groups %v", auth.Username, groups) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, err } + isAdmin := ldap.HasAdminAccess(*userDN) + isApplication := ldap.HasApplicationAccess(*userDN) + isOps := ldap.HasOpsAccess(*userDN) + isViewer := ldap.HasViewerAccess(*userDN) + isService := ldap.HasServiceAccess(*userDN) + var token *string = nil if len(scopes) > 0 { - token, err = issuer.GenerateExtraToken(auth.Username, *mail, ldap.HasAdminAccess(*userDN), ldap.HasApplicationAccess(*userDN), ldap.HasOpsAccess(*userDN), scopes) + if !(isAdmin || isApplication || isOps) { + return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", auth.Username, isAdmin, isApplication, isOps) + } + claims, err := issuer.generateServiceJWTClaims(auth.Username, *mail, scopes) + if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("unable to generate the token %v", err) + } + token, err = signJWTClaims(claims, issuer) + if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("unable to sign the token %v", err) + } } else { - token, err = issuer.GenerateUserToken(groups, auth.Username, *mail, ldap.HasAdminAccess(*userDN), ldap.HasApplicationAccess(*userDN), ldap.HasOpsAccess(*userDN), ldap.HasViewerAccess(*userDN), ldap.HasServiceAccess(*userDN)) - } - - if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, err + claims, err := issuer.generateUserJWTClaims(groups, auth.Username, *mail, isAdmin, isApplication, isOps, isViewer, isService) + if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("unable to generate the token %v", err) + } + token, err = signJWTClaims(claims, issuer) + if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("unable to sign the token %v", err) + } } if token != nil { diff --git a/internal/services/token-provider_test.go b/internal/services/token-provider_test.go index bcf95477..b29e4c8d 100644 --- a/internal/services/token-provider_test.go +++ b/internal/services/token-provider_test.go @@ -2,12 +2,11 @@ package services import ( "crypto/ecdsa" + "net/url" "os" "reflect" - "slices" - "sort" - "strings" "testing" + "time" "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" @@ -15,48 +14,89 @@ import ( "github.com/stretchr/testify/assert" ) -func TestECDSA(t *testing.T) { +func Test_signJWTClaims(t *testing.T) { + duration, _ := time.ParseDuration("4h") + url, _ := url.Parse("https://kubi.example.com") + ecdsaPem, err := os.ReadFile("./../../test/ecdsa-key.pem") if err != nil { - utils.Log.Fatal().Msgf("Unable to read ECDSA private key: %v", err) + t.Fatalf("Unable to read ECDSA private key: %v", err) } ecdsaPubPem, err := os.ReadFile("./../../test/ecdsa-pub.pem") if err != nil { - utils.Log.Fatal().Msgf("Unable to read ECDSA public key: %v", err) + t.Fatalf("Unable to read ECDSA public key: %v", err) } var ecdsaKey *ecdsa.PrivateKey var ecdsaPub *ecdsa.PublicKey if ecdsaKey, err = jwt.ParseECPrivateKeyFromPEM(ecdsaPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA private key: %v", err) + t.Fatalf("Unable to parse ECDSA private key: %v", err) } if ecdsaPub, err = jwt.ParseECPublicKeyFromPEM(ecdsaPubPem); err != nil { - utils.Log.Fatal().Msgf("Unable to parse ECDSA public key: %v", err) + t.Fatalf("Unable to parse ECDSA public key: %v", err) } - issuer := TokenIssuer{ - EcdsaPrivate: ecdsaKey, - EcdsaPublic: ecdsaPub, - TokenDuration: "4h", - Locator: utils.KubiLocatorIntranet, + issuer := &TokenIssuer{ + EcdsaPrivate: ecdsaKey, + EcdsaPublic: ecdsaPub, + TokenDuration: duration, + ExtraTokenDuration: duration, + Locator: utils.KubiLocatorIntranet, + PublicApiServerURL: url, + Tenant: "tenant", } - t.Run("Generate a valid User token", func(t *testing.T) { + claims := types.AuthJWTClaims{ + User: "testuser", + Contact: "testuser@example.com", + Locator: issuer.Locator, + Endpoint: issuer.PublicApiServerURL.Host, + Tenant: issuer.Tenant, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(duration).Unix(), + Issuer: "Kubi Server", + }, + } - token, err := issuer.GenerateUserToken([]string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin"}, "unit", "noreply@demo.com", true, true, false, false, false) + t.Run("Valid JWT signing", func(t *testing.T) { + token, err := signJWTClaims(claims, issuer) assert.Nil(t, err) assert.NotNil(t, token) - utils.Log.Info().Msgf("The token is %s", *token) - - method := jwt.SigningMethodES512 - - tokenSplits := strings.Split(*token, ".") - err = method.Verify(strings.Join(tokenSplits[0:2], "."), tokenSplits[2], ecdsaPub) + parsedToken, err := jwt.ParseWithClaims(*token, &types.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) { + return issuer.EcdsaPublic, nil + }) assert.Nil(t, err) + assert.True(t, parsedToken.Valid) }) + + // t.Run("Invalid JWT signing with nil private key", func(t *testing.T) { + // issuer.EcdsaPrivate = nil + // token, err := signJWTClaims(claims, issuer) + // assert.NotNil(t, err) + // assert.Nil(t, token) + // }) } -func Test_generateUserClaims(t *testing.T) { +func Test_generateUserJWTClaims(t *testing.T) { + duration, _ := time.ParseDuration("4h") + url, _ := url.Parse("https://kubi.example.com") + stdAuths := []*types.Project{ + { + Project: "ns-development", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + { + Project: "ns-devops-automation-integration", + Role: "", + Source: "", + Environment: "", + Contact: "", + }, + } + type args struct { auths []*types.Project groups []string @@ -71,7 +111,7 @@ func Test_generateUserClaims(t *testing.T) { } type want struct { err error - auths []string + auths int // number of auths/projects groups []string user string } @@ -83,22 +123,7 @@ func Test_generateUserClaims(t *testing.T) { { name: "Regular user token contains project and all its groups", args: args{ - auths: []*types.Project{ - &types.Project{ - Project: "ns-development", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - &types.Project{ - Project: "ns-devops-automation-integration", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - }, + auths: stdAuths, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, username: "foo", email: "foo@bar.baz", @@ -107,11 +132,11 @@ func Test_generateUserClaims(t *testing.T) { hasOpsAccess: false, hasViewerAccess: false, hasServiceAccess: false, - issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: duration, Locator: utils.KubiLocatorIntranet, PublicApiServerURL: url}, }, expected: want{ err: nil, - auths: []string{"ns-development", "ns-devops-automation-integration"}, + auths: 2, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, user: "foo", }, @@ -119,96 +144,59 @@ func Test_generateUserClaims(t *testing.T) { { name: "Admin user does not have projects", args: args{ - auths: []*types.Project{ - &types.Project{ - Project: "ns-development", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - &types.Project{ - Project: "ns-devops-automation-integration", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - }, + auths: stdAuths, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, - username: "foo", + username: "bar_admin", email: "foo@bar.baz", hasAdminAccess: true, hasApplicationAccess: false, hasOpsAccess: false, hasViewerAccess: false, hasServiceAccess: false, - issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: duration, Locator: utils.KubiLocatorIntranet, PublicApiServerURL: url}, }, expected: want{ err: nil, - auths: []string{}, + auths: 0, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, - user: "foo", + user: "bar_admin", }, }, { name: "Appops user does not have projects", args: args{ - auths: []*types.Project{ - &types.Project{ - Project: "ns-development", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - &types.Project{ - Project: "ns-devops-automation-integration", - Role: "", - Source: "", - Environment: "", - Contact: "", - }, - }, + auths: stdAuths, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, - username: "foo", + username: "baz_appops", email: "foo@bar.baz", hasAdminAccess: false, hasApplicationAccess: false, hasOpsAccess: true, hasViewerAccess: false, hasServiceAccess: false, - issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: "4h", Locator: utils.KubiLocatorIntranet}, + issuer: &TokenIssuer{EcdsaPrivate: &ecdsa.PrivateKey{}, EcdsaPublic: &ecdsa.PublicKey{}, TokenDuration: duration, Locator: utils.KubiLocatorIntranet, PublicApiServerURL: url}, }, expected: want{ err: nil, - auths: []string{}, + auths: 0, groups: []string{"DL_ns-development_admin", "DL_ns-devops-automation-integration_admin", "babar"}, - user: "foo", + user: "baz_appops", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotToken, gotErr := generateUserClaims(tt.args.auths, tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess, tt.args.issuer) + gotToken, gotErr := tt.args.issuer.generateUserJWTClaims(tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess) if gotErr != nil { assert.Equal(t, gotErr, tt.expected.err) } - assert.Equalf(t, gotToken.User, tt.expected.user, "generateUserClaims(%v, %v, %v, %v, %v, %v, %v, %v, %v, %v)", tt.args.auths, tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess, tt.args.issuer) + assert.Equalf(t, gotToken.User, tt.expected.user, "generateUserJWTClaims(%v, %v, %v, %v, %v, %v, %v, %v)", tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess) if !reflect.DeepEqual(gotToken.Groups, tt.expected.groups) { - t.Errorf("generateUserClaims() got = %v, want %v", gotToken.Groups, tt.expected.groups) - } - - var listAuths []string - for _, projectAuthName := range gotToken.Auths { - listAuths = append(listAuths, projectAuthName.Project) + t.Errorf("generateUserJWTClaims() got = %v, want %v", gotToken.Groups, tt.expected.groups) } - sort.Strings(listAuths) - sort.Strings(tt.expected.auths) - if !slices.Equal(listAuths, tt.expected.auths) { - t.Errorf("generateUserClaims() got = %v, want %v", gotToken.Auths, tt.expected.auths) + if len(gotToken.Auths) != tt.expected.auths { + t.Errorf("generateUserJWTClaims() got %v as projects, wanted a total of %v projects", gotToken.Auths, tt.expected.auths) } }) } From e6d4a1679dfbcc17e870d222a36af6727943d158 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 08:51:03 +0100 Subject: [PATCH 03/26] Prevent panic on empty private key Without this, creating a token provider with no private key will still try to sign the token. This should never happen. However, acting on it, even on tests, means a generic panic, instead of a just an error. This handles the pointer dereferencing to ensure the code does not panic, as it should already fatal on main through the constructor. --- internal/services/token-provider.go | 9 ++++++--- internal/services/token-provider_test.go | 14 +++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index d7b275c0..9048ead8 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -132,8 +132,11 @@ func (issuer *TokenIssuer) generateUserJWTClaims(groups []string, username strin return claims, nil } -func signJWTClaims(claims types.AuthJWTClaims, issuer *TokenIssuer) (*string, error) { +func (issuer *TokenIssuer) signJWTClaims(claims types.AuthJWTClaims) (*string, error) { token := jwt.NewWithClaims(jwt.SigningMethodES512, claims) + if issuer.EcdsaPrivate == nil { + return nil, fmt.Errorf("the private key is nil") // should not happen, avoid panic. + } signedToken, err := token.SignedString(issuer.EcdsaPrivate) if err != nil { return nil, err @@ -171,7 +174,7 @@ func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*s utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } - token, err = signJWTClaims(claims, issuer) + token, err = issuer.signJWTClaims(claims) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to sign the token %v", err) @@ -182,7 +185,7 @@ func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*s utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } - token, err = signJWTClaims(claims, issuer) + token, err = issuer.signJWTClaims(claims) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to sign the token %v", err) diff --git a/internal/services/token-provider_test.go b/internal/services/token-provider_test.go index b29e4c8d..aae1d11e 100644 --- a/internal/services/token-provider_test.go +++ b/internal/services/token-provider_test.go @@ -58,7 +58,7 @@ func Test_signJWTClaims(t *testing.T) { } t.Run("Valid JWT signing", func(t *testing.T) { - token, err := signJWTClaims(claims, issuer) + token, err := issuer.signJWTClaims(claims) assert.Nil(t, err) assert.NotNil(t, token) @@ -69,12 +69,12 @@ func Test_signJWTClaims(t *testing.T) { assert.True(t, parsedToken.Valid) }) - // t.Run("Invalid JWT signing with nil private key", func(t *testing.T) { - // issuer.EcdsaPrivate = nil - // token, err := signJWTClaims(claims, issuer) - // assert.NotNil(t, err) - // assert.Nil(t, token) - // }) + t.Run("Invalid JWT signing with nil private key", func(t *testing.T) { + issuer.EcdsaPrivate = nil + token, err := issuer.signJWTClaims(claims) + assert.NotNil(t, err) + assert.Nil(t, token) + }) } func Test_generateUserJWTClaims(t *testing.T) { From 20019952f84d858b548525b17fb0d2d7b5d11a2b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 10:13:53 +0100 Subject: [PATCH 04/26] Move to use a generic basicAuth handler Without this, the basic auth is included deep in the call stack. This is a problem, as it means multiple calls have the information about passwords, and need to carry useless data around. This fixes it by ensuring a new middleware for basicAuth was added, directly connecting to ldap. The user information is then filtered to keep only username, userDN, and email. It is then passed in a context for use in the next httpHandlers. At the same time, it allowed me to see that the GenerateConfig would not fail if the generation of the token results in an error. I fixed it by adding the same logic in GenerateConfig and in GenerateJWT http handlers. --- cmd/api/main.go | 4 +- internal/services/httphandlers.go | 47 +++++++++++++ internal/services/token-provider.go | 101 ++++++++++++++-------------- pkg/types/types.go | 6 ++ 4 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 internal/services/httphandlers.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 95ef3916..e43ecd52 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -43,8 +43,8 @@ func main() { }) router.HandleFunc("/ca", services.CA).Methods(http.MethodGet) - router.HandleFunc("/config", tokenIssuer.GenerateConfig).Methods(http.MethodGet) - router.HandleFunc("/token", tokenIssuer.GenerateJWT).Methods(http.MethodGet) + router.HandleFunc("/config", services.WithBasicAuth(tokenIssuer.GenerateConfig)).Methods(http.MethodGet) + router.HandleFunc("/token", services.WithBasicAuth(tokenIssuer.GenerateJWT)).Methods(http.MethodGet) router.Handle("/metrics", promhttp.Handler()) utils.Log.Info().Msgf(" Preparing to serve request, port: %d", 8000) diff --git a/internal/services/httphandlers.go b/internal/services/httphandlers.go new file mode 100644 index 00000000..d9fbcdb3 --- /dev/null +++ b/internal/services/httphandlers.go @@ -0,0 +1,47 @@ +package services + +import ( + "context" + "net/http" + + ldap "github.com/ca-gip/kubi/internal/authprovider" + "github.com/ca-gip/kubi/pkg/types" +) + +type contextKey string + +const userContextKey contextKey = "user" + +func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract the username and password from the request + // Authorization header. If no Authentication header is present + // or the header value is invalid, then the 'ok' return value + // will be false. + username, password, ok := r.BasicAuth() + if ok { + // TODO: Improve authenticateUser to return a boolean, and a type containing the user data. + // for that, use the user struct here below. + userDN, mail, err := ldap.AuthenticateUser(username, password) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + user := types.User{ + Username: username, + UserDN: *userDN, + Email: *mail, + } + ctx := context.WithValue(r.Context(), userContextKey, user) // This is ugly, but at least it cleans up the code and matches the usual patterns. + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // If the Authentication header is not present, is invalid, or the + // username or password is wrong, then set a WWW-Authenticate + // header to inform the client that we expect them to use basic + // authentication and send a 401 Unauthorized response. + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 9048ead8..e2a678c4 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -2,7 +2,6 @@ package services import ( "crypto/ecdsa" - "encoding/base64" "fmt" "io" "net/http" @@ -144,32 +143,28 @@ func (issuer *TokenIssuer) signJWTClaims(claims types.AuthJWTClaims) (*string, e return &signedToken, err } -func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*string, error) { +func (issuer *TokenIssuer) baseGenerateToken(user types.User, scopes string) (*string, error) { - userDN, mail, err := ldap.AuthenticateUser(auth.Username, auth.Password) - if err != nil { - return nil, err - } - - groups, err := ldap.GetUserGroups(*userDN) - utils.Log.Info().Msgf("The user %s is part of the groups %v", auth.Username, groups) + groups, err := ldap.GetUserGroups(user.UserDN) + utils.Log.Info().Msgf("The user %s is part of the groups %v", user.Username, groups) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, err } - isAdmin := ldap.HasAdminAccess(*userDN) - isApplication := ldap.HasApplicationAccess(*userDN) - isOps := ldap.HasOpsAccess(*userDN) - isViewer := ldap.HasViewerAccess(*userDN) - isService := ldap.HasServiceAccess(*userDN) + isAdmin := ldap.HasAdminAccess(user.UserDN) + isApplication := ldap.HasApplicationAccess(user.UserDN) + isOps := ldap.HasOpsAccess(user.UserDN) + isViewer := ldap.HasViewerAccess(user.UserDN) + isService := ldap.HasServiceAccess(user.UserDN) var token *string = nil if len(scopes) > 0 { if !(isAdmin || isApplication || isOps) { - return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", auth.Username, isAdmin, isApplication, isOps) + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, isAdmin, isApplication, isOps) } - claims, err := issuer.generateServiceJWTClaims(auth.Username, *mail, scopes) + claims, err := issuer.generateServiceJWTClaims(user.Username, user.Email, scopes) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) @@ -180,7 +175,7 @@ func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*s return nil, fmt.Errorf("unable to sign the token %v", err) } } else { - claims, err := issuer.generateUserJWTClaims(groups, auth.Username, *mail, isAdmin, isApplication, isOps, isViewer, isService) + claims, err := issuer.generateUserJWTClaims(groups, user.Username, user.Email, isAdmin, isApplication, isOps, isViewer, isService) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) @@ -200,29 +195,31 @@ func (issuer *TokenIssuer) baseGenerateToken(auth types.Auth, scopes string) (*s } func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { - auth, err := issuer.basicAuth(r) - if err != nil { - utils.Log.Info().Err(err) + + userContext := r.Context().Value(userContextKey) + if userContext == nil { + utils.Log.Error().Msgf("No user found in the context") w.WriteHeader(http.StatusUnauthorized) - io.WriteString(w, "Basic Auth: Invalid credentials") + return } - + user := userContext.(types.User) scopes := r.URL.Query().Get("scopes") - token, err := issuer.baseGenerateToken(*auth, scopes) + + token, err := issuer.baseGenerateToken(user, scopes) if err != nil { - utils.Log.Error().Msgf("Granting token fail for user %v", auth.Username) + utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) w.WriteHeader(http.StatusUnauthorized) return } if token == nil { - utils.Log.Error().Msgf("Granting token fail for user %v", auth.Username) + utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) w.WriteHeader(http.StatusForbidden) return } - utils.Log.Info().Msgf("Granting token for user %v", auth.Username) + utils.Log.Info().Msgf("Granting token for user %v", user.Username) w.WriteHeader(http.StatusCreated) io.WriteString(w, *token) } @@ -231,31 +228,35 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { // and cluster information. It can be directly used out of the box // by kubectl. It returns a well formatted yaml func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request) { - auth, err := issuer.basicAuth(r) - if err != nil { - utils.Log.Info().Msg(err.Error()) + userContext := r.Context().Value(userContextKey) + if userContext == nil { + utils.Log.Error().Msgf("No user found in the context") w.WriteHeader(http.StatusUnauthorized) - io.WriteString(w, "Basic Auth: Invalid credentials") return } + user := userContext.(types.User) - token, err := issuer.baseGenerateToken(*auth, utils.Empty) - if err == nil { - utils.Log.Info().Msgf("Granting token for user %v", auth.Username) - } else { - utils.Log.Error().Msgf("Granting token fail for user %v", auth.Username) + token, err := issuer.baseGenerateToken(user, utils.Empty) + + // No reason to generate a config if the token is wrong. + if token == nil { + utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) + w.WriteHeader(http.StatusForbidden) + return } if err != nil { - utils.Log.Info().Err(err) + utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) w.WriteHeader(http.StatusUnauthorized) return } + utils.Log.Info().Msgf("Granting token for user %v", user.Username) + // Create a DNS 1123 cluster name and user name clusterName := strings.TrimPrefix(utils.Config.PublicApiServerURL, "https://api.") - username := fmt.Sprintf("%s_%s", auth.Username, clusterName) + username := fmt.Sprintf("%s_%s", user.Username, clusterName) config := &types.KubeConfig{ ApiVersion: "v1", @@ -328,16 +329,16 @@ func (issuer *TokenIssuer) VerifyToken(usertoken string) error { return method.Verify(strings.Join(tokenSplits[0:2], "."), tokenSplits[2], issuer.EcdsaPublic) } -func (issuer *TokenIssuer) basicAuth(r *http.Request) (*types.Auth, error) { - auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2) - - if len(auth) != 2 || auth[0] != "Basic" { - return nil, fmt.Errorf("invalid auth") - } - payload, err := base64.StdEncoding.DecodeString(auth[1]) - if err != nil { - return nil, fmt.Errorf("not valid base64 string %v - %w", auth[1], err) - } - pair := strings.SplitN(string(payload), ":", 2) - return &types.Auth{Username: pair[0], Password: pair[1]}, nil -} +// func (issuer *TokenIssuer) basicAuth(r *http.Request) (*types.Auth, error) { +// auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + +// if len(auth) != 2 || auth[0] != "Basic" { +// return nil, fmt.Errorf("invalid auth") +// } +// payload, err := base64.StdEncoding.DecodeString(auth[1]) +// if err != nil { +// return nil, fmt.Errorf("not valid base64 string %v - %w", auth[1], err) +// } +// pair := strings.SplitN(string(payload), ":", 2) +// return &types.Auth{Username: pair[0], Password: pair[1]}, nil +// } diff --git a/pkg/types/types.go b/pkg/types/types.go index 3873e34e..76df8540 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -143,3 +143,9 @@ type BlackWhitelist struct { Blacklist []string `json:"blacklist"` Whitelist []string `json:"whitelist"` } + +type User struct { + Username string + UserDN string + Email string +} From a6a6ca377e4d7ce2f436c5a5cc1ba3e25bdf1970 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 10:23:39 +0100 Subject: [PATCH 05/26] Extract kubeConfig generation for futureproofing Without this we will not be able to refactor GenerateJWT and GerateConfig. This further allows testability for the config generation. --- internal/services/token-provider.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index e2a678c4..55a20050 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -227,6 +227,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { // GenerateConfig generates a config in yaml, including JWT token // and cluster information. It can be directly used out of the box // by kubectl. It returns a well formatted yaml +// TODO: Refactor to use the same code as GenerateJWT func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request) { userContext := r.Context().Value(userContextKey) @@ -255,6 +256,18 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request utils.Log.Info().Msgf("Granting token for user %v", user.Username) // Create a DNS 1123 cluster name and user name + yml, err := generateKubeConfig(user, token) + if err != nil { + utils.Log.Error().Err(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/x-yaml; charset=utf-8") + w.WriteHeader(http.StatusCreated) + w.Write(yml) +} + +func generateKubeConfig(user types.User, token *string) ([]byte, error) { clusterName := strings.TrimPrefix(utils.Config.PublicApiServerURL, "https://api.") username := fmt.Sprintf("%s_%s", user.Username, clusterName) @@ -288,12 +301,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request } yml, err := yaml.Marshal(config) - - utils.Log.Error().Err(err) - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "text/x-yaml; charset=utf-8") - w.Write(yml) - + return yml, err } func (issuer *TokenIssuer) CurrentJWT(usertoken string) (*types.AuthJWTClaims, error) { From 3dddf81ac8c123ba9f1713a480e773ef4a9a9afe Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 10:46:17 +0100 Subject: [PATCH 06/26] Use User type for Authenticate user This allows simplification of the code. --- internal/authprovider/ldap.go | 37 +++++++++++++++++++++---------- internal/services/httphandlers.go | 11 ++------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/internal/authprovider/ldap.go b/internal/authprovider/ldap.go index bc52c704..eec46760 100644 --- a/internal/authprovider/ldap.go +++ b/internal/authprovider/ldap.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/ca-gip/kubi/internal/utils" + "github.com/ca-gip/kubi/pkg/types" "github.com/pkg/errors" "github.com/rs/zerolog/log" "gopkg.in/ldap.v2" @@ -64,27 +65,39 @@ func GetAllGroups() ([]string, error) { // Authenticate a user through LDAP or LDS // return if bind was ok, the userDN for next usage, and error if occurred -func AuthenticateUser(username string, password string) (*string, *string, error) { +// TODO: Probably worth splitting this function to take the search domain as a parameter +func AuthenticateUser(username string, password string) (types.User, error) { if len(password) == 0 { - return nil, nil, errors.New("Empty password, you must give a password.") + return types.User{}, errors.New("Empty password, you must give a password.") } // Get User Distinguished Name for Standard User userDN, mail, err := getUserDN(utils.Config.Ldap.UserBase, username) if err == nil { - return &userDN, &mail, checkAuthenticate(userDN, password) - } else if len(utils.Config.Ldap.AdminUserBase) > 0 { - userDN, _, err := getUserDN(utils.Config.Ldap.AdminUserBase, username) - if err != nil { - return &userDN, &mail, err - } - return &userDN, &mail, checkAuthenticate(userDN, password) - } else { - utils.Log.Error().Msg(err.Error()) - return nil, &mail, err + return types.User{ + Username: username, + UserDN: userDN, + Email: mail, + }, checkAuthenticate(userDN, password) + } + + if len(utils.Config.Ldap.AdminUserBase) <= 0 { + return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) } + + // Retry as admin + userDN, mail, err = getUserDN(utils.Config.Ldap.AdminUserBase, username) + if err != nil { + return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) + } + return types.User{ + Username: username, + UserDN: userDN, + Email: mail, + }, checkAuthenticate(userDN, password) + } func checkAuthenticate(userDN string, password string) error { diff --git a/internal/services/httphandlers.go b/internal/services/httphandlers.go index d9fbcdb3..96506c28 100644 --- a/internal/services/httphandlers.go +++ b/internal/services/httphandlers.go @@ -5,7 +5,6 @@ import ( "net/http" ldap "github.com/ca-gip/kubi/internal/authprovider" - "github.com/ca-gip/kubi/pkg/types" ) type contextKey string @@ -20,18 +19,12 @@ func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { // will be false. username, password, ok := r.BasicAuth() if ok { - // TODO: Improve authenticateUser to return a boolean, and a type containing the user data. - // for that, use the user struct here below. - userDN, mail, err := ldap.AuthenticateUser(username, password) + user, err := ldap.AuthenticateUser(username, password) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - user := types.User{ - Username: username, - UserDN: *userDN, - Email: *mail, - } + ctx := context.WithValue(r.Context(), userContextKey, user) // This is ugly, but at least it cleans up the code and matches the usual patterns. next.ServeHTTP(w, r.WithContext(ctx)) return From 477fbb0bed0c4d63f77dd2513e81d69504605681 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 11:24:01 +0100 Subject: [PATCH 07/26] Simplify Authentication flow This makes it easier to reason around the getUser details. --- internal/authprovider/ldap.go | 42 +++++++++++++++++-------------- internal/services/httphandlers.go | 5 ++++ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/internal/authprovider/ldap.go b/internal/authprovider/ldap.go index eec46760..3fab5cae 100644 --- a/internal/authprovider/ldap.go +++ b/internal/authprovider/ldap.go @@ -63,41 +63,45 @@ func GetAllGroups() ([]string, error) { return groups, nil } +func getUser(base string, username string, password string) (types.User, error) { + userDN, mail, err := getUserDN(base, username) + if err != nil { + return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) + } + + if err := checkAuthenticate(userDN, password); err != nil { + return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) + } + + return types.User{ + Username: username, + UserDN: userDN, + Email: mail, + }, nil +} + // Authenticate a user through LDAP or LDS // return if bind was ok, the userDN for next usage, and error if occurred -// TODO: Probably worth splitting this function to take the search domain as a parameter func AuthenticateUser(username string, password string) (types.User, error) { - if len(password) == 0 { - return types.User{}, errors.New("Empty password, you must give a password.") - } - // Get User Distinguished Name for Standard User - userDN, mail, err := getUserDN(utils.Config.Ldap.UserBase, username) - + user, err := getUser(utils.Config.Ldap.UserBase, username, password) if err == nil { - return types.User{ - Username: username, - UserDN: userDN, - Email: mail, - }, checkAuthenticate(userDN, password) + return user, nil } + // Now handling errors to get standard user, falling back to admin user, if + // config allows it if len(utils.Config.Ldap.AdminUserBase) <= 0 { return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) } // Retry as admin - userDN, mail, err = getUserDN(utils.Config.Ldap.AdminUserBase, username) + user, err = getUser(utils.Config.Ldap.AdminUserBase, username, password) if err != nil { return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) } - return types.User{ - Username: username, - UserDN: userDN, - Email: mail, - }, checkAuthenticate(userDN, password) - + return user, nil } func checkAuthenticate(userDN string, password string) error { diff --git a/internal/services/httphandlers.go b/internal/services/httphandlers.go index 96506c28..0427fc0e 100644 --- a/internal/services/httphandlers.go +++ b/internal/services/httphandlers.go @@ -19,6 +19,11 @@ func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { // will be false. username, password, ok := r.BasicAuth() if ok { + if len(password) == 0 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + user, err := ldap.AuthenticateUser(username, password) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) From cb5a51274e3ea78949799698d1e496fb76ed5fe1 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 16:26:08 +0100 Subject: [PATCH 08/26] Refactor code to be more idiomatic Without this, the naming is a bit hard to follow, and the indentation is not really matching the usual go patterns. This fixes it to make the code more readable. --- internal/services/token-provider.go | 67 +++++------ .../services/webhook-tokenauthenticator.go | 105 ++++++++---------- 2 files changed, 75 insertions(+), 97 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 55a20050..4921b7ac 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -143,7 +143,7 @@ func (issuer *TokenIssuer) signJWTClaims(claims types.AuthJWTClaims) (*string, e return &signedToken, err } -func (issuer *TokenIssuer) baseGenerateToken(user types.User, scopes string) (*string, error) { +func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*string, error) { groups, err := ldap.GetUserGroups(user.UserDN) utils.Log.Info().Msgf("The user %s is part of the groups %v", user.Username, groups) @@ -158,39 +158,38 @@ func (issuer *TokenIssuer) baseGenerateToken(user types.User, scopes string) (*s isViewer := ldap.HasViewerAccess(user.UserDN) isService := ldap.HasServiceAccess(user.UserDN) + var claims types.AuthJWTClaims + var token *string = nil if len(scopes) > 0 { if !(isAdmin || isApplication || isOps) { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, isAdmin, isApplication, isOps) } - claims, err := issuer.generateServiceJWTClaims(user.Username, user.Email, scopes) + claims, err = issuer.generateServiceJWTClaims(user.Username, user.Email, scopes) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } - token, err = issuer.signJWTClaims(claims) - if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, fmt.Errorf("unable to sign the token %v", err) - } } else { - claims, err := issuer.generateUserJWTClaims(groups, user.Username, user.Email, isAdmin, isApplication, isOps, isViewer, isService) + claims, err = issuer.generateUserJWTClaims(groups, user.Username, user.Email, isAdmin, isApplication, isOps, isViewer, isService) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } - token, err = issuer.signJWTClaims(claims) - if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, fmt.Errorf("unable to sign the token %v", err) - } } - if token != nil { - utils.TokenCounter.WithLabelValues("token_success").Inc() + token, err = issuer.signJWTClaims(claims) + if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("unable to sign the token %v", err) } + if token == nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() + return nil, fmt.Errorf("the token is nil") + } + utils.TokenCounter.WithLabelValues("token_success").Inc() return token, nil } @@ -205,7 +204,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { user := userContext.(types.User) scopes := r.URL.Query().Get("scopes") - token, err := issuer.baseGenerateToken(user, scopes) + token, err := issuer.createAccessToken(user, scopes) if err != nil { utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) @@ -213,12 +212,6 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { return } - if token == nil { - utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) - w.WriteHeader(http.StatusForbidden) - return - } - utils.Log.Info().Msgf("Granting token for user %v", user.Username) w.WriteHeader(http.StatusCreated) io.WriteString(w, *token) @@ -238,15 +231,8 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request } user := userContext.(types.User) - token, err := issuer.baseGenerateToken(user, utils.Empty) - - // No reason to generate a config if the token is wrong. - if token == nil { - utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) - w.WriteHeader(http.StatusForbidden) - return - } - + token, err := issuer.createAccessToken(user, utils.Empty) + // no need to generate config if the user cannot access it. if err != nil { utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) w.WriteHeader(http.StatusUnauthorized) @@ -304,8 +290,9 @@ func generateKubeConfig(user types.User, token *string) ([]byte, error) { return yml, err } -func (issuer *TokenIssuer) CurrentJWT(usertoken string) (*types.AuthJWTClaims, error) { +func (issuer *TokenIssuer) VerifyToken(usertoken string) (*types.AuthJWTClaims, error) { + // this verifies the token and its signature token, err := jwt.ParseWithClaims(usertoken, &types.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) { return issuer.EcdsaPublic, nil }) @@ -328,14 +315,14 @@ func (issuer *TokenIssuer) CurrentJWT(usertoken string) (*types.AuthJWTClaims, e } } -func (issuer *TokenIssuer) VerifyToken(usertoken string) error { - method := jwt.SigningMethodES512 - tokenSplits := strings.Split(usertoken, ".") - if len(tokenSplits) != 3 { - return fmt.Errorf("the token %s is not a JWT token", usertoken) - } - return method.Verify(strings.Join(tokenSplits[0:2], "."), tokenSplits[2], issuer.EcdsaPublic) -} +// func (issuer *TokenIssuer) VerifyToken(usertoken string) error { +// method := jwt.SigningMethodES512 +// tokenSplits := strings.Split(usertoken, ".") +// if len(tokenSplits) != 3 { +// return fmt.Errorf("the token %s is not a JWT token", usertoken) +// } +// return method.Verify(strings.Join(tokenSplits[0:2], "."), tokenSplits[2], issuer.EcdsaPublic) +// } // func (issuer *TokenIssuer) basicAuth(r *http.Request) (*types.Auth, error) { // auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2) diff --git a/internal/services/webhook-tokenauthenticator.go b/internal/services/webhook-tokenauthenticator.go index dcdfdf6e..81ccc02c 100644 --- a/internal/services/webhook-tokenauthenticator.go +++ b/internal/services/webhook-tokenauthenticator.go @@ -15,9 +15,6 @@ import ( func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var code int - var tokenStatus error - bodyString, err := io.ReadAll(r.Body) if err != nil { utils.Log.Error().Err(err) @@ -28,80 +25,74 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { utils.Log.Error().Msg(err.Error()) } - token, err := issuer.CurrentJWT(tokenReview.Spec.Token) - if err == nil { - tokenStatus = issuer.VerifyToken(tokenReview.Spec.Token) - } + token, err := issuer.VerifyToken(tokenReview.Spec.Token) - if err != nil || tokenStatus != nil { + if err != nil { resp := v1beta1.TokenReview{ Status: v1beta1.TokenReviewStatus{ Authenticated: false, }, } - code = http.StatusUnauthorized - w.WriteHeader(code) + w.WriteHeader(http.StatusUnauthorized) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) - } else { - utils.Log.Info().Msgf("Challenging token for user %v", token.User) + } - var groups []string - groups = append(groups, utils.AuthenticatedGroup) - groups = append(groups, fmt.Sprintf(utils.KubiClusterRoleBindingReaderName)) + utils.Log.Info().Msgf("Challenging token for user %v", token.User) - // Other ldap group are injected - for _, auth := range token.Auths { - groups = append(groups, fmt.Sprintf("%s-%s", auth.Namespace(), auth.Role)) - } + var groups []string + groups = append(groups, utils.AuthenticatedGroup) + groups = append(groups, fmt.Sprintf(utils.KubiClusterRoleBindingReaderName)) - groups = append(groups, token.Groups...) + // Other ldap group are injected + for _, auth := range token.Auths { + groups = append(groups, fmt.Sprintf("%s-%s", auth.Namespace(), auth.Role)) + } - if token.AdminAccess { - groups = append(groups, utils.AdminGroup) - } + groups = append(groups, token.Groups...) - if token.OpsAccess { - groups = append(groups, utils.OPSMaster) - } + if token.AdminAccess { + groups = append(groups, utils.AdminGroup) + } - if token.ApplicationAccess { - groups = append(groups, utils.ApplicationMaster) - } + if token.OpsAccess { + groups = append(groups, utils.OPSMaster) + } - if token.ServiceAccess { - groups = append(groups, utils.ServiceMaster) - } + if token.ApplicationAccess { + groups = append(groups, utils.ApplicationMaster) + } - if token.ViewerAccess { - groups = append(groups, utils.ApplicationViewer) - } + if token.ServiceAccess { + groups = append(groups, utils.ServiceMaster) + } - resp := v1beta1.TokenReview{ - Status: v1beta1.TokenReviewStatus{ - Authenticated: true, - User: v1beta1.UserInfo{ - Username: token.User, - Groups: groups, - }, + if token.ViewerAccess { + groups = append(groups, utils.ApplicationViewer) + } + + resp := v1beta1.TokenReview{ + Status: v1beta1.TokenReviewStatus{ + Authenticated: true, + User: v1beta1.UserInfo{ + Username: token.User, + Groups: groups, }, - } - w.Header().Set("Content-Type", "application/json") - code = http.StatusOK - w.WriteHeader(code) - - jwtTokenString, marshallError := json.Marshal(resp) - if marshallError == nil { - utils.Log.Debug().Msgf("%v", string(jwtTokenString)) - } else { - utils.Log.Error().Msgf("Errot serializing json to token review: %s", marshallError.Error()) - } + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) - err = json.NewEncoder(w).Encode(resp) - if err != nil { - utils.Log.Error().Msg(err.Error()) - } + jwtTokenString, marshallError := json.Marshal(resp) + if marshallError == nil { + utils.Log.Debug().Msgf("%v", string(jwtTokenString)) + } else { + utils.Log.Error().Msgf("Errot serializing json to token review: %s", marshallError.Error()) + } + err = json.NewEncoder(w).Encode(resp) + if err != nil { + utils.Log.Error().Msg(err.Error()) } } From 6a7b156ab2aaa1ed29ea917fc3ac052a1582a783 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 19 Dec 2024 16:59:46 +0100 Subject: [PATCH 09/26] Do not double validate token Without this, we validate the token format twice: one when doing parsewithclaims, one when we do our own validation. This serves no purposes, and introduces fragile code. This fixes it by removing the useless commits. --- internal/services/token-provider.go | 31 +---------------------------- 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 4921b7ac..608b8a8a 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -296,14 +296,8 @@ func (issuer *TokenIssuer) VerifyToken(usertoken string) (*types.AuthJWTClaims, token, err := jwt.ParseWithClaims(usertoken, &types.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) { return issuer.EcdsaPublic, nil }) - - tokenSplits := strings.Split(usertoken, ".") - if len(tokenSplits) != 3 { - return nil, fmt.Errorf("the token %s is not a JWT token", usertoken) - } - if err != nil { - utils.Log.Info().Msgf("Bad token: %v. The public token part is %s", err.Error(), tokenSplits[1]) + utils.Log.Info().Msgf("Bad token: %v", err.Error()) return nil, err } @@ -314,26 +308,3 @@ func (issuer *TokenIssuer) VerifyToken(usertoken string) (*types.AuthJWTClaims, return nil, err } } - -// func (issuer *TokenIssuer) VerifyToken(usertoken string) error { -// method := jwt.SigningMethodES512 -// tokenSplits := strings.Split(usertoken, ".") -// if len(tokenSplits) != 3 { -// return fmt.Errorf("the token %s is not a JWT token", usertoken) -// } -// return method.Verify(strings.Join(tokenSplits[0:2], "."), tokenSplits[2], issuer.EcdsaPublic) -// } - -// func (issuer *TokenIssuer) basicAuth(r *http.Request) (*types.Auth, error) { -// auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2) - -// if len(auth) != 2 || auth[0] != "Basic" { -// return nil, fmt.Errorf("invalid auth") -// } -// payload, err := base64.StdEncoding.DecodeString(auth[1]) -// if err != nil { -// return nil, fmt.Errorf("not valid base64 string %v - %w", auth[1], err) -// } -// pair := strings.SplitN(string(payload), ":", 2) -// return &types.Auth{Username: pair[0], Password: pair[1]}, nil -// } From 59030d60dfd9493aeaa1a3c15c474af729fb3bfa Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Fri, 20 Dec 2024 18:48:48 +0100 Subject: [PATCH 10/26] Inline ldap single calls We use different but similiar constructor methods for the Has* calls. This is a problem, as it makes the ldap methods unnecessary harder to read. This fixes it by ensuring the code is more readable, and allowed us optimisations, like regrouping code to connect, query, and return results with our defaults. --- internal/authprovider/ldap.go | 404 ++++++------------ internal/services/token-provider.go | 2 + .../services/webhook-tokenauthenticator.go | 12 +- 3 files changed, 146 insertions(+), 272 deletions(-) diff --git a/internal/authprovider/ldap.go b/internal/authprovider/ldap.go index 3fab5cae..d56f1663 100644 --- a/internal/authprovider/ldap.go +++ b/internal/authprovider/ldap.go @@ -11,26 +11,42 @@ import ( "gopkg.in/ldap.v2" ) -type Authenticator struct { +func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) + if err != nil { + return nil, err + } + defer conn.Close() + results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) + if err != nil { + return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) + } + return results, nil + } // Authenticate a user through LDAP or LDS // return if bind was ok, the userDN for next usage, and error if occurred func GetUserGroups(userDN string) ([]string, error) { - // First TCP connect - conn, err := getBindedConnection() + // This is better than binding directly to the userDN and querying memberOf: + // in case of nested groups or other complex group structures, the memberOf + // attribute may not be populated correctly. + req := ldap.NewSearchRequest( + utils.Config.Ldap.GroupBase, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, 0, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + results, err := ldapQuery(*req) if err != nil { - return nil, err + return nil, fmt.Errorf("error searching base %v with filter %v due to %w", req.BaseDN, req.Filter, err) } - defer conn.Close() - - request := newUserGroupSearchRequest(userDN) - results, err := conn.SearchWithPaging(request, utils.Config.Ldap.PageSize) - if err != nil { - return nil, errors.Wrapf(err, "error searching for user's group for %s", userDN) - } + // TODO: REMOVE THIS, ONLY DEBUGGING PURPOSES + utils.Log.Debug().Msg(fmt.Sprintf("User %s is in groups %v", userDN, results.Entries)) var groups []string for _, entry := range results.Entries { @@ -43,15 +59,8 @@ func GetUserGroups(userDN string) ([]string, error) { // return if bind was ok, the userDN for next usage, and error if occurred func GetAllGroups() ([]string, error) { - conn, err := getBindedConnection() - if err != nil { - return nil, err - } - defer conn.Close() - request := newGroupSearchRequest() - results, err := conn.SearchWithPaging(request, utils.Config.Ldap.PageSize) - + results, err := ldapQuery(*request) if err != nil { return nil, errors.Wrap(err, "Error searching all groups") } @@ -63,29 +72,12 @@ func GetAllGroups() ([]string, error) { return groups, nil } -func getUser(base string, username string, password string) (types.User, error) { - userDN, mail, err := getUserDN(base, username) - if err != nil { - return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) - } - - if err := checkAuthenticate(userDN, password); err != nil { - return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) - } - - return types.User{ - Username: username, - UserDN: userDN, - Email: mail, - }, nil -} - // Authenticate a user through LDAP or LDS // return if bind was ok, the userDN for next usage, and error if occurred func AuthenticateUser(username string, password string) (types.User, error) { // Get User Distinguished Name for Standard User - user, err := getUser(utils.Config.Ldap.UserBase, username, password) + user, err := validateUserCredentials(utils.Config.Ldap.UserBase, username, password) if err == nil { return user, nil } @@ -97,50 +89,49 @@ func AuthenticateUser(username string, password string) (types.User, error) { } // Retry as admin - user, err = getUser(utils.Config.Ldap.AdminUserBase, username, password) + user, err = validateUserCredentials(utils.Config.Ldap.AdminUserBase, username, password) if err != nil { return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) } return user, nil } -func checkAuthenticate(userDN string, password string) error { - conn, err := getBindedConnection() +// Finds an user and check if its password is correct. +func validateUserCredentials(base string, username string, password string) (types.User, error) { + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) if err != nil { - return err + return types.User{}, err } defer conn.Close() - tlsConfig := &tls.Config{ - ServerName: utils.Config.Ldap.Host, - InsecureSkipVerify: utils.Config.Ldap.SkipTLSVerification, - } + req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) + res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - if utils.Config.Ldap.UseSSL { - conn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port), tlsConfig) - } else { - conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port)) + switch { + case err != nil: + return types.User{}, fmt.Errorf("error searching for user %s, %w", username, err) + case len(res.Entries) == 0: + return types.User{}, fmt.Errorf("no result for the user search filter '%s'", req.Filter) + case len(res.Entries) > 1: + return types.User{}, fmt.Errorf("multiple entries found for the user search filter '%s'", req.Filter) } - if utils.Config.Ldap.StartTLS { - err = conn.StartTLS(tlsConfig) - if err != nil { - utils.Log.Error().Err(errors.Wrapf(err, "unable to setup TLS connection")) - return err - } + userDN := res.Entries[0].DN + mail := res.Entries[0].GetAttributeValue("mail") + user := types.User{ + Username: username, + UserDN: userDN, + Email: mail, } + _, err = ldapConnectAndBind(userDN, password) if err != nil { - utils.Log.Error().Err(errors.Wrapf(err, "unable to create ldap connector for %s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port)) - return err + return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) } - - // Bind with BindAccount - err = conn.Bind(userDN, password) - return err + return user, nil } -func getBindedConnection() (*ldap.Conn, error) { +func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { var ( err error conn *ldap.Conn @@ -168,7 +159,7 @@ func getBindedConnection() (*ldap.Conn, error) { } // Bind with BindAccount - err = conn.Bind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) + err = conn.Bind(login, password) if err != nil { return nil, errors.WithStack(err) } @@ -176,55 +167,31 @@ func getBindedConnection() (*ldap.Conn, error) { return conn, nil } -// Get User DN for searching in group -func getUserDN(userBaseDN string, username string) (string, string, error) { - // First TCP connect - conn, err := getBindedConnection() - if err != nil { - return utils.Empty, utils.Empty, err - } - defer conn.Close() - - req := newUserSearchRequest(userBaseDN, username) - - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - - if err != nil { - return utils.Empty, utils.Empty, errors.Wrapf(err, "Error searching for user %s", username) - } - - if len(res.Entries) == 0 { - return utils.Empty, utils.Empty, errors.Errorf("No result for the user search filter '%s'", req.Filter) - - } else if len(res.Entries) > 1 { - return utils.Empty, utils.Empty, errors.Errorf("Multiple entries found for the user search filter '%s'", req.Filter) - } - - userDN := res.Entries[0].DN - mail := res.Entries[0].GetAttributeValue("mail") - return userDN, mail, nil -} - // Check if a user is in admin LDAP group -// return true if it belong to AdminGroup, false otherwise +// return true if it belong to AdminGroup, false otherwise (including errors or misconfiguration) +// TODO: Work on the Has* functions - use composition? func HasAdminAccess(userDN string) bool { // No need to go further, there is no Admin Group Base if len(utils.Config.Ldap.AdminGroupBase) == 0 { return false } + req := ldap.NewSearchRequest( + utils.Config.Ldap.AdminGroupBase, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) - conn, err := getBindedConnection() + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for admin access %v", err.Error())) return false } - defer conn.Close() - - req := newUserAdminSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - return err == nil && len(res.Entries) > 0 + return len(res.Entries) > 0 } // Return true if the user manage application at cluster wide scope @@ -232,8 +199,8 @@ func HasApplicationAccess(userDN string) bool { return hasApplicationAccess(userDN) || hasCustomerOpsAccess(userDN) } -// Check if a user is in admin LDAP group -// return true if it belong to ApplicationGroup, false otherwise +// Check if a user is in application LDAP group +// return true if it belong to ApplicationGroup, false otherwise (including errors or misconfiguration) func hasApplicationAccess(userDN string) bool { // No need to go further, there is no Application Group Base @@ -241,66 +208,76 @@ func hasApplicationAccess(userDN string) bool { return false } - conn, err := getBindedConnection() + req := ldap.NewSearchRequest( + utils.Config.Ldap.AppMasterGroupBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for application access %v", err.Error())) return false } - defer conn.Close() - - req := newUserApplicationSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - return err == nil && len(res.Entries) > 0 + return len(res.Entries) > 0 } -// Check if a user is in viewer LDAP group -// return true if it belong to viewerGroup, false otherwise -func HasViewerAccess(userDN string) bool { +// Check if a user is in customer ops LDAP group +// return true if it belong to CustomerOpsGroup, false otherwise (including errors or misconfiguration) +func hasCustomerOpsAccess(userDN string) bool { // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.ViewerGroupBase) == 0 { + if len(utils.Config.Ldap.CustomerOpsGroupBase) == 0 { return false } - conn, err := getBindedConnection() + req := ldap.NewSearchRequest( + utils.Config.Ldap.CustomerOpsGroupBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for customer ops access %v", err.Error())) return false } - defer conn.Close() - - req := newUserViewerSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - return err == nil && len(res.Entries) > 0 + return len(res.Entries) > 0 } -// Check if a user is in customer ops LDAP group -// return true if it belong to CustomerOpsGroup, false otherwise -func hasCustomerOpsAccess(userDN string) bool { +// Check if a user is in viewer LDAP group +// return true if it belong to viewerGroup, false otherwise (including errors or misconfiguration) +func HasViewerAccess(userDN string) bool { // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.CustomerOpsGroupBase) == 0 { + if len(utils.Config.Ldap.ViewerGroupBase) == 0 { return false } - - conn, err := getBindedConnection() + req := ldap.NewSearchRequest( + utils.Config.Ldap.ViewerGroupBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for viewer access %v", err.Error())) return false } - defer conn.Close() - - req := newCustomerOpsSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - return err == nil && len(res.Entries) > 0 + return len(res.Entries) > 0 } // Check if a user is in service LDAP group -// return true if it belong to ServiceGroup, false otherwise -// Service is map to a service cluster role ( which must be deploy beside ) +// return true if it belong to ServiceGroup, false otherwise (including errors or misconfiguration) +// Service is map to a service cluster role ( which must be deploy beside ) # ?!? I don't understand this comment // Service user must be in LDAP_ADMIN_USERBASE or LDAP_USERBASE func HasServiceAccess(userDN string) bool { @@ -310,21 +287,25 @@ func HasServiceAccess(userDN string) bool { return false } - conn, err := getBindedConnection() + req := ldap.NewSearchRequest( + utils.Config.Ldap.ServiceGroupBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for service access %v", err.Error())) return false } - defer conn.Close() - req := newServiceSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - - return err == nil && len(res.Entries) > 0 + return len(res.Entries) > 0 } -// Check if a user is in admin LDAP group -// return true if it belong to OpsGroup, false otherwise +// Check if a user is in cloudops LDAP group +// return true if it belong to OpsGroup, false otherwise (including errors or misconfiguration) func HasOpsAccess(userDN string) bool { // No need to go further, there is no Application Group Base @@ -332,136 +313,21 @@ func HasOpsAccess(userDN string) bool { return false } - conn, err := getBindedConnection() + req := ldap.NewSearchRequest( + utils.Config.Ldap.OpsMasterGroupBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + + res, err := ldapQuery(*req) if err != nil { - utils.Log.Error().Msg(err.Error()) + utils.Log.Error().Msg(fmt.Sprintf("issue querying for ops access %v", err.Error())) return false } - defer conn.Close() - - req := newUserOpsSearchRequest(userDN) - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - - return err == nil && len(res.Entries) > 0 -} - -// request to search user -func newUserSearchRequest(userBaseDN string, username string) *ldap.SearchRequest { - userFilter := fmt.Sprintf(utils.Config.Ldap.UserFilter, username) - return &ldap.SearchRequest{ - BaseDN: userBaseDN, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 2, // limit number of entries in result - TimeLimit: 10, - TypesOnly: false, - Filter: userFilter, // filter default format : (&(objectClass=person)(uid=%s)) - } -} - -// request to get user group list -func newUserGroupSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.GroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 0, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} - -// request to get user group list -func newUserAdminSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.AdminGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} - -// request to get user group list -func newUserApplicationSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.AppMasterGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} - -// request to get user group list -func newUserViewerSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.ViewerGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} - -// request to get user group list -func newCustomerOpsSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.CustomerOpsGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} -// request to get user group list -func newServiceSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.ServiceGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} - -// request to get user group list -func newUserOpsSearchRequest(userDN string) *ldap.SearchRequest { - groupFilter := fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN) - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.OpsMasterGroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 1, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: groupFilter, // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } + return len(res.Entries) > 0 } // request to get group list ( for all namespaces ) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 608b8a8a..ee7552ca 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -152,6 +152,8 @@ func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*s return nil, err } + // to keep for historical reasons: We continue to issue tokens with old data until + // ArgoCD + promote + other? is updated to use the new groups. isAdmin := ldap.HasAdminAccess(user.UserDN) isApplication := ldap.HasApplicationAccess(user.UserDN) isOps := ldap.HasOpsAccess(user.UserDN) diff --git a/internal/services/webhook-tokenauthenticator.go b/internal/services/webhook-tokenauthenticator.go index 81ccc02c..a9d8658e 100644 --- a/internal/services/webhook-tokenauthenticator.go +++ b/internal/services/webhook-tokenauthenticator.go @@ -42,15 +42,17 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { var groups []string groups = append(groups, utils.AuthenticatedGroup) + + // LEGACY GROUPS, only for comptability purposes + // TODO: After this version of kubi is released, wait for a month (expiration + // of the service token) and remove the following groups. + // Ensure it is also done on the argocd's dex kubi plugin. groups = append(groups, fmt.Sprintf(utils.KubiClusterRoleBindingReaderName)) - // Other ldap group are injected for _, auth := range token.Auths { groups = append(groups, fmt.Sprintf("%s-%s", auth.Namespace(), auth.Role)) } - groups = append(groups, token.Groups...) - if token.AdminAccess { groups = append(groups, utils.AdminGroup) } @@ -71,6 +73,10 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { groups = append(groups, utils.ApplicationViewer) } + // New Group mapping: In the future, we just expose the token's groups. + // Filtering will be made solely on the kubi API server side in the future. + groups = append(groups, token.Groups...) + resp := v1beta1.TokenReview{ Status: v1beta1.TokenReviewStatus{ Authenticated: true, From 175107896bf2c314eed961cc017f9a6a51c04d79 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Fri, 20 Dec 2024 23:58:07 +0100 Subject: [PATCH 11/26] Clarify group membership Without this, one might expect to have groups[] to be always populated, and passed to JWTClaims generation. However, this is not the case, as the groups are empty if the user does not have direct access rights to the cluster. This is a problem, as it will prevent further evolution of the group management. To fix this, I made sure that _ALL THE GROUPS_, including the special groups (appops, customerops, cloudops, containerops) have their groups fetched and generated in the JWT. I also moved the code from ldap to project.go, as none of the code was actually relevant from ldap perspective: All the code was manipulating project objects. Tests were added to ensure the behaviour was intact. The code also took the opportunity to remove incorrectly exported functions back to internal functions (and fixing their tests). --- internal/authprovider/ldap.go | 323 +++++++++-------------- internal/authprovider/ldap_test.go | 90 +++++++ internal/services/ldap.go | 93 ------- internal/services/ldap_test.go | 323 ----------------------- internal/services/project.go | 107 ++++++++ internal/services/project_test.go | 176 ++++++++++++ internal/services/provisionner.go | 6 +- internal/services/token-provider.go | 51 ++-- internal/services/token-provider_test.go | 14 +- 9 files changed, 532 insertions(+), 651 deletions(-) create mode 100644 internal/authprovider/ldap_test.go delete mode 100644 internal/services/ldap.go delete mode 100644 internal/services/ldap_test.go create mode 100644 internal/services/project.go create mode 100644 internal/services/project_test.go diff --git a/internal/authprovider/ldap.go b/internal/authprovider/ldap.go index d56f1663..aea80e80 100644 --- a/internal/authprovider/ldap.go +++ b/internal/authprovider/ldap.go @@ -7,59 +7,151 @@ import ( "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" "github.com/pkg/errors" - "github.com/rs/zerolog/log" "gopkg.in/ldap.v2" ) -func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { - conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) +type UserMemberships struct { + AdminAccess []*ldap.Entry + AppOpsAccess []*ldap.Entry + CustomerOpsAccess []*ldap.Entry + ViewerAccess []*ldap.Entry + ServiceAccess []*ldap.Entry + CloudOpsAccess []*ldap.Entry + ClusterGroupsAccess []*ldap.Entry // This represents the groups that are cluster-scoped (=projects) +} + +// Constructing UserMemberships struct with all the special groups the user is member of. +// This does not include the standard groups like "authenticated" or "system:authenticated" +// or cluster based groups. +func (m *UserMemberships) FromUserDN(userDN string) error { + var err error + m.AdminAccess, err = getGroupsContainingUser(utils.Config.Ldap.AdminGroupBase, userDN) if err != nil { - return nil, err + return errors.Wrap(err, "error getting admin access") } - defer conn.Close() - results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) + + m.AppOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.AppMasterGroupBase, userDN) if err != nil { - return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) + return errors.Wrap(err, "error getting app ops access") } - return results, nil -} + m.CustomerOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.CustomerOpsGroupBase, userDN) + if err != nil { + return errors.Wrap(err, "error getting customer ops access") + } -// Authenticate a user through LDAP or LDS -// return if bind was ok, the userDN for next usage, and error if occurred -func GetUserGroups(userDN string) ([]string, error) { + m.ViewerAccess, err = getGroupsContainingUser(utils.Config.Ldap.ViewerGroupBase, userDN) + if err != nil { + return errors.Wrap(err, "error getting viewer access") + } + + m.ServiceAccess, err = getGroupsContainingUser(utils.Config.Ldap.ServiceGroupBase, userDN) + if err != nil { + return errors.Wrap(err, "error getting service access") + } + + m.CloudOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.OpsMasterGroupBase, userDN) + if err != nil { + return errors.Wrap(err, "error getting cloud ops access") + } // This is better than binding directly to the userDN and querying memberOf: // in case of nested groups or other complex group structures, the memberOf // attribute may not be populated correctly. + m.ClusterGroupsAccess, err = getGroupsContainingUser(utils.Config.Ldap.GroupBase, userDN) + if err != nil { + return errors.Wrap(err, "error getting cluster groups access") + } + + return nil +} + +// ListGroups retuns a slice for all the group names the user is member of, +// rather than their full LDAP entries. +func (m *UserMemberships) ListGroups() []string { + var groups []string + for _, entry := range m.AdminAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + for _, entry := range m.AppOpsAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + for _, entry := range m.CustomerOpsAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + for _, entry := range m.ViewerAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + for _, entry := range m.ServiceAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + for _, entry := range m.CloudOpsAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + groups = append(groups, m.ListClusterGroups()...) + return groups +} + +// ListClusterGroups is a convenience method to return the cluster-scoped groups +// rather than full membership entries. +func (m *UserMemberships) ListClusterGroups() []string { + var groups []string + for _, entry := range m.ClusterGroupsAccess { + groups = append(groups, entry.GetAttributeValue("cn")) + } + return groups +} + +func getGroupsContainingUser(groupBaseDN string, userDN string) ([]*ldap.Entry, error) { + if len(groupBaseDN) == 0 { + return []*ldap.Entry{}, nil + } req := ldap.NewSearchRequest( - utils.Config.Ldap.GroupBase, + groupBaseDN, ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, 0, 30, false, + ldap.NeverDerefAliases, 1, 30, false, fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), []string{"cn"}, nil, ) - results, err := ldapQuery(*req) + + res, err := ldapQuery(*req) if err != nil { - return nil, fmt.Errorf("error searching base %v with filter %v due to %w", req.BaseDN, req.Filter, err) + return nil, errors.Wrap(err, "error querying for group memberships") } - // TODO: REMOVE THIS, ONLY DEBUGGING PURPOSES - utils.Log.Debug().Msg(fmt.Sprintf("User %s is in groups %v", userDN, results.Entries)) + return res.Entries, nil +} - var groups []string - for _, entry := range results.Entries { - groups = append(groups, entry.GetAttributeValue("cn")) +// Query LDAP with default credentials and paging parameters +func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) + if err != nil { + return nil, err } - return groups, nil + defer conn.Close() + results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) + if err != nil { + return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) + } + return results, nil + } -// Authenticate a user through LDAP or LDS -// return if bind was ok, the userDN for next usage, and error if occurred +// Get All groups for the cluster from LDAP func GetAllGroups() ([]string, error) { - request := newGroupSearchRequest() + request := &ldap.SearchRequest{ + BaseDN: utils.Config.Ldap.GroupBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + SizeLimit: 0, // limit number of entries in result, 0 values means no limitations + TimeLimit: 30, + TypesOnly: false, + Filter: "(|(objectClass=groupOfNames)(objectClass=group))", // filter default format : (&(objectClass=groupOfNames)(member=%s)) + Attributes: []string{"cn"}, + } + results, err := ldapQuery(*request) if err != nil { return nil, errors.Wrap(err, "Error searching all groups") @@ -98,13 +190,14 @@ func AuthenticateUser(username string, password string) (types.User, error) { // Finds an user and check if its password is correct. func validateUserCredentials(base string, username string, password string) (types.User, error) { + req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) if err != nil { return types.User{}, err } defer conn.Close() - req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) switch { @@ -131,6 +224,7 @@ func validateUserCredentials(base string, username string, password string) (typ return user, nil } +// Connect to LDAP and bind with given credentials func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { var ( err error @@ -166,180 +260,3 @@ func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { return conn, nil } - -// Check if a user is in admin LDAP group -// return true if it belong to AdminGroup, false otherwise (including errors or misconfiguration) -// TODO: Work on the Has* functions - use composition? -func HasAdminAccess(userDN string) bool { - - // No need to go further, there is no Admin Group Base - if len(utils.Config.Ldap.AdminGroupBase) == 0 { - return false - } - req := ldap.NewSearchRequest( - utils.Config.Ldap.AdminGroupBase, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for admin access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// Return true if the user manage application at cluster wide scope -func HasApplicationAccess(userDN string) bool { - return hasApplicationAccess(userDN) || hasCustomerOpsAccess(userDN) -} - -// Check if a user is in application LDAP group -// return true if it belong to ApplicationGroup, false otherwise (including errors or misconfiguration) -func hasApplicationAccess(userDN string) bool { - - // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.AppMasterGroupBase) == 0 { - return false - } - - req := ldap.NewSearchRequest( - utils.Config.Ldap.AppMasterGroupBase, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for application access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// Check if a user is in customer ops LDAP group -// return true if it belong to CustomerOpsGroup, false otherwise (including errors or misconfiguration) -func hasCustomerOpsAccess(userDN string) bool { - - // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.CustomerOpsGroupBase) == 0 { - return false - } - - req := ldap.NewSearchRequest( - utils.Config.Ldap.CustomerOpsGroupBase, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for customer ops access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// Check if a user is in viewer LDAP group -// return true if it belong to viewerGroup, false otherwise (including errors or misconfiguration) -func HasViewerAccess(userDN string) bool { - - // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.ViewerGroupBase) == 0 { - return false - } - req := ldap.NewSearchRequest( - utils.Config.Ldap.ViewerGroupBase, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for viewer access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// Check if a user is in service LDAP group -// return true if it belong to ServiceGroup, false otherwise (including errors or misconfiguration) -// Service is map to a service cluster role ( which must be deploy beside ) # ?!? I don't understand this comment -// Service user must be in LDAP_ADMIN_USERBASE or LDAP_USERBASE -func HasServiceAccess(userDN string) bool { - - // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.ServiceGroupBase) == 0 { - log.Debug().Msgf("Using ldap groupbase %s", utils.Config.Ldap.ServiceGroupBase) - return false - } - - req := ldap.NewSearchRequest( - utils.Config.Ldap.ServiceGroupBase, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for service access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// Check if a user is in cloudops LDAP group -// return true if it belong to OpsGroup, false otherwise (including errors or misconfiguration) -func HasOpsAccess(userDN string) bool { - - // No need to go further, there is no Application Group Base - if len(utils.Config.Ldap.OpsMasterGroupBase) == 0 { - return false - } - - req := ldap.NewSearchRequest( - utils.Config.Ldap.OpsMasterGroupBase, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("issue querying for ops access %v", err.Error())) - return false - } - - return len(res.Entries) > 0 -} - -// request to get group list ( for all namespaces ) -func newGroupSearchRequest() *ldap.SearchRequest { - return &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.GroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 0, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: "(|(objectClass=groupOfNames)(objectClass=group))", // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } -} diff --git a/internal/authprovider/ldap_test.go b/internal/authprovider/ldap_test.go new file mode 100644 index 00000000..a2de2156 --- /dev/null +++ b/internal/authprovider/ldap_test.go @@ -0,0 +1,90 @@ +package ldap + +import ( + "testing" + + "gopkg.in/ldap.v2" +) + +func TestListGroups(t *testing.T) { + tests := []struct { + name string + members UserMemberships + expected []string + }{ + { + name: "All groups", + members: UserMemberships{ + AdminAccess: []*ldap.Entry{ + {DN: "cn=admin1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin1"}}}}, + {DN: "cn=admin2", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin2"}}}}, + }, + AppOpsAccess: []*ldap.Entry{ + {DN: "cn=appops1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"appops1"}}}}, + }, + CustomerOpsAccess: []*ldap.Entry{ + {DN: "cn=customerops1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"customerops1"}}}}, + }, + ViewerAccess: []*ldap.Entry{ + {DN: "cn=viewer1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"viewer1"}}}}, + }, + ServiceAccess: []*ldap.Entry{ + {DN: "cn=service1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"service1"}}}}, + }, + CloudOpsAccess: []*ldap.Entry{ + {DN: "cn=cloudops1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"cloudops1"}}}}, + }, + ClusterGroupsAccess: []*ldap.Entry{ + {DN: "cn=cluster1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"cluster1"}}}}, + }, + }, + expected: []string{"admin1", "admin2", "appops1", "customerops1", "viewer1", "service1", "cloudops1", "cluster1"}, + }, + { + name: "No groups", + members: UserMemberships{ + AdminAccess: []*ldap.Entry{}, + AppOpsAccess: []*ldap.Entry{}, + CustomerOpsAccess: []*ldap.Entry{}, + ViewerAccess: []*ldap.Entry{}, + ServiceAccess: []*ldap.Entry{}, + CloudOpsAccess: []*ldap.Entry{}, + ClusterGroupsAccess: []*ldap.Entry{}, + }, + expected: []string{}, + }, + { + name: "Some groups", + members: UserMemberships{ + AdminAccess: []*ldap.Entry{ + {DN: "cn=admin1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin1"}}}}, + }, + AppOpsAccess: []*ldap.Entry{ + {DN: "cn=appops1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"appops1"}}}}, + }, + CustomerOpsAccess: []*ldap.Entry{}, + ViewerAccess: []*ldap.Entry{ + {DN: "cn=viewer1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"viewer1"}}}}, + }, + ServiceAccess: []*ldap.Entry{}, + CloudOpsAccess: []*ldap.Entry{}, + ClusterGroupsAccess: []*ldap.Entry{}, + }, + expected: []string{"admin1", "appops1", "viewer1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.members.ListGroups() + if len(got) != len(tt.expected) { + t.Errorf("ListGroups() = %v, want %v", got, tt.expected) + } + for i, group := range got { + if group != tt.expected[i] { + t.Errorf("ListGroups() = %v, want %v", got, tt.expected) + } + } + }) + } +} diff --git a/internal/services/ldap.go b/internal/services/ldap.go deleted file mode 100644 index 7496163e..00000000 --- a/internal/services/ldap.go +++ /dev/null @@ -1,93 +0,0 @@ -package services - -import ( - "fmt" - "regexp" - "slices" - "strings" - - "github.com/ca-gip/kubi/internal/utils" - "github.com/ca-gip/kubi/pkg/types" -) - -var DnsParser = regexp.MustCompile("(?:.+_+)*(?P.+)_(?P.+)$") - -// Get Namespace, Role for a list of group name -func GetUserNamespaces(groups []string) []*types.Project { - res := make([]*types.Project, 0) - for _, groupname := range groups { - tupple, err := GetUserNamespace(groupname) - if err == nil { - res = append(res, tupple) - } else { - utils.Log.Error().Msg(err.Error()) - } - } - return res -} - -// Parse an ldap namespace an extract: -// - Kubernetes namespace -// - Project ( namespace without environment) -// - Environment -// If environment not found, return the namespace as is -func NamespaceParser(namespace string) types.Project { - var project = types.Project{} - - if !utils.HasSuffixes(namespace, utils.AllEnvironments) { - project.Project = namespace - return project - } - - splits := strings.Split(namespace, "-") - environment := splits[len(splits)-1] - if utils.LdapMapping[environment] != utils.Empty { - environment = utils.LdapMapping[environment] - } - project.Environment = environment - project.Project = strings.Join(splits[:len(splits)-1], "-") - return project -} - -// Get Namespace, Role for a group name -func GetUserNamespace(group string) (*types.Project, error) { - - lowerGroup := strings.ToLower(group) - keys := DnsParser.SubexpNames() - if len(keys) < 3 { - return nil, fmt.Errorf("the ldap group parser does not have the two required keys (namespace and role) as it only contains %v", keys) - } - - countSplits := len(DnsParser.FindStringSubmatch(lowerGroup)) - - if countSplits != 3 { - return nil, fmt.Errorf("cannot find a namespace and a role - the ldap group '%v' cannot be parsed", group) - } - - rawNamespace, role := DnsParser.ReplaceAllString(lowerGroup, "${namespace}"), DnsParser.ReplaceAllString(lowerGroup, "${role}") - project := NamespaceParser(rawNamespace) - project.Role = role - project.Source = group - - isNamespaceValid, err := regexp.MatchString(utils.Dns1123LabelFmt, project.Namespace()) - if err != nil { - return nil, err - } - isInBlacklistedNamespace := slices.Contains(utils.BlacklistedNamespaces, project.Namespace()) - isRoleValid := slices.Contains(utils.WhitelistedRoles, project.Role) - - switch { - case isInBlacklistedNamespace: - return nil, fmt.Errorf("the ldap group %v cannot be created, its namespace %v is protected through blacklist", group, project.Namespace()) - case !isNamespaceValid: - return nil, fmt.Errorf("the ldap group %v cannot be created, its namespace %v is not dns1123 compliant", group, project.Namespace()) - case !isRoleValid: - return nil, fmt.Errorf("the ldap group %v cannot be created, its role %v is not valid", group, project.Namespace()) - case len(project.Namespace()) > utils.DNS1123LabelMaxLength: - return nil, fmt.Errorf("the name for namespace cannot exceeded %v characters", utils.DNS1123LabelMaxLength) - case len(role) > utils.DNS1123LabelMaxLength: - return nil, fmt.Errorf("the name for role cannot exceeded %v characters", utils.DNS1123LabelMaxLength) - default: - return &project, nil - } -} diff --git a/internal/services/ldap_test.go b/internal/services/ldap_test.go deleted file mode 100644 index ce9f8bde..00000000 --- a/internal/services/ldap_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package services_test - -import ( - "github.com/ca-gip/kubi/internal/services" - "github.com/ca-gip/kubi/internal/utils" - "github.com/stretchr/testify/assert" - "regexp" - "testing" -) - -func TestEnvironmentMapping(t *testing.T) { - t.Run("with_valid_short_name", func(t *testing.T) { - result := services.NamespaceParser("whatever-dev") - assert.NotNil(t, result) - assert.Equal(t, "whatever-development", result.Namespace()) - assert.Equal(t, "development", result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_short_name-int", func(t *testing.T) { - result := services.NamespaceParser("whatever-int") - assert.NotNil(t, result) - assert.Equal(t, "whatever-integration", result.Namespace()) - assert.Equal(t, "integration", result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_short_name-uat", func(t *testing.T) { - result := services.NamespaceParser("whatever-uat") - assert.NotNil(t, result) - assert.Equal(t, "whatever-uat", result.Namespace()) - assert.Equal(t, "uat", result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_short_name-prod", func(t *testing.T) { - result := services.NamespaceParser("whatever-prd") - assert.NotNil(t, result) - assert.Equal(t, "whatever-production", result.Namespace()) - assert.Equal(t, "production", result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_short_name-preprod", func(t *testing.T) { - result := services.NamespaceParser("whatever-pprd") - assert.NotNil(t, result) - assert.Equal(t, "whatever-preproduction", result.Namespace()) - assert.Equal(t, utils.KubiEnvironmentPreproduction, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name", func(t *testing.T) { - result := services.NamespaceParser("whatever-development") - assert.NotNil(t, result) - assert.Equal(t, "whatever-development", result.Namespace()) - assert.Equal(t, "development", result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name-int", func(t *testing.T) { - result := services.NamespaceParser("whatever-integration") - assert.NotNil(t, result) - assert.Equal(t, "whatever-integration", result.Namespace()) - assert.Equal(t, utils.KubiEnvironmentIntegration, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name-uat", func(t *testing.T) { - result := services.NamespaceParser("whatever-uat") - assert.NotNil(t, result) - assert.Equal(t, "whatever-uat", result.Namespace()) - assert.Equal(t, utils.KubiEnvironmentUAT, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name-prod", func(t *testing.T) { - result := services.NamespaceParser("whatever-production") - assert.NotNil(t, result) - assert.Equal(t, "whatever-production", result.Namespace()) - assert.Equal(t, utils.KubiEnvironmentProduction, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name-preproduction", func(t *testing.T) { - result := services.NamespaceParser("whatever-preproduction") - assert.NotNil(t, result) - assert.Equal(t, "whatever-preproduction", result.Namespace()) - assert.Equal(t, utils.KubiEnvironmentPreproduction, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) - - t.Run("with_valid_name-without-env", func(t *testing.T) { - result := services.NamespaceParser("whatever") - assert.NotNil(t, result) - assert.Equal(t, "whatever", result.Namespace()) - assert.Equal(t, utils.Empty, result.Environment) - assert.Equal(t, "whatever", result.Project) - }) -} - -func TestGetUserNamespace(t *testing.T) { - groups := []string{ - "valid_group_admin", - "valid_GROUP_ADMIN", - "valid_group_with_a_lot_of_split", - "notvalid", - } - - t.Run("with_valid_name", func(t *testing.T) { - - result, err := services.GetUserNamespace(groups[0]) - - assert.Nil(t, err) - assert.NotNil(t, result) - assert.Equal(t, "group", result.Namespace()) - assert.Equal(t, utils.Empty, result.Environment) - assert.Equal(t, "admin", result.Role) - - }) - - t.Run("with_uppercase_name", func(t *testing.T) { - - result, err := services.GetUserNamespace(groups[1]) - - assert.Nil(t, err) - assert.NotNil(t, result) - assert.Equal(t, "group", result.Namespace()) - assert.Equal(t, "admin", result.Role) - - }) - - t.Run("with more than 2 split and invalid role", func(t *testing.T) { - - result, err := services.GetUserNamespace("valid_group_with_a_lot_of_service") - - assert.Nil(t, err) - assert.NotNil(t, result) - - }) - - t.Run("with more than 2 split and valid role", func(t *testing.T) { - - result, err := services.GetUserNamespace("valid_group_with_a_lot_of_service") - - assert.Nil(t, err) - assert.NotNil(t, result) - assert.Equal(t, "service", result.Role) - assert.Equal(t, "of", result.Namespace()) - - }) - - t.Run("with_missing_role", func(t *testing.T) { - - result, err := services.GetUserNamespace(groups[3]) - - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with no separator", func(t *testing.T) { - - result, err := services.GetUserNamespace("test-test") - - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with the separator character", func(t *testing.T) { - - result, err := services.GetUserNamespace("_") - - assert.NotNil(t, err) - assert.Nil(t, result) - }) - - t.Run("with multiple separator", func(t *testing.T) { - - result, err := services.GetUserNamespace("______") - - assert.NotNil(t, err) - assert.Nil(t, result) - }) - - t.Run("with invalid caracter separator", func(t *testing.T) { - - result, err := services.GetUserNamespace("_$_@_!") - - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with valid caracter but not DNS-1123 compliant", func(t *testing.T) { - - result, err := services.GetUserNamespace("_-_a-b") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with valid caracter but not DNS-1123 compliant for namespace", func(t *testing.T) { - - result, err := services.GetUserNamespace("_-_ab") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with valid caracter but not DNS-1123 compliant for role", func(t *testing.T) { - - result, err := services.GetUserNamespace("ok-ca-va_-ab") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("exceeded max DNS-1123 size for namespace", func(t *testing.T) { - - result, err := services.GetUserNamespace("namespacetoolongtocheckthedns1123maxlenghineedtoaddcharactertogoto63imready_admin") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("exceeded max DNS-1123 size for role", func(t *testing.T) { - - result, err := services.GetUserNamespace("demo_namespacetoolongtocheckthedns1123maxlenghineedtoaddcharactertogoto63imready") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("empty", func(t *testing.T) { - - result, err := services.GetUserNamespace("") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("empty role", func(t *testing.T) { - - result, err := services.GetUserNamespace("_test_") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("invalid regexp", func(t *testing.T) { - goodRegexp := services.DnsParser - services.DnsParser = regexp.MustCompile("(?:.+_+)*_(?P.*)$") - result, err := services.GetUserNamespace("") - services.DnsParser = goodRegexp - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - - t.Run("with_valid_name_and_env", func(t *testing.T) { - - result, err := services.GetUserNamespace("DL_NATIVE-dev_ADMIN") - - assert.Nil(t, err) - assert.NotNil(t, result) - assert.Equal(t, "native-development", result.Namespace()) - assert.Equal(t, "development", result.Environment) - assert.Equal(t, "admin", result.Role) - - }) - - t.Run("with_valid_name_and_env_pprd", func(t *testing.T) { - - result, err := services.GetUserNamespace("DL_NATIVE-pprd_ADMIN") - - assert.Nil(t, err) - assert.NotNil(t, result) - assert.Equal(t, "native-preproduction", result.Namespace()) - assert.Equal(t, "preproduction", result.Environment) - assert.Equal(t, "admin", result.Role) - - }) - - t.Run("blacklisted kubi-admins clusterRoleBinding name should be protected", func(t *testing.T) { - result, err := services.GetUserNamespace("kubi_admins") - assert.NotNil(t, err) - assert.Nil(t, result) - - }) - -} - -func TestGetUserNamespaces(t *testing.T) { - groups := []string{ - "valid_group_admin", - "valid_GROUP_ADMIN", - "valid_group_with_a_lot_of_service", - "notvalid", - "____", - "--_--_--_", - } - - t.Run("with only 3 valid group", func(t *testing.T) { - result := services.GetUserNamespaces(groups) - assert.NotNil(t, result) - assert.Len(t, result, 3) - - }) - - t.Run("with blacklisted namespaces", func(t *testing.T) { - result := services.GetUserNamespaces([]string{ - "kube-system_admin", - "kube-public_admin", - "ingress-nginx_admin", - "default_admin", - }) - assert.NotNil(t, result) - assert.Len(t, result, 0) - - }) - -} diff --git a/internal/services/project.go b/internal/services/project.go new file mode 100644 index 00000000..ade03bd1 --- /dev/null +++ b/internal/services/project.go @@ -0,0 +1,107 @@ +package services + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/ca-gip/kubi/internal/utils" + "github.com/ca-gip/kubi/pkg/types" +) + +// DNSParser is a regex to parse a group name into a namespace and a role. +// Please note the underscore behaviour: +// The last one is used for parsing. +// What's after the last underscore becomes the role. +// What's before is the namespace, but only if it is a complete word +// (all the previous underscores content are removed). +var DnsParser = regexp.MustCompile("(?:.+_+)*(?P.+)_(?P.+)$") + +// Parse an ldap namespace an extract: +// - Project ( namespace without environment) +// - Environment +// If environment not found, return the namespace as is +// This is a convenience function for test purposes +func namespaceParser(namespaceInput string) (projectName string, environment string) { + + if !utils.HasSuffixes(namespaceInput, utils.AllEnvironments) { + projectName = namespaceInput + environment = "" + return + } + + splits := strings.Split(namespaceInput, "-") + + environment = splits[len(splits)-1] + if val, ok := utils.LdapMapping[environment]; ok { + environment = val + } + + projectName = strings.Join(splits[:len(splits)-1], "-") + return +} + +// Constructor to create a project structure based on the groupname +func NewProject(group string) (*types.Project, error) { + + lowerGroup := strings.ToLower(group) + keys := DnsParser.SubexpNames() + if len(keys) < 3 { + return nil, fmt.Errorf("the group parser does not have the two required keys (namespace and role) as it only contains %v", keys) + } + + countSplits := len(DnsParser.FindStringSubmatch(lowerGroup)) + + if countSplits != 3 { + return nil, fmt.Errorf("cannot find a namespace and a role - the group '%v' cannot be parsed", group) + } + + rawNamespace, role := DnsParser.ReplaceAllString(lowerGroup, "${namespace}"), DnsParser.ReplaceAllString(lowerGroup, "${role}") + fmt.Println("namespace:", rawNamespace, "role:", role) + projectName, environment := namespaceParser(rawNamespace) + project := &types.Project{ + Project: projectName, + Role: role, + Source: group, + Environment: environment, + } + + isNamespaceValid, err := regexp.MatchString(utils.Dns1123LabelFmt, project.Namespace()) + if err != nil { + return nil, err + } + isInBlacklistedNamespace := slices.Contains(utils.BlacklistedNamespaces, project.Namespace()) + isRoleValid := slices.Contains(utils.WhitelistedRoles, project.Role) + + switch { + case len(project.Namespace()) > utils.DNS1123LabelMaxLength: + return nil, fmt.Errorf("the name for namespace cannot exceeded %v characters", utils.DNS1123LabelMaxLength) + case len(role) > utils.DNS1123LabelMaxLength: + return nil, fmt.Errorf("the name for role cannot exceeded %v characters", utils.DNS1123LabelMaxLength) + case isInBlacklistedNamespace: + return nil, fmt.Errorf("the project from group %v cannot be created, its namespace %v is protected through blacklist", group, project.Namespace()) + case !isNamespaceValid: + return nil, fmt.Errorf("the project from group %v cannot be created, its namespace %v is not dns1123 compliant", group, project.Namespace()) + case !isRoleValid: + return nil, fmt.Errorf("the project from group %v cannot be created, its role %v is not valid", group, role) + default: + return project, nil + } +} + +// GetAllProjects returns a slice of new projects from a list of groups +// This is useful to see all the projects matching all the groups from the luster +// Or to see all the projects a user has access to (based on the user's groups) +func GetAllProjects(groups []string) []*types.Project { + res := make([]*types.Project, 0) + for _, groupname := range groups { + tupple, err := NewProject(groupname) + if err == nil { + res = append(res, tupple) + } else { + utils.Log.Error().Msg(err.Error()) + } + } + return res +} diff --git a/internal/services/project_test.go b/internal/services/project_test.go new file mode 100644 index 00000000..3310fe10 --- /dev/null +++ b/internal/services/project_test.go @@ -0,0 +1,176 @@ +package services + +import ( + "fmt" + "testing" + + "github.com/ca-gip/kubi/internal/utils" + "github.com/ca-gip/kubi/pkg/types" +) + +func TestNamespaceParser(t *testing.T) { + tests := []struct { + name string + input string + expectedProjectName string + expectedProjectEnvironment string + }{ + {"with_valid_short_name", "whatever-dev", "whatever", "development"}, + {"with_valid_short_name-int", "whatever-int", "whatever", "integration"}, + {"with_valid_short_name-uat", "whatever-uat", "whatever", "uat"}, + {"with_valid_short_name-preprod", "whatever-pprd", "whatever", "preproduction"}, + {"with_valid_short_name-prod", "whatever-prd", "whatever", "production"}, + {"with_valid_name", "whatever-development", "whatever", "development"}, + {"with_valid_name-int", "whatever-integration", "whatever", "integration"}, + {"with_valid_name-uat", "whatever-uat", "whatever", "uat"}, + {"with_valid_name-preproduction", "whatever-preproduction", "whatever", "preproduction"}, + {"with_valid_name-prod", "whatever-production", "whatever", "production"}, + {"with_valid_name-without-env", "whatever", "whatever", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resultProjectName, resultEnvironmentName := namespaceParser(tt.input) + if tt.expectedProjectName != resultProjectName { + t.Errorf("expected %s, got %s", tt.expectedProjectName, resultProjectName) + } + if tt.expectedProjectEnvironment != resultEnvironmentName { + t.Errorf("expected %s, got %s", tt.expectedProjectEnvironment, resultEnvironmentName) + } + }) + } +} + +func TestNewProject(t *testing.T) { + tests := []struct { + name string + group string + expectedError string + expectedProj *types.Project + }{ + { + name: "valid_group_with_environment", + group: "DL_NATIVE-dev_ADMIN", + expectedProj: &types.Project{ + Project: "native", + Role: "admin", + Source: "DL_NATIVE-dev_ADMIN", + Environment: "development", + }, + }, + { + name: "valid_group_without_environment", + group: "DL_NATIVE_ADMIN", + expectedProj: &types.Project{ + Project: "native", + Role: "admin", + Source: "DL_NATIVE_ADMIN", + Environment: "", + }, + }, + { + name: "complex_valid_group_with_environment", + group: "DL_KUB_CATS_NATIVE-dev_ADMIN", + expectedProj: &types.Project{ + Project: "native", + Role: "admin", + Source: "DL_NATIVE-dev_ADMIN", + Environment: "development", + }, + }, + { + name: "complex_valid_group_without_environment", + group: "DL_KUB_CATS_NATIVE_ADMIN", + expectedProj: &types.Project{ + Project: "native", + Role: "admin", + Source: "DL_NATIVE_ADMIN", + Environment: "", + }, + }, + { + name: "no_separator", + group: "test-test", + expectedError: "cannot find a namespace and a role - the group 'test-test' cannot be parsed", + }, + { + name: "empty_group", + group: "", + expectedError: "cannot find a namespace and a role - the group '' cannot be parsed", + }, + { + name: "only_a_single_separator", + group: "_", + expectedError: "cannot find a namespace and a role - the group '_' cannot be parsed", + }, + { + // In this case the role and the namespace are '_' + name: "only_separators", + group: "___", + expectedError: "the project from group ___ cannot be created, its namespace _ is not dns1123 compliant", + }, + { + name: "invalid_chars_as_namespace", + group: "_$_@_!_ADMIN", + expectedError: "the project from group _$_@_!_ADMIN cannot be created, its namespace ! is not dns1123 compliant", + }, + { + name: "blacklisted_namespace", + group: "kube-system_ADMIN", + expectedError: "the project from group kube-system_ADMIN cannot be created, its namespace kube-system is protected through blacklist", + }, + { + name: "invalid_role", + group: "DL_NATIVE_invalidrole", + expectedError: "the project from group DL_NATIVE_invalidrole cannot be created, its role invalidrole is not valid", + }, + { + name: "invalid_role", + group: "DL_NATIVE", + expectedError: "the project from group DL_NATIVE cannot be created, its role native is not valid", + }, + { + name: "namespace_exceeds_max_length", + group: "thisisaveryveryveryverylongnamespacethatexceedsthemaxallowedlength_ADMIN", + expectedError: fmt.Sprintf("the name for namespace cannot exceeded %v characters", utils.DNS1123LabelMaxLength), + }, + { + name: "role_exceeds_max_length", + group: "DL_NATIVE_thisisaveryveryveryveryveryverylongrolethatexceedsthemaxallowedlength", + expectedError: fmt.Sprintf("the name for role cannot exceeded %v characters", utils.DNS1123LabelMaxLength), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, err := NewProject(tt.group) + if tt.expectedError != "" { + if err == nil { + t.Errorf("expected error but got nil") + } else if err.Error() != tt.expectedError { + t.Errorf("expected error:\n%s\ngot:\n%s\n", tt.expectedError, err.Error()) + } + if project != nil { + t.Errorf("expected project to be nil, but got %v", project) + } + } else { + if err != nil { + t.Errorf("expected no error, but got %v", err) + } + if project == nil { + t.Errorf("expected project, but got nil") + } else { + if project.Project != tt.expectedProj.Project { + t.Errorf("expected project:\n%s\ngot:\n%s", tt.expectedProj.Project, project.Project) + } + if project.Role != tt.expectedProj.Role { + t.Errorf("expected role:\n%s\ngot:\n%s", tt.expectedProj.Role, project.Role) + } + if project.Environment != tt.expectedProj.Environment { + t.Errorf("expected environment:\n%s\ngot:\n%s", tt.expectedProj.Environment, project.Environment) + } + } + } + }) + } +} diff --git a/internal/services/provisionner.go b/internal/services/provisionner.go index 4654bc62..c7dce773 100644 --- a/internal/services/provisionner.go +++ b/internal/services/provisionner.go @@ -44,15 +44,15 @@ func GenerateResources() error { api := clientSet.CoreV1() blackWhiteList := types.BlackWhitelist{} - groups, err := ldap.GetAllGroups() + allClusterGroups, err := ldap.GetAllGroups() if err != nil { utils.Log.Error().Msg(err.Error()) return err } - if len(groups) == 0 { + if len(allClusterGroups) == 0 { return fmt.Errorf("no ldap groups found") } - auths := GetUserNamespaces(groups) + auths := GetAllProjects(allClusterGroups) blacklistCM, errRB := GetBlackWhitelistCM(api) if errRB != nil { diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index ee7552ca..b139125f 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -67,7 +67,6 @@ func NewTokenIssuer(privateKey []byte, publicKey []byte, tokenDuration string, e func (issuer *TokenIssuer) generateServiceJWTClaims(username string, email string, scopes string) (types.AuthJWTClaims, error) { expiration := time.Now().Add(issuer.ExtraTokenDuration) - utils.Log.Info().Msgf("Generating extra token with scope %s ", scopes) // Create the Claims claims := types.AuthJWTClaims{ @@ -88,22 +87,24 @@ func (issuer *TokenIssuer) generateServiceJWTClaims(username string, email strin } // Generate a user token from a user account -func (issuer *TokenIssuer) generateUserJWTClaims(groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool) (types.AuthJWTClaims, error) { +// TODO evrardjp: Pass User as parameters. +func (issuer *TokenIssuer) generateUserJWTClaims(auths []*types.Project, groups []string, username string, email string, hasAdminAccess bool, hasApplicationAccess bool, hasOpsAccess bool, hasViewerAccess bool, hasServiceAccess bool) (types.AuthJWTClaims, error) { - var auths = []*types.Project{} if hasAdminAccess || hasApplicationAccess || hasOpsAccess || hasServiceAccess { - utils.Log.Info().Msgf("The user %s will have transversal access, removing all the projects (admin: %v, application: %v, ops: %v, service: %v)", username, hasAdminAccess, hasApplicationAccess, hasOpsAccess, hasServiceAccess) + utils.Log.Debug().Msgf("The user %s will have transversal access, removing all the projects (admin: %v, application: %v, ops: %v, service: %v)", username, hasAdminAccess, hasApplicationAccess, hasOpsAccess, hasServiceAccess) + // To be removed when ppl will have the right to have both transversal and project access + // Currently removed because too many groups. + auths = []*types.Project{} } else { - auths = GetUserNamespaces(groups) - utils.Log.Info().Msgf("The user %s will have access to the projects %v", username, auths) + utils.Log.Debug().Msgf("The user %s will have access to the projects %v", username, auths) } var expirationTime time.Time - switch hasServiceAccess { - case true: + if hasServiceAccess { + utils.Log.Debug().Msgf("The user %s will have an extra token duration of %v", username, issuer.ExtraTokenDuration) expirationTime = time.Now().Add(issuer.ExtraTokenDuration) - default: + } else { expirationTime = time.Now().Add(issuer.TokenDuration) } @@ -145,28 +146,30 @@ func (issuer *TokenIssuer) signJWTClaims(claims types.AuthJWTClaims) (*string, e func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*string, error) { - groups, err := ldap.GetUserGroups(user.UserDN) - utils.Log.Info().Msgf("The user %s is part of the groups %v", user.Username, groups) - if err != nil { + memberships := &ldap.UserMemberships{} + if err := memberships.FromUserDN(user.UserDN); err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, err } + groups := memberships.ListGroups() + utils.Log.Info().Msgf("The user %s is part of the groups %v", user.Username, groups) // to keep for historical reasons: We continue to issue tokens with old data until // ArgoCD + promote + other? is updated to use the new groups. - isAdmin := ldap.HasAdminAccess(user.UserDN) - isApplication := ldap.HasApplicationAccess(user.UserDN) - isOps := ldap.HasOpsAccess(user.UserDN) - isViewer := ldap.HasViewerAccess(user.UserDN) - isService := ldap.HasServiceAccess(user.UserDN) + isAdmin := len(memberships.AdminAccess) > 0 + isAppOps := (len(memberships.AppOpsAccess) > 0) || (len(memberships.CustomerOpsAccess) > 0) + isViewer := len(memberships.ViewerAccess) > 0 + isService := len(memberships.ServiceAccess) > 0 + isCloudOps := len(memberships.CloudOpsAccess) > 0 var claims types.AuthJWTClaims - + var err error var token *string = nil + if len(scopes) > 0 { - if !(isAdmin || isApplication || isOps) { + if !(isAdmin || isAppOps || isCloudOps) { utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, isAdmin, isApplication, isOps) + return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, isAdmin, isAppOps, isCloudOps) } claims, err = issuer.generateServiceJWTClaims(user.Username, user.Email, scopes) if err != nil { @@ -174,7 +177,10 @@ func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*s return nil, fmt.Errorf("unable to generate the token %v", err) } } else { - claims, err = issuer.generateUserJWTClaims(groups, user.Username, user.Email, isAdmin, isApplication, isOps, isViewer, isService) + // Do not pass the full group list, as they wont parse as Projects. + projectAccesses := GetAllProjects(memberships.ListClusterGroups()) + + claims, err = issuer.generateUserJWTClaims(projectAccesses, groups, user.Username, user.Email, isAdmin, isAppOps, isCloudOps, isViewer, isService) if err != nil { utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) @@ -191,6 +197,7 @@ func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*s utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("the token is nil") } + // TODO: Expose a metric or a log about the type of token generated (its scope) utils.TokenCounter.WithLabelValues("token_success").Inc() return token, nil } @@ -214,7 +221,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { return } - utils.Log.Info().Msgf("Granting token for user %v", user.Username) + utils.Log.Info().Msgf("Granting token for user %v with scopes %v", user.Username, scopes) w.WriteHeader(http.StatusCreated) io.WriteString(w, *token) } diff --git a/internal/services/token-provider_test.go b/internal/services/token-provider_test.go index aae1d11e..3fa688b8 100644 --- a/internal/services/token-provider_test.go +++ b/internal/services/token-provider_test.go @@ -82,17 +82,17 @@ func Test_generateUserJWTClaims(t *testing.T) { url, _ := url.Parse("https://kubi.example.com") stdAuths := []*types.Project{ { - Project: "ns-development", - Role: "", + Project: "ns", + Role: "admin", Source: "", - Environment: "", + Environment: "development", Contact: "", }, { - Project: "ns-devops-automation-integration", - Role: "", + Project: "ns-devops-automation", + Role: "admin", Source: "", - Environment: "", + Environment: "integration", Contact: "", }, } @@ -186,7 +186,7 @@ func Test_generateUserJWTClaims(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotToken, gotErr := tt.args.issuer.generateUserJWTClaims(tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess) + gotToken, gotErr := tt.args.issuer.generateUserJWTClaims(stdAuths, tt.args.groups, tt.args.username, tt.args.email, tt.args.hasAdminAccess, tt.args.hasApplicationAccess, tt.args.hasOpsAccess, tt.args.hasViewerAccess, tt.args.hasServiceAccess) if gotErr != nil { assert.Equal(t, gotErr, tt.expected.err) } From c6a7be43417a9e4c09679fb024b206d30633b3d2 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 13:43:44 +0100 Subject: [PATCH 12/26] Rename authprovider Now that authprovider is merely doing ldap functions (it was already doing that), be explicit and call the package ldap. --- internal/ldap/auth.go | 69 +++++++++++ internal/ldap/base.go | 62 ++++++++++ .../ldap.go => ldap/membership.go} | 114 ------------------ .../ldap_test.go => ldap/membership_test.go} | 0 internal/services/httphandlers.go | 2 +- internal/services/provisionner.go | 2 +- internal/services/token-provider.go | 2 +- 7 files changed, 134 insertions(+), 117 deletions(-) create mode 100644 internal/ldap/auth.go create mode 100644 internal/ldap/base.go rename internal/{authprovider/ldap.go => ldap/membership.go} (56%) rename internal/{authprovider/ldap_test.go => ldap/membership_test.go} (100%) diff --git a/internal/ldap/auth.go b/internal/ldap/auth.go new file mode 100644 index 00000000..c8ef7171 --- /dev/null +++ b/internal/ldap/auth.go @@ -0,0 +1,69 @@ +package ldap + +import ( + "fmt" + + "github.com/ca-gip/kubi/internal/utils" + "github.com/ca-gip/kubi/pkg/types" + "gopkg.in/ldap.v2" +) + +// Authenticate a user through LDAP or LDS +// return if bind was ok, the userDN for next usage, and error if occurred +func AuthenticateUser(username string, password string) (types.User, error) { + + // Get User Distinguished Name for Standard User + user, err := validateUserCredentials(utils.Config.Ldap.UserBase, username, password) + if err == nil { + return user, nil + } + + // Now handling errors to get standard user, falling back to admin user, if + // config allows it + if len(utils.Config.Ldap.AdminUserBase) <= 0 { + return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) + } + + // Retry as admin + user, err = validateUserCredentials(utils.Config.Ldap.AdminUserBase, username, password) + if err != nil { + return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) + } + return user, nil +} + +// Finds an user and check if its password is correct. +func validateUserCredentials(base string, username string, password string) (types.User, error) { + req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) + + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) + if err != nil { + return types.User{}, err + } + defer conn.Close() + + res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) + + switch { + case err != nil: + return types.User{}, fmt.Errorf("error searching for user %s, %w", username, err) + case len(res.Entries) == 0: + return types.User{}, fmt.Errorf("no result for the user search filter '%s'", req.Filter) + case len(res.Entries) > 1: + return types.User{}, fmt.Errorf("multiple entries found for the user search filter '%s'", req.Filter) + } + + userDN := res.Entries[0].DN + mail := res.Entries[0].GetAttributeValue("mail") + user := types.User{ + Username: username, + UserDN: userDN, + Email: mail, + } + + _, err = ldapConnectAndBind(userDN, password) + if err != nil { + return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) + } + return user, nil +} diff --git a/internal/ldap/base.go b/internal/ldap/base.go new file mode 100644 index 00000000..7cb172b7 --- /dev/null +++ b/internal/ldap/base.go @@ -0,0 +1,62 @@ +package ldap + +import ( + "crypto/tls" + "fmt" + + "github.com/ca-gip/kubi/internal/utils" + "github.com/pkg/errors" + "gopkg.in/ldap.v2" +) + +// Query LDAP with default credentials and paging parameters +func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { + conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) + if err != nil { + return nil, err + } + defer conn.Close() + results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) + if err != nil { + return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) + } + return results, nil + +} + +// Connect to LDAP and bind with given credentials +func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { + var ( + err error + conn *ldap.Conn + ) + tlsConfig := &tls.Config{ + ServerName: utils.Config.Ldap.Host, + InsecureSkipVerify: utils.Config.Ldap.SkipTLSVerification, + } + + if utils.Config.Ldap.UseSSL { + conn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port), tlsConfig) + } else { + conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port)) + } + + if utils.Config.Ldap.StartTLS { + err = conn.StartTLS(tlsConfig) + if err != nil { + return nil, errors.Wrapf(err, "unable to setup TLS connection") + } + } + + if err != nil { + return nil, errors.Wrapf(err, "unable to create ldap connector for %s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port) + } + + // Bind with BindAccount + err = conn.Bind(login, password) + if err != nil { + return nil, errors.WithStack(err) + } + + return conn, nil +} diff --git a/internal/authprovider/ldap.go b/internal/ldap/membership.go similarity index 56% rename from internal/authprovider/ldap.go rename to internal/ldap/membership.go index aea80e80..65030b90 100644 --- a/internal/authprovider/ldap.go +++ b/internal/ldap/membership.go @@ -1,11 +1,9 @@ package ldap import ( - "crypto/tls" "fmt" "github.com/ca-gip/kubi/internal/utils" - "github.com/ca-gip/kubi/pkg/types" "github.com/pkg/errors" "gopkg.in/ldap.v2" ) @@ -123,21 +121,6 @@ func getGroupsContainingUser(groupBaseDN string, userDN string) ([]*ldap.Entry, return res.Entries, nil } -// Query LDAP with default credentials and paging parameters -func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { - conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) - if err != nil { - return nil, err - } - defer conn.Close() - results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) - if err != nil { - return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) - } - return results, nil - -} - // Get All groups for the cluster from LDAP func GetAllGroups() ([]string, error) { @@ -163,100 +146,3 @@ func GetAllGroups() ([]string, error) { } return groups, nil } - -// Authenticate a user through LDAP or LDS -// return if bind was ok, the userDN for next usage, and error if occurred -func AuthenticateUser(username string, password string) (types.User, error) { - - // Get User Distinguished Name for Standard User - user, err := validateUserCredentials(utils.Config.Ldap.UserBase, username, password) - if err == nil { - return user, nil - } - - // Now handling errors to get standard user, falling back to admin user, if - // config allows it - if len(utils.Config.Ldap.AdminUserBase) <= 0 { - return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) - } - - // Retry as admin - user, err = validateUserCredentials(utils.Config.Ldap.AdminUserBase, username, password) - if err != nil { - return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) - } - return user, nil -} - -// Finds an user and check if its password is correct. -func validateUserCredentials(base string, username string, password string) (types.User, error) { - req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) - - conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) - if err != nil { - return types.User{}, err - } - defer conn.Close() - - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - - switch { - case err != nil: - return types.User{}, fmt.Errorf("error searching for user %s, %w", username, err) - case len(res.Entries) == 0: - return types.User{}, fmt.Errorf("no result for the user search filter '%s'", req.Filter) - case len(res.Entries) > 1: - return types.User{}, fmt.Errorf("multiple entries found for the user search filter '%s'", req.Filter) - } - - userDN := res.Entries[0].DN - mail := res.Entries[0].GetAttributeValue("mail") - user := types.User{ - Username: username, - UserDN: userDN, - Email: mail, - } - - _, err = ldapConnectAndBind(userDN, password) - if err != nil { - return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) - } - return user, nil -} - -// Connect to LDAP and bind with given credentials -func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { - var ( - err error - conn *ldap.Conn - ) - tlsConfig := &tls.Config{ - ServerName: utils.Config.Ldap.Host, - InsecureSkipVerify: utils.Config.Ldap.SkipTLSVerification, - } - - if utils.Config.Ldap.UseSSL { - conn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port), tlsConfig) - } else { - conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port)) - } - - if utils.Config.Ldap.StartTLS { - err = conn.StartTLS(tlsConfig) - if err != nil { - return nil, errors.Wrapf(err, "unable to setup TLS connection") - } - } - - if err != nil { - return nil, errors.Wrapf(err, "unable to create ldap connector for %s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port) - } - - // Bind with BindAccount - err = conn.Bind(login, password) - if err != nil { - return nil, errors.WithStack(err) - } - - return conn, nil -} diff --git a/internal/authprovider/ldap_test.go b/internal/ldap/membership_test.go similarity index 100% rename from internal/authprovider/ldap_test.go rename to internal/ldap/membership_test.go diff --git a/internal/services/httphandlers.go b/internal/services/httphandlers.go index 0427fc0e..39dc0fd5 100644 --- a/internal/services/httphandlers.go +++ b/internal/services/httphandlers.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - ldap "github.com/ca-gip/kubi/internal/authprovider" + "github.com/ca-gip/kubi/internal/ldap" ) type contextKey string diff --git a/internal/services/provisionner.go b/internal/services/provisionner.go index c7dce773..5ecc8cee 100644 --- a/internal/services/provisionner.go +++ b/internal/services/provisionner.go @@ -9,7 +9,7 @@ import ( "strings" "time" - ldap "github.com/ca-gip/kubi/internal/authprovider" + "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/utils" v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index b139125f..2ce2fb11 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -9,7 +9,7 @@ import ( "strings" "time" - ldap "github.com/ca-gip/kubi/internal/authprovider" + "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" "github.com/dgrijalva/jwt-go" From 9d6462b32b4b9fbfbfc3590f1dc8ed20be947abd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 13:50:14 +0100 Subject: [PATCH 13/26] Move CA to main Presenting the CA is a very small endpoint, lost in the middle of the "services" internal package. This is a problem, as it makes it annoying to find. On top of that, it needed to be passed global variables instead of having direct access to config data. This fixes it by making sure this endpoint (only used once) is directly readable from the main, as it's a two-liner. --- cmd/api/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index e43ecd52..a882cfae 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,6 +1,7 @@ package main import ( + "io" "net/http" "os" @@ -42,7 +43,11 @@ func main() { utils.Log.Warn().Msgf("%d %s %s", http.StatusNotFound, req.Method, req.URL.String()) }) - router.HandleFunc("/ca", services.CA).Methods(http.MethodGet) + router.HandleFunc("/ca", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + io.WriteString(w, config.KubeCaText) + }).Methods(http.MethodGet) + router.HandleFunc("/config", services.WithBasicAuth(tokenIssuer.GenerateConfig)).Methods(http.MethodGet) router.HandleFunc("/token", services.WithBasicAuth(tokenIssuer.GenerateJWT)).Methods(http.MethodGet) router.Handle("/metrics", promhttp.Handler()) From fde0e7bbe1a12c5ca256a38ec8e1562ec9dfe44b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 14:04:10 +0100 Subject: [PATCH 14/26] Migrate middlewares outside the services folder Services does not really means what this code does. This is a problem, as it makes the debugging tedious for a new contributor. This fixes it by moving the middlewares to their own package. --- cmd/api/main.go | 9 ++++++--- cmd/authorization-webhook/main.go | 3 ++- cmd/operator/main.go | 3 ++- .../httphandlers.go => middlewares/httpauth.go} | 6 +++--- .../middleware.go => middlewares/prometheus.go} | 11 +++++++++-- internal/services/ca.go | 12 ------------ internal/services/token-provider.go | 5 +++-- internal/utils/prometheus.go | 6 ------ 8 files changed, 25 insertions(+), 30 deletions(-) rename internal/{services/httphandlers.go => middlewares/httpauth.go} (91%) rename internal/{utils/middleware.go => middlewares/prometheus.go} (53%) delete mode 100644 internal/services/ca.go diff --git a/cmd/api/main.go b/cmd/api/main.go index a882cfae..dff6bb11 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" + "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" "github.com/gorilla/mux" @@ -19,6 +20,8 @@ func main() { log.Fatal().Msg("Config error") os.Exit(1) } + // TODO Remove this aberration - L17 should be a constructor and we should + // use the config as live object instead of mutating it. utils.Config = config // TODO Move to config ( for validation ) @@ -37,7 +40,7 @@ func main() { } router := mux.NewRouter() - router.Use(utils.PrometheusMiddleware) + router.Use(middlewares.Prometheus) router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusNotFound) utils.Log.Warn().Msgf("%d %s %s", http.StatusNotFound, req.Method, req.URL.String()) @@ -48,8 +51,8 @@ func main() { io.WriteString(w, config.KubeCaText) }).Methods(http.MethodGet) - router.HandleFunc("/config", services.WithBasicAuth(tokenIssuer.GenerateConfig)).Methods(http.MethodGet) - router.HandleFunc("/token", services.WithBasicAuth(tokenIssuer.GenerateJWT)).Methods(http.MethodGet) + router.HandleFunc("/config", middlewares.WithBasicAuth(tokenIssuer.GenerateConfig)).Methods(http.MethodGet) + router.HandleFunc("/token", middlewares.WithBasicAuth(tokenIssuer.GenerateJWT)).Methods(http.MethodGet) router.Handle("/metrics", promhttp.Handler()) utils.Log.Info().Msgf(" Preparing to serve request, port: %d", 8000) diff --git a/cmd/authorization-webhook/main.go b/cmd/authorization-webhook/main.go index b6c9d821..1dc0cbb7 100644 --- a/cmd/authorization-webhook/main.go +++ b/cmd/authorization-webhook/main.go @@ -4,6 +4,7 @@ import ( "net/http" "os" + "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" "github.com/gorilla/mux" @@ -44,7 +45,7 @@ func main() { } router := mux.NewRouter() - router.Use(utils.PrometheusMiddleware) + router.Use(middlewares.Prometheus) router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusNotFound) utils.Log.Warn().Msgf("%d %s %s", http.StatusNotFound, req.Method, req.URL.String()) diff --git a/cmd/operator/main.go b/cmd/operator/main.go index eec2cf87..b1826585 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" "github.com/gorilla/mux" @@ -30,7 +31,7 @@ func main() { log.Error().Err(err) } router := mux.NewRouter() - router.Use(utils.PrometheusMiddleware) + router.Use(middlewares.Prometheus) router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusNotFound) utils.Log.Warn().Msgf("%d %s %s", http.StatusNotFound, req.Method, req.URL.String()) diff --git a/internal/services/httphandlers.go b/internal/middlewares/httpauth.go similarity index 91% rename from internal/services/httphandlers.go rename to internal/middlewares/httpauth.go index 39dc0fd5..81ed22b0 100644 --- a/internal/services/httphandlers.go +++ b/internal/middlewares/httpauth.go @@ -1,4 +1,4 @@ -package services +package middlewares import ( "context" @@ -9,7 +9,7 @@ import ( type contextKey string -const userContextKey contextKey = "user" +const UserContextKey contextKey = "user" func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -30,7 +30,7 @@ func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { return } - ctx := context.WithValue(r.Context(), userContextKey, user) // This is ugly, but at least it cleans up the code and matches the usual patterns. + ctx := context.WithValue(r.Context(), UserContextKey, user) // This is ugly, but at least it cleans up the code and matches the usual patterns. next.ServeHTTP(w, r.WithContext(ctx)) return } diff --git a/internal/utils/middleware.go b/internal/middlewares/prometheus.go similarity index 53% rename from internal/utils/middleware.go rename to internal/middlewares/prometheus.go index af3d6dd9..416c5120 100644 --- a/internal/utils/middleware.go +++ b/internal/middlewares/prometheus.go @@ -1,4 +1,4 @@ -package utils +package middlewares import ( "log" @@ -6,9 +6,16 @@ import ( "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" ) -func PrometheusMiddleware(next http.Handler) http.Handler { +var Histogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "kubi_http_requests", + Help: "Time per requests", + Buckets: []float64{1, 2, 5, 6, 10}, //defining small buckets as this app should not take more than 1 sec to respond +}, []string{"path"}) // this will be partitioned by the HTTP code. + +func Prometheus(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route := mux.CurrentRoute(r) path, err := route.GetPathTemplate() diff --git a/internal/services/ca.go b/internal/services/ca.go deleted file mode 100644 index 37a8024b..00000000 --- a/internal/services/ca.go +++ /dev/null @@ -1,12 +0,0 @@ -package services - -import ( - "github.com/ca-gip/kubi/internal/utils" - "io" - "net/http" -) - -func CA(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - io.WriteString(w, utils.Config.KubeCaText) -} diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 2ce2fb11..747713c0 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ca-gip/kubi/internal/ldap" + "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" "github.com/dgrijalva/jwt-go" @@ -204,7 +205,7 @@ func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*s func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { - userContext := r.Context().Value(userContextKey) + userContext := r.Context().Value(middlewares.UserContextKey) if userContext == nil { utils.Log.Error().Msgf("No user found in the context") w.WriteHeader(http.StatusUnauthorized) @@ -232,7 +233,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { // TODO: Refactor to use the same code as GenerateJWT func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request) { - userContext := r.Context().Value(userContextKey) + userContext := r.Context().Value(middlewares.UserContextKey) if userContext == nil { utils.Log.Error().Msgf("No user found in the context") w.WriteHeader(http.StatusUnauthorized) diff --git a/internal/utils/prometheus.go b/internal/utils/prometheus.go index 35d3688a..fc639518 100644 --- a/internal/utils/prometheus.go +++ b/internal/utils/prometheus.go @@ -10,12 +10,6 @@ var TokenCounter = promauto.NewCounterVec(prometheus.CounterOpts{ Help: "Total number of tokens issued", }, []string{"status"}) -var Histogram = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "kubi_http_requests", - Help: "Time per requests", - Buckets: []float64{1, 2, 5, 6, 10}, //defining small buckets as this app should not take more than 1 sec to respond -}, []string{"path"}) // this will be partitioned by the HTTP code. - var ProjectCreation = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "kubi_project_creation", Help: "Number of project created", From 884ae40f0715bf5b258ba14f2996d3055be00749 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 14:24:19 +0100 Subject: [PATCH 15/26] Bring constants closer to their usage Without this, one might wonder where the constants are used. One might even think that Service account is used to provision some parts of kubi, where in fact it is only used for auth. This is a problem, as it could lead to misunderstandings in the code. In other words, I believe that moving the constants will make it more explicit about their scope and maintenance. Therefore it makes clear that the constants moved here are ONLY usable for auth. To avoid issues, this removes the constant from generic "utils" use. --- internal/services/webhook-tokenauthenticator.go | 16 +++++++++++----- internal/utils/constants.go | 9 ++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/services/webhook-tokenauthenticator.go b/internal/services/webhook-tokenauthenticator.go index a9d8658e..7eaa15f4 100644 --- a/internal/services/webhook-tokenauthenticator.go +++ b/internal/services/webhook-tokenauthenticator.go @@ -10,6 +10,11 @@ import ( "k8s.io/api/authentication/v1beta1" ) +const ( + AdminGroup = "system:masters" + ServiceMaster = "service:masters" +) + // Authenticate service for kubernetes Api Server // https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { @@ -53,8 +58,13 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { groups = append(groups, fmt.Sprintf("%s-%s", auth.Namespace(), auth.Role)) } + // Hard coded special groups + if token.ServiceAccess { + groups = append(groups, ServiceMaster) + } + if token.AdminAccess { - groups = append(groups, utils.AdminGroup) + groups = append(groups, AdminGroup) } if token.OpsAccess { @@ -65,10 +75,6 @@ func AuthenticateHandler(issuer *TokenIssuer) http.HandlerFunc { groups = append(groups, utils.ApplicationMaster) } - if token.ServiceAccess { - groups = append(groups, utils.ServiceMaster) - } - if token.ViewerAccess { groups = append(groups, utils.ApplicationViewer) } diff --git a/internal/utils/constants.go b/internal/utils/constants.go index d354af9a..535f3ccc 100644 --- a/internal/utils/constants.go +++ b/internal/utils/constants.go @@ -28,11 +28,10 @@ const ( KubiServiceAccountDefaultName = "default" AuthenticatedGroup = "system:authenticated" - AdminGroup = "system:masters" - ApplicationMaster = "application:masters" - ServiceMaster = "service:masters" - ApplicationViewer = "application:view" - OPSMaster = "ops:masters" + + ApplicationMaster = "application:masters" + ApplicationViewer = "application:view" + OPSMaster = "ops:masters" KubiStageScratch = "scratch" KubiStageStaging = "staging" From 897ac6a1f362164eb561ec96618481cc9e193bd6 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 15:03:21 +0100 Subject: [PATCH 16/26] Cleanup helpers Without this, the helpers become a big mess of functions that are only partially used or are inferior in implementation to what you can find in other parts of the code. This fixes it by: - regroup the config parsing into config.go - inlining functions with little use, which can be replaced by a few idiomatic golang calls. - temporarily moving some implementations until the inlining is safe to implement (needs test coverage) - removing duplicate calls: ldap host parsing in the config, fatal + os.exit(1)... --- cmd/api/main.go | 4 +- cmd/authorization-webhook/main.go | 4 +- cmd/operator/main.go | 5 +- internal/services/project.go | 13 +- internal/services/provisionner.go | 31 +++- internal/services/token-provider.go | 2 +- internal/utils/config.go | 272 ++++++++++++++++------------ internal/utils/constants.go | 2 - internal/utils/helpers.go | 55 ------ internal/utils/strings.go | 13 -- 10 files changed, 197 insertions(+), 204 deletions(-) delete mode 100644 internal/utils/helpers.go delete mode 100644 internal/utils/strings.go diff --git a/cmd/api/main.go b/cmd/api/main.go index dff6bb11..5de38912 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "net/http" "os" @@ -17,8 +18,7 @@ func main() { config, err := utils.MakeConfig() if err != nil { - log.Fatal().Msg("Config error") - os.Exit(1) + log.Fatal().Msg(fmt.Sprintf("Config error: %v", err)) } // TODO Remove this aberration - L17 should be a constructor and we should // use the config as live object instead of mutating it. diff --git a/cmd/authorization-webhook/main.go b/cmd/authorization-webhook/main.go index 1dc0cbb7..1f551b8a 100644 --- a/cmd/authorization-webhook/main.go +++ b/cmd/authorization-webhook/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "os" @@ -16,8 +17,7 @@ func main() { config, err := utils.MakeConfig() if err != nil { - log.Fatal().Msg("Config error") - os.Exit(1) + log.Fatal().Msg(fmt.Sprintf("Config error: %v", err)) } utils.Config = config diff --git a/cmd/operator/main.go b/cmd/operator/main.go index b1826585..99ec495f 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -1,8 +1,8 @@ package main import ( + "fmt" "net/http" - "os" "time" "github.com/ca-gip/kubi/internal/middlewares" @@ -17,8 +17,7 @@ func main() { config, err := utils.MakeConfig() if err != nil { - log.Fatal().Msg("Config error") - os.Exit(1) + log.Fatal().Msg(fmt.Sprintf("Config error: %v", err)) } utils.Config = config diff --git a/internal/services/project.go b/internal/services/project.go index ade03bd1..ea0f3565 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -25,7 +25,18 @@ var DnsParser = regexp.MustCompile("(?:.+_+)*(?P.+)_(?P.+)$") // This is a convenience function for test purposes func namespaceParser(namespaceInput string) (projectName string, environment string) { - if !utils.HasSuffixes(namespaceInput, utils.AllEnvironments) { + var namespaceHasSuffix bool + + // check whether any of our environments names (short and longs) + // are part of the namespace given in input + for _, environmentSuffix := range utils.AllEnvironments { + if strings.HasSuffix(namespaceInput, "-"+environmentSuffix) { + namespaceHasSuffix = true + break + } + } + + if !namespaceHasSuffix { projectName = namespaceInput environment = "" return diff --git a/internal/services/provisionner.go b/internal/services/provisionner.go index 5ecc8cee..e416051d 100644 --- a/internal/services/provisionner.go +++ b/internal/services/provisionner.go @@ -107,6 +107,15 @@ func GenerateProjects(context []*types.Project, blackWhiteList *types.BlackWhite return createdProjects, deletedProjects, ignoredProjects } +func appendIfMissing(slice []string, i string) []string { + for _, ele := range slice { + if ele == i { + return slice + } + } + return append(slice, i) +} + // generate a project config or update it if exists func generateProject(projectInfos *types.Project) { kconfig, _ := rest.InClusterConfig() @@ -183,7 +192,7 @@ func generateProject(projectInfos *types.Project) { existingProject.Spec.Tenant = project.Spec.Tenant } for _, stage := range project.Spec.Stages { - existingProject.Spec.Stages = utils.AppendIfMissing(existingProject.Spec.Stages, stage) + existingProject.Spec.Stages = appendIfMissing(existingProject.Spec.Stages, stage) } existingProject.Spec.SourceEntity = projectInfos.Source existingProject.Spec.SourceDN = fmt.Sprintf("CN=%s,%s", projectInfos.Source, utils.Config.Ldap.GroupBase) @@ -506,6 +515,14 @@ func updateExistingNamespace(project *v12.Project, api v13.CoreV1Interface) erro return nil } +// Join two maps by value copy non-recursively +func union(a map[string]string, b map[string]string) map[string]string { + for k, v := range b { + a[k] = v + } + return a +} + // Generate CustomLabels that should be applied on Kubi's Namespaces func generateNamespaceLabels(project *v12.Project) (labels map[string]string) { @@ -518,12 +535,12 @@ func generateNamespaceLabels(project *v12.Project) (labels map[string]string) { "pod-security.kubernetes.io/warn": string(utils.Config.PodSecurityAdmissionWarning), "pod-security.kubernetes.io/audit": string(utils.Config.PodSecurityAdmissionAudit), } - - return utils.Union(defaultLabels, utils.Config.CustomLabels) + // Todo: Decide whether this is still worth a separate function for testability. + return union(defaultLabels, utils.Config.CustomLabels) } func GetPodSecurityStandardName(namespace string) string { - if utils.IsInPrivilegedNsList(namespace) { + if slices.Contains(utils.Config.PrivilegedNamespaces, namespace) { utils.Log.Warn().Msgf("Namespace %v is labeled as privileged", namespace) return string(podSecurity.LevelPrivileged) } @@ -832,19 +849,21 @@ func generateNetworkPolicy(namespace string, networkPolicyConfig *v12.NetworkPol _, err := api.NetworkPolicies(namespace).Create(context.TODO(), networkpolicy, metav1.CreateOptions{}) if err != nil { utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + // Todo: Wrap this error correctly and use slog. + utils.Log.Error().Msg(fmt.Sprintf("error creating netpol %v", err.Error())) } else { utils.NetworkPolicyCreation.WithLabelValues("created", namespace, utils.KubiDefaultNetworkPolicyName).Inc() } - utils.Check(err) return } else { _, err := api.NetworkPolicies(namespace).Update(context.TODO(), networkpolicy, metav1.UpdateOptions{}) if err != nil { utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + // Todo: Wrap this error correctly and use slog. + utils.Log.Error().Msg(fmt.Sprintf("error updating netpol %v", err.Error())) } else { utils.NetworkPolicyCreation.WithLabelValues("updated", namespace, utils.KubiDefaultNetworkPolicyName).Inc() } - utils.Check(err) return } } diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 747713c0..99d7aedb 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -241,7 +241,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request } user := userContext.(types.User) - token, err := issuer.createAccessToken(user, utils.Empty) + token, err := issuer.createAccessToken(user, "") // no need to generate config if the user cannot access it. if err != nil { utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) diff --git a/internal/utils/config.go b/internal/utils/config.go index 904662f0..1f912c98 100644 --- a/internal/utils/config.go +++ b/internal/utils/config.go @@ -4,15 +4,15 @@ import ( "crypto/tls" "crypto/x509" "encoding/base64" - "log" + "errors" + "fmt" + "net/url" "os" "regexp" "strconv" "strings" "github.com/ca-gip/kubi/pkg/types" - validation "github.com/go-ozzo/ozzo-validation" - "github.com/go-ozzo/ozzo-validation/is" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/client-go/rest" @@ -21,179 +21,213 @@ import ( var Config *types.Config -// Build the configuration from environment variable -// and validate that is consistent. If false, the program exit -// with validation message. The validation is not error safe but -// it limit misconfiguration ( lack of parameter ). +// Convenience function to default to a fallback string if the env var is not set +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback +} + +// Build and validates the configuration from the environment variables +// Todo: Split the makeconfig in two: One for the api+webhook, one for the operator. func MakeConfig() (*types.Config, error) { // Check cluster deployment host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") if len(host) == 0 || len(port) == 0 { - log.Fatalf("Cannot retrieve environment variable for Kubernetes service") return nil, rest.ErrNotInCluster } kubeToken, errToken := os.ReadFile(TokenFile) - Check(errToken) + if errToken != nil { + return nil, fmt.Errorf("cannot read token file %s", TokenFile) + } kubeCA, errCA := os.ReadFile(TlsCaFile) - Check(errCA) - - caEncoded := base64.StdEncoding.EncodeToString(kubeCA) + if errCA != nil { + return nil, fmt.Errorf("cannot read CA file %s", TlsCaFile) + } - // Get the SystemCertPool, continue with an empty pool on error - rootCAs, err := x509.SystemCertPool() - if err != nil { - log.Fatalf("Cannot retrieve system cert pool, exiting for security reason") + // LDAP validation + ldapUserBase := os.Getenv("LDAP_USERBASE") + switch { + case ldapUserBase == "": + return nil, errors.New("userBase is required") + case len(ldapUserBase) < 2 || len(ldapUserBase) > 200: + return nil, fmt.Errorf("length for LDAP_USERBASE must be between 2 and 200 characters, got %v of len %v", ldapUserBase, len(ldapUserBase)) } - if rootCAs == nil { - rootCAs = x509.NewCertPool() + + ldapGroupBase := os.Getenv("LDAP_GROUPBASE") + switch { + case ldapGroupBase == "": + return nil, errors.New("groupBase is required") + case len(ldapGroupBase) < 2 || len(ldapGroupBase) > 200: + return nil, fmt.Errorf("length for LDAP_GROUPBASE must be between 2 and 200 characters, got %v of len %v", ldapUserBase, len(ldapUserBase)) } - if ok := rootCAs.AppendCertsFromPEM(kubeCA); !ok { - log.Fatalf("Cannot add Kubernetes CA, exiting for security reason") + ldapServer := os.Getenv("LDAP_SERVER") + if ldapServer == "" { + return nil, errors.New("host is required") } - // Trust the augmented cert pool in our client - tlsConfig := &tls.Config{ - InsecureSkipVerify: false, - RootCAs: rootCAs, + ldapBindDN := os.Getenv("LDAP_BINDDN") + if ldapBindDN == "" { + return nil, errors.New("BindDN is required") + } + if len(ldapBindDN) < 2 || len(ldapBindDN) > 200 { + return nil, fmt.Errorf("length for LDAP_BINDDN must be between 2 and 200 characters, got %v of len %v", ldapBindDN, len(ldapBindDN)) } - // LDAP validation + ldapBindPassword := os.Getenv("LDAP_PASSWD") + if ldapBindPassword == "" { + return nil, errors.New("BindPassword is required") + } + if len(ldapBindPassword) < 2 || len(ldapBindPassword) > 200 { + return nil, fmt.Errorf("length for LDAP_PASSWD must be between 2 and 200 characters, got len %v", len(ldapBindPassword)) + } - ldapPageSize, errLdapPageSize := strconv.Atoi(getEnv("LDAP_PAGE_SIZE", "1000")) - Checkf(errLdapPageSize, "Invalid LDAP_PAGE_SIZE, must be an integer") + ldapUserFilter := getEnv("LDAP_USERFILTER", "(cn=%s)") - ldapPort, errLdapPort := strconv.Atoi(getEnv("LDAP_PORT", "389")) - Checkf(errLdapPort, "Invalid LDAP_PORT, must be an integer") + ldapPageSizeEnv := getEnv("LDAP_PAGE_SIZE", "1000") + ldapPageSize, errLdapPageSize := strconv.Atoi(ldapPageSizeEnv) + if errLdapPageSize != nil { + return nil, fmt.Errorf("invalid LDAP_PAGE_SIZE %s, must be an integer", errLdapPageSize) + } useSSL, errLdapSSL := strconv.ParseBool(getEnv("LDAP_USE_SSL", "false")) - Checkf(errLdapSSL, "Invalid LDAP_USE_SSL, must be a boolean") + if errLdapSSL != nil { + return nil, fmt.Errorf("invalid LDAP_USE_SSL %s, must be a boolean", errLdapSSL) + } skipTLSVerification, errSkipTLS := strconv.ParseBool(getEnv("LDAP_SKIP_TLS_VERIFICATION", "true")) - Checkf(errSkipTLS, "Invalid LDAP_SKIP_TLS_VERIFICATION, must be a boolean") + if errSkipTLS != nil { + return nil, fmt.Errorf("invalid LDAP_SKIP_TLS_VERIFICATION %s, must be a boolean", errSkipTLS) + } startTLS, errStartTLS := strconv.ParseBool(getEnv("LDAP_START_TLS", "false")) - Checkf(errStartTLS, "Invalid LDAP_START_TLS, must be a boolean") + if errStartTLS != nil { + return nil, fmt.Errorf("invalid LDAP_START_TLS %s, must be a boolean", errStartTLS) + } + ldapPortEnv := getEnv("LDAP_PORT", "389") + ldapPort, err := strconv.Atoi(ldapPortEnv) + if err != nil { + return nil, fmt.Errorf("invalid LDAP_PORT %s, must be an integer", err) + } + if ldapPort == 389 && os.Getenv("LDAP_SKIP_TLS") == "false" { + skipTLSVerification = false + } + if ldapPort == 636 && os.Getenv("LDAP_SKIP_TLS") == "false" { + skipTLSVerification = false + useSSL = true + } + + // feature validation and parsing whitelist, errWhitelist := strconv.ParseBool(getEnv("WHITELIST", "false")) - Checkf(errWhitelist, "Invalid WHITELIST, must be a boolean") - - if len(os.Getenv("LDAP_PORT")) > 0 { - envLdapPort, err := strconv.Atoi(os.Getenv("LDAP_PORT")) - Check(err) - ldapPort = envLdapPort - if ldapPort == 389 && os.Getenv("LDAP_SKIP_TLS") == "false" { - skipTLSVerification = false - } - if ldapPort == 636 && os.Getenv("LDAP_SKIP_TLS") == "false" { - skipTLSVerification = false - useSSL = true - } + if errWhitelist != nil { + return nil, fmt.Errorf("invalid WHITELIST %s, must be a boolean", errWhitelist) } networkpolicyEnabled, errNetpol := strconv.ParseBool(getEnv("PROVISIONING_NETWORK_POLICIES", "true")) - Checkf(errNetpol, "Invalid LDAP_START_TLS, must be a boolean") + if errNetpol != nil { + return nil, fmt.Errorf("invalid PROVISIONING_NETWORK_POLICIES %s, must be a boolean", errNetpol) + } customLabels := parseCustomLabels(getEnv("CUSTOM_LABELS", "")) - ldapUserFilter := getEnv("LDAP_USERFILTER", "(cn=%s)") tenant := strings.ToLower(getEnv("TENANT", KubiTenantUndeterminable)) // No need to state a default or crash, because kubernetes defaults to restricted. podSecurityAdmissionEnforcement, errPodSecurityAdmissionEnforcement := podSecurity.ParseLevel(strings.ToLower(getEnv("PODSECURITYADMISSION_ENFORCEMENT", string(podSecurity.LevelRestricted)))) if errPodSecurityAdmissionEnforcement != nil { - Log.Error().Msgf("PODSECURITYADMISSION_ENFORCEMENT is incorrect. %s ", errPodSecurityAdmissionEnforcement.Error()) + return nil, fmt.Errorf("level for PODSECURITYADMISSION_ENFORCEMENT is incorrect. %v", errPodSecurityAdmissionEnforcement) } // No need to state a default or crash, because kubernetes defaults to restricted. podSecurityAdmissionWarning, errPodSecurityAdmissionWarning := podSecurity.ParseLevel(strings.ToLower(getEnv("PODSECURITYADMISSION_WARNING", string(podSecurity.LevelRestricted)))) if errPodSecurityAdmissionWarning != nil { - Log.Error().Msgf("PODSECURITYADMISSION_WARNING is incorrect. %s ", errPodSecurityAdmissionWarning.Error()) + return nil, fmt.Errorf("level for PODSECURITYADMISSION_WARNING is incorrect. %v", errPodSecurityAdmissionWarning) } // No need to state a default or crash, because kubernetes defaults to restricted. podSecurityAdmissionAudit, errPodSecurityAdmissionAudit := podSecurity.ParseLevel(strings.ToLower(getEnv("PODSECURITYADMISSION_AUDIT", string(podSecurity.LevelRestricted)))) if errPodSecurityAdmissionAudit != nil { - Log.Error().Msgf("PODSECURITYADMISSION_AUDIT is incorrect. %s ", errPodSecurityAdmissionAudit.Error()) - } - - ldapConfig := types.LdapConfig{ - UserBase: os.Getenv("LDAP_USERBASE"), - GroupBase: os.Getenv("LDAP_GROUPBASE"), - AppMasterGroupBase: getEnv("LDAP_APP_GROUPBASE", ""), - CustomerOpsGroupBase: getEnv("LDAP_CUSTOMER_OPS_GROUPBASE", ""), - ServiceGroupBase: getEnv("LDAP_SERVICE_GROUPBASE", ""), - OpsMasterGroupBase: getEnv("LDAP_OPS_GROUPBASE", ""), - AdminUserBase: getEnv("LDAP_ADMIN_USERBASE", ""), - AdminGroupBase: getEnv("LDAP_ADMIN_GROUPBASE", ""), - ViewerGroupBase: getEnv("LDAP_VIEWER_GROUPBASE", ""), - PageSize: uint32(ldapPageSize), - Host: os.Getenv("LDAP_SERVER"), - Port: ldapPort, - UseSSL: useSSL, - StartTLS: startTLS, - SkipTLSVerification: skipTLSVerification, - BindDN: os.Getenv("LDAP_BINDDN"), - BindPassword: os.Getenv("LDAP_PASSWD"), - UserFilter: ldapUserFilter, - GroupFilter: "(member=%s)", - Attributes: []string{"givenName", "sn", "mail", "uid", "cn", "userPrincipalName"}, - } - config := &types.Config{ - Tenant: tenant, - PodSecurityAdmissionEnforcement: podSecurityAdmissionEnforcement, - PodSecurityAdmissionWarning: podSecurityAdmissionWarning, - PodSecurityAdmissionAudit: podSecurityAdmissionAudit, - Ldap: ldapConfig, - KubeCa: caEncoded, - KubeCaText: string(kubeCA), - KubeToken: string(kubeToken), - PublicApiServerURL: getEnv("PUBLIC_APISERVER_URL", ""), - ApiServerTLSConfig: *tlsConfig, - TokenLifeTime: getEnv("TOKEN_LIFETIME", "4h"), - ExtraTokenLifeTime: getEnv("EXTRA_TOKEN_LIFETIME", "720h"), - Locator: getEnv("LOCATOR", KubiLocatorIntranet), - NetworkPolicy: networkpolicyEnabled, - CustomLabels: customLabels, - DefaultPermission: getEnv("DEFAULT_PERMISSION", ""), - PrivilegedNamespaces: strings.Split(getEnv("PRIVILEGED_NAMESPACES", ""), ","), - Blacklist: strings.Split(getEnv("BLACKLIST", ""), ","), - Whitelist: whitelist, - BlackWhitelistNamespace: getEnv("BLACK_WHITELIST_NAMESPACE", "default"), - } - - // TODO: Remove validation through ozzo-validation - err = validation.ValidateStruct(config, - validation.Field(&config.KubeToken, validation.Required), - validation.Field(&config.KubeCa, validation.Required, is.Base64), - validation.Field(&config.PublicApiServerURL, validation.Required, is.URL), - ) - // TODO: Get rid of Check method - Check(err) - - errLdap := validation.ValidateStruct(&ldapConfig, - validation.Field(&ldapConfig.UserBase, validation.Required, validation.Length(2, 200)), - validation.Field(&ldapConfig.GroupBase, validation.Required, validation.Length(2, 200)), - validation.Field(&ldapConfig.Host, validation.Required, is.URL), - validation.Field(&ldapConfig.BindDN, validation.Required, validation.Length(2, 200)), - validation.Field(&ldapConfig.BindPassword, validation.Required, validation.Length(2, 200)), - ) + return nil, fmt.Errorf("level for PODSECURITYADMISSION_AUDIT is incorrect. %v", errPodSecurityAdmissionAudit) + } + publicApiServerURL := os.Getenv("PUBLIC_APISERVER_URL") + if publicApiServerURL == "" { + return nil, errors.New("publicApiServerURL is required") + } + if _, err := url.ParseRequestURI(publicApiServerURL); err != nil { + return nil, fmt.Errorf("publicApiServerURL must be a valid URL, got %v", err) + } + + caEncoded := base64.StdEncoding.EncodeToString(kubeCA) + + // Get the SystemCertPool, continue with an empty pool on error + rootCAs, err := x509.SystemCertPool() if err != nil { - Log.Error().Msgf(strings.Replace(err.Error(), "; ", "\n", -1)) - return nil, err + return nil, fmt.Errorf("cannot retrieve system cert pool, exiting for security reason") } - if errLdap != nil { - Log.Error().Msgf(strings.Replace(errLdap.Error(), "; ", "\n", -1)) - return nil, err + if rootCAs == nil { + rootCAs = x509.NewCertPool() } - return config, nil + + if ok := rootCAs.AppendCertsFromPEM(kubeCA); !ok { + return nil, fmt.Errorf("cannot add Kubernetes CA, exiting for security reason") + } + + return &types.Config{ + Tenant: tenant, + PodSecurityAdmissionEnforcement: podSecurityAdmissionEnforcement, + PodSecurityAdmissionWarning: podSecurityAdmissionWarning, + PodSecurityAdmissionAudit: podSecurityAdmissionAudit, + Ldap: types.LdapConfig{ + UserBase: ldapUserBase, + GroupBase: ldapGroupBase, + AppMasterGroupBase: getEnv("LDAP_APP_GROUPBASE", ""), + CustomerOpsGroupBase: getEnv("LDAP_CUSTOMER_OPS_GROUPBASE", ""), + ServiceGroupBase: getEnv("LDAP_SERVICE_GROUPBASE", ""), + OpsMasterGroupBase: getEnv("LDAP_OPS_GROUPBASE", ""), + AdminUserBase: getEnv("LDAP_ADMIN_USERBASE", ""), + AdminGroupBase: getEnv("LDAP_ADMIN_GROUPBASE", ""), + ViewerGroupBase: getEnv("LDAP_VIEWER_GROUPBASE", ""), + PageSize: uint32(ldapPageSize), + Host: ldapServer, + Port: ldapPort, + UseSSL: useSSL, + StartTLS: startTLS, + SkipTLSVerification: skipTLSVerification, + BindDN: ldapBindDN, + BindPassword: ldapBindPassword, + UserFilter: ldapUserFilter, + GroupFilter: "(member=%s)", + Attributes: []string{"givenName", "sn", "mail", "uid", "cn", "userPrincipalName"}, + }, + KubeCa: caEncoded, + KubeCaText: string(kubeCA), + KubeToken: string(kubeToken), + PublicApiServerURL: publicApiServerURL, + ApiServerTLSConfig: tls.Config{ + InsecureSkipVerify: false, + RootCAs: rootCAs, + }, + TokenLifeTime: getEnv("TOKEN_LIFETIME", "4h"), + ExtraTokenLifeTime: getEnv("EXTRA_TOKEN_LIFETIME", "720h"), + Locator: getEnv("LOCATOR", KubiLocatorIntranet), + NetworkPolicy: networkpolicyEnabled, + CustomLabels: customLabels, + DefaultPermission: getEnv("DEFAULT_PERMISSION", ""), + PrivilegedNamespaces: strings.Split(getEnv("PRIVILEGED_NAMESPACES", ""), ","), + Blacklist: strings.Split(getEnv("BLACKLIST", ""), ","), + Whitelist: whitelist, + BlackWhitelistNamespace: getEnv("BLACK_WHITELIST_NAMESPACE", "default"), + }, nil } // Parse CustomLabels from a string to a map holding the key value diff --git a/internal/utils/constants.go b/internal/utils/constants.go index 535f3ccc..05293c2e 100644 --- a/internal/utils/constants.go +++ b/internal/utils/constants.go @@ -13,8 +13,6 @@ const ( ) const ( - Empty = "" - KubiResourcePrefix = "kubi" KubiClusterRoleBindingReaderName = "kubi-reader" diff --git a/internal/utils/helpers.go b/internal/utils/helpers.go deleted file mode 100644 index 47b5c2b6..00000000 --- a/internal/utils/helpers.go +++ /dev/null @@ -1,55 +0,0 @@ -package utils - -import ( - "os" - "strings" -) - -func IsEmpty(value string) bool { - return len(value) == 0 -} - -// Print error and exit if error occurred -func Check(e error) { - if e != nil { - Log.Error().Msg(e.Error()) - } -} - -func Checkf(e error, msg string) { - if e != nil { - Log.Error().Msgf("%v : %v", msg, e) - } -} - -func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - return fallback -} - -func AppendIfMissing(slice []string, i string) []string { - for _, ele := range slice { - if ele == i { - return slice - } - } - return append(slice, i) -} - -func Union(a map[string]string, b map[string]string) map[string]string { - for k, v := range b { - a[k] = v - } - return a -} - -func IsInPrivilegedNsList(namespace string) bool { - for _, nsItem := range Config.PrivilegedNamespaces { - if strings.Contains(nsItem, namespace) { - return true - } - } - return false -} diff --git a/internal/utils/strings.go b/internal/utils/strings.go deleted file mode 100644 index 41dc0f9f..00000000 --- a/internal/utils/strings.go +++ /dev/null @@ -1,13 +0,0 @@ -package utils - -import "strings" - -// Test if a string a one of suffixes array present. -func HasSuffixes(word string, suffixes []string) bool { - for _, suffix := range suffixes { - if strings.HasSuffix(word, "-"+suffix) { - return true - } - } - return false -} From 153ccff577f41a2643c05b306d3fcc10b8460821 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 16:59:42 +0100 Subject: [PATCH 17/26] Remove ozzo validation This commit tidies the go.mod after cleaning the code removing the usage of ozzo validation. --- go.mod | 2 -- go.sum | 4 ---- 2 files changed, 6 deletions(-) diff --git a/go.mod b/go.mod index 4cbb1e52..802dc3f6 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.23.3 require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible - github.com/go-ozzo/ozzo-validation v3.6.0+incompatible github.com/gorilla/mux v1.8.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.20.5 @@ -20,7 +19,6 @@ require ( ) require ( - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index f2eb5095..fb2ba233 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= -github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -22,8 +20,6 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= -github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= From 2c2c62dc4740a137bfa727d1f9fa75543425405c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Mon, 23 Dec 2024 17:08:42 +0100 Subject: [PATCH 18/26] Extend logging for token issues the createAccessToken method is bubbling up errors, but we never show them. This is a problem, as it prevents observability of the errors. This fixes it by logging the issues at error level. --- internal/services/token-provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 99d7aedb..a0d515d8 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -217,7 +217,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { token, err := issuer.createAccessToken(user, scopes) if err != nil { - utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) + utils.Log.Error().Msgf("Granting token fail for user %v, %v ", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return } @@ -244,7 +244,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request token, err := issuer.createAccessToken(user, "") // no need to generate config if the user cannot access it. if err != nil { - utils.Log.Error().Msgf("Granting token fail for user %v", user.Username) + utils.Log.Error().Msgf("Granting token fail for user %v, %v", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return } From 9ed4f5c9d178362ff569b2ac1208b29106c5482d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Wed, 8 Jan 2025 17:51:02 +0100 Subject: [PATCH 19/26] Improve testability Without this, much code is relying on global variables, or to state in the different functions. This is a problem, as it removes the testability, and code is hard to refactor. This fixes it by slowly getting away from spaghetti code, starting by refactoring the data structures. This prevents leakage of data. It will also allow us later to get rid of the global variables. This makes the code more idiomatic, and split the internal packages based on what they should provide (and how can they interface). Therefore, the code should be more readable in the long run. --- cmd/api/main.go | 7 +- cmd/authorization-webhook/main.go | 10 +- cmd/operator/main.go | 16 +- internal/ldap/auth.go | 69 ----- internal/ldap/base.go | 62 ----- internal/ldap/calls.go | 87 ++++++ internal/ldap/client.go | 90 +++++++ internal/ldap/membership.go | 100 ++----- internal/ldap/membership_test.go | 16 +- internal/ldap/projectname_from_ldap.go | 32 +++ internal/ldap/user_validation.go | 44 ++++ internal/middlewares/httpauth.go | 19 +- internal/project/const.go | 53 ++++ .../project.go => project/constructors.go} | 51 ++-- .../constructors_test.go} | 9 +- internal/project/filter.go | 47 ++++ .../filter_test.go} | 249 ++++++++---------- internal/services/blackwhitelist.go | 32 --- internal/services/provisionner.go | 69 +---- internal/services/token-provider.go | 36 +-- internal/utils/constants.go | 63 +---- pkg/types/types.go | 13 +- 22 files changed, 604 insertions(+), 570 deletions(-) delete mode 100644 internal/ldap/auth.go delete mode 100644 internal/ldap/base.go create mode 100644 internal/ldap/calls.go create mode 100644 internal/ldap/client.go create mode 100644 internal/ldap/projectname_from_ldap.go create mode 100644 internal/ldap/user_validation.go create mode 100644 internal/project/const.go rename internal/{services/project.go => project/constructors.go} (68%) rename internal/{services/project_test.go => project/constructors_test.go} (96%) create mode 100644 internal/project/filter.go rename internal/{services/blackwhitelist_test.go => project/filter_test.go} (62%) diff --git a/cmd/api/main.go b/cmd/api/main.go index 5de38912..05521a9a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" + "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" @@ -24,6 +25,8 @@ func main() { // use the config as live object instead of mutating it. utils.Config = config + ldapClient := ldap.NewLDAPClient(config.Ldap) + // TODO Move to config ( for validation ) ecdsaPem, err := os.ReadFile(utils.ECDSAKeyPath) if err != nil { @@ -51,8 +54,8 @@ func main() { io.WriteString(w, config.KubeCaText) }).Methods(http.MethodGet) - router.HandleFunc("/config", middlewares.WithBasicAuth(tokenIssuer.GenerateConfig)).Methods(http.MethodGet) - router.HandleFunc("/token", middlewares.WithBasicAuth(tokenIssuer.GenerateJWT)).Methods(http.MethodGet) + router.HandleFunc("/config", middlewares.WithBasicAuth(ldapClient, tokenIssuer.GenerateConfig)).Methods(http.MethodGet) + router.HandleFunc("/token", middlewares.WithBasicAuth(ldapClient, tokenIssuer.GenerateJWT)).Methods(http.MethodGet) router.Handle("/metrics", promhttp.Handler()) utils.Log.Info().Msgf(" Preparing to serve request, port: %d", 8000) diff --git a/cmd/authorization-webhook/main.go b/cmd/authorization-webhook/main.go index 1f551b8a..c51e4c63 100644 --- a/cmd/authorization-webhook/main.go +++ b/cmd/authorization-webhook/main.go @@ -34,11 +34,11 @@ func main() { tokenIssuer, err := services.NewTokenIssuer( ecdsaPem, ecdsaPubPem, - utils.Config.TokenLifeTime, - utils.Config.ExtraTokenLifeTime, // This had to be included in refactor. TODO: Check side effects - utils.Config.Locator, - utils.Config.PublicApiServerURL, - utils.Config.Tenant, + config.TokenLifeTime, + config.ExtraTokenLifeTime, // This had to be included in refactor. TODO: Check side effects + config.Locator, + config.PublicApiServerURL, + config.Tenant, ) if err != nil { utils.Log.Fatal().Msgf("Unable to create token issuer: %v", err) diff --git a/cmd/operator/main.go b/cmd/operator/main.go index 99ec495f..ddb19b8a 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/middlewares" "github.com/ca-gip/kubi/internal/services" "github.com/ca-gip/kubi/internal/utils" @@ -19,16 +20,15 @@ func main() { if err != nil { log.Fatal().Msg(fmt.Sprintf("Config error: %v", err)) } + + ldapClient := ldap.NewLDAPClient(config.Ldap) + utils.Config = config // Generate namespace and role binding for ldap groups // no need to wait here utils.Log.Info().Msg("Generating resources from LDAP groups") - err = services.GenerateResources() - if err != nil { - log.Error().Err(err) - } router := mux.NewRouter() router.Use(middlewares.Prometheus) router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { @@ -48,8 +48,12 @@ func main() { go func() { for t := range timerKubiRefresh.C { - utils.Log.Info().Msgf("Refreshing Projects at %s", t.String()) - services.RefreshK8SResources() + utils.Log.Info().Msgf("Create or Update Projects at %s", t.String()) + projects, err := ldapClient.ListProjects() + if err != nil { + utils.Log.Error().Msgf("cannot get project list from ldap: %v", err) + } + services.HandleProject(projects) } }() diff --git a/internal/ldap/auth.go b/internal/ldap/auth.go deleted file mode 100644 index c8ef7171..00000000 --- a/internal/ldap/auth.go +++ /dev/null @@ -1,69 +0,0 @@ -package ldap - -import ( - "fmt" - - "github.com/ca-gip/kubi/internal/utils" - "github.com/ca-gip/kubi/pkg/types" - "gopkg.in/ldap.v2" -) - -// Authenticate a user through LDAP or LDS -// return if bind was ok, the userDN for next usage, and error if occurred -func AuthenticateUser(username string, password string) (types.User, error) { - - // Get User Distinguished Name for Standard User - user, err := validateUserCredentials(utils.Config.Ldap.UserBase, username, password) - if err == nil { - return user, nil - } - - // Now handling errors to get standard user, falling back to admin user, if - // config allows it - if len(utils.Config.Ldap.AdminUserBase) <= 0 { - return types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) - } - - // Retry as admin - user, err = validateUserCredentials(utils.Config.Ldap.AdminUserBase, username, password) - if err != nil { - return types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) - } - return user, nil -} - -// Finds an user and check if its password is correct. -func validateUserCredentials(base string, username string, password string) (types.User, error) { - req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(utils.Config.Ldap.UserFilter, username), []string{"dn", "mail"}, nil) - - conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) - if err != nil { - return types.User{}, err - } - defer conn.Close() - - res, err := conn.SearchWithPaging(req, utils.Config.Ldap.PageSize) - - switch { - case err != nil: - return types.User{}, fmt.Errorf("error searching for user %s, %w", username, err) - case len(res.Entries) == 0: - return types.User{}, fmt.Errorf("no result for the user search filter '%s'", req.Filter) - case len(res.Entries) > 1: - return types.User{}, fmt.Errorf("multiple entries found for the user search filter '%s'", req.Filter) - } - - userDN := res.Entries[0].DN - mail := res.Entries[0].GetAttributeValue("mail") - user := types.User{ - Username: username, - UserDN: userDN, - Email: mail, - } - - _, err = ldapConnectAndBind(userDN, password) - if err != nil { - return types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) - } - return user, nil -} diff --git a/internal/ldap/base.go b/internal/ldap/base.go deleted file mode 100644 index 7cb172b7..00000000 --- a/internal/ldap/base.go +++ /dev/null @@ -1,62 +0,0 @@ -package ldap - -import ( - "crypto/tls" - "fmt" - - "github.com/ca-gip/kubi/internal/utils" - "github.com/pkg/errors" - "gopkg.in/ldap.v2" -) - -// Query LDAP with default credentials and paging parameters -func ldapQuery(request ldap.SearchRequest) (*ldap.SearchResult, error) { - conn, err := ldapConnectAndBind(utils.Config.Ldap.BindDN, utils.Config.Ldap.BindPassword) - if err != nil { - return nil, err - } - defer conn.Close() - results, err := conn.SearchWithPaging(&request, utils.Config.Ldap.PageSize) - if err != nil { - return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) - } - return results, nil - -} - -// Connect to LDAP and bind with given credentials -func ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { - var ( - err error - conn *ldap.Conn - ) - tlsConfig := &tls.Config{ - ServerName: utils.Config.Ldap.Host, - InsecureSkipVerify: utils.Config.Ldap.SkipTLSVerification, - } - - if utils.Config.Ldap.UseSSL { - conn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port), tlsConfig) - } else { - conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port)) - } - - if utils.Config.Ldap.StartTLS { - err = conn.StartTLS(tlsConfig) - if err != nil { - return nil, errors.Wrapf(err, "unable to setup TLS connection") - } - } - - if err != nil { - return nil, errors.Wrapf(err, "unable to create ldap connector for %s:%d", utils.Config.Ldap.Host, utils.Config.Ldap.Port) - } - - // Bind with BindAccount - err = conn.Bind(login, password) - if err != nil { - return nil, errors.WithStack(err) - } - - return conn, nil -} diff --git a/internal/ldap/calls.go b/internal/ldap/calls.go new file mode 100644 index 00000000..ca308496 --- /dev/null +++ b/internal/ldap/calls.go @@ -0,0 +1,87 @@ +package ldap + +import ( + "crypto/tls" + "fmt" + + "github.com/pkg/errors" + "gopkg.in/ldap.v2" +) + +// Connect to LDAP and bind with given credentials +func (c *LDAPClient) ldapConnectAndBind(login string, password string) (*ldap.Conn, error) { + var ( + err error + conn *ldap.Conn + ) + tlsConfig := &tls.Config{ + ServerName: c.Host, + InsecureSkipVerify: c.SkipTLSVerification, + } + + switch { + case c.UseSSL: + conn, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", c.Host, c.Port), tlsConfig) + if err != nil { + return nil, errors.Wrapf(err, "unable to create ldap tcp connection for %s:%d", c.Host, c.Port) + } + case c.StartTLS: + conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", c.Host, c.Port)) + if err != nil { + return nil, errors.Wrapf(err, "unable to create ldap tcp connection for %s:%d", c.Host, c.Port) + } + err = conn.StartTLS(tlsConfig) + if err != nil { + return nil, errors.Wrapf(err, "unable to setup TLS connection") + } + default: + conn, err = ldap.Dial("tcp", fmt.Sprintf("%s:%d", c.Host, c.Port)) + if err != nil { + return nil, errors.Wrapf(err, "unable to create INSECURE ldap tcp connection for %s:%d", c.Host, c.Port) + } + } + + // Bind with BindAccount + err = conn.Bind(login, password) + if err != nil { + return nil, errors.WithStack(err) + } + + return conn, nil +} + +// Query LDAP with default credentials and paging parameters +func (c *LDAPClient) Query(request ldap.SearchRequest) (*ldap.SearchResult, error) { + conn, err := c.ldapConnectAndBind(c.BindDN, c.BindPassword) + if err != nil { + return nil, err + } + defer conn.Close() + results, err := conn.SearchWithPaging(&request, c.PageSize) + if err != nil { + return nil, fmt.Errorf("error searching in LDAP with request %v, %v", request, err) + } + return results, nil + +} + +func (c *LDAPClient) getGroupsContainingUser(groupBaseDN string, userDN string) ([]*ldap.Entry, error) { + if len(groupBaseDN) == 0 { + return []*ldap.Entry{}, nil + } + req := ldap.NewSearchRequest( + groupBaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), + []string{"cn"}, + nil, + ) + + res, err := c.Query(*req) + if err != nil { + return nil, errors.Wrap(err, "error querying for group memberships") + } + + return res.Entries, nil +} diff --git a/internal/ldap/client.go b/internal/ldap/client.go new file mode 100644 index 00000000..53d59e7f --- /dev/null +++ b/internal/ldap/client.go @@ -0,0 +1,90 @@ +package ldap + +import ( + "fmt" + + "github.com/ca-gip/kubi/internal/project" + "github.com/ca-gip/kubi/pkg/types" +) + +// This is the internal API for LDAP auth. +// The rest of the implementation is in the internal/ldap package. + +type LDAPClient struct { + types.LdapConfig +} + +func NewLDAPClient(config types.LdapConfig) *LDAPClient { + return &LDAPClient{ + config, + } +} + +// Authenticate a user through LDAP or LDS +// return if bind was ok, the userDN for next usage, and error if occurred +func (c *LDAPClient) AuthN(username string, password string) (*types.User, error) { + + user := &types.User{} + // Get User Distinguished Name for Standard User + user, err := c.validateUserCredentials(c.UserBase, username, password) + if err == nil { + return user, nil + } + + // Now handling errors to get standard user, falling back to admin user, if + // config allows it + if len(c.AdminUserBase) <= 0 { + return &types.User{}, fmt.Errorf("cannot find user %s in LDAP", username) + } + + // Retry as admin + user, err = c.validateUserCredentials(c.AdminUserBase, username, password) + if err != nil { + return &types.User{}, fmt.Errorf("cannot find admin user %s in LDAP", username) + } + + return user, nil +} + +func (c *LDAPClient) AuthZ(user *types.User) (*types.User, error) { + // Get User Memberships + if user == nil { + return &types.User{}, fmt.Errorf("cannot get memberships for nil user") + } + if user.Email == "" || user.UserDN == "" || user.Username == "" { + return &types.User{}, fmt.Errorf("cannot get memberships for empty user %v in LDAP", user) + } + + // to keep for historical reasons: We continue to issue tokens with old data until + // ArgoCD + promote + other? is updated to use the new groups. + // When migration is over, we can simplify the User struct and remove the old fields. + + ldapMemberships, err := c.getMemberships(user.UserDN) + if err != nil { + return &types.User{}, fmt.Errorf("cannot get memberships for user %s in LDAP", user.Username) + } + + user.Groups = ldapMemberships.toGroupNames() + + user.IsAdmin = len(ldapMemberships.AdminAccess) > 0 + user.IsAppOps = (len(ldapMemberships.AppOpsAccess) > 0) || (len(ldapMemberships.CustomerOpsAccess) > 0) + user.IsCloudOps = len(ldapMemberships.CloudOpsAccess) > 0 + user.IsViewer = len(ldapMemberships.ViewerAccess) > 0 + user.IsService = len(ldapMemberships.ServiceAccess) > 0 + + user.ProjectAccesses = ldapMemberships.toProjectNames() + + return user, nil +} + +// ListProjects Implement ProjectLister interface to be able to replace with a list of projects for testing. +func (c *LDAPClient) ListProjects() ([]*types.Project, error) { + allClusterGroups, err := c.getProjectGroups() + if err != nil { + return nil, err + } + if len(allClusterGroups) == 0 { + return nil, fmt.Errorf("no ldap groups found") + } + return project.GetProjectsFromGrouplist(allClusterGroups), nil +} diff --git a/internal/ldap/membership.go b/internal/ldap/membership.go index 65030b90..4c2eaf6e 100644 --- a/internal/ldap/membership.go +++ b/internal/ldap/membership.go @@ -1,14 +1,11 @@ package ldap import ( - "fmt" - - "github.com/ca-gip/kubi/internal/utils" "github.com/pkg/errors" "gopkg.in/ldap.v2" ) -type UserMemberships struct { +type LDAPMemberships struct { AdminAccess []*ldap.Entry AppOpsAccess []*ldap.Entry CustomerOpsAccess []*ldap.Entry @@ -18,55 +15,57 @@ type UserMemberships struct { ClusterGroupsAccess []*ldap.Entry // This represents the groups that are cluster-scoped (=projects) } -// Constructing UserMemberships struct with all the special groups the user is member of. +// Constructing LDAPMemberships struct with all the special groups the user is member of. // This does not include the standard groups like "authenticated" or "system:authenticated" // or cluster based groups. -func (m *UserMemberships) FromUserDN(userDN string) error { +func (c *LDAPClient) getMemberships(userDN string) (*LDAPMemberships, error) { + m := &LDAPMemberships{} + var err error - m.AdminAccess, err = getGroupsContainingUser(utils.Config.Ldap.AdminGroupBase, userDN) + m.AdminAccess, err = c.getGroupsContainingUser(c.AdminGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting admin access") + return nil, errors.Wrap(err, "error getting admin access") } - m.AppOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.AppMasterGroupBase, userDN) + m.AppOpsAccess, err = c.getGroupsContainingUser(c.AppMasterGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting app ops access") + return nil, errors.Wrap(err, "error getting app ops access") } - m.CustomerOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.CustomerOpsGroupBase, userDN) + m.CustomerOpsAccess, err = c.getGroupsContainingUser(c.CustomerOpsGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting customer ops access") + return nil, errors.Wrap(err, "error getting customer ops access") } - m.ViewerAccess, err = getGroupsContainingUser(utils.Config.Ldap.ViewerGroupBase, userDN) + m.ViewerAccess, err = c.getGroupsContainingUser(c.ViewerGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting viewer access") + return nil, errors.Wrap(err, "error getting viewer access") } - m.ServiceAccess, err = getGroupsContainingUser(utils.Config.Ldap.ServiceGroupBase, userDN) + m.ServiceAccess, err = c.getGroupsContainingUser(c.ServiceGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting service access") + return nil, errors.Wrap(err, "error getting service access") } - m.CloudOpsAccess, err = getGroupsContainingUser(utils.Config.Ldap.OpsMasterGroupBase, userDN) + m.CloudOpsAccess, err = c.getGroupsContainingUser(c.OpsMasterGroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting cloud ops access") + return nil, errors.Wrap(err, "error getting cloud ops access") } // This is better than binding directly to the userDN and querying memberOf: // in case of nested groups or other complex group structures, the memberOf // attribute may not be populated correctly. - m.ClusterGroupsAccess, err = getGroupsContainingUser(utils.Config.Ldap.GroupBase, userDN) + m.ClusterGroupsAccess, err = c.getGroupsContainingUser(c.GroupBase, userDN) if err != nil { - return errors.Wrap(err, "error getting cluster groups access") + return nil, errors.Wrap(err, "error getting cluster groups access") } - return nil + return m, nil } -// ListGroups retuns a slice for all the group names the user is member of, +// toGroupNames returns a slice for all the group names the user is member of, // rather than their full LDAP entries. -func (m *UserMemberships) ListGroups() []string { +func (m *LDAPMemberships) toGroupNames() []string { var groups []string for _, entry := range m.AdminAccess { groups = append(groups, entry.GetAttributeValue("cn")) @@ -86,63 +85,18 @@ func (m *UserMemberships) ListGroups() []string { for _, entry := range m.CloudOpsAccess { groups = append(groups, entry.GetAttributeValue("cn")) } - groups = append(groups, m.ListClusterGroups()...) - return groups -} - -// ListClusterGroups is a convenience method to return the cluster-scoped groups -// rather than full membership entries. -func (m *UserMemberships) ListClusterGroups() []string { - var groups []string for _, entry := range m.ClusterGroupsAccess { groups = append(groups, entry.GetAttributeValue("cn")) } return groups } -func getGroupsContainingUser(groupBaseDN string, userDN string) ([]*ldap.Entry, error) { - if len(groupBaseDN) == 0 { - return []*ldap.Entry{}, nil - } - req := ldap.NewSearchRequest( - groupBaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, 1, 30, false, - fmt.Sprintf("(&(|(objectClass=groupOfNames)(objectClass=group))(member=%s))", userDN), - []string{"cn"}, - nil, - ) - - res, err := ldapQuery(*req) - if err != nil { - return nil, errors.Wrap(err, "error querying for group memberships") - } - - return res.Entries, nil -} - -// Get All groups for the cluster from LDAP -func GetAllGroups() ([]string, error) { - - request := &ldap.SearchRequest{ - BaseDN: utils.Config.Ldap.GroupBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - SizeLimit: 0, // limit number of entries in result, 0 values means no limitations - TimeLimit: 30, - TypesOnly: false, - Filter: "(|(objectClass=groupOfNames)(objectClass=group))", // filter default format : (&(objectClass=groupOfNames)(member=%s)) - Attributes: []string{"cn"}, - } - - results, err := ldapQuery(*request) - if err != nil { - return nil, errors.Wrap(err, "Error searching all groups") - } - +// toProjectNames retuns a slice for all the project names the user is member of, +// rather than their full LDAP entries. This is not returning a slice of the projects. +func (m *LDAPMemberships) toProjectNames() []string { var groups []string - for _, entry := range results.Entries { + for _, entry := range m.ClusterGroupsAccess { groups = append(groups, entry.GetAttributeValue("cn")) } - return groups, nil + return groups } diff --git a/internal/ldap/membership_test.go b/internal/ldap/membership_test.go index a2de2156..d10ba5a4 100644 --- a/internal/ldap/membership_test.go +++ b/internal/ldap/membership_test.go @@ -6,15 +6,15 @@ import ( "gopkg.in/ldap.v2" ) -func TestListGroups(t *testing.T) { +func TestToGroupNames(t *testing.T) { tests := []struct { name string - members UserMemberships + members LDAPMemberships expected []string }{ { name: "All groups", - members: UserMemberships{ + members: LDAPMemberships{ AdminAccess: []*ldap.Entry{ {DN: "cn=admin1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin1"}}}}, {DN: "cn=admin2", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin2"}}}}, @@ -42,7 +42,7 @@ func TestListGroups(t *testing.T) { }, { name: "No groups", - members: UserMemberships{ + members: LDAPMemberships{ AdminAccess: []*ldap.Entry{}, AppOpsAccess: []*ldap.Entry{}, CustomerOpsAccess: []*ldap.Entry{}, @@ -55,7 +55,7 @@ func TestListGroups(t *testing.T) { }, { name: "Some groups", - members: UserMemberships{ + members: LDAPMemberships{ AdminAccess: []*ldap.Entry{ {DN: "cn=admin1", Attributes: []*ldap.EntryAttribute{{Name: "cn", Values: []string{"admin1"}}}}, }, @@ -76,13 +76,13 @@ func TestListGroups(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.members.ListGroups() + got := tt.members.toGroupNames() if len(got) != len(tt.expected) { - t.Errorf("ListGroups() = %v, want %v", got, tt.expected) + t.Errorf("toGroupNames() = %v, want %v", got, tt.expected) } for i, group := range got { if group != tt.expected[i] { - t.Errorf("ListGroups() = %v, want %v", got, tt.expected) + t.Errorf("toGroupNames() = %v, want %v", got, tt.expected) } } }) diff --git a/internal/ldap/projectname_from_ldap.go b/internal/ldap/projectname_from_ldap.go new file mode 100644 index 00000000..f209c707 --- /dev/null +++ b/internal/ldap/projectname_from_ldap.go @@ -0,0 +1,32 @@ +package ldap + +import ( + "github.com/pkg/errors" + "gopkg.in/ldap.v2" +) + +// getProjectGroups returns all groupnames that are useful for projects. +func (c *LDAPClient) getProjectGroups() ([]string, error) { + + request := &ldap.SearchRequest{ + BaseDN: c.GroupBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + SizeLimit: 0, // limit number of entries in result, 0 values means no limitations + TimeLimit: 30, + TypesOnly: false, + Filter: "(|(objectClass=groupOfNames)(objectClass=group))", // filter default format : (&(objectClass=groupOfNames)(member=%s)) + Attributes: []string{"cn"}, + } + + results, err := c.Query(*request) + if err != nil { + return nil, errors.Wrap(err, "Error searching all groups") + } + + var groups []string + for _, entry := range results.Entries { + groups = append(groups, entry.GetAttributeValue("cn")) + } + return groups, nil +} diff --git a/internal/ldap/user_validation.go b/internal/ldap/user_validation.go new file mode 100644 index 00000000..b187ba56 --- /dev/null +++ b/internal/ldap/user_validation.go @@ -0,0 +1,44 @@ +package ldap + +import ( + "fmt" + + "github.com/ca-gip/kubi/pkg/types" + "gopkg.in/ldap.v2" +) + +// Finds an user and check if its password is correct. +func (c *LDAPClient) validateUserCredentials(base string, username string, password string) (*types.User, error) { + req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 10, false, fmt.Sprintf(c.UserFilter, username), []string{"dn", "mail"}, nil) + + conn, err := c.ldapConnectAndBind(c.BindDN, c.BindPassword) + if err != nil { + return &types.User{}, err + } + defer conn.Close() + + res, err := conn.SearchWithPaging(req, c.PageSize) + + switch { + case err != nil: + return &types.User{}, fmt.Errorf("error searching for user %s, %w", username, err) + case len(res.Entries) == 0: + return &types.User{}, fmt.Errorf("no result for the user search filter '%s'", req.Filter) + case len(res.Entries) > 1: + return &types.User{}, fmt.Errorf("multiple entries found for the user search filter '%s'", req.Filter) + } + + userDN := res.Entries[0].DN + mail := res.Entries[0].GetAttributeValue("mail") + user := &types.User{ + Username: username, + UserDN: userDN, + Email: mail, + } + + _, err = c.ldapConnectAndBind(userDN, password) + if err != nil { + return &types.User{}, fmt.Errorf("cannot authenticate user %s in LDAP", username) + } + return user, nil +} diff --git a/internal/middlewares/httpauth.go b/internal/middlewares/httpauth.go index 81ed22b0..a66c82ef 100644 --- a/internal/middlewares/httpauth.go +++ b/internal/middlewares/httpauth.go @@ -4,14 +4,19 @@ import ( "context" "net/http" - "github.com/ca-gip/kubi/internal/ldap" + "github.com/ca-gip/kubi/pkg/types" ) type contextKey string const UserContextKey contextKey = "user" -func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { +type Authenticator interface { + AuthN(username, password string) (*types.User, error) + AuthZ(user *types.User) (*types.User, error) +} + +func WithBasicAuth(authenticator Authenticator, next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Extract the username and password from the request // Authorization header. If no Authentication header is present @@ -24,7 +29,15 @@ func WithBasicAuth(next http.HandlerFunc) http.HandlerFunc { return } - user, err := ldap.AuthenticateUser(username, password) + user, err := authenticator.AuthN(username, password) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // If the username and password are correct, then search for the user's group, + // and only add the user and its group to the request context if successful. + user, err = authenticator.AuthZ(user) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/internal/project/const.go b/internal/project/const.go new file mode 100644 index 00000000..5b8958fa --- /dev/null +++ b/internal/project/const.go @@ -0,0 +1,53 @@ +package project + +const ( + DNS1123LabelMaxLength int = 63 + Dns1123LabelFmt string = "^[a-z0-9][-a-z0-9]*$" + KubiResourcePrefix = "kubi" + KubiEnvironmentProduction = "production" + KubiEnvironmentShortProduction = "prd" + KubiEnvironmentIntegration = "integration" + KubiEnvironmentShortInt = "int" + KubiEnvironmentUAT = "uat" + KubiEnvironmentPreproduction = "preproduction" + KubiEnvironmentShortPreproduction = "pprd" + KubiEnvironmentDevelopment = "development" + KubiEnvironmentShortDevelopment = "dev" +) + +// TODO: Sort this and use the same names! +var AllEnvironments = []string{ + KubiEnvironmentProduction, + KubiEnvironmentShortProduction, + KubiEnvironmentIntegration, + KubiEnvironmentShortInt, + KubiEnvironmentUAT, + KubiEnvironmentPreproduction, + KubiEnvironmentShortPreproduction, + KubiEnvironmentDevelopment, + KubiEnvironmentShortDevelopment, +} + +var WhitelistedRoles = []string{ + "admin", + "service", + "user", +} + +// TODO: Make this dynamic +var BlacklistedNamespaces = []string{ + "kube-system", + "kube-public", + "ingress-nginx", + "admin", + "default", + KubiResourcePrefix, +} + +var EnvironmentNamesMapping = map[string]string{ + KubiEnvironmentShortDevelopment: KubiEnvironmentDevelopment, + KubiEnvironmentShortInt: KubiEnvironmentIntegration, + KubiEnvironmentUAT: KubiEnvironmentUAT, + KubiEnvironmentShortPreproduction: KubiEnvironmentPreproduction, + KubiEnvironmentShortProduction: KubiEnvironmentProduction, +} diff --git a/internal/services/project.go b/internal/project/constructors.go similarity index 68% rename from internal/services/project.go rename to internal/project/constructors.go index ea0f3565..0550c835 100644 --- a/internal/services/project.go +++ b/internal/project/constructors.go @@ -1,15 +1,19 @@ -package services +package project import ( "fmt" + "log/slog" "regexp" "slices" "strings" - "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" ) +type ProjectLister interface { + ListProjects() ([]*types.Project, error) +} + // DNSParser is a regex to parse a group name into a namespace and a role. // Please note the underscore behaviour: // The last one is used for parsing. @@ -23,13 +27,13 @@ var DnsParser = regexp.MustCompile("(?:.+_+)*(?P.+)_(?P.+)$") // - Environment // If environment not found, return the namespace as is // This is a convenience function for test purposes -func namespaceParser(namespaceInput string) (projectName string, environment string) { +func parseNamespace(namespaceInput string) (projectName string, environment string) { var namespaceHasSuffix bool // check whether any of our environments names (short and longs) // are part of the namespace given in input - for _, environmentSuffix := range utils.AllEnvironments { + for _, environmentSuffix := range AllEnvironments { if strings.HasSuffix(namespaceInput, "-"+environmentSuffix) { namespaceHasSuffix = true break @@ -45,7 +49,7 @@ func namespaceParser(namespaceInput string) (projectName string, environment str splits := strings.Split(namespaceInput, "-") environment = splits[len(splits)-1] - if val, ok := utils.LdapMapping[environment]; ok { + if val, ok := EnvironmentNamesMapping[environment]; ok { environment = val } @@ -70,7 +74,7 @@ func NewProject(group string) (*types.Project, error) { rawNamespace, role := DnsParser.ReplaceAllString(lowerGroup, "${namespace}"), DnsParser.ReplaceAllString(lowerGroup, "${role}") fmt.Println("namespace:", rawNamespace, "role:", role) - projectName, environment := namespaceParser(rawNamespace) + projectName, environment := parseNamespace(rawNamespace) project := &types.Project{ Project: projectName, Role: role, @@ -78,18 +82,18 @@ func NewProject(group string) (*types.Project, error) { Environment: environment, } - isNamespaceValid, err := regexp.MatchString(utils.Dns1123LabelFmt, project.Namespace()) + isNamespaceValid, err := regexp.MatchString(Dns1123LabelFmt, project.Namespace()) if err != nil { return nil, err } - isInBlacklistedNamespace := slices.Contains(utils.BlacklistedNamespaces, project.Namespace()) - isRoleValid := slices.Contains(utils.WhitelistedRoles, project.Role) + isInBlacklistedNamespace := slices.Contains(BlacklistedNamespaces, project.Namespace()) + isRoleValid := slices.Contains(WhitelistedRoles, project.Role) switch { - case len(project.Namespace()) > utils.DNS1123LabelMaxLength: - return nil, fmt.Errorf("the name for namespace cannot exceeded %v characters", utils.DNS1123LabelMaxLength) - case len(role) > utils.DNS1123LabelMaxLength: - return nil, fmt.Errorf("the name for role cannot exceeded %v characters", utils.DNS1123LabelMaxLength) + case len(project.Namespace()) > DNS1123LabelMaxLength: + return nil, fmt.Errorf("the name for namespace cannot exceeded %v characters", DNS1123LabelMaxLength) + case len(role) > DNS1123LabelMaxLength: + return nil, fmt.Errorf("the name for role cannot exceeded %v characters", DNS1123LabelMaxLength) case isInBlacklistedNamespace: return nil, fmt.Errorf("the project from group %v cannot be created, its namespace %v is protected through blacklist", group, project.Namespace()) case !isNamespaceValid: @@ -101,18 +105,15 @@ func NewProject(group string) (*types.Project, error) { } } -// GetAllProjects returns a slice of new projects from a list of groups -// This is useful to see all the projects matching all the groups from the luster -// Or to see all the projects a user has access to (based on the user's groups) -func GetAllProjects(groups []string) []*types.Project { - res := make([]*types.Project, 0) - for _, groupname := range groups { - tupple, err := NewProject(groupname) - if err == nil { - res = append(res, tupple) - } else { - utils.Log.Error().Msg(err.Error()) +func GetProjectsFromGrouplist(groups []string) []*types.Project { + projects := make([]*types.Project, 0) + for _, projectGroup := range groups { + project, err := NewProject(projectGroup) + if err != nil { + slog.Error(fmt.Sprintf("Could not generate project name from group %v", projectGroup)) + continue } + projects = append(projects, project) } - return res + return projects } diff --git a/internal/services/project_test.go b/internal/project/constructors_test.go similarity index 96% rename from internal/services/project_test.go rename to internal/project/constructors_test.go index 3310fe10..73465ba4 100644 --- a/internal/services/project_test.go +++ b/internal/project/constructors_test.go @@ -1,10 +1,9 @@ -package services +package project import ( "fmt" "testing" - "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" ) @@ -30,7 +29,7 @@ func TestNamespaceParser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resultProjectName, resultEnvironmentName := namespaceParser(tt.input) + resultProjectName, resultEnvironmentName := parseNamespace(tt.input) if tt.expectedProjectName != resultProjectName { t.Errorf("expected %s, got %s", tt.expectedProjectName, resultProjectName) } @@ -132,12 +131,12 @@ func TestNewProject(t *testing.T) { { name: "namespace_exceeds_max_length", group: "thisisaveryveryveryverylongnamespacethatexceedsthemaxallowedlength_ADMIN", - expectedError: fmt.Sprintf("the name for namespace cannot exceeded %v characters", utils.DNS1123LabelMaxLength), + expectedError: fmt.Sprintf("the name for namespace cannot exceeded %v characters", DNS1123LabelMaxLength), }, { name: "role_exceeds_max_length", group: "DL_NATIVE_thisisaveryveryveryveryveryverylongrolethatexceedsthemaxallowedlength", - expectedError: fmt.Sprintf("the name for role cannot exceeded %v characters", utils.DNS1123LabelMaxLength), + expectedError: fmt.Sprintf("the name for role cannot exceeded %v characters", DNS1123LabelMaxLength), }, } diff --git a/internal/project/filter.go b/internal/project/filter.go new file mode 100644 index 00000000..62dbe366 --- /dev/null +++ b/internal/project/filter.go @@ -0,0 +1,47 @@ +package project + +import ( + "slices" + "strings" + + "github.com/ca-gip/kubi/pkg/types" +) + +// FilterProjects filters projects based on the black and whitelist +func FilterProjects(whitelistEnabled bool, context []*types.Project, blackWhiteList *types.BlackWhitelist) ([]*types.Project, []*types.Project, []*types.Project) { + + var createdProjects, deletedProjects, ignoredProjects []*types.Project + for _, auth := range context { + isBlacklisted := slices.Contains(blackWhiteList.Blacklist, auth.Namespace()) + isWhitelisted := slices.Contains(blackWhiteList.Whitelist, auth.Namespace()) + + switch { + //we treat blacklisted projects as a priority, project will be deleted + case blackWhiteList.Blacklist[0] != "" && isBlacklisted: + deletedProjects = append(deletedProjects, auth) + continue + // If whitelist is enabled, do not create project unless it's explictly mentioned + case whitelistEnabled && isWhitelisted: + createdProjects = append(createdProjects, auth) + //project will be ignored if whitelist is enabled and project not present on whitelisted projects + case whitelistEnabled && !isWhitelisted: + ignoredProjects = append(ignoredProjects, auth) + //project will be created if whitelist is disabled and no projects in blacklist + case !whitelistEnabled: + createdProjects = append(createdProjects, auth) + } + } + + return createdProjects, deletedProjects, ignoredProjects +} + +func MakeBlackWhitelist(blackWhiteCMData map[string]string) types.BlackWhitelist { + + blackWhiteList := types.BlackWhitelist{ + Blacklist: strings.Split(blackWhiteCMData["blacklist"], ","), + Whitelist: strings.Split(blackWhiteCMData["whitelist"], ","), + } + + return blackWhiteList + +} diff --git a/internal/services/blackwhitelist_test.go b/internal/project/filter_test.go similarity index 62% rename from internal/services/blackwhitelist_test.go rename to internal/project/filter_test.go index 013b7dfe..9265dbf8 100644 --- a/internal/services/blackwhitelist_test.go +++ b/internal/project/filter_test.go @@ -1,83 +1,15 @@ -package services +package project import ( "encoding/json" "testing" - "github.com/ca-gip/kubi/internal/utils" + v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/ca-gip/kubi/pkg/types" "github.com/stretchr/testify/assert" ) -func TestBlackWhiteList(t *testing.T) { - - fakeProjectJson := []byte(`{ - "apiVersion": "cagip.github.com/v1", - "kind": "Project", - "metadata": { - "labels": { - "creator": "kubi" - }, - "name": "native-development", - }, - "spec": { - "environment": "development", - "project": "native", - "sourceDN": "CN=DL_KUB_CAGIP-DEVOPS-HP_NATIVE-DEVELOPMENT_ADMIN,OU=DEVOPS-HORS-PROD,OU=CAGIP,OU=PAAS_CONTAINER,OU=Applications,OU=Groupes,O=CA", - "sourceEntity": "DL_KUB_CAGIP-DEVOPS-HP_NATIVE-DEVELOPMENT_ADMIN", - "stages": [ - "scratch", - "staging", - "stable" - ], - "tenant": "cagip" - } - }`) - - fakeProject := &v12.Project{} - - json.Unmarshal(fakeProjectJson, fakeProject) - - t.Run("with_nil_object", func(t *testing.T) { - - result := MakeBlackWhitelist(nil) - assert.Equal(t, []string([]string{""}), result.Blacklist) - assert.Equal(t, []string([]string{""}), result.Whitelist) - }) - - t.Run("with_empty_map", func(t *testing.T) { - blackWhitelistData := map[string]string{} - - result := MakeBlackWhitelist(blackWhitelistData) - assert.Equal(t, []string([]string{""}), result.Blacklist) - assert.Equal(t, []string([]string{""}), result.Whitelist) - }) - - t.Run("with_non_sense_data", func(t *testing.T) { - blackWhitelistData := map[string]string{ - "blacldzada": "dzadzdaz", - "dzadz$Ùdzadza": "fefezfez, 6z556/*/R/ÉR*/", - } - - result := MakeBlackWhitelist(blackWhitelistData) - assert.Equal(t, []string([]string{""}), result.Blacklist) - assert.Equal(t, []string([]string{""}), result.Whitelist) - }) - - t.Run("with_real_data", func(t *testing.T) { - blackWhitelistData := map[string]string{ - "blacklist": "native-developpement, native-integration", - "whitelist": "", - } - - result := MakeBlackWhitelist(blackWhitelistData) - assert.Equal(t, []string([]string{"native-developpement", " native-integration"}), result.Blacklist) - assert.Equal(t, []string([]string{""}), result.Whitelist) - }) - -} - -func TestGenerateProjects(t *testing.T) { +func TestFilterProjects(t *testing.T) { fakeProject := []*types.Project{ { @@ -94,8 +26,6 @@ func TestGenerateProjects(t *testing.T) { }, } - - //WHITELIST t.Run("blacklisted projects are deleted and no whithelisted projects are ignored", func(t *testing.T) { @@ -103,8 +33,7 @@ func TestGenerateProjects(t *testing.T) { "blacklist": "native-development,native-integration", "whitelist": "", } - expectedCreate := []*types.Project{ - } + expectedCreate := []*types.Project{} expectedDelete := []*types.Project{ { Project: "native", @@ -114,7 +43,7 @@ func TestGenerateProjects(t *testing.T) { Project: "native", Environment: "integration", }, - } + } expectedIgnore := []*types.Project{ { Project: "native", @@ -124,11 +53,10 @@ func TestGenerateProjects(t *testing.T) { blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: true} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(true, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) t.Run("blacklist takes priority and no whitlisted projects are ignored", func(t *testing.T) { @@ -138,8 +66,7 @@ func TestGenerateProjects(t *testing.T) { "whitelist": "native-development", } - expectedCreate := []*types.Project{ - } + expectedCreate := []*types.Project{} expectedDelete := []*types.Project{ { @@ -150,7 +77,7 @@ func TestGenerateProjects(t *testing.T) { Project: "native", Environment: "integration", }, - } + } expectedIgnore := []*types.Project{ { @@ -160,11 +87,10 @@ func TestGenerateProjects(t *testing.T) { } blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: true} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(true, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) t.Run("no project is created unless explicitly defined in whitelist; blacklisted projects are deleted", func(t *testing.T) { @@ -190,18 +116,16 @@ func TestGenerateProjects(t *testing.T) { Project: "native", Environment: "integration", }, - } - - expectedIgnore := []*types.Project{ } + expectedIgnore := []*types.Project{} + blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: true} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(true, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) t.Run("ignore all projects if confimap contains invalid data", func(t *testing.T) { @@ -210,11 +134,9 @@ func TestGenerateProjects(t *testing.T) { "blaaeza": "rrzerzF", } - expectedCreate := []*types.Project{ - } + expectedCreate := []*types.Project{} - expectedDelete := []*types.Project{ - } + expectedDelete := []*types.Project{} expectedIgnore := []*types.Project{ { @@ -232,11 +154,10 @@ func TestGenerateProjects(t *testing.T) { } blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: true} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(true, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) //BLACKLIST @@ -262,23 +183,18 @@ func TestGenerateProjects(t *testing.T) { }, } - expectedDelete := []*types.Project{ - } + expectedDelete := []*types.Project{} - expectedIgnore := []*types.Project{ - } + expectedIgnore := []*types.Project{} blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: false} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(false, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) - - t.Run("project don't require whitelisting to be created", func(t *testing.T) { blackWhitelistData := map[string]string{ @@ -301,18 +217,16 @@ func TestGenerateProjects(t *testing.T) { Project: "native", Environment: "integration", }, - } - - expectedIgnore := []*types.Project{ } + expectedIgnore := []*types.Project{} + blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: false} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(false, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) t.Run("all projects are created if confimap contains invalid data", func(t *testing.T) { @@ -335,19 +249,84 @@ func TestGenerateProjects(t *testing.T) { }, } - expectedDelete := []*types.Project{ - } + expectedDelete := []*types.Project{} - expectedIgnore := []*types.Project{ - } + expectedIgnore := []*types.Project{} blackWhitelist := MakeBlackWhitelist(blackWhitelistData) - utils.Config = &types.Config{Whitelist: false} - gotCreated,gotDeleted,gotIgnored := GenerateProjects(fakeProject, &blackWhitelist) - assert.ElementsMatch(t,gotCreated,expectedCreate, "the expected create projects match created list ") - assert.ElementsMatch(t,gotDeleted,expectedDelete, "the expected delete projects match deleted list ") - assert.ElementsMatch(t,gotIgnored,expectedIgnore, "the expected ignore projects match ignored list") + gotCreated, gotDeleted, gotIgnored := FilterProjects(false, fakeProject, &blackWhitelist) + assert.ElementsMatch(t, gotCreated, expectedCreate, "the expected create projects match created list ") + assert.ElementsMatch(t, gotDeleted, expectedDelete, "the expected delete projects match deleted list ") + assert.ElementsMatch(t, gotIgnored, expectedIgnore, "the expected ignore projects match ignored list") }) -} \ No newline at end of file +} + +func TestBlackWhiteList(t *testing.T) { + + fakeProjectJson := []byte(`{ + "apiVersion": "cagip.github.com/v1", + "kind": "Project", + "metadata": { + "labels": { + "creator": "kubi" + }, + "name": "native-development", + }, + "spec": { + "environment": "development", + "project": "native", + "sourceDN": "CN=DL_KUB_CAGIP-DEVOPS-HP_NATIVE-DEVELOPMENT_ADMIN,OU=DEVOPS-HORS-PROD,OU=CAGIP,OU=PAAS_CONTAINER,OU=Applications,OU=Groupes,O=CA", + "sourceEntity": "DL_KUB_CAGIP-DEVOPS-HP_NATIVE-DEVELOPMENT_ADMIN", + "stages": [ + "scratch", + "staging", + "stable" + ], + "tenant": "cagip" + } + }`) + + fakeProject := &v12.Project{} + + json.Unmarshal(fakeProjectJson, fakeProject) + + t.Run("with_nil_object", func(t *testing.T) { + + result := MakeBlackWhitelist(nil) + assert.Equal(t, []string([]string{""}), result.Blacklist) + assert.Equal(t, []string([]string{""}), result.Whitelist) + }) + + t.Run("with_empty_map", func(t *testing.T) { + blackWhitelistData := map[string]string{} + + result := MakeBlackWhitelist(blackWhitelistData) + assert.Equal(t, []string([]string{""}), result.Blacklist) + assert.Equal(t, []string([]string{""}), result.Whitelist) + }) + + t.Run("with_non_sense_data", func(t *testing.T) { + blackWhitelistData := map[string]string{ + "blacldzada": "dzadzdaz", + "dzadz$Ùdzadza": "fefezfez, 6z556/*/R/ÉR*/", + } + + result := MakeBlackWhitelist(blackWhitelistData) + assert.Equal(t, []string([]string{""}), result.Blacklist) + assert.Equal(t, []string([]string{""}), result.Whitelist) + }) + + t.Run("with_real_data", func(t *testing.T) { + blackWhitelistData := map[string]string{ + "blacklist": "native-developpement, native-integration", + "whitelist": "", + } + + result := MakeBlackWhitelist(blackWhitelistData) + assert.Equal(t, []string([]string{"native-developpement", " native-integration"}), result.Blacklist) + assert.Equal(t, []string([]string{""}), result.Whitelist) + }) + +} diff --git a/internal/services/blackwhitelist.go b/internal/services/blackwhitelist.go index b99a94cb..c8623da9 100644 --- a/internal/services/blackwhitelist.go +++ b/internal/services/blackwhitelist.go @@ -2,13 +2,11 @@ package services import ( "context" - "strings" "github.com/ca-gip/kubi/internal/utils" corev1 "k8s.io/api/core/v1" v1 "k8s.io/client-go/kubernetes/typed/core/v1" - "github.com/ca-gip/kubi/pkg/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -23,33 +21,3 @@ func GetBlackWhitelistCM(api v1.CoreV1Interface) (*corev1.ConfigMap, error) { return blacklistCM, nil } - -func CreateBlackWhitelistEvent(errEvent string, api v1.CoreV1Interface) error { - - event := &corev1.Event{ - ObjectMeta: metav1.ObjectMeta{ - Name: "Black&Whitelistfailed", - }, - Reason: "Black&Whitelistfailed", - Message: errEvent, - Source: corev1.EventSource{}, - } - - if _, err := api.Events(utils.Config.BlackWhitelistNamespace).Create(context.TODO(), event, metav1.CreateOptions{}); err != nil { - utils.Log.Error().Err(err) - return err - } - - return nil -} - -func MakeBlackWhitelist(blackWhiteCMData map[string]string) types.BlackWhitelist { - - blackWhiteList := types.BlackWhitelist{ - Blacklist: strings.Split(blackWhiteCMData["blacklist"], ","), - Whitelist: strings.Split(blackWhiteCMData["whitelist"], ","), - } - - return blackWhiteList - -} diff --git a/internal/services/provisionner.go b/internal/services/provisionner.go index e416051d..45181f6b 100644 --- a/internal/services/provisionner.go +++ b/internal/services/provisionner.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/ca-gip/kubi/internal/ldap" + projectpkg "github.com/ca-gip/kubi/internal/project" "github.com/ca-gip/kubi/internal/utils" v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" @@ -29,40 +29,22 @@ import ( podSecurity "k8s.io/pod-security-admission/api" ) -// Handler to regenerate all resources created by kubi -func RefreshK8SResources() { - err := GenerateResources() - if err != nil { - utils.Log.Error().Msg(err.Error()) - } -} - -// Generate Namespaces and Rolebinding from Ldap groups -func GenerateResources() error { +// HandleProject is the Main loop to create the projects and the associated resources +func HandleProject(clusterProjects []*types.Project) error { kconfig, _ := rest.InClusterConfig() clientSet, _ := kubernetes.NewForConfig(kconfig) api := clientSet.CoreV1() blackWhiteList := types.BlackWhitelist{} - allClusterGroups, err := ldap.GetAllGroups() - if err != nil { - utils.Log.Error().Msg(err.Error()) - return err - } - if len(allClusterGroups) == 0 { - return fmt.Errorf("no ldap groups found") - } - auths := GetAllProjects(allClusterGroups) - blacklistCM, errRB := GetBlackWhitelistCM(api) if errRB != nil { utils.Log.Info().Msg("Can't get Black&Whitelist") - return err + return errRB } else { - blackWhiteList = MakeBlackWhitelist(blacklistCM.Data) + blackWhiteList = projectpkg.MakeBlackWhitelist(blacklistCM.Data) } - createdproject, deletedprojects, ignoredProjects := GenerateProjects(auths, &blackWhiteList) + createdproject, deletedprojects, ignoredProjects := projectpkg.FilterProjects(utils.Config.Whitelist, clusterProjects, &blackWhiteList) for _, project := range ignoredProjects { utils.Log.Error().Msgf("Cannot find project %s in whitelist", project.Namespace()) } @@ -78,35 +60,6 @@ func GenerateResources() error { return nil } -// A loop wrapper for generateProject -// splitted for unit test ! -func GenerateProjects(context []*types.Project, blackWhiteList *types.BlackWhitelist) ([]*types.Project, []*types.Project, []*types.Project) { - - var createdProjects, deletedProjects, ignoredProjects []*types.Project - for _, auth := range context { - isBlacklisted := slices.Contains(blackWhiteList.Blacklist, auth.Namespace()) - isWhitelisted := slices.Contains(blackWhiteList.Whitelist, auth.Namespace()) - - switch { - //we treat blacklisted projects as a priority, project will be deleted - case blackWhiteList.Blacklist[0] != "" && isBlacklisted: - deletedProjects = append(deletedProjects, auth) - continue - // If whitelist is enabled, do not create project unless it's explictly mentioned - case utils.Config.Whitelist && isWhitelisted: - createdProjects = append(createdProjects, auth) - //project will be ignored if whitelist is enabled and project not present on whitelisted projects - case utils.Config.Whitelist && !isWhitelisted: - ignoredProjects = append(ignoredProjects, auth) - //project will be created if whitelist is disabled and no projects in blacklist - default: - createdProjects = append(createdProjects, auth) - } - } - - return createdProjects, deletedProjects, ignoredProjects -} - func appendIfMissing(slice []string, i string) []string { for _, ele := range slice { if ele == i { @@ -149,15 +102,15 @@ func generateProject(projectInfos *types.Project) { project.Spec.Environment = projectInfos.Environment switch projectInfos.Environment { - case utils.KubiEnvironmentDevelopment: + case projectpkg.KubiEnvironmentDevelopment: project.Spec.Stages = []string{utils.KubiStageScratch, utils.KubiStageStaging, utils.KubiStageStable} - case utils.KubiEnvironmentIntegration: + case projectpkg.KubiEnvironmentIntegration: project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} - case utils.KubiEnvironmentUAT: + case projectpkg.KubiEnvironmentUAT: project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} - case utils.KubiEnvironmentPreproduction: + case projectpkg.KubiEnvironmentPreproduction: project.Spec.Stages = []string{utils.KubiStageStable} - case utils.KubiEnvironmentProduction: + case projectpkg.KubiEnvironmentProduction: project.Spec.Stages = []string{utils.KubiStageStable} default: utils.Log.Warn().Msgf("Provisionner: Can't map stage and environment for project %v.", projectInfos.Namespace()) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index a0d515d8..f846a757 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/middlewares" + "github.com/ca-gip/kubi/internal/project" "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" "github.com/dgrijalva/jwt-go" @@ -147,58 +147,38 @@ func (issuer *TokenIssuer) signJWTClaims(claims types.AuthJWTClaims) (*string, e func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*string, error) { - memberships := &ldap.UserMemberships{} - if err := memberships.FromUserDN(user.UserDN); err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, err - } - groups := memberships.ListGroups() - utils.Log.Info().Msgf("The user %s is part of the groups %v", user.Username, groups) - - // to keep for historical reasons: We continue to issue tokens with old data until - // ArgoCD + promote + other? is updated to use the new groups. - isAdmin := len(memberships.AdminAccess) > 0 - isAppOps := (len(memberships.AppOpsAccess) > 0) || (len(memberships.CustomerOpsAccess) > 0) - isViewer := len(memberships.ViewerAccess) > 0 - isService := len(memberships.ServiceAccess) > 0 - isCloudOps := len(memberships.CloudOpsAccess) > 0 - var claims types.AuthJWTClaims var err error var token *string = nil if len(scopes) > 0 { - if !(isAdmin || isAppOps || isCloudOps) { - utils.TokenCounter.WithLabelValues("token_error").Inc() - return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, isAdmin, isAppOps, isCloudOps) + if !(user.IsAdmin || user.IsAppOps || user.IsCloudOps) { + return nil, fmt.Errorf("the user %s cannot generate extra token with no transversal access (admin: %v, application: %v, ops: %v)", user.Username, user.IsAdmin, user.IsAppOps, user.IsCloudOps) } claims, err = issuer.generateServiceJWTClaims(user.Username, user.Email, scopes) if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } } else { // Do not pass the full group list, as they wont parse as Projects. - projectAccesses := GetAllProjects(memberships.ListClusterGroups()) + // When the Project Access will be removed, the createAccessToken will become a simple wrapper around generateUserJWTClaims and their signature. + // We can then use Factory or Strategy pattern to clean up the code further. + projects := project.GetProjectsFromGrouplist(user.ProjectAccesses) - claims, err = issuer.generateUserJWTClaims(projectAccesses, groups, user.Username, user.Email, isAdmin, isAppOps, isCloudOps, isViewer, isService) + claims, err = issuer.generateUserJWTClaims(projects, user.Groups, user.Username, user.Email, user.IsAdmin, user.IsAppOps, user.IsCloudOps, user.IsViewer, user.IsService) if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to generate the token %v", err) } } token, err = issuer.signJWTClaims(claims) if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("unable to sign the token %v", err) } if token == nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() return nil, fmt.Errorf("the token is nil") } - // TODO: Expose a metric or a log about the type of token generated (its scope) utils.TokenCounter.WithLabelValues("token_success").Inc() return token, nil } @@ -217,6 +197,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { token, err := issuer.createAccessToken(user, scopes) if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() utils.Log.Error().Msgf("Granting token fail for user %v, %v ", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return @@ -244,6 +225,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request token, err := issuer.createAccessToken(user, "") // no need to generate config if the user cannot access it. if err != nil { + utils.TokenCounter.WithLabelValues("token_error").Inc() utils.Log.Error().Msgf("Granting token fail for user %v, %v", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return diff --git a/internal/utils/constants.go b/internal/utils/constants.go index 05293c2e..53e4bb6e 100644 --- a/internal/utils/constants.go +++ b/internal/utils/constants.go @@ -2,18 +2,12 @@ package utils const ( // TODO Add Environment variable - ECDSAPublicPath = "/var/run/secrets/ecdsa/ecdsa-public.pem" - ECDSAKeyPath = "/var/run/secrets/ecdsa/ecdsa-key.pem" - TlsCertPath = "/var/run/secrets/certs/tls.crt" - TlsKeyPath = "/var/run/secrets/certs/tls.key" - TlsCaFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - TokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" - Dns1123LabelFmt string = "^[a-z0-9][-a-z0-9]*$" - DNS1123LabelMaxLength int = 63 -) - -const ( - KubiResourcePrefix = "kubi" + ECDSAPublicPath = "/var/run/secrets/ecdsa/ecdsa-public.pem" + ECDSAKeyPath = "/var/run/secrets/ecdsa/ecdsa-key.pem" + TlsCertPath = "/var/run/secrets/certs/tls.crt" + TlsKeyPath = "/var/run/secrets/certs/tls.key" + TlsCaFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + TokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" KubiClusterRoleBindingReaderName = "kubi-reader" KubiDefaultNetworkPolicyName = "kubi-default" @@ -38,50 +32,5 @@ const ( KubiLocatorIntranet = "intranet" KubiLocatorExtranet = "extranet" - KubiEnvironmentProduction = "production" - KubiEnvironmentShortProduction = "prd" - KubiEnvironmentIntegration = "integration" - KubiEnvironmentShortInt = "int" - KubiEnvironmentUAT = "uat" - KubiEnvironmentPreproduction = "preproduction" - KubiEnvironmentShortPreproduction = "pprd" - KubiEnvironmentDevelopment = "development" - KubiEnvironmentShortDevelopment = "dev" - KubiTenantUndeterminable = "undeterminable" ) - -var BlacklistedNamespaces = []string{ - "kube-system", - "kube-public", - "ingress-nginx", - "admin", - "default", - KubiResourcePrefix, -} - -var WhitelistedRoles = []string{ - "admin", - "service", - "user", -} - -var AllEnvironments = []string{ - KubiEnvironmentProduction, - KubiEnvironmentShortProduction, - KubiEnvironmentIntegration, - KubiEnvironmentShortInt, - KubiEnvironmentUAT, - KubiEnvironmentPreproduction, - KubiEnvironmentShortPreproduction, - KubiEnvironmentDevelopment, - KubiEnvironmentShortDevelopment, -} - -var LdapMapping = map[string]string{ - KubiEnvironmentShortDevelopment: KubiEnvironmentDevelopment, - KubiEnvironmentShortInt: KubiEnvironmentIntegration, - KubiEnvironmentUAT: KubiEnvironmentUAT, - KubiEnvironmentShortPreproduction: KubiEnvironmentPreproduction, - KubiEnvironmentShortProduction: KubiEnvironmentProduction, -} diff --git a/pkg/types/types.go b/pkg/types/types.go index 76df8540..57920232 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -145,7 +145,14 @@ type BlackWhitelist struct { } type User struct { - Username string - UserDN string - Email string + Username string + UserDN string + Email string + Groups []string + IsAdmin bool + IsAppOps bool + IsCloudOps bool + IsViewer bool + IsService bool + ProjectAccesses []string // Purposedly a string instead of a []*Project: This will allow the testing based on the project names instead of projects, but also makes it a very clean separation between the ldap package, and its implementation. This will allow further cleanup should another method be used later. } From df47d61be1ae6df9bf7b07b7cf52e76df8facbe5 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Wed, 8 Jan 2025 18:32:44 +0100 Subject: [PATCH 20/26] Split operator for readability The code of the operator was in a massive single file. To improve readability, this was split into multiple files. It also took the liberty to merge the anonymous function with the handler doing approximatively the same work, as none of those were tested. --- cmd/operator/main.go | 19 +- ..._from_ldap.go => projectname-from-ldap.go} | 0 ...{user_validation.go => user-validation.go} | 0 internal/services/namespaces.go | 122 +++ internal/services/netpolconfig-operator.go | 115 +++ internal/services/netpols.go | 153 ++++ internal/services/project-operator.go | 99 +++ internal/services/project-provisionner.go | 170 ++++ internal/services/provisionner.go | 822 ------------------ internal/services/rolebindings.go | 202 +++++ internal/services/serviceaccounts.go | 45 + 11 files changed, 910 insertions(+), 837 deletions(-) rename internal/ldap/{projectname_from_ldap.go => projectname-from-ldap.go} (100%) rename internal/ldap/{user_validation.go => user-validation.go} (100%) create mode 100644 internal/services/namespaces.go create mode 100644 internal/services/netpolconfig-operator.go create mode 100644 internal/services/netpols.go create mode 100644 internal/services/project-operator.go create mode 100644 internal/services/project-provisionner.go delete mode 100644 internal/services/provisionner.go create mode 100644 internal/services/rolebindings.go create mode 100644 internal/services/serviceaccounts.go diff --git a/cmd/operator/main.go b/cmd/operator/main.go index ddb19b8a..883e18b1 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" "net/http" - "time" "github.com/ca-gip/kubi/internal/ldap" "github.com/ca-gip/kubi/internal/middlewares" @@ -23,6 +22,8 @@ func main() { ldapClient := ldap.NewLDAPClient(config.Ldap) + go services.RefreshProjectsFromLdap(ldapClient, config.Whitelist) + utils.Config = config // Generate namespace and role binding for ldap groups @@ -37,25 +38,13 @@ func main() { }) router.Handle("/metrics", promhttp.Handler()) + services.WatchProjects() + if config.NetworkPolicy { services.WatchNetPolConfig() } else { utils.Log.Info().Msg("NetworkPolicies generation is disabled.") } - services.WatchProjects() - - timerKubiRefresh := time.NewTicker(10 * time.Minute) - go func() { - for t := range timerKubiRefresh.C { - - utils.Log.Info().Msgf("Create or Update Projects at %s", t.String()) - projects, err := ldapClient.ListProjects() - if err != nil { - utils.Log.Error().Msgf("cannot get project list from ldap: %v", err) - } - services.HandleProject(projects) - } - }() utils.Log.Info().Msgf(" Preparing to serve request, port: %d", 8002) utils.Log.Info().Msg(http.ListenAndServeTLS(":8002", utils.TlsCertPath, utils.TlsKeyPath, router).Error()) diff --git a/internal/ldap/projectname_from_ldap.go b/internal/ldap/projectname-from-ldap.go similarity index 100% rename from internal/ldap/projectname_from_ldap.go rename to internal/ldap/projectname-from-ldap.go diff --git a/internal/ldap/user_validation.go b/internal/ldap/user-validation.go similarity index 100% rename from internal/ldap/user_validation.go rename to internal/ldap/user-validation.go diff --git a/internal/services/namespaces.go b/internal/services/namespaces.go new file mode 100644 index 00000000..765645cb --- /dev/null +++ b/internal/services/namespaces.go @@ -0,0 +1,122 @@ +package services + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + + "github.com/ca-gip/kubi/internal/utils" + v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + corev1 "k8s.io/api/core/v1" + kerror "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubernetes "k8s.io/client-go/kubernetes" + v13 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + podSecurity "k8s.io/pod-security-admission/api" +) + +// generateNamespace from a name +// If it doesn't exist or the number of labels is different from what it should be +func generateNamespace(project *v12.Project) (err error) { + if project == nil { + return errors.New("project reference is empty") + } + + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.CoreV1() + + ns, errNs := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) + + if kerror.IsNotFound(errNs) { + err = createNamespace(project, api) + } else if errNs == nil && !reflect.DeepEqual(ns.Labels, generateNamespaceLabels(project)) { + err = updateExistingNamespace(project, api) + } else { + utils.NamespaceCreation.WithLabelValues("ok", project.Name).Inc() + } + return +} + +func createNamespace(project *v12.Project, api v13.CoreV1Interface) error { + utils.Log.Info().Msgf("Creating ns %v", project.Name) + ns := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: project.Name, + Labels: generateNamespaceLabels(project), + }, + } + _, err := api.Namespaces().Create(context.TODO(), ns, metav1.CreateOptions{}) + if err != nil { + utils.Log.Error().Err(err) + utils.NamespaceCreation.WithLabelValues("error", project.Name).Inc() + } else { + utils.NamespaceCreation.WithLabelValues("created", project.Name).Inc() + } + return err +} + +func updateExistingNamespace(project *v12.Project, api v13.CoreV1Interface) error { + utils.Log.Info().Msgf("Updating ns %v", project.Name) + + ns, errns := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) + if errns != nil { + msgError := fmt.Errorf("could not get namespace in updating ns in updateExistingNamespace() %v", errns) + utils.Log.Error().Err(msgError) + return msgError + } + + ns.Name = project.Name + ns.ObjectMeta.Labels = generateNamespaceLabels(project) + + _, err := api.Namespaces().Update(context.TODO(), ns, metav1.UpdateOptions{}) + if err != nil { + msgError := fmt.Errorf("could not update ns in updateExistingNamespace() %v", errns) + utils.Log.Error().Err(msgError) + utils.NamespaceCreation.WithLabelValues("error", project.Name).Inc() + return msgError + } + + utils.NamespaceCreation.WithLabelValues("updated", project.Name).Inc() + + return nil +} + +// Join two maps by value copy non-recursively +func union(a map[string]string, b map[string]string) map[string]string { + for k, v := range b { + a[k] = v + } + return a +} + +// Generate CustomLabels that should be applied on Kubi's Namespaces +func generateNamespaceLabels(project *v12.Project) (labels map[string]string) { + + defaultLabels := map[string]string{ + "name": project.Name, + "type": "customer", + "creator": "kubi", + "environment": project.Spec.Environment, + "pod-security.kubernetes.io/enforce": GetPodSecurityStandardName(project.Name), + "pod-security.kubernetes.io/warn": string(utils.Config.PodSecurityAdmissionWarning), + "pod-security.kubernetes.io/audit": string(utils.Config.PodSecurityAdmissionAudit), + } + // Todo: Decide whether this is still worth a separate function for testability. + return union(defaultLabels, utils.Config.CustomLabels) +} + +func GetPodSecurityStandardName(namespace string) string { + if slices.Contains(utils.Config.PrivilegedNamespaces, namespace) { + utils.Log.Warn().Msgf("Namespace %v is labeled as privileged", namespace) + return string(podSecurity.LevelPrivileged) + } + return string(utils.Config.PodSecurityAdmissionEnforcement) +} diff --git a/internal/services/netpolconfig-operator.go b/internal/services/netpolconfig-operator.go new file mode 100644 index 00000000..243b2b8d --- /dev/null +++ b/internal/services/netpolconfig-operator.go @@ -0,0 +1,115 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/ca-gip/kubi/internal/utils" + v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// Watch NetworkPolicyConfig, which is a config object for namespace network bubble +// This CRD allow user to deploy global configuration for network configuration +// for update, the default network config is updated +// for deletion, it is automatically recreated +// for create, just create it +func WatchNetPolConfig() cache.Store { + kconfig, err := rest.InClusterConfig() + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. + return nil + } + + v3, err := versioned.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) + return nil + } + + watchlist := cache.NewFilteredListWatchFromClient(v3.CagipV1().RESTClient(), "networkpolicyconfigs", metav1.NamespaceAll, utils.DefaultWatchOptionModifier) + + resyncPeriod := 30 * time.Minute + + store, controller := cache.NewInformer(watchlist, &v12.NetworkPolicyConfig{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ + AddFunc: networkPolicyConfigCreated, + DeleteFunc: networkPolicyConfigDelete, + UpdateFunc: networkPolicyConfigUpdate, + }) + + go controller.Run(wait.NeverStop) + + return store +} + +func networkPolicyConfigUpdate(old interface{}, new interface{}) { + netpolconfig := new.(*v12.NetworkPolicyConfig) + utils.Log.Info().Msgf("Operator: the network config %v has changed, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) + + kconfig, err := rest.InClusterConfig() + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. + return + } + + clientSet, err := versioned.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) + return + } + + projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) + + if err != nil { + utils.Log.Error().Msg(err.Error()) + return + } + + for _, project := range projects.Items { + utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) + if utils.Config.NetworkPolicy { + generateNetworkPolicy(project.Name, netpolconfig) + } + } + +} + +func networkPolicyConfigCreated(obj interface{}) { + netpolconfig := obj.(*v12.NetworkPolicyConfig) + utils.Log.Info().Msgf("Operator: the network config %v has been created, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) + + kconfig, err := rest.InClusterConfig() + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. + return + } + + clientSet, err := versioned.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) + return + } + + projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + return + } + + for _, project := range projects.Items { + utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) + if utils.Config.NetworkPolicy { + generateNetworkPolicy(project.Name, netpolconfig) + } + } +} + +func networkPolicyConfigDelete(obj interface{}) { + netpolconfig := obj.(*v12.NetworkPolicyConfig) + utils.Log.Info().Msgf("Operator: the network config %v has been deleted, please delete networkpolicies for all kubi's namespaces. Be careful !", netpolconfig.Name) +} diff --git a/internal/services/netpols.go b/internal/services/netpols.go new file mode 100644 index 00000000..225c548f --- /dev/null +++ b/internal/services/netpols.go @@ -0,0 +1,153 @@ +package services + +import ( + "context" + "fmt" + + "github.com/ca-gip/kubi/internal/utils" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + kubernetes "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// Generate a NetworkPolicy based on NetworkPolicyConfig +// If exists, the existing netpol is updated else it is created +func generateNetworkPolicy(namespace string, networkPolicyConfig *cagipv1.NetworkPolicyConfig) { + + kconfig, err := rest.InClusterConfig() + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. + return + } + + if networkPolicyConfig == nil { + extendedClientSet, err := versioned.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes extended clientset, %v", err.Error())) + return + } + existingNetworkPolicyConfig, err := extendedClientSet.CagipV1().NetworkPolicyConfigs().Get(context.TODO(), utils.KubiDefaultNetworkPolicyName, metav1.GetOptions{}) + if err != nil { + utils.Log.Info().Msgf("Operator: No default network policy config \"%v\" found, cannot create/update namespace security !, Error: %v", utils.KubiDefaultNetworkPolicyName, err.Error()) + utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + } + networkPolicyConfig = existingNetworkPolicyConfig + } + + clientSet, err := kubernetes.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) + return + } + + api := clientSet.NetworkingV1() + _, errNetpol := api.NetworkPolicies(namespace).Get(context.TODO(), utils.KubiDefaultNetworkPolicyName, metav1.GetOptions{}) + + UDP := corev1.ProtocolUDP + TCP := corev1.ProtocolTCP + + var ingressRules []networkingv1.NetworkPolicyPeer + + // Add default intra namespace communication + ingressRules = append(ingressRules, networkingv1.NetworkPolicyPeer{ + PodSelector: &metav1.LabelSelector{MatchLabels: nil}, + }) + + // Add default whitelisted namespace ingress rules + for _, namespace := range networkPolicyConfig.Spec.Ingress.Namespaces { + ingressRules = append(ingressRules, networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": namespace}}, + PodSelector: &metav1.LabelSelector{MatchLabels: nil}, + }) + } + + var netpolPorts []networkingv1.NetworkPolicyPort + + if len(networkPolicyConfig.Spec.Egress.Ports) > 0 { + for _, port := range networkPolicyConfig.Spec.Egress.Ports { + netpolPorts = append(netpolPorts, networkingv1.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: int32(port)}, Protocol: &UDP}) + netpolPorts = append(netpolPorts, networkingv1.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: int32(port)}, Protocol: &TCP}) + } + } + netpolPorts = append(netpolPorts, networkingv1.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: 53}, Protocol: &UDP}) + + policyPeers := []networkingv1.NetworkPolicyPeer{ + {PodSelector: &metav1.LabelSelector{MatchLabels: nil}}, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"name": "kube-system"}}, + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "component": "kube-apiserver", + "tier": "control-plane", + }, + }, + }, + } + + // Add default whitelisted namespace egress rules + for _, namespace := range networkPolicyConfig.Spec.Egress.Namespaces { + policyPeers = append(policyPeers, networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": namespace}}, + PodSelector: &metav1.LabelSelector{MatchLabels: nil}, + }) + } + + for _, cidr := range networkPolicyConfig.Spec.Egress.Cidrs { + policyPeers = append(policyPeers, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: cidr}}) + } + + networkpolicy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.KubiDefaultNetworkPolicyName, + Namespace: namespace, + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: nil, + }, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: ingressRules, + }, + }, + Egress: []networkingv1.NetworkPolicyEgressRule{ + { + Ports: netpolPorts, + }, + { + To: policyPeers, + }, + }, + PolicyTypes: []networkingv1.PolicyType{ + networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress, + }, + }, + } + if errNetpol != nil { + _, err := api.NetworkPolicies(namespace).Create(context.TODO(), networkpolicy, metav1.CreateOptions{}) + if err != nil { + utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + // Todo: Wrap this error correctly and use slog. + utils.Log.Error().Msg(fmt.Sprintf("error creating netpol %v", err.Error())) + } else { + utils.NetworkPolicyCreation.WithLabelValues("created", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + } + return + } else { + _, err := api.NetworkPolicies(namespace).Update(context.TODO(), networkpolicy, metav1.UpdateOptions{}) + if err != nil { + utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + // Todo: Wrap this error correctly and use slog. + utils.Log.Error().Msg(fmt.Sprintf("error updating netpol %v", err.Error())) + } else { + utils.NetworkPolicyCreation.WithLabelValues("updated", namespace, utils.KubiDefaultNetworkPolicyName).Inc() + } + return + } +} diff --git a/internal/services/project-operator.go b/internal/services/project-operator.go new file mode 100644 index 00000000..f03b9d0a --- /dev/null +++ b/internal/services/project-operator.go @@ -0,0 +1,99 @@ +package services + +import ( + "fmt" + "strings" + "time" + + "github.com/ca-gip/kubi/internal/utils" + v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +// Watch NetworkPolicyConfig, which is a config object for namespace network bubble +// This CRD allow user to deploy global configuration for network configuration +// for update, the default network config is updated +// for deletion, it is automatically recreated +// for create, just create it +func WatchProjects() cache.Store { + kconfig, err := rest.InClusterConfig() + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. + return nil + } + + v3, err := versioned.NewForConfig(kconfig) + if err != nil { + utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) + return nil + } + + watchlist := cache.NewFilteredListWatchFromClient(v3.CagipV1().RESTClient(), "projects", metav1.NamespaceAll, utils.DefaultWatchOptionModifier) + resyncPeriod := 30 * time.Minute + + store, controller := cache.NewInformer(watchlist, &v12.Project{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ + AddFunc: projectCreated, + DeleteFunc: projectDelete, + UpdateFunc: projectUpdate, + }) + + go controller.Run(wait.NeverStop) + + return store +} + +func projectUpdate(old interface{}, new interface{}) { + newProject := new.(*v12.Project) + utils.Log.Info().Msgf("Operator: the project %v has been updated, updating associated resources: namespace, networkpolicies.", newProject.Name) + + err := generateNamespace(newProject) + if err != nil { + utils.Log.Warn().Msgf("Unexpected error %s", err) + return + } + + if utils.Config.NetworkPolicy { + generateNetworkPolicy(newProject.Name, nil) + } + // TODO: Refactor with a non static list of roles + GenerateUserRoleBinding(newProject.Name, "admin") + GenerateAppServiceAccount(newProject.Name) + GenerateAppRoleBinding(newProject.Name) + if !strings.EqualFold(utils.Config.DefaultPermission, "") { + GenerateDefaultRoleBinding(newProject.Name) + } + +} + +func projectCreated(obj interface{}) { + project := obj.(*v12.Project) + utils.Log.Info().Msgf("Operator: the project %v has been created, generating associated resources: namespace, networkpolicies.", project.Name) + + err := generateNamespace(project) + if err != nil { + utils.Log.Warn().Msgf("Unexpected error %s", err) + return + } + + if utils.Config.NetworkPolicy { + generateNetworkPolicy(project.Name, nil) + } + + // TODO: Refactor with a non static list of roles + GenerateUserRoleBinding(project.Name, "admin") + GenerateAppServiceAccount(project.Name) + GenerateAppRoleBinding(project.Name) + if !strings.EqualFold(utils.Config.DefaultPermission, "") { + GenerateDefaultRoleBinding(project.Name) + } + +} + +func projectDelete(obj interface{}) { + project := obj.(*v12.Project) + utils.Log.Info().Msgf("Operator: the project %v has been deleted, Kubi won't delete anything, please delete the namespace %v manualy", project.Name, project.Name) +} diff --git a/internal/services/project-provisionner.go b/internal/services/project-provisionner.go new file mode 100644 index 00000000..6c3a4dea --- /dev/null +++ b/internal/services/project-provisionner.go @@ -0,0 +1,170 @@ +package services + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ca-gip/kubi/internal/ldap" + projectpkg "github.com/ca-gip/kubi/internal/project" + "github.com/ca-gip/kubi/internal/utils" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" + "github.com/ca-gip/kubi/pkg/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubernetes "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func RefreshProjectsFromLdap(ldapClient *ldap.LDAPClient, whitelistEnabled bool) { + timerKubiRefresh := time.NewTicker(10 * time.Minute) + + for t := range timerKubiRefresh.C { + + utils.Log.Info().Msgf("Create or Update Projects from LDAP %s", t.String()) + clusterProjects, err := ldapClient.ListProjects() + if err != nil { + utils.Log.Error().Msgf("cannot get project list from ldap: %v", err) + } + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.CoreV1() + blackWhiteList := types.BlackWhitelist{} + + blacklistCM, errRB := GetBlackWhitelistCM(api) + if errRB != nil { + utils.Log.Info().Msg("Can't get Black&Whitelist") + } else { + blackWhiteList = projectpkg.MakeBlackWhitelist(blacklistCM.Data) + } + + createdproject, deletedprojects, ignoredProjects := projectpkg.FilterProjects(whitelistEnabled, clusterProjects, &blackWhiteList) + for _, project := range ignoredProjects { + utils.Log.Error().Msgf("Cannot find project %s in whitelist", project.Namespace()) + } + for _, project := range deletedprojects { + utils.Log.Info().Msgf("delete project %s in blacklist", project.Namespace()) + deleteProject(project) + } + // now that the project is well categorized we know that a project cannot be at the same time to be deleted and to be generated + for _, project := range createdproject { + utils.Log.Info().Msgf("Project %s is whitelisted", project.Namespace()) + generateProject(project) + } + } +} + +func appendIfMissing(slice []string, i string) []string { + for _, ele := range slice { + if ele == i { + return slice + } + } + return append(slice, i) +} + +// generate a project config or update it if exists +func generateProject(projectInfos *types.Project) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := versioned.NewForConfig(kconfig) + + existingProject, errProject := clientSet.CagipV1().Projects().Get(context.TODO(), projectInfos.Namespace(), metav1.GetOptions{}) + + splits := strings.Split(projectInfos.Namespace(), "-") + if len(splits) < 2 { + utils.Log.Warn().Msgf("Provisionner: The project %v could'nt be split in two part: -.", projectInfos.Namespace()) + } + + project := &cagipv1.Project{ + Spec: cagipv1.ProjectSpec{}, + Status: cagipv1.ProjectSpecStatus{ + Name: cagipv1.ProjectStatusCreated, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: projectInfos.Namespace(), + Labels: map[string]string{ + "creator": "kubi", + }, + }, + } + + if utils.Config.Tenant != utils.KubiTenantUndeterminable { + project.Spec.Tenant = utils.Config.Tenant + } + + project.Spec.Project = projectInfos.Project + project.Spec.Environment = projectInfos.Environment + + switch projectInfos.Environment { + case projectpkg.KubiEnvironmentDevelopment: + project.Spec.Stages = []string{utils.KubiStageScratch, utils.KubiStageStaging, utils.KubiStageStable} + case projectpkg.KubiEnvironmentIntegration: + project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} + case projectpkg.KubiEnvironmentUAT: + project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} + case projectpkg.KubiEnvironmentPreproduction: + project.Spec.Stages = []string{utils.KubiStageStable} + case projectpkg.KubiEnvironmentProduction: + project.Spec.Stages = []string{utils.KubiStageStable} + default: + utils.Log.Warn().Msgf("Provisionner: Can't map stage and environment for project %v.", projectInfos.Namespace()) + } + + project.Spec.SourceEntity = projectInfos.Source + project.Spec.SourceDN = fmt.Sprintf("CN=%s,%s", projectInfos.Source, utils.Config.Ldap.GroupBase) + if utils.Config.Tenant != utils.KubiTenantUndeterminable { + project.Spec.Tenant = utils.Config.Tenant + } + + if errProject != nil { + utils.Log.Info().Msgf("Project: %v doesn't exist, will be created", projectInfos.Namespace()) + _, errorCreate := clientSet.CagipV1().Projects().Create(context.TODO(), project, metav1.CreateOptions{}) + if errorCreate != nil { + utils.Log.Error().Msg(errorCreate.Error()) + utils.ProjectCreation.WithLabelValues("error", projectInfos.Project).Inc() + } else { + utils.ProjectCreation.WithLabelValues("created", projectInfos.Project).Inc() + } + return + } else { + utils.Log.Info().Msgf("Project: %v already exists, will be updated", projectInfos.Namespace()) + existingProject.Spec.Project = project.Spec.Project + if len(project.Spec.Contact) > 0 { + existingProject.Spec.Contact = project.Spec.Contact + } + if len(project.Spec.Environment) > 0 { + existingProject.Spec.Environment = project.Spec.Environment + } + if len(existingProject.Spec.Tenant) == 0 { + existingProject.Spec.Tenant = project.Spec.Tenant + } + for _, stage := range project.Spec.Stages { + existingProject.Spec.Stages = appendIfMissing(existingProject.Spec.Stages, stage) + } + existingProject.Spec.SourceEntity = projectInfos.Source + existingProject.Spec.SourceDN = fmt.Sprintf("CN=%s,%s", projectInfos.Source, utils.Config.Ldap.GroupBase) + _, errUpdate := clientSet.CagipV1().Projects().Update(context.TODO(), existingProject, metav1.UpdateOptions{}) + if errUpdate != nil { + utils.Log.Error().Msg(errUpdate.Error()) + utils.ProjectCreation.WithLabelValues("error", projectInfos.Project).Inc() + } else { + utils.ProjectCreation.WithLabelValues("updated", projectInfos.Project).Inc() + } + } +} + +// delete a project ( for blacklist purpose ) +func deleteProject(projectInfos *types.Project) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := versioned.NewForConfig(kconfig) + + errDeletionProject := clientSet.CagipV1().Projects().Delete(context.TODO(), projectInfos.Namespace(), metav1.DeleteOptions{}) + + if errDeletionProject != nil { + utils.Log.Info().Msgf("Cannot delete project: %v", projectInfos.Namespace()) + return + } + + utils.Log.Info().Msgf("Project: %v deleted", projectInfos.Namespace()) +} diff --git a/internal/services/provisionner.go b/internal/services/provisionner.go deleted file mode 100644 index 45181f6b..00000000 --- a/internal/services/provisionner.go +++ /dev/null @@ -1,822 +0,0 @@ -package services - -import ( - "context" - "errors" - "fmt" - "reflect" - "slices" - "strings" - "time" - - projectpkg "github.com/ca-gip/kubi/internal/project" - "github.com/ca-gip/kubi/internal/utils" - v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" - "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" - "github.com/ca-gip/kubi/pkg/types" - corev1 "k8s.io/api/core/v1" - v1n "k8s.io/api/networking/v1" - v1 "k8s.io/api/rbac/v1" - kerror "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/apimachinery/pkg/util/wait" - kubernetes "k8s.io/client-go/kubernetes" - v13 "k8s.io/client-go/kubernetes/typed/core/v1" - v14 "k8s.io/client-go/kubernetes/typed/rbac/v1" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/cache" - podSecurity "k8s.io/pod-security-admission/api" -) - -// HandleProject is the Main loop to create the projects and the associated resources -func HandleProject(clusterProjects []*types.Project) error { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.CoreV1() - blackWhiteList := types.BlackWhitelist{} - - blacklistCM, errRB := GetBlackWhitelistCM(api) - if errRB != nil { - utils.Log.Info().Msg("Can't get Black&Whitelist") - return errRB - } else { - blackWhiteList = projectpkg.MakeBlackWhitelist(blacklistCM.Data) - } - - createdproject, deletedprojects, ignoredProjects := projectpkg.FilterProjects(utils.Config.Whitelist, clusterProjects, &blackWhiteList) - for _, project := range ignoredProjects { - utils.Log.Error().Msgf("Cannot find project %s in whitelist", project.Namespace()) - } - for _, project := range deletedprojects { - utils.Log.Info().Msgf("delete project %s in blacklist", project.Namespace()) - deleteProject(project) - } - // now that the project is well categorized we know that a project cannot be at the same time to be deleted and to be generated - for _, project := range createdproject { - utils.Log.Info().Msgf("Project %s is whitelisted", project.Namespace()) - generateProject(project) - } - return nil -} - -func appendIfMissing(slice []string, i string) []string { - for _, ele := range slice { - if ele == i { - return slice - } - } - return append(slice, i) -} - -// generate a project config or update it if exists -func generateProject(projectInfos *types.Project) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := versioned.NewForConfig(kconfig) - - existingProject, errProject := clientSet.CagipV1().Projects().Get(context.TODO(), projectInfos.Namespace(), metav1.GetOptions{}) - - splits := strings.Split(projectInfos.Namespace(), "-") - if len(splits) < 2 { - utils.Log.Warn().Msgf("Provisionner: The project %v could'nt be split in two part: -.", projectInfos.Namespace()) - } - - project := &v12.Project{ - Spec: v12.ProjectSpec{}, - Status: v12.ProjectSpecStatus{ - Name: v12.ProjectStatusCreated, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: projectInfos.Namespace(), - Labels: map[string]string{ - "creator": "kubi", - }, - }, - } - - if utils.Config.Tenant != utils.KubiTenantUndeterminable { - project.Spec.Tenant = utils.Config.Tenant - } - - project.Spec.Project = projectInfos.Project - project.Spec.Environment = projectInfos.Environment - - switch projectInfos.Environment { - case projectpkg.KubiEnvironmentDevelopment: - project.Spec.Stages = []string{utils.KubiStageScratch, utils.KubiStageStaging, utils.KubiStageStable} - case projectpkg.KubiEnvironmentIntegration: - project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} - case projectpkg.KubiEnvironmentUAT: - project.Spec.Stages = []string{utils.KubiStageStaging, utils.KubiStageStable} - case projectpkg.KubiEnvironmentPreproduction: - project.Spec.Stages = []string{utils.KubiStageStable} - case projectpkg.KubiEnvironmentProduction: - project.Spec.Stages = []string{utils.KubiStageStable} - default: - utils.Log.Warn().Msgf("Provisionner: Can't map stage and environment for project %v.", projectInfos.Namespace()) - } - - project.Spec.SourceEntity = projectInfos.Source - project.Spec.SourceDN = fmt.Sprintf("CN=%s,%s", projectInfos.Source, utils.Config.Ldap.GroupBase) - if utils.Config.Tenant != utils.KubiTenantUndeterminable { - project.Spec.Tenant = utils.Config.Tenant - } - - if errProject != nil { - utils.Log.Info().Msgf("Project: %v doesn't exist, will be created", projectInfos.Namespace()) - _, errorCreate := clientSet.CagipV1().Projects().Create(context.TODO(), project, metav1.CreateOptions{}) - if errorCreate != nil { - utils.Log.Error().Msg(errorCreate.Error()) - utils.ProjectCreation.WithLabelValues("error", projectInfos.Project).Inc() - } else { - utils.ProjectCreation.WithLabelValues("created", projectInfos.Project).Inc() - } - return - } else { - utils.Log.Info().Msgf("Project: %v already exists, will be updated", projectInfos.Namespace()) - existingProject.Spec.Project = project.Spec.Project - if len(project.Spec.Contact) > 0 { - existingProject.Spec.Contact = project.Spec.Contact - } - if len(project.Spec.Environment) > 0 { - existingProject.Spec.Environment = project.Spec.Environment - } - if len(existingProject.Spec.Tenant) == 0 { - existingProject.Spec.Tenant = project.Spec.Tenant - } - for _, stage := range project.Spec.Stages { - existingProject.Spec.Stages = appendIfMissing(existingProject.Spec.Stages, stage) - } - existingProject.Spec.SourceEntity = projectInfos.Source - existingProject.Spec.SourceDN = fmt.Sprintf("CN=%s,%s", projectInfos.Source, utils.Config.Ldap.GroupBase) - _, errUpdate := clientSet.CagipV1().Projects().Update(context.TODO(), existingProject, metav1.UpdateOptions{}) - if errUpdate != nil { - utils.Log.Error().Msg(errUpdate.Error()) - utils.ProjectCreation.WithLabelValues("error", projectInfos.Project).Inc() - } else { - utils.ProjectCreation.WithLabelValues("updated", projectInfos.Project).Inc() - } - } -} - -// delete a project ( for blacklist purpose ) -func deleteProject(projectInfos *types.Project) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := versioned.NewForConfig(kconfig) - - errDeletionProject := clientSet.CagipV1().Projects().Delete(context.TODO(), projectInfos.Namespace(), metav1.DeleteOptions{}) - - if errDeletionProject != nil { - utils.Log.Info().Msgf("Cannot delete project: %v", projectInfos.Namespace()) - return - } - - utils.Log.Info().Msgf("Project: %v deleted", projectInfos.Namespace()) -} - -// GenerateRolebinding from tupple -// If exists, nothing is done, only creating ! -func GenerateUserRoleBinding(namespace string, role string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.RbacV1() - - roleBinding(fmt.Sprintf("%s-%s", "namespaced", role), api, namespace, subjectAdmin(namespace, role)) - roleBinding("view", api, namespace, subjectView()) -} - -func subjectView() []v1.Subject { - subjectView := []v1.Subject{ - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.ApplicationViewer, - }, - } - return subjectView -} - -func subjectAdmin(namespace string, role string) []v1.Subject { - subjectAdmin := []v1.Subject{ - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: fmt.Sprintf("%s-%s", namespace, role), - }, - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.ApplicationMaster, - }, - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.OPSMaster, - }, - } - return subjectAdmin -} - -func roleBinding(roleBindingName string, api v14.RbacV1Interface, namespace string, subjectAdmin []v1.Subject) { - - newRoleBinding := v1.RoleBinding{ - RoleRef: v1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: roleBindingName, - }, - Subjects: subjectAdmin, - ObjectMeta: metav1.ObjectMeta{ - Name: roleBindingName, - Namespace: namespace, - Labels: map[string]string{ - "name": roleBindingName, - "creator": "kubi", - "version": "v3", - }, - }, - } - - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), roleBindingName, metav1.GetOptions{}) - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) - if err != nil { - utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() - utils.Log.Error().Msg(err.Error()) - } - utils.ServiceAccountCreation.WithLabelValues("created", namespace, roleBindingName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) - return - } - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() - return - } - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, roleBindingName).Inc() - utils.Log.Info().Msgf("rolebinding %v has been updated for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) -} - -func GenerateAppRoleBinding(namespace string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.RbacV1() - - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingAppName, metav1.GetOptions{}) - - newRoleBinding := v1.RoleBinding{ - RoleRef: v1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: utils.KubiClusterRoleAppName, - }, - Subjects: []v1.Subject{ - { - Kind: "ServiceAccount", - Name: utils.KubiServiceAccountAppName, - Namespace: namespace, - }, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiRoleBindingAppName, - Namespace: namespace, - Labels: map[string]string{ - "name": utils.KubiRoleBindingAppName, - "creator": "kubi", - "version": "v3", - }, - }, - } - - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) - return - } - - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) -} - -func GenerateDefaultRoleBinding(namespace string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.RbacV1() - - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingDefaultName, metav1.GetOptions{}) - - newRoleBinding := v1.RoleBinding{ - RoleRef: v1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: utils.Config.DefaultPermission, - }, - Subjects: []v1.Subject{ - { - Kind: "ServiceAccount", - Name: utils.KubiServiceAccountDefaultName, - Namespace: namespace, - }, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiRoleBindingDefaultName, - Namespace: namespace, - Labels: map[string]string{ - "name": utils.KubiRoleBindingDefaultName, - "creator": "kubi", - "version": "v3", - }, - }, - } - - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) - utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() -} - -// Generate -func GenerateAppServiceAccount(namespace string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.CoreV1() - - _, errRB := api.ServiceAccounts(namespace).Get(context.TODO(), utils.KubiServiceAccountAppName, metav1.GetOptions{}) - - newServiceAccount := corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiServiceAccountAppName, - Namespace: namespace, - Labels: map[string]string{ - "name": utils.KubiServiceAccountAppName, - "creator": "kubi", - "version": "v3", - }, - }, - } - - if errRB != nil { - _, err := api.ServiceAccounts(namespace).Create(context.TODO(), &newServiceAccount, metav1.CreateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.ServiceAccountCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.Log.Info().Msgf("Service Account %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) - utils.ServiceAccountCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.ServiceAccountCreation.WithLabelValues("ok", namespace, utils.KubiServiceAccountAppName).Inc() -} - -// generateNamespace from a name -// If it doesn't exist or the number of labels is different from what it should be -func generateNamespace(project *v12.Project) (err error) { - if project == nil { - return errors.New("project reference is empty") - } - - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.CoreV1() - - ns, errNs := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) - - if kerror.IsNotFound(errNs) { - err = createNamespace(project, api) - } else if errNs == nil && !reflect.DeepEqual(ns.Labels, generateNamespaceLabels(project)) { - err = updateExistingNamespace(project, api) - } else { - utils.NamespaceCreation.WithLabelValues("ok", project.Name).Inc() - } - return -} - -func createNamespace(project *v12.Project, api v13.CoreV1Interface) error { - utils.Log.Info().Msgf("Creating ns %v", project.Name) - ns := &corev1.Namespace{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Namespace", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: project.Name, - Labels: generateNamespaceLabels(project), - }, - } - _, err := api.Namespaces().Create(context.TODO(), ns, metav1.CreateOptions{}) - if err != nil { - utils.Log.Error().Err(err) - utils.NamespaceCreation.WithLabelValues("error", project.Name).Inc() - } else { - utils.NamespaceCreation.WithLabelValues("created", project.Name).Inc() - } - return err -} - -func updateExistingNamespace(project *v12.Project, api v13.CoreV1Interface) error { - utils.Log.Info().Msgf("Updating ns %v", project.Name) - - ns, errns := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) - if errns != nil { - msgError := fmt.Errorf("could not get namespace in updating ns in updateExistingNamespace() %v", errns) - utils.Log.Error().Err(msgError) - return msgError - } - - ns.Name = project.Name - ns.ObjectMeta.Labels = generateNamespaceLabels(project) - - _, err := api.Namespaces().Update(context.TODO(), ns, metav1.UpdateOptions{}) - if err != nil { - msgError := fmt.Errorf("could not update ns in updateExistingNamespace() %v", errns) - utils.Log.Error().Err(msgError) - utils.NamespaceCreation.WithLabelValues("error", project.Name).Inc() - return msgError - } - - utils.NamespaceCreation.WithLabelValues("updated", project.Name).Inc() - - return nil -} - -// Join two maps by value copy non-recursively -func union(a map[string]string, b map[string]string) map[string]string { - for k, v := range b { - a[k] = v - } - return a -} - -// Generate CustomLabels that should be applied on Kubi's Namespaces -func generateNamespaceLabels(project *v12.Project) (labels map[string]string) { - - defaultLabels := map[string]string{ - "name": project.Name, - "type": "customer", - "creator": "kubi", - "environment": project.Spec.Environment, - "pod-security.kubernetes.io/enforce": GetPodSecurityStandardName(project.Name), - "pod-security.kubernetes.io/warn": string(utils.Config.PodSecurityAdmissionWarning), - "pod-security.kubernetes.io/audit": string(utils.Config.PodSecurityAdmissionAudit), - } - // Todo: Decide whether this is still worth a separate function for testability. - return union(defaultLabels, utils.Config.CustomLabels) -} - -func GetPodSecurityStandardName(namespace string) string { - if slices.Contains(utils.Config.PrivilegedNamespaces, namespace) { - utils.Log.Warn().Msgf("Namespace %v is labeled as privileged", namespace) - return string(podSecurity.LevelPrivileged) - } - return string(utils.Config.PodSecurityAdmissionEnforcement) -} - -// Watch NetworkPolicyConfig, which is a config object for namespace network bubble -// This CRD allow user to deploy global configuration for network configuration -// for update, the default network config is updated -// for deletion, it is automatically recreated -// for create, just create it -func WatchProjects() cache.Store { - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return nil - } - - v3, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return nil - } - - watchlist := cache.NewFilteredListWatchFromClient(v3.CagipV1().RESTClient(), "projects", metav1.NamespaceAll, utils.DefaultWatchOptionModifier) - resyncPeriod := 30 * time.Minute - - store, controller := cache.NewInformer(watchlist, &v12.Project{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ - AddFunc: projectCreated, - DeleteFunc: projectDelete, - UpdateFunc: projectUpdate, - }) - - go controller.Run(wait.NeverStop) - - return store -} - -func projectUpdate(old interface{}, new interface{}) { - newProject := new.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been updated, updating associated resources: namespace, networkpolicies.", newProject.Name) - - err := generateNamespace(newProject) - if err != nil { - utils.Log.Warn().Msgf("Unexpected error %s", err) - return - } - - if utils.Config.NetworkPolicy { - generateNetworkPolicy(newProject.Name, nil) - } - // TODO: Refactor with a non static list of roles - GenerateUserRoleBinding(newProject.Name, "admin") - GenerateAppServiceAccount(newProject.Name) - GenerateAppRoleBinding(newProject.Name) - if !strings.EqualFold(utils.Config.DefaultPermission, "") { - GenerateDefaultRoleBinding(newProject.Name) - } - -} - -func projectCreated(obj interface{}) { - project := obj.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been created, generating associated resources: namespace, networkpolicies.", project.Name) - - err := generateNamespace(project) - if err != nil { - utils.Log.Warn().Msgf("Unexpected error %s", err) - return - } - - if utils.Config.NetworkPolicy { - generateNetworkPolicy(project.Name, nil) - } - - // TODO: Refactor with a non static list of roles - GenerateUserRoleBinding(project.Name, "admin") - GenerateAppServiceAccount(project.Name) - GenerateAppRoleBinding(project.Name) - if !strings.EqualFold(utils.Config.DefaultPermission, "") { - GenerateDefaultRoleBinding(project.Name) - } - -} - -func projectDelete(obj interface{}) { - project := obj.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been deleted, Kubi won't delete anything, please delete the namespace %v manualy", project.Name, project.Name) -} - -// Watch NetworkPolicyConfig, which is a config object for namespace network bubble -// This CRD allow user to deploy global configuration for network configuration -// for update, the default network config is updated -// for deletion, it is automatically recreated -// for create, just create it -func WatchNetPolConfig() cache.Store { - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return nil - } - - v3, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return nil - } - - watchlist := cache.NewFilteredListWatchFromClient(v3.CagipV1().RESTClient(), "networkpolicyconfigs", metav1.NamespaceAll, utils.DefaultWatchOptionModifier) - - resyncPeriod := 30 * time.Minute - - store, controller := cache.NewInformer(watchlist, &v12.NetworkPolicyConfig{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ - AddFunc: networkPolicyConfigCreated, - DeleteFunc: networkPolicyConfigDelete, - UpdateFunc: networkPolicyConfigUpdate, - }) - - go controller.Run(wait.NeverStop) - - return store -} - -func networkPolicyConfigUpdate(old interface{}, new interface{}) { - netpolconfig := new.(*v12.NetworkPolicyConfig) - utils.Log.Info().Msgf("Operator: the network config %v has changed, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) - - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return - } - - clientSet, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return - } - - projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) - - if err != nil { - utils.Log.Error().Msg(err.Error()) - return - } - - for _, project := range projects.Items { - utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) - if utils.Config.NetworkPolicy { - generateNetworkPolicy(project.Name, netpolconfig) - } - } - -} - -func networkPolicyConfigCreated(obj interface{}) { - netpolconfig := obj.(*v12.NetworkPolicyConfig) - utils.Log.Info().Msgf("Operator: the network config %v has been created, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) - - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return - } - - clientSet, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return - } - - projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - return - } - - for _, project := range projects.Items { - utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) - if utils.Config.NetworkPolicy { - generateNetworkPolicy(project.Name, netpolconfig) - } - } -} - -func networkPolicyConfigDelete(obj interface{}) { - netpolconfig := obj.(*v12.NetworkPolicyConfig) - utils.Log.Info().Msgf("Operator: the network config %v has been deleted, please delete networkpolicies for all kubi's namespaces. Be careful !", netpolconfig.Name) -} - -// Generate a NetworkPolicy based on NetworkPolicyConfig -// If exists, the existing netpol is updated else it is created -func generateNetworkPolicy(namespace string, networkPolicyConfig *v12.NetworkPolicyConfig) { - - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return - } - - if networkPolicyConfig == nil { - extendedClientSet, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes extended clientset, %v", err.Error())) - return - } - existingNetworkPolicyConfig, err := extendedClientSet.CagipV1().NetworkPolicyConfigs().Get(context.TODO(), utils.KubiDefaultNetworkPolicyName, metav1.GetOptions{}) - if err != nil { - utils.Log.Info().Msgf("Operator: No default network policy config \"%v\" found, cannot create/update namespace security !, Error: %v", utils.KubiDefaultNetworkPolicyName, err.Error()) - utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() - } - networkPolicyConfig = existingNetworkPolicyConfig - } - - clientSet, err := kubernetes.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return - } - - api := clientSet.NetworkingV1() - _, errNetpol := api.NetworkPolicies(namespace).Get(context.TODO(), utils.KubiDefaultNetworkPolicyName, metav1.GetOptions{}) - - UDP := corev1.ProtocolUDP - TCP := corev1.ProtocolTCP - - var ingressRules []v1n.NetworkPolicyPeer - - // Add default intra namespace communication - ingressRules = append(ingressRules, v1n.NetworkPolicyPeer{ - PodSelector: &metav1.LabelSelector{MatchLabels: nil}, - }) - - // Add default whitelisted namespace ingress rules - for _, namespace := range networkPolicyConfig.Spec.Ingress.Namespaces { - ingressRules = append(ingressRules, v1n.NetworkPolicyPeer{ - NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": namespace}}, - PodSelector: &metav1.LabelSelector{MatchLabels: nil}, - }) - } - - var netpolPorts []v1n.NetworkPolicyPort - - if len(networkPolicyConfig.Spec.Egress.Ports) > 0 { - for _, port := range networkPolicyConfig.Spec.Egress.Ports { - netpolPorts = append(netpolPorts, v1n.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: int32(port)}, Protocol: &UDP}) - netpolPorts = append(netpolPorts, v1n.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: int32(port)}, Protocol: &TCP}) - } - } - netpolPorts = append(netpolPorts, v1n.NetworkPolicyPort{Port: &intstr.IntOrString{IntVal: 53}, Protocol: &UDP}) - - policyPeers := []v1n.NetworkPolicyPeer{ - {PodSelector: &metav1.LabelSelector{MatchLabels: nil}}, - { - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"name": "kube-system"}}, - PodSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "component": "kube-apiserver", - "tier": "control-plane", - }, - }, - }, - } - - // Add default whitelisted namespace egress rules - for _, namespace := range networkPolicyConfig.Spec.Egress.Namespaces { - policyPeers = append(policyPeers, v1n.NetworkPolicyPeer{ - NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": namespace}}, - PodSelector: &metav1.LabelSelector{MatchLabels: nil}, - }) - } - - for _, cidr := range networkPolicyConfig.Spec.Egress.Cidrs { - policyPeers = append(policyPeers, v1n.NetworkPolicyPeer{IPBlock: &v1n.IPBlock{CIDR: cidr}}) - } - - networkpolicy := &v1n.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiDefaultNetworkPolicyName, - Namespace: namespace, - }, - Spec: v1n.NetworkPolicySpec{ - PodSelector: metav1.LabelSelector{ - MatchLabels: nil, - }, - Ingress: []v1n.NetworkPolicyIngressRule{ - { - From: ingressRules, - }, - }, - Egress: []v1n.NetworkPolicyEgressRule{ - { - Ports: netpolPorts, - }, - { - To: policyPeers, - }, - }, - PolicyTypes: []v1n.PolicyType{ - v1n.PolicyTypeIngress, v1n.PolicyTypeEgress, - }, - }, - } - if errNetpol != nil { - _, err := api.NetworkPolicies(namespace).Create(context.TODO(), networkpolicy, metav1.CreateOptions{}) - if err != nil { - utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() - // Todo: Wrap this error correctly and use slog. - utils.Log.Error().Msg(fmt.Sprintf("error creating netpol %v", err.Error())) - } else { - utils.NetworkPolicyCreation.WithLabelValues("created", namespace, utils.KubiDefaultNetworkPolicyName).Inc() - } - return - } else { - _, err := api.NetworkPolicies(namespace).Update(context.TODO(), networkpolicy, metav1.UpdateOptions{}) - if err != nil { - utils.NetworkPolicyCreation.WithLabelValues("error", namespace, utils.KubiDefaultNetworkPolicyName).Inc() - // Todo: Wrap this error correctly and use slog. - utils.Log.Error().Msg(fmt.Sprintf("error updating netpol %v", err.Error())) - } else { - utils.NetworkPolicyCreation.WithLabelValues("updated", namespace, utils.KubiDefaultNetworkPolicyName).Inc() - } - return - } -} diff --git a/internal/services/rolebindings.go b/internal/services/rolebindings.go new file mode 100644 index 00000000..76dcecb1 --- /dev/null +++ b/internal/services/rolebindings.go @@ -0,0 +1,202 @@ +package services + +import ( + "context" + "fmt" + + "github.com/ca-gip/kubi/internal/utils" + v1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubernetes "k8s.io/client-go/kubernetes" + v14 "k8s.io/client-go/kubernetes/typed/rbac/v1" + "k8s.io/client-go/rest" +) + +// GenerateRolebinding from tupple +// If exists, nothing is done, only creating ! +func GenerateUserRoleBinding(namespace string, role string) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.RbacV1() + + roleBinding(fmt.Sprintf("%s-%s", "namespaced", role), api, namespace, subjectAdmin(namespace, role)) + roleBinding("view", api, namespace, subjectView()) +} + +func subjectView() []v1.Subject { + subjectView := []v1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.ApplicationViewer, + }, + } + return subjectView +} + +func subjectAdmin(namespace string, role string) []v1.Subject { + subjectAdmin := []v1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: fmt.Sprintf("%s-%s", namespace, role), + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.ApplicationMaster, + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.OPSMaster, + }, + } + return subjectAdmin +} + +func roleBinding(roleBindingName string, api v14.RbacV1Interface, namespace string, subjectAdmin []v1.Subject) { + + newRoleBinding := v1.RoleBinding{ + RoleRef: v1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: roleBindingName, + }, + Subjects: subjectAdmin, + ObjectMeta: metav1.ObjectMeta{ + Name: roleBindingName, + Namespace: namespace, + Labels: map[string]string{ + "name": roleBindingName, + "creator": "kubi", + "version": "v3", + }, + }, + } + + _, errRB := api.RoleBindings(namespace).Get(context.TODO(), roleBindingName, metav1.GetOptions{}) + if errRB != nil { + _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) + if err != nil { + utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() + utils.Log.Error().Msg(err.Error()) + } + utils.ServiceAccountCreation.WithLabelValues("created", namespace, roleBindingName).Inc() + utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) + return + } + _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() + return + } + utils.RoleBindingsCreation.WithLabelValues("updated", namespace, roleBindingName).Inc() + utils.Log.Info().Msgf("rolebinding %v has been updated for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) +} + +func GenerateAppRoleBinding(namespace string) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.RbacV1() + + _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingAppName, metav1.GetOptions{}) + + newRoleBinding := v1.RoleBinding{ + RoleRef: v1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: utils.KubiClusterRoleAppName, + }, + Subjects: []v1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.KubiServiceAccountAppName, + Namespace: namespace, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.KubiRoleBindingAppName, + Namespace: namespace, + Labels: map[string]string{ + "name": utils.KubiRoleBindingAppName, + "creator": "kubi", + "version": "v3", + }, + }, + } + + if errRB != nil { + _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() + utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) + return + } + + _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() + utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) +} + +func GenerateDefaultRoleBinding(namespace string) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.RbacV1() + + _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingDefaultName, metav1.GetOptions{}) + + newRoleBinding := v1.RoleBinding{ + RoleRef: v1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: utils.Config.DefaultPermission, + }, + Subjects: []v1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.KubiServiceAccountDefaultName, + Namespace: namespace, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.KubiRoleBindingDefaultName, + Namespace: namespace, + Labels: map[string]string{ + "name": utils.KubiRoleBindingDefaultName, + "creator": "kubi", + "version": "v3", + }, + }, + } + + if errRB != nil { + _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) + utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) + utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() +} diff --git a/internal/services/serviceaccounts.go b/internal/services/serviceaccounts.go new file mode 100644 index 00000000..d6e89562 --- /dev/null +++ b/internal/services/serviceaccounts.go @@ -0,0 +1,45 @@ +package services + +import ( + "context" + + "github.com/ca-gip/kubi/internal/utils" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kubernetes "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +// Generate +func GenerateAppServiceAccount(namespace string) { + kconfig, _ := rest.InClusterConfig() + clientSet, _ := kubernetes.NewForConfig(kconfig) + api := clientSet.CoreV1() + + _, errRB := api.ServiceAccounts(namespace).Get(context.TODO(), utils.KubiServiceAccountAppName, metav1.GetOptions{}) + + newServiceAccount := corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.KubiServiceAccountAppName, + Namespace: namespace, + Labels: map[string]string{ + "name": utils.KubiServiceAccountAppName, + "creator": "kubi", + "version": "v3", + }, + }, + } + + if errRB != nil { + _, err := api.ServiceAccounts(namespace).Create(context.TODO(), &newServiceAccount, metav1.CreateOptions{}) + if err != nil { + utils.Log.Error().Msg(err.Error()) + utils.ServiceAccountCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.Log.Info().Msgf("Service Account %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) + utils.ServiceAccountCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() + return + } + utils.ServiceAccountCreation.WithLabelValues("ok", namespace, utils.KubiServiceAccountAppName).Inc() +} From af73018b7698afd48216680d7d82e367e81823d2 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 9 Jan 2025 14:59:46 +0100 Subject: [PATCH 21/26] Apply DRY on code A few calls are long duplicates. Without this patch, the code for netpols, rolebindings, and projects is duplicated in a few methods. This is a problem, as it makes the code harder to read, and harder to maintain. This fixes it by refactoring the calls, making them more testable (although without adding tests), and making them simpler to read. --- cmd/operator/main.go | 2 + internal/services/namespaces.go | 11 +- internal/services/netpolconfig-operator.go | 59 ++--- internal/services/project-operator.go | 59 ++--- internal/services/project-provisionner.go | 5 +- internal/services/rolebindings.go | 249 ++++++++------------- internal/utils/prometheus.go | 5 - 7 files changed, 147 insertions(+), 243 deletions(-) diff --git a/cmd/operator/main.go b/cmd/operator/main.go index 883e18b1..c511456e 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -40,6 +40,8 @@ func main() { services.WatchProjects() + // TODO, get rid of the guard and auto watch netpol config if that's + // relevant to keep. if config.NetworkPolicy { services.WatchNetPolConfig() } else { diff --git a/internal/services/namespaces.go b/internal/services/namespaces.go index 765645cb..fef8a842 100644 --- a/internal/services/namespaces.go +++ b/internal/services/namespaces.go @@ -8,7 +8,7 @@ import ( "slices" "github.com/ca-gip/kubi/internal/utils" - v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" corev1 "k8s.io/api/core/v1" kerror "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,7 +20,7 @@ import ( // generateNamespace from a name // If it doesn't exist or the number of labels is different from what it should be -func generateNamespace(project *v12.Project) (err error) { +func generateNamespace(project *cagipv1.Project) (err error) { if project == nil { return errors.New("project reference is empty") } @@ -31,6 +31,7 @@ func generateNamespace(project *v12.Project) (err error) { ns, errNs := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) + //TODO: Cleanup and handle the case of errNS not nil and not is not found if kerror.IsNotFound(errNs) { err = createNamespace(project, api) } else if errNs == nil && !reflect.DeepEqual(ns.Labels, generateNamespaceLabels(project)) { @@ -41,7 +42,7 @@ func generateNamespace(project *v12.Project) (err error) { return } -func createNamespace(project *v12.Project, api v13.CoreV1Interface) error { +func createNamespace(project *cagipv1.Project, api v13.CoreV1Interface) error { utils.Log.Info().Msgf("Creating ns %v", project.Name) ns := &corev1.Namespace{ TypeMeta: metav1.TypeMeta{ @@ -63,7 +64,7 @@ func createNamespace(project *v12.Project, api v13.CoreV1Interface) error { return err } -func updateExistingNamespace(project *v12.Project, api v13.CoreV1Interface) error { +func updateExistingNamespace(project *cagipv1.Project, api v13.CoreV1Interface) error { utils.Log.Info().Msgf("Updating ns %v", project.Name) ns, errns := api.Namespaces().Get(context.TODO(), project.Name, metav1.GetOptions{}) @@ -98,7 +99,7 @@ func union(a map[string]string, b map[string]string) map[string]string { } // Generate CustomLabels that should be applied on Kubi's Namespaces -func generateNamespaceLabels(project *v12.Project) (labels map[string]string) { +func generateNamespaceLabels(project *cagipv1.Project) (labels map[string]string) { defaultLabels := map[string]string{ "name": project.Name, diff --git a/internal/services/netpolconfig-operator.go b/internal/services/netpolconfig-operator.go index 243b2b8d..be2390a6 100644 --- a/internal/services/netpolconfig-operator.go +++ b/internal/services/netpolconfig-operator.go @@ -6,7 +6,7 @@ import ( "time" "github.com/ca-gip/kubi/internal/utils" - v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" @@ -36,52 +36,29 @@ func WatchNetPolConfig() cache.Store { resyncPeriod := 30 * time.Minute - store, controller := cache.NewInformer(watchlist, &v12.NetworkPolicyConfig{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ + store, controller := cache.NewInformer(watchlist, &cagipv1.NetworkPolicyConfig{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ AddFunc: networkPolicyConfigCreated, - DeleteFunc: networkPolicyConfigDelete, - UpdateFunc: networkPolicyConfigUpdate, + DeleteFunc: networkPolicyConfigDeleted, + UpdateFunc: networkPolicyConfigUpdated, }) go controller.Run(wait.NeverStop) return store } +func networkPolicyConfigCreated(obj interface{}) { + netpolconfig := obj.(*cagipv1.NetworkPolicyConfig) + utils.Log.Info().Msgf("Operator: the network config %v has been created, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) + createOrUpdateNetpols(netpolconfig) +} -func networkPolicyConfigUpdate(old interface{}, new interface{}) { - netpolconfig := new.(*v12.NetworkPolicyConfig) +func networkPolicyConfigUpdated(old interface{}, new interface{}) { + netpolconfig := new.(*cagipv1.NetworkPolicyConfig) utils.Log.Info().Msgf("Operator: the network config %v has changed, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) - - kconfig, err := rest.InClusterConfig() - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating in cluster config %v", err.Error())) // TODO: Cleanup those calls to be less wrapped and simpler. - return - } - - clientSet, err := versioned.NewForConfig(kconfig) - if err != nil { - utils.Log.Error().Msg(fmt.Sprintf("error creating kubernetes clientset, %v", err.Error())) - return - } - - projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) - - if err != nil { - utils.Log.Error().Msg(err.Error()) - return - } - - for _, project := range projects.Items { - utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) - if utils.Config.NetworkPolicy { - generateNetworkPolicy(project.Name, netpolconfig) - } - } - + createOrUpdateNetpols(netpolconfig) } -func networkPolicyConfigCreated(obj interface{}) { - netpolconfig := obj.(*v12.NetworkPolicyConfig) - utils.Log.Info().Msgf("Operator: the network config %v has been created, refreshing associated resources: networkpolicies, for all kubi's namespaces.", netpolconfig.Name) +func createOrUpdateNetpols(netpolconfig *cagipv1.NetworkPolicyConfig) { kconfig, err := rest.InClusterConfig() if err != nil { @@ -96,6 +73,7 @@ func networkPolicyConfigCreated(obj interface{}) { } projects, err := clientSet.CagipV1().Projects().List(context.TODO(), metav1.ListOptions{}) + if err != nil { utils.Log.Error().Msg(err.Error()) return @@ -103,13 +81,12 @@ func networkPolicyConfigCreated(obj interface{}) { for _, project := range projects.Items { utils.Log.Info().Msgf("Operator: refresh network policy for %v", project.Name) - if utils.Config.NetworkPolicy { - generateNetworkPolicy(project.Name, netpolconfig) - } + generateNetworkPolicy(project.Name, netpolconfig) } + } -func networkPolicyConfigDelete(obj interface{}) { - netpolconfig := obj.(*v12.NetworkPolicyConfig) +func networkPolicyConfigDeleted(obj interface{}) { + netpolconfig := obj.(*cagipv1.NetworkPolicyConfig) utils.Log.Info().Msgf("Operator: the network config %v has been deleted, please delete networkpolicies for all kubi's namespaces. Be careful !", netpolconfig.Name) } diff --git a/internal/services/project-operator.go b/internal/services/project-operator.go index f03b9d0a..7c218e12 100644 --- a/internal/services/project-operator.go +++ b/internal/services/project-operator.go @@ -2,11 +2,10 @@ package services import ( "fmt" - "strings" "time" "github.com/ca-gip/kubi/internal/utils" - v12 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/ca-gip/kubi/pkg/generated/clientset/versioned" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" @@ -35,43 +34,34 @@ func WatchProjects() cache.Store { watchlist := cache.NewFilteredListWatchFromClient(v3.CagipV1().RESTClient(), "projects", metav1.NamespaceAll, utils.DefaultWatchOptionModifier) resyncPeriod := 30 * time.Minute - store, controller := cache.NewInformer(watchlist, &v12.Project{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ + store, controller := cache.NewInformer(watchlist, &cagipv1.Project{}, resyncPeriod, cache.ResourceEventHandlerFuncs{ AddFunc: projectCreated, - DeleteFunc: projectDelete, - UpdateFunc: projectUpdate, + DeleteFunc: projectDeleted, + UpdateFunc: projectUpdated, }) go controller.Run(wait.NeverStop) return store } +func projectCreated(obj interface{}) { + project := obj.(*cagipv1.Project) + utils.Log.Info().Msgf("Operator: the project %v has been created, generating associated resources: namespace, networkpolicies.", project.Name) + createOrUpdateProjectResources(project) +} -func projectUpdate(old interface{}, new interface{}) { - newProject := new.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been updated, updating associated resources: namespace, networkpolicies.", newProject.Name) - - err := generateNamespace(newProject) - if err != nil { - utils.Log.Warn().Msgf("Unexpected error %s", err) - return - } - - if utils.Config.NetworkPolicy { - generateNetworkPolicy(newProject.Name, nil) - } - // TODO: Refactor with a non static list of roles - GenerateUserRoleBinding(newProject.Name, "admin") - GenerateAppServiceAccount(newProject.Name) - GenerateAppRoleBinding(newProject.Name) - if !strings.EqualFold(utils.Config.DefaultPermission, "") { - GenerateDefaultRoleBinding(newProject.Name) - } +func projectUpdated(old interface{}, new interface{}) { + project := new.(*cagipv1.Project) + utils.Log.Info().Msgf("Operator: the project %v has been updated, updating associated resources: namespace, networkpolicies.", project.Name) + createOrUpdateProjectResources(project) +} +func projectDeleted(obj interface{}) { + project := obj.(*cagipv1.Project) + utils.Log.Info().Msgf("Operator: the project %v has been deleted, Kubi won't delete anything, please delete the namespace %v manualy", project.Name, project.Name) } -func projectCreated(obj interface{}) { - project := obj.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been created, generating associated resources: namespace, networkpolicies.", project.Name) +func createOrUpdateProjectResources(project *cagipv1.Project) { err := generateNamespace(project) if err != nil { @@ -79,21 +69,12 @@ func projectCreated(obj interface{}) { return } + // TODO: Get rid of the guard, and automatically add netpol if utils.Config.NetworkPolicy { generateNetworkPolicy(project.Name, nil) } - // TODO: Refactor with a non static list of roles - GenerateUserRoleBinding(project.Name, "admin") GenerateAppServiceAccount(project.Name) - GenerateAppRoleBinding(project.Name) - if !strings.EqualFold(utils.Config.DefaultPermission, "") { - GenerateDefaultRoleBinding(project.Name) - } + generateRoleBindings(project.Name, utils.Config.DefaultPermission) } - -func projectDelete(obj interface{}) { - project := obj.(*v12.Project) - utils.Log.Info().Msgf("Operator: the project %v has been deleted, Kubi won't delete anything, please delete the namespace %v manualy", project.Name, project.Name) -} diff --git a/internal/services/project-provisionner.go b/internal/services/project-provisionner.go index 6c3a4dea..aa7d37cc 100644 --- a/internal/services/project-provisionner.go +++ b/internal/services/project-provisionner.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "log/slog" "strings" "time" @@ -18,11 +19,13 @@ import ( ) func RefreshProjectsFromLdap(ldapClient *ldap.LDAPClient, whitelistEnabled bool) { + slog.Info("Generating resources from LDAP groups") + timerKubiRefresh := time.NewTicker(10 * time.Minute) for t := range timerKubiRefresh.C { - utils.Log.Info().Msgf("Create or Update Projects from LDAP %s", t.String()) + utils.Log.Info().Msgf("new tick, now creating or updating projects from LDAP %s", t.String()) clusterProjects, err := ldapClient.ListProjects() if err != nil { utils.Log.Error().Msgf("cannot get project list from ldap: %v", err) diff --git a/internal/services/rolebindings.go b/internal/services/rolebindings.go index 76dcecb1..83ed22a3 100644 --- a/internal/services/rolebindings.go +++ b/internal/services/rolebindings.go @@ -3,200 +3,145 @@ package services import ( "context" "fmt" + "log/slog" "github.com/ca-gip/kubi/internal/utils" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" v1 "k8s.io/api/rbac/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubernetes "k8s.io/client-go/kubernetes" - v14 "k8s.io/client-go/kubernetes/typed/rbac/v1" + rbacv1 "k8s.io/client-go/kubernetes/typed/rbac/v1" "k8s.io/client-go/rest" ) -// GenerateRolebinding from tupple -// If exists, nothing is done, only creating ! -func GenerateUserRoleBinding(namespace string, role string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.RbacV1() - - roleBinding(fmt.Sprintf("%s-%s", "namespaced", role), api, namespace, subjectAdmin(namespace, role)) - roleBinding("view", api, namespace, subjectView()) -} +var RoleBindingsCreation = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "kubi_rolebindings_creation", + Help: "Number of role bindings created", +}, []string{"status", "target_namespace", "name"}) -func subjectView() []v1.Subject { - subjectView := []v1.Subject{ - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.ApplicationViewer, - }, - } - return subjectView -} - -func subjectAdmin(namespace string, role string) []v1.Subject { - subjectAdmin := []v1.Subject{ - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: fmt.Sprintf("%s-%s", namespace, role), - }, - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.ApplicationMaster, - }, - { - APIGroup: "rbac.authorization.k8s.io", - Kind: "Group", - Name: utils.OPSMaster, - }, - } - return subjectAdmin -} - -func roleBinding(roleBindingName string, api v14.RbacV1Interface, namespace string, subjectAdmin []v1.Subject) { - - newRoleBinding := v1.RoleBinding{ +// generateRoleBinding is convenience function for readability, returning a properly formatted rolebinding object. +func newRoleBinding(name string, namespace string, clusterRole string, subjects []v1.Subject) *v1.RoleBinding { + return &v1.RoleBinding{ RoleRef: v1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", - Name: roleBindingName, + Name: clusterRole, }, - Subjects: subjectAdmin, + Subjects: subjects, ObjectMeta: metav1.ObjectMeta{ - Name: roleBindingName, + Name: name, Namespace: namespace, Labels: map[string]string{ - "name": roleBindingName, + "name": name, "creator": "kubi", "version": "v3", }, }, } +} - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), roleBindingName, metav1.GetOptions{}) - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) +// createOrUpdateRoleBinding applies the roleBinding into the cluster? +func createOrUpdateRoleBinding(api rbacv1.RbacV1Interface, roleBinding *v1.RoleBinding) error { + _, err := api.RoleBindings(roleBinding.ObjectMeta.Namespace).Get(context.TODO(), roleBinding.ObjectMeta.Name, metav1.GetOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + RoleBindingsCreation.WithLabelValues("error", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name).Inc() + return fmt.Errorf("unknown error %v", err) + } + if err != nil && k8serrors.IsNotFound(err) { + _, err := api.RoleBindings(roleBinding.ObjectMeta.Namespace).Create(context.TODO(), roleBinding, metav1.CreateOptions{}) if err != nil { - utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() - utils.Log.Error().Msg(err.Error()) + RoleBindingsCreation.WithLabelValues("error", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name).Inc() + return fmt.Errorf("error while creating new rolebinding %v/%v: %v", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name, err) } - utils.ServiceAccountCreation.WithLabelValues("created", namespace, roleBindingName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) - return + RoleBindingsCreation.WithLabelValues("created", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name).Inc() + return err } - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, roleBindingName).Inc() - return + // Exists, force update. + if _, err := api.RoleBindings(roleBinding.ObjectMeta.Namespace).Update(context.TODO(), roleBinding, metav1.UpdateOptions{}); err != nil { + RoleBindingsCreation.WithLabelValues("error", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name).Inc() + return fmt.Errorf("unable to update rolebinding %v/%v: %v", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name, err) } - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, roleBindingName).Inc() - utils.Log.Info().Msgf("rolebinding %v has been updated for namespace %v and roleBindingName %v", roleBindingName, namespace, roleBindingName) + RoleBindingsCreation.WithLabelValues("updated", roleBinding.ObjectMeta.Namespace, roleBinding.ObjectMeta.Name).Inc() + return nil } -func GenerateAppRoleBinding(namespace string) { +// generateRoleBindings handles ALL the rolebindings for a namespace. +func generateRoleBindings(namespace string, defaultServiceAccountRole string) { kconfig, _ := rest.InClusterConfig() clientSet, _ := kubernetes.NewForConfig(kconfig) api := clientSet.RbacV1() - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingAppName, metav1.GetOptions{}) - - newRoleBinding := v1.RoleBinding{ - RoleRef: v1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: utils.KubiClusterRoleAppName, - }, - Subjects: []v1.Subject{ - { - Kind: "ServiceAccount", - Name: utils.KubiServiceAccountAppName, - Namespace: namespace, + roleBindings := []struct { + name string + clusterRole string + subjects []v1.Subject + }{ + { + name: "namespaced-admin", + clusterRole: "namespaced-admin", + subjects: []v1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: fmt.Sprintf("%s-%s", namespace, "admin"), + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.ApplicationMaster, + }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.OPSMaster, + }, }, }, - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiRoleBindingAppName, - Namespace: namespace, - Labels: map[string]string{ - "name": utils.KubiRoleBindingAppName, - "creator": "kubi", - "version": "v3", + { + name: "view", + clusterRole: "view", + subjects: []v1.Subject{ + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: utils.ApplicationViewer, + }, }, }, - } - - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) - return - } - - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() - utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) -} - -func GenerateDefaultRoleBinding(namespace string) { - kconfig, _ := rest.InClusterConfig() - clientSet, _ := kubernetes.NewForConfig(kconfig) - api := clientSet.RbacV1() - - _, errRB := api.RoleBindings(namespace).Get(context.TODO(), utils.KubiRoleBindingDefaultName, metav1.GetOptions{}) - - newRoleBinding := v1.RoleBinding{ - RoleRef: v1.RoleRef{ - APIGroup: "rbac.authorization.k8s.io", - Kind: "ClusterRole", - Name: utils.Config.DefaultPermission, - }, - Subjects: []v1.Subject{ - { - Kind: "ServiceAccount", - Name: utils.KubiServiceAccountDefaultName, - Namespace: namespace, + { + name: utils.KubiRoleBindingAppName, + clusterRole: utils.KubiClusterRoleAppName, + subjects: []v1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.KubiServiceAccountAppName, + Namespace: namespace, + }, }, }, - ObjectMeta: metav1.ObjectMeta{ - Name: utils.KubiRoleBindingDefaultName, - Namespace: namespace, - Labels: map[string]string{ - "name": utils.KubiRoleBindingDefaultName, - "creator": "kubi", - "version": "v3", + { + name: utils.KubiRoleBindingDefaultName, + clusterRole: defaultServiceAccountRole, + subjects: []v1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.KubiServiceAccountDefaultName, + Namespace: namespace, + }, }, }, } - - if errRB != nil { - _, err := api.RoleBindings(namespace).Create(context.TODO(), &newRoleBinding, metav1.CreateOptions{}) + for _, rb := range roleBindings { + // For the rare (ancient!) case where the default clusterRole + // was empty (before it was pod-reader). If we want to remove it, we can. + if rb.clusterRole == "" { + continue + } + err := createOrUpdateRoleBinding(api, newRoleBinding(rb.name, namespace, rb.clusterRole, rb.subjects)) if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return + slog.Error(fmt.Sprintf("could not handle rolebinding %v/%v, %v", namespace, rb.name, err)) } - utils.Log.Info().Msgf("Rolebinding %v has been created for namespace %v", utils.KubiServiceAccountAppName, namespace) - utils.RoleBindingsCreation.WithLabelValues("created", namespace, utils.KubiServiceAccountAppName).Inc() - return - } - _, err := api.RoleBindings(namespace).Update(context.TODO(), &newRoleBinding, metav1.UpdateOptions{}) - if err != nil { - utils.Log.Error().Msg(err.Error()) - utils.RoleBindingsCreation.WithLabelValues("error", namespace, utils.KubiServiceAccountAppName).Inc() - return } - utils.Log.Info().Msgf("Rolebinding %v has been update for namespace %v", utils.KubiServiceAccountAppName, namespace) - utils.RoleBindingsCreation.WithLabelValues("updated", namespace, utils.KubiServiceAccountAppName).Inc() } diff --git a/internal/utils/prometheus.go b/internal/utils/prometheus.go index fc639518..8e725225 100644 --- a/internal/utils/prometheus.go +++ b/internal/utils/prometheus.go @@ -20,11 +20,6 @@ var NamespaceCreation = promauto.NewCounterVec(prometheus.CounterOpts{ Help: "Number of namespace created", }, []string{"status", "name"}) -var RoleBindingsCreation = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "kubi_rolebindings_creation", - Help: "Number of role bindings created", -}, []string{"status", "target_namespace", "name"}) - var ServiceAccountCreation = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "kubi_service_account_creation", Help: "Number of service account created", From e40e94b2a9097df0ff9f59e26102ff74a3e30a1c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 9 Jan 2025 15:12:11 +0100 Subject: [PATCH 22/26] Add a few unit tests While those are not technically necessary, they have helped me in my refactors. Instead of throwing them away, I will keep them here. If necessary, delete them, as they were refactor unit tests. --- .../services/project-provisionner_test.go | 48 ++++++++++++++ internal/services/rolebindings_test.go | 62 +++++++++++++++++ internal/services/token-provider_test.go | 66 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 internal/services/project-provisionner_test.go create mode 100644 internal/services/rolebindings_test.go diff --git a/internal/services/project-provisionner_test.go b/internal/services/project-provisionner_test.go new file mode 100644 index 00000000..a4707def --- /dev/null +++ b/internal/services/project-provisionner_test.go @@ -0,0 +1,48 @@ +package services + +import ( + "testing" +) + +func TestAppendIfMissing(t *testing.T) { + tests := []struct { + name string + slice []string + element string + expected []string + }{ + { + name: "Element is missing", + slice: []string{"a", "b", "c"}, + element: "d", + expected: []string{"a", "b", "c", "d"}, + }, + { + name: "Element is present", + slice: []string{"a", "b", "c"}, + element: "b", + expected: []string{"a", "b", "c"}, + }, + { + name: "Empty slice", + slice: []string{}, + element: "a", + expected: []string{"a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := appendIfMissing(tt.slice, tt.element) + if len(result) != len(tt.expected) { + t.Errorf("expected length %d, got %d", len(tt.expected), len(result)) + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("expected %v, got %v", tt.expected, result) + break + } + } + }) + } +} diff --git a/internal/services/rolebindings_test.go b/internal/services/rolebindings_test.go new file mode 100644 index 00000000..ef76189b --- /dev/null +++ b/internal/services/rolebindings_test.go @@ -0,0 +1,62 @@ +package services + +import ( + "reflect" + "testing" + + v1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewRoleBinding(t *testing.T) { + tests := []struct { + name string + namespace string + clusterRole string + subjects []v1.Subject + expected *v1.RoleBinding + }{ + { + name: "test-rolebinding", + namespace: "test-namespace", + clusterRole: "test-clusterrole", + subjects: []v1.Subject{ + { + Kind: "User", + Name: "test-user", + }, + }, + expected: &v1.RoleBinding{ + RoleRef: v1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "test-clusterrole", + }, + Subjects: []v1.Subject{ + { + Kind: "User", + Name: "test-user", + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rolebinding", + Namespace: "test-namespace", + Labels: map[string]string{ + "name": "test-rolebinding", + "creator": "kubi", + "version": "v3", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := newRoleBinding(tt.name, tt.namespace, tt.clusterRole, tt.subjects) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("newRoleBinding() = %v, expected %v", result, tt.expected) + } + }) + } +} diff --git a/internal/services/token-provider_test.go b/internal/services/token-provider_test.go index 3fa688b8..d4205710 100644 --- a/internal/services/token-provider_test.go +++ b/internal/services/token-provider_test.go @@ -201,3 +201,69 @@ func Test_generateUserJWTClaims(t *testing.T) { }) } } +func Test_generateServiceJWTClaims(t *testing.T) { + duration, _ := time.ParseDuration("4h") + extraDuration, _ := time.ParseDuration("8h") + url, _ := url.Parse("https://kubi.example.com") + + ecdsaPem, err := os.ReadFile("./../../test/ecdsa-key.pem") + if err != nil { + t.Fatalf("Unable to read ECDSA private key: %v", err) + } + ecdsaPubPem, err := os.ReadFile("./../../test/ecdsa-pub.pem") + if err != nil { + t.Fatalf("Unable to read ECDSA public key: %v", err) + } + var ecdsaKey *ecdsa.PrivateKey + var ecdsaPub *ecdsa.PublicKey + if ecdsaKey, err = jwt.ParseECPrivateKeyFromPEM(ecdsaPem); err != nil { + t.Fatalf("Unable to parse ECDSA private key: %v", err) + } + if ecdsaPub, err = jwt.ParseECPublicKeyFromPEM(ecdsaPubPem); err != nil { + t.Fatalf("Unable to parse ECDSA public key: %v", err) + } + + issuer := &TokenIssuer{ + EcdsaPrivate: ecdsaKey, + EcdsaPublic: ecdsaPub, + TokenDuration: duration, + ExtraTokenDuration: extraDuration, + Locator: utils.KubiLocatorIntranet, + PublicApiServerURL: url, + Tenant: "tenant", + } + + t.Run("Valid service JWT claims generation", func(t *testing.T) { + username := "testuser" + email := "testuser@example.com" + scopes := "promote" + + claims, err := issuer.generateServiceJWTClaims(username, email, scopes) + assert.Nil(t, err) + assert.Equal(t, username, claims.User) + assert.Equal(t, email, claims.Contact) + assert.Equal(t, issuer.Locator, claims.Locator) + assert.Equal(t, issuer.PublicApiServerURL.Host, claims.Endpoint) + assert.Equal(t, issuer.Tenant, claims.Tenant) + assert.Equal(t, scopes, claims.Scopes) + assert.WithinDuration(t, time.Now().Add(extraDuration), time.Unix(claims.ExpiresAt, 0), time.Minute) + assert.Equal(t, "Kubi Server", claims.Issuer) + }) + + t.Run("Empty scopes in service JWT claims generation", func(t *testing.T) { + username := "testuser" + email := "testuser@example.com" + scopes := "" + + claims, err := issuer.generateServiceJWTClaims(username, email, scopes) + assert.Nil(t, err) + assert.Equal(t, username, claims.User) + assert.Equal(t, email, claims.Contact) + assert.Equal(t, issuer.Locator, claims.Locator) + assert.Equal(t, issuer.PublicApiServerURL.Host, claims.Endpoint) + assert.Equal(t, issuer.Tenant, claims.Tenant) + assert.Equal(t, scopes, claims.Scopes) + assert.WithinDuration(t, time.Now().Add(extraDuration), time.Unix(claims.ExpiresAt, 0), time.Minute) + assert.Equal(t, "Kubi Server", claims.Issuer) + }) +} From a584644a6e7b4c36bb1722f718448467ad255c5e Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Thu, 9 Jan 2025 15:36:48 +0100 Subject: [PATCH 23/26] Expose LDAP Group to rolebinding Without this, we will rely on our legacy group names. This is a problem, as it means we do not gain the flexibility brought by the previous "add group" feature automatically in each project. This fixes it by ensuring the rolebinding adds a new line for the users of the group. --- internal/services/project-operator.go | 2 +- internal/services/rolebindings.go | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/services/project-operator.go b/internal/services/project-operator.go index 7c218e12..140ca67e 100644 --- a/internal/services/project-operator.go +++ b/internal/services/project-operator.go @@ -75,6 +75,6 @@ func createOrUpdateProjectResources(project *cagipv1.Project) { } GenerateAppServiceAccount(project.Name) - generateRoleBindings(project.Name, utils.Config.DefaultPermission) + generateRoleBindings(project, utils.Config.DefaultPermission) } diff --git a/internal/services/rolebindings.go b/internal/services/rolebindings.go index 83ed22a3..e9b690e5 100644 --- a/internal/services/rolebindings.go +++ b/internal/services/rolebindings.go @@ -6,6 +6,7 @@ import ( "log/slog" "github.com/ca-gip/kubi/internal/utils" + cagipv1 "github.com/ca-gip/kubi/pkg/apis/cagip/v1" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" v1 "k8s.io/api/rbac/v1" @@ -68,7 +69,8 @@ func createOrUpdateRoleBinding(api rbacv1.RbacV1Interface, roleBinding *v1.RoleB } // generateRoleBindings handles ALL the rolebindings for a namespace. -func generateRoleBindings(namespace string, defaultServiceAccountRole string) { +func generateRoleBindings(project *cagipv1.Project, defaultServiceAccountRole string) { + namespace := project.Name kconfig, _ := rest.InClusterConfig() clientSet, _ := kubernetes.NewForConfig(kconfig) api := clientSet.RbacV1() @@ -97,6 +99,11 @@ func generateRoleBindings(namespace string, defaultServiceAccountRole string) { Kind: "Group", Name: utils.OPSMaster, }, + { + APIGroup: "rbac.authorization.k8s.io", + Kind: "Group", + Name: project.Spec.SourceEntity, + }, }, }, { From 21b3335f419550d37f60dd0d39a7864507e1d8dd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Fri, 10 Jan 2025 10:19:10 +0100 Subject: [PATCH 24/26] Cleanup TokenCounter No reason to use it in utils if it is not used in a global way. --- internal/services/token-provider.go | 13 ++++++++++--- internal/utils/prometheus.go | 5 ----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index f846a757..d1c17134 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -14,6 +14,8 @@ import ( "github.com/ca-gip/kubi/internal/utils" "github.com/ca-gip/kubi/pkg/types" "github.com/dgrijalva/jwt-go" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "gopkg.in/yaml.v2" ) @@ -27,6 +29,11 @@ type TokenIssuer struct { Tenant string } +var TokenCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "kubi_valid_token_total", + Help: "Total number of tokens issued", +}, []string{"status"}) + func NewTokenIssuer(privateKey []byte, publicKey []byte, tokenDuration string, extraTokenDuration string, locator string, publicApiServerURL string, tenant string) (*TokenIssuer, error) { duration, err := time.ParseDuration(tokenDuration) if err != nil { @@ -179,7 +186,7 @@ func (issuer *TokenIssuer) createAccessToken(user types.User, scopes string) (*s if token == nil { return nil, fmt.Errorf("the token is nil") } - utils.TokenCounter.WithLabelValues("token_success").Inc() + TokenCounter.WithLabelValues("token_success").Inc() return token, nil } @@ -197,7 +204,7 @@ func (issuer *TokenIssuer) GenerateJWT(w http.ResponseWriter, r *http.Request) { token, err := issuer.createAccessToken(user, scopes) if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() + TokenCounter.WithLabelValues("token_error").Inc() utils.Log.Error().Msgf("Granting token fail for user %v, %v ", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return @@ -225,7 +232,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request token, err := issuer.createAccessToken(user, "") // no need to generate config if the user cannot access it. if err != nil { - utils.TokenCounter.WithLabelValues("token_error").Inc() + TokenCounter.WithLabelValues("token_error").Inc() utils.Log.Error().Msgf("Granting token fail for user %v, %v", user.Username, err) w.WriteHeader(http.StatusUnauthorized) return diff --git a/internal/utils/prometheus.go b/internal/utils/prometheus.go index 8e725225..5ced102f 100644 --- a/internal/utils/prometheus.go +++ b/internal/utils/prometheus.go @@ -5,11 +5,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) -var TokenCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "kubi_valid_token_total", - Help: "Total number of tokens issued", -}, []string{"status"}) - var ProjectCreation = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "kubi_project_creation", Help: "Number of project created", From 0d08bb79a3944158347f6f6dbf44d9c373d3951f Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Fri, 10 Jan 2025 10:20:42 +0100 Subject: [PATCH 25/26] Remove the global variables from kubeconfig The kubeconfig generation does not need to use global variables, if they are passed from issuer/global CA. This simplifies the code further to improve testability. --- internal/services/token-provider.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index d1c17134..2b0be46e 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -241,7 +241,7 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request utils.Log.Info().Msgf("Granting token for user %v", user.Username) // Create a DNS 1123 cluster name and user name - yml, err := generateKubeConfig(user, token) + yml, err := generateKubeConfig(issuer.PublicApiServerURL.String(), utils.Config.KubeCa, user, token) if err != nil { utils.Log.Error().Err(err) w.WriteHeader(http.StatusInternalServerError) @@ -252,8 +252,8 @@ func (issuer *TokenIssuer) GenerateConfig(w http.ResponseWriter, r *http.Request w.Write(yml) } -func generateKubeConfig(user types.User, token *string) ([]byte, error) { - clusterName := strings.TrimPrefix(utils.Config.PublicApiServerURL, "https://api.") +func generateKubeConfig(serverURL string, CA string, user types.User, token *string) ([]byte, error) { + clusterName := strings.TrimPrefix(serverURL, "https://api.") username := fmt.Sprintf("%s_%s", user.Username, clusterName) config := &types.KubeConfig{ @@ -263,8 +263,8 @@ func generateKubeConfig(user types.User, token *string) ([]byte, error) { { Name: clusterName, Cluster: types.KubeConfigClusterData{ - Server: utils.Config.PublicApiServerURL, - CertificateData: utils.Config.KubeCa, + Server: serverURL, + CertificateData: CA, }, }, }, From 87989dd946a0abfe7ae8c97723b3775ff472afdd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Evrard Date: Fri, 10 Jan 2025 10:22:08 +0100 Subject: [PATCH 26/26] Ensure key is not nil If the public key is nil, the key validation function from ParseWithClaims should NOT return the issuer public Key. The code snippet checks if the EcdsaPublic field of the issuer (an instance of TokenIssuer) is nil. The EcdsaPublic field holds the public key used to verify the token's signature. If this field is nil, it means that the public key is not available, and the method cannot proceed with the token verification. If the EcdsaPublic field is indeed nil, the method now returns nil and an error. This ensures that the caller of the VerifyToken method is informed about the missing public key, which is crucial for debugging and handling the error appropriately. This check is essential to prevent the method from attempting to verify the token without a valid public key, which would result in a runtime error or incorrect verification results. --- cmd/api/main.go | 10 +++++++++- internal/services/token-provider.go | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 05521a9a..c9380eb2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -37,7 +37,15 @@ func main() { utils.Log.Fatal().Msgf("Unable to read ECDSA public key: %v", err) } - tokenIssuer, err := services.NewTokenIssuer(ecdsaPem, ecdsaPubPem, utils.Config.TokenLifeTime, utils.Config.ExtraTokenLifeTime, utils.Config.Locator, utils.Config.PublicApiServerURL, utils.Config.Tenant) + tokenIssuer, err := services.NewTokenIssuer( + ecdsaPem, + ecdsaPubPem, + config.TokenLifeTime, + config.ExtraTokenLifeTime, + config.Locator, + config.PublicApiServerURL, + config.Tenant, + ) if err != nil { utils.Log.Fatal().Msgf("Unable to create token issuer: %v", err) } diff --git a/internal/services/token-provider.go b/internal/services/token-provider.go index 2b0be46e..03d4a477 100644 --- a/internal/services/token-provider.go +++ b/internal/services/token-provider.go @@ -292,7 +292,10 @@ func generateKubeConfig(serverURL string, CA string, user types.User, token *str func (issuer *TokenIssuer) VerifyToken(usertoken string) (*types.AuthJWTClaims, error) { // this verifies the token and its signature - token, err := jwt.ParseWithClaims(usertoken, &types.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) { + token, err := jwt.ParseWithClaims(usertoken, types.AuthJWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if issuer.EcdsaPublic == nil { + return nil, fmt.Errorf("the public key is nil") + } return issuer.EcdsaPublic, nil }) if err != nil {