Skip to content

Commit

Permalink
Add certificate and token auth and authz on the POST API endpoint
Browse files Browse the repository at this point in the history
If a certificate is provided, the HTTP server will run over HTTPS which
enables certificate authentication.

The authorization requires a user to have patch access to the policy
status on the managed cluster namespace of the managed cluster that the
compliance event is for.

Relates:
https://issues.redhat.com/browse/ACM-6866

Signed-off-by: mprahl <[email protected]>
(cherry picked from commit 857a468)
  • Loading branch information
mprahl authored and Magic Mirror committed Feb 13, 2024
1 parent 2474e73 commit 33e0e11
Show file tree
Hide file tree
Showing 17 changed files with 816 additions and 48 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ bin
testbin/*

kubeconfig*
dev-tls.crt
dev-tls.key
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ postgres: cert-manager
--from-literal="host=$(POSTGRES_HOST)" \
--from-literal="dbname=ocm-compliance-history" \
--from-literal="ca=$$(kubectl -n $(KIND_NAMESPACE) get secret postgres-cert -o json | jq -r '.data["ca.crt"]' | base64 -d)"

@echo "Copying the compliance API certificates locally"
kubectl -n $(KIND_NAMESPACE) get secret compliance-api-cert -o json | jq -r '.data["tls.crt"]' | base64 -d > dev-tls.crt
kubectl -n $(KIND_NAMESPACE) get secret compliance-api-cert -o json | jq -r '.data["ca.crt"]' | base64 -d >> dev-ca.crt
kubectl -n $(KIND_NAMESPACE) get secret compliance-api-cert -o json | jq -r '.data["tls.key"]' | base64 -d > dev-tls.key

webhook: cert-manager
-kubectl create ns $(KIND_NAMESPACE)
Expand Down
4 changes: 3 additions & 1 deletion build/common/Makefile.common.mk
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,11 @@ kind-ensure-sa:
kind-controller-kubeconfig: install-resources
kubectl -n $(CONTROLLER_NAMESPACE) apply -f test/resources/e2e_controller_secret.yaml --kubeconfig=$(PWD)/kubeconfig_$(CLUSTER_NAME)_e2e
-rm kubeconfig_$(CLUSTER_NAME)
@kubectl config view --minify -o jsonpath='{.clusters[].cluster.certificate-authority-data}' --kubeconfig=kubeconfig_$(CLUSTER_NAME)_e2e --raw | base64 -d > temp-ca.crt
@kubectl config set-cluster $(KIND_CLUSTER_NAME) --kubeconfig=$(PWD)/kubeconfig_$(CLUSTER_NAME) \
--server=$(shell kubectl config view --minify -o jsonpath='{.clusters[].cluster.server}' --kubeconfig=kubeconfig_$(CLUSTER_NAME)_e2e) \
--insecure-skip-tls-verify=true
--certificate-authority=temp-ca.crt --embed-certs=true
@rm -f temp-ca.crt
@kubectl config set-credentials $(KIND_CLUSTER_NAME) --kubeconfig=$(PWD)/kubeconfig_$(CLUSTER_NAME) \
--token=$$(kubectl get secret -n $(CONTROLLER_NAMESPACE) $(CONTROLLER_NAME) -o jsonpath='{.data.token}' --kubeconfig=$(PWD)/kubeconfig_$(CLUSTER_NAME)_e2e | $(BASE64) --decode)
@kubectl config set-context $(KIND_CLUSTER_NAME) --kubeconfig=$(PWD)/kubeconfig_$(CLUSTER_NAME) \
Expand Down
23 changes: 23 additions & 0 deletions build/kind/postgres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,26 @@ spec:
targetPort: 8384
nodePort: 30838
type: NodePort
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: compliance-api-selfsigned-issuer
namespace: open-cluster-management
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: compliance-api-cert
namespace: open-cluster-management
spec:
dnsNames:
- compliance-api-external.open-cluster-management.svc
- compliance-api-external.open-cluster-management.svc.cluster.local
- localhost
issuerRef:
kind: Issuer
name: compliance-api-selfsigned-issuer
secretName: compliance-api-cert
190 changes: 190 additions & 0 deletions controllers/complianceeventsapi/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Copyright Contributors to the Open Cluster Management project
package complianceeventsapi

import (
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"

authzv1 "k8s.io/api/authorization/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apiserverx509 "k8s.io/apiserver/pkg/authentication/request/x509"
"k8s.io/apiserver/pkg/server/dynamiccertificates"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)

// parseToken will return the token string in the Authorization header.
func parseToken(req *http.Request) string {
return strings.TrimSpace(strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer"))
}

// getClientFromToken will generate a Kubernetes client using the input config and token. No authentication data from
// the input config is used in the generated client.
func getClientFromToken(cfg *rest.Config, token string) (*kubernetes.Clientset, error) {
userConfig := &rest.Config{
Host: cfg.Host,
APIPath: cfg.APIPath,
TLSClientConfig: rest.TLSClientConfig{
CAFile: cfg.TLSClientConfig.CAFile,
CAData: cfg.TLSClientConfig.CAData,
ServerName: cfg.TLSClientConfig.ServerName,
Insecure: cfg.TLSClientConfig.Insecure,
},
BearerToken: token,
}

return kubernetes.NewForConfig(userConfig)
}

// canRecordComplianceEvent will perform certificate or token authentication and perform a subject access review to
// ensure the input user has patch access to patch the policy status in the managed cluster namespace. An error is
// returned if the authorization could not be determined. Note that authenticatedClient and authenticator can be nil
// if certificate authentication isn't used. If both certificate and token authentication is present, certificate takes
// precedence.
func canRecordComplianceEvent(
cfg *rest.Config,
authenticatedClient *kubernetes.Clientset,
authenticator *apiserverx509.Authenticator,
clusterName string,
req *http.Request,
) (bool, error) {
postRules := authzv1.ResourceAttributes{
Group: "policy.open-cluster-management.io",
Version: "v1",
Resource: "policies",
Verb: "patch",
Namespace: clusterName,
Subresource: "status",
}

// req.TLS.PeerCertificates will be empty if certificate authentication is not enabled (e.g. endpoint is not HTTPS)
if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
resp, ok, err := authenticator.AuthenticateRequest(req)
if err != nil {
if errors.As(err, &x509.UnknownAuthorityError{}) || errors.As(err, &x509.CertificateInvalidError{}) {
return false, ErrNotAuthorized
}

return false, err
}

if !ok {
return false, ErrNotAuthorized
}

review, err := authenticatedClient.AuthorizationV1().SubjectAccessReviews().Create(
req.Context(),
&authzv1.SubjectAccessReview{
Spec: authzv1.SubjectAccessReviewSpec{
ResourceAttributes: &postRules,
User: resp.User.GetName(),
Groups: resp.User.GetGroups(),
UID: resp.User.GetUID(),
},
},
metav1.CreateOptions{},
)
if err != nil {
return false, err
}

if !review.Status.Allowed {
log.V(0).Info(
"The user is not authorized to record a compliance event",
"cluster", clusterName,
"user", resp.User.GetName(),
)
}

return review.Status.Allowed, nil
}

token := parseToken(req)
if token == "" {
return false, ErrNotAuthorized
}

userClient, err := getClientFromToken(cfg, token)
if err != nil {
return false, err
}

result, err := userClient.AuthorizationV1().SelfSubjectAccessReviews().Create(
req.Context(),
&authzv1.SelfSubjectAccessReview{
Spec: authzv1.SelfSubjectAccessReviewSpec{
ResourceAttributes: &postRules,
},
},
metav1.CreateOptions{},
)
if err != nil {
if k8serrors.IsUnauthorized(err) {
return false, ErrNotAuthorized
}

return false, err
}

if !result.Status.Allowed {
log.V(0).Info(
"The user is not authorized to record a compliance event",
"cluster", clusterName,
"user", getTokenUsername(token),
)
}

return result.Status.Allowed, nil
}

// getTokenUsername will parse the token and return the username. If the token is invalid, an empty string is returned.
func getTokenUsername(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
log.V(2).Info("The token does not have the expected three parts")

return ""
}

userInfoBytes, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
log.V(2).Info("The token does not have valid base64")

return ""
}

userInfo := map[string]interface{}{}

err = json.Unmarshal(userInfoBytes, &userInfo)
if err != nil {
log.V(2).Info("The token does not have valid JSON")

return ""
}

username, ok := userInfo["sub"].(string)
if !ok {
return ""
}

return username
}

// getCertAuthenticator returns an Authenticator that can validate that an input certificate is signed by the API
// server represented in the input cfg and that the certificate can be used for client authentication (e.g. key usage).
func getCertAuthenticator(cfg *rest.Config) (*apiserverx509.Authenticator, error) {
p, err := dynamiccertificates.NewStaticCAContent("client-ca", cfg.CAData)
if err != nil {
return nil, err
}

// This is the same approach taken by kube-rbac-proxy.
authenticator := apiserverx509.NewDynamic(p.VerifyOptions, apiserverx509.CommonNameUserConversion)

return authenticator, nil
}
23 changes: 23 additions & 0 deletions controllers/complianceeventsapi/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package complianceeventsapi

import (
"testing"
)

func TestGetTokenUsername(t *testing.T) {
t.Parallel()

token := "part1.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc" +
"3BhY2UiOiJvcGVuLWNsdXN0ZXItbWFuYWdlbWVudCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJnb3Zl" +
"cm5hbmNlLXBvbGljeS1wcm9wYWdhdG9yIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQubmFtZSI6Imdv" +
"dmVybmFuY2UtcG9saWN5LXByb3BhZ2F0b3IiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC51aWQiOiJ" +
"lMjQzZDBlNi03YjJkLTRjZjQtYmExMC1mMTE5NWQwMGUxZTYiLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6b3Blbi1jbHVzdGVyLW" +
"1hbmFnZW1lbnQ6Z292ZXJuYW5jZS1wb2xpY3ktcHJvcGFnYXRvciJ9.part3"

username := getTokenUsername(token)
expected := "system:serviceaccount:open-cluster-management:governance-policy-propagator"

if username != expected {
t.Fatalf("Expected %s but got %s", expected, username)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ type ComplianceDBSecretReconciler struct {
// WARNING: In production, this should be namespaced to the namespace the controller is running in.
//+kubebuilder:rbac:groups=core,resources=secrets,resourceNames=governance-policy-database,verbs=get;list;watch
//+kubebuilder:rbac:groups=core,resources=events,verbs=create
//+kubebuilder:rbac:groups=authorization.k8s.io,resources=subjectaccessreviews,verbs=create

// Reconcile watches the governance-policy-database secret in the controller namespace. On updates it'll trigger
// a database migration and update the shared database connection.
Expand Down
Loading

0 comments on commit 33e0e11

Please sign in to comment.