Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions pkg/route/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package route

import (
"context"
"fmt"

authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/endpoints/request"

routev1 "github.com/openshift/api/route/v1"
"github.com/openshift/library-go/pkg/authorization/authorizationutil"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// SubjectAccessReviewCreator is an interface for performing subject access reviews
type SubjectAccessReviewCreator interface {
Create(ctx context.Context, sar *authorizationv1.SubjectAccessReview, opts metav1.CreateOptions) (*authorizationv1.SubjectAccessReview, error)
}

// RouteValidationOptions used to tweak how/what fields are validated. These
// options are propagated by the apiserver.
type RouteValidationOptions struct {

// AllowExternalCertificates option is set when the RouteExternalCertificate
// feature gate is enabled.
AllowExternalCertificates bool
}

// CheckRouteCustomHostSAR checks if user has permission to create and update routes/custom-host
// sub-resource
func CheckRouteCustomHostSAR(ctx context.Context, fldPath *field.Path, sarc SubjectAccessReviewCreator) field.ErrorList {
user, ok := request.UserFrom(ctx)
if !ok {
return field.ErrorList{field.InternalError(fldPath, fmt.Errorf("unable to verify host field can be set"))}
}

var errs field.ErrorList
if err := authorizationutil.Authorize(sarc, user, &authorizationv1.ResourceAttributes{
Namespace: request.NamespaceValue(ctx),
Verb: "create",
Group: routev1.GroupName,
Resource: "routes",
Subresource: "custom-host",
}); err != nil {
errs = append(errs, field.Forbidden(fldPath, "user does not have create permission on custom-host"))
}

if err := authorizationutil.Authorize(sarc, user, &authorizationv1.ResourceAttributes{
Namespace: request.NamespaceValue(ctx),
Verb: "update",
Group: routev1.GroupName,
Resource: "routes",
Subresource: "custom-host",
}); err != nil {
errs = append(errs, field.Forbidden(fldPath, "user does not have update permission on custom-host"))
}

return errs
}
67 changes: 52 additions & 15 deletions pkg/route/hostassignment/assignment.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,31 @@ import (

routev1 "github.com/openshift/api/route/v1"
"github.com/openshift/library-go/pkg/authorization/authorizationutil"
"github.com/openshift/library-go/pkg/route"
)

// HostGeneratedAnnotationKey is the key for an annotation set to "true" if the route's host was generated
const HostGeneratedAnnotationKey = "openshift.io/host.generated"

// Registry is an interface for performing subject access reviews
type SubjectAccessReviewCreator interface {
Create(ctx context.Context, sar *authorizationv1.SubjectAccessReview, opts metav1.CreateOptions) (*authorizationv1.SubjectAccessReview, error)
}

type HostnameGenerator interface {
GenerateHostname(*routev1.Route) (string, error)
}

// AllocateHost allocates a host name ONLY if the route doesn't specify a subdomain wildcard policy and
// the host name on the route is empty and an allocator is configured.
// It must first allocate the shard and may return an error if shard allocation fails.
func AllocateHost(ctx context.Context, route *routev1.Route, sarc SubjectAccessReviewCreator, routeAllocator HostnameGenerator) field.ErrorList {
func AllocateHost(ctx context.Context, route *routev1.Route, sarc route.SubjectAccessReviewCreator, routeAllocator HostnameGenerator, opts route.RouteValidationOptions) field.ErrorList {
hostSet := len(route.Spec.Host) > 0
certSet := route.Spec.TLS != nil && (len(route.Spec.TLS.CACertificate) > 0 || len(route.Spec.TLS.Certificate) > 0 || len(route.Spec.TLS.DestinationCACertificate) > 0 || len(route.Spec.TLS.Key) > 0)
certSet := route.Spec.TLS != nil &&
(len(route.Spec.TLS.CACertificate) > 0 ||
len(route.Spec.TLS.Certificate) > 0 ||
len(route.Spec.TLS.DestinationCACertificate) > 0 ||
len(route.Spec.TLS.Key) > 0)

if opts.AllowExternalCertificates && route.Spec.TLS != nil && route.Spec.TLS.ExternalCertificate != nil {
certSet = certSet || len(route.Spec.TLS.ExternalCertificate.Name) > 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that the tests don't cover this case (i.e. creating a new route that specifies externalCertificate). These test cases would give you coverage:

		{
			name:           "create-with-external-certificate-denied",
			host:           "host",
			expected:       "host",
			tls:            &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
			wildcardPolicy: routev1.WildcardPolicyNone,
			allow:          false,
			errs:           1,

			opts: route.RouteValidationOptions{AllowExternalCertificates: true},
		},
		{
			name:           "create-with-external-certificate-allowed",
			host:           "host",
			expected:       "host",
			tls:            &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
			wildcardPolicy: routev1.WildcardPolicyNone,
			allow:          true,
			errs:           0,

			opts: route.RouteValidationOptions{AllowExternalCertificates: true},
		},

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered in #1706

}

if hostSet || certSet {
user, ok := request.UserFrom(ctx)
if !ok {
Expand Down Expand Up @@ -86,41 +91,65 @@ func AllocateHost(ctx context.Context, route *routev1.Route, sarc SubjectAccessR
return nil
}

func hasCertificateInfo(tls *routev1.TLSConfig) bool {
func hasCertificateInfo(tls *routev1.TLSConfig, opts route.RouteValidationOptions) bool {
if tls == nil {
return false
}
return len(tls.Certificate) > 0 ||
hasInfo := len(tls.Certificate) > 0 ||
len(tls.Key) > 0 ||
len(tls.CACertificate) > 0 ||
len(tls.DestinationCACertificate) > 0

if opts.AllowExternalCertificates && tls.ExternalCertificate != nil {
hasInfo = hasInfo || len(tls.ExternalCertificate.Name) > 0
}
return hasInfo
}

func certificateChangeRequiresAuth(route, older *routev1.Route) bool {
// certificateChangeRequiresAuth determines whether changes to the TLS certificate configuration require authentication.
// Note: If either route uses externalCertificate, this function always returns true, as we cannot definitively verify if
// the content of the referenced secret has been modified. Even if the secret name remains the same,
// we must assume that the secret content is changed, necessitating authentication.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// we must assume that the secret content is changed, necessitating authentication.
// we must assume that the secret content is changed, necessitating authorization.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered in #1706

func certificateChangeRequiresAuth(route, older *routev1.Route, opts route.RouteValidationOptions) bool {
switch {
case route.Spec.TLS != nil && older.Spec.TLS != nil:
a, b := route.Spec.TLS, older.Spec.TLS
if !hasCertificateInfo(a) {
if !hasCertificateInfo(a, opts) {
// removing certificate info is allowed
return false
}
return a.CACertificate != b.CACertificate ||

certChanged := a.CACertificate != b.CACertificate ||
a.Certificate != b.Certificate ||
a.DestinationCACertificate != b.DestinationCACertificate ||
a.Key != b.Key

if opts.AllowExternalCertificates {
if route.Spec.TLS.ExternalCertificate != nil || older.Spec.TLS.ExternalCertificate != nil {
certChanged = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the cert is considered changed here? Shouldn't we check whether the name of the external certificate changed before setting certChanged=true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we cannot definitively verify if the content of the secret that is referenced has been modified.

The reason this also mentioned in the EP:

If the old route or the new route uses .spec.tls.externalCertificate this validation will always have the precondition certificateChangeRequiresAuth() return true since we cannot definitively verify if the content of the secret that is referenced has been modified. Since the previous validation func (ValidateHostExternalCertificate()) would have already validation user permissions, we can safely make this assumption.

Copy link
Contributor

@alebedev87 alebedev87 Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering about the secret name, not the secret contents.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the external certificate secret name change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! The external certificate secret name can be changed, but the caveat here is even if the secret name remains the same, we need to assume that the secret content is changed, and hence, the certificate is changed.

Copy link
Contributor

@alebedev87 alebedev87 Feb 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but the caveat here is even if the secret name remains the same, we need to assume that the secret content is changed

True, we have no choice here but treat this as "always changed".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do see you added a comment for this caveat to ValidateHostUpdate, but perhaps it deserves a code comment in certificateChangeRequiresAuth as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I've added a comment for certificateChangeRequiresAuth as well

}
}

return certChanged
case route.Spec.TLS != nil:
// using any default certificate is allowed
return hasCertificateInfo(route.Spec.TLS)
return hasCertificateInfo(route.Spec.TLS, opts)
default:
// all other cases we are not adding additional certificate info
return false
}
}

func ValidateHostUpdate(ctx context.Context, route, older *routev1.Route, sarc SubjectAccessReviewCreator) field.ErrorList {
// ValidateHostUpdate checks if the user has the correct permissions based on the updates
// done to the route object. If the route's host/subdomain has been updated it checks if
// the user has "update" permission on custom-host subresource. If only the certificate
// has changed, it checks if the user has "create" permission on the custom-host subresource.
// Caveat here is that if the route uses externalCertificate, the certChanged condition will
// always be true since we cannot verify state of external secret object.
func ValidateHostUpdate(ctx context.Context, route, older *routev1.Route, sarc route.SubjectAccessReviewCreator, opts route.RouteValidationOptions) field.ErrorList {
hostChanged := route.Spec.Host != older.Spec.Host
subdomainChanged := route.Spec.Subdomain != older.Spec.Subdomain
certChanged := certificateChangeRequiresAuth(route, older)
certChanged := certificateChangeRequiresAuth(route, older, opts)
if !hostChanged && !certChanged && !subdomainChanged {
return nil
}
Expand Down Expand Up @@ -190,6 +219,14 @@ func ValidateHostUpdate(ctx context.Context, route, older *routev1.Route, sarc S
errs = append(errs, apimachineryvalidation.ValidateImmutableField(route.Spec.TLS.Certificate, older.Spec.TLS.Certificate, field.NewPath("spec", "tls", "certificate"))...)
errs = append(errs, apimachineryvalidation.ValidateImmutableField(route.Spec.TLS.DestinationCACertificate, older.Spec.TLS.DestinationCACertificate, field.NewPath("spec", "tls", "destinationCACertificate"))...)
errs = append(errs, apimachineryvalidation.ValidateImmutableField(route.Spec.TLS.Key, older.Spec.TLS.Key, field.NewPath("spec", "tls", "key"))...)

if opts.AllowExternalCertificates {
if route.Spec.TLS.ExternalCertificate == nil || older.Spec.TLS.ExternalCertificate == nil {
errs = append(errs, apimachineryvalidation.ValidateImmutableField(route.Spec.TLS.ExternalCertificate, older.Spec.TLS.ExternalCertificate, field.NewPath("spec", "tls", "externalCertificate"))...)
} else {
errs = append(errs, apimachineryvalidation.ValidateImmutableField(route.Spec.TLS.ExternalCertificate.Name, older.Spec.TLS.ExternalCertificate.Name, field.NewPath("spec", "tls", "externalCertificate"))...)
}
}
return errs
}
}
Expand Down
140 changes: 138 additions & 2 deletions pkg/route/hostassignment/assignment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"k8s.io/apiserver/pkg/endpoints/request"

routev1 "github.com/openshift/api/route/v1"
"github.com/openshift/library-go/pkg/route"
)

type testAllocator struct {
Expand Down Expand Up @@ -52,6 +53,9 @@ func TestHostWithWildcardPolicies(t *testing.T) {
expected string
expectedSubdomain string

// field for externalCertificate
opts route.RouteValidationOptions

errs int
allow bool
}{
Expand Down Expand Up @@ -215,6 +219,138 @@ func TestHostWithWildcardPolicies(t *testing.T) {
allow: false,
errs: 1,
},
{
name: "no-certificate-changed-to-external-certificate-denied",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: false,
errs: 1,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "no-certificate-changed-to-external-certificate-allowed",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: true,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "external-certificate-changed-to-certificate-denied",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, Certificate: "a"},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: false,
errs: 2,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "external-certificate-changed-to-certificate-allowed",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, Certificate: "a"},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: true,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "certificate-changed-to-external-certificate-denied",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, Certificate: "a"},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: false,
errs: 2,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "certificate-changed-to-external-certificate-allowed",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "b"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, Certificate: "a"},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: true,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "certificate-changed-to-external-certificate-allowed-but-featuregate-is-not-set",
host: "host",
expected: "host",
oldHost: "host",
// if the featuregate was disabled, and ExternalCertificate wasn't previously set, apiserver will strip ExternalCertificate field.
// https://github.com/openshift/openshift-apiserver/blob/1fac5e7e3a6153efae875185af2dba48fbad41ab/pkg/route/apiserver/registry/route/strategy.go#L73-L93
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: nil},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, Certificate: "a"},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: true,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: false},
},
{
name: "external-certificate-changed-denied",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "a"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "old-b"}},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: false,
errs: 1,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "external-certificate-changed-allowed",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "a"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "old-b"}},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: true,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "external-certificate-secret-unchanged",
host: "host",
expected: "host",
oldHost: "host",
tls: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "a"}},
oldTLS: &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge, ExternalCertificate: &routev1.LocalObjectReference{Name: "a"}},
wildcardPolicy: routev1.WildcardPolicyNone,
allow: false,
errs: 0,

opts: route.RouteValidationOptions{AllowExternalCertificates: true},
},
{
name: "ca-certificate-unchanged",
host: "host",
Expand Down Expand Up @@ -368,9 +504,9 @@ func TestHostWithWildcardPolicies(t *testing.T) {
},
},
}
errs = ValidateHostUpdate(ctx, route, oldRoute, &testSAR{allow: tc.allow})
errs = ValidateHostUpdate(ctx, route, oldRoute, &testSAR{allow: tc.allow}, tc.opts)
} else {
errs = AllocateHost(ctx, route, &testSAR{allow: tc.allow}, testAllocator{})
errs = AllocateHost(ctx, route, &testSAR{allow: tc.allow}, testAllocator{}, tc.opts)
}

if route.Spec.Host != tc.expected {
Expand Down
35 changes: 35 additions & 0 deletions pkg/route/hostassignment/externalcertificate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package hostassignment

import (
"context"

"k8s.io/apimachinery/pkg/util/validation/field"

routev1 "github.com/openshift/api/route/v1"
routecommon "github.com/openshift/library-go/pkg/route"
)

// ValidateHostExternalCertificate checks if the user has permissions to create and update
// custom-host subresource of routes. This check is required to be done prior to ValidateHostUpdate()
// since updating hosts while using externalCertificate is contingent on the user having both these
// permissions. The ValidateHostUpdate() cannot differentiate if the certificate has changed since
// now the certificates will be present as a secret object, due to this it proceeds with the assumption
// that the certificate has changed when the route has externalCertificate set.
Comment on lines +13 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately, ValidateUpdate is going to call ValidateHostExternalCertificate and then call ValidateHostUpdate, right? This comment is helpful, but it still isn't quite clear why the logic in ValidateHostExternalCertificate cannot simply be added to ValidateHostUpdate.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminded me of my comment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EP did mention about invoking ValidateHostExternalCertificate prior to ValidateHostUpdate, but it's body lacks the actual reason why we decided to choose this approach.

As per our discussion over Slack and further investigation, I've added a TODO to consider merging ValidateHostExternalCertificate function into ValidateHostUpdate later.

// TODO: Consider merging this function into ValidateHostUpdate.
func ValidateHostExternalCertificate(ctx context.Context, new, older *routev1.Route, sarc routecommon.SubjectAccessReviewCreator, opts routecommon.RouteValidationOptions) field.ErrorList {

if !opts.AllowExternalCertificates {
// Return nil since the feature gate is off.
// ValidateHostUpdate() is sufficient to validate
// permissions.
return nil
}

newTLS := new.Spec.TLS
oldTLS := older.Spec.TLS
if (newTLS != nil && newTLS.ExternalCertificate != nil) || (oldTLS != nil && oldTLS.ExternalCertificate != nil) {
return routecommon.CheckRouteCustomHostSAR(ctx, field.NewPath("spec", "tls", "externalCertificate"), sarc)
}

return nil
}
Loading