diff --git a/api/v1alpha1/authorizationconfiguration_types.go b/api/v1alpha1/authorizationconfiguration_types.go new file mode 100644 index 00000000..f53ea6be --- /dev/null +++ b/api/v1alpha1/authorizationconfiguration_types.go @@ -0,0 +1,22 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// AuthorizationConfiguration provides versioned configuration for authorization. +type AuthorizationConfiguration struct { + metav1.TypeMeta + Type string `json:"type"` + CEL *CELConfiguration `json:"cel,omitempty"` +} + +type CELConfiguration struct { + Expression string `json:"expression"` +} + +func init() { + SchemeBuilder.Register(&AuthorizationConfiguration{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 7f21fd38..4fcc5071 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -58,6 +58,50 @@ func (in *AuthenticationConfiguration) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthorizationConfiguration) DeepCopyInto(out *AuthorizationConfiguration) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.CEL != nil { + in, out := &in.CEL, &out.CEL + *out = new(CELConfiguration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthorizationConfiguration. +func (in *AuthorizationConfiguration) DeepCopy() *AuthorizationConfiguration { + if in == nil { + return nil + } + out := new(AuthorizationConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AuthorizationConfiguration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CELConfiguration) DeepCopyInto(out *CELConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CELConfiguration. +func (in *CELConfiguration) DeepCopy() *CELConfiguration { + if in == nil { + return nil + } + out := new(CELConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Client) DeepCopyInto(out *Client) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index bfe1c8b0..19f5b0a2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,7 +40,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1" - "github.com/jumpstarter-dev/jumpstarter-controller/internal/authentication" "github.com/jumpstarter-dev/jumpstarter-controller/internal/authorization" "github.com/jumpstarter-dev/jumpstarter-controller/internal/config" "github.com/jumpstarter-dev/jumpstarter-controller/internal/controller" @@ -152,7 +151,7 @@ func main() { os.Exit(1) } - authenticator, err := config.LoadConfiguration( + authn, authz, err := config.LoadConfiguration( context.Background(), mgr.GetAPIReader(), mgr.GetScheme(), @@ -205,8 +204,8 @@ func main() { if err = (&service.ControllerService{ Client: watchClient, Scheme: mgr.GetScheme(), - Authn: authentication.NewBearerTokenAuthenticator(authenticator), - Authz: authorization.NewBasicAuthorizer(watchClient, oidcSigner.Prefix()), + Authn: authn, + Authz: authz, Attr: authorization.NewMetadataAttributesGetter(authorization.MetadataAttributesGetterConfig{ NamespaceKey: "jumpstarter-namespace", ResourceKey: "jumpstarter-kind", diff --git a/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml b/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml index 620f09f0..facc3573 100644 --- a/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml +++ b/deploy/helm/jumpstarter/charts/jumpstarter-controller/templates/cms/controller-cm.yaml @@ -11,3 +11,4 @@ metadata: {{ end }} data: authentication: {{- .Values.authenticationConfig | toYaml | indent 1 }} + authorization: {{- .Values.authorizationConfig | toYaml | indent 1 }} diff --git a/deploy/helm/jumpstarter/values.yaml b/deploy/helm/jumpstarter/values.yaml index 6b90c60c..7d75a795 100644 --- a/deploy/helm/jumpstarter/values.yaml +++ b/deploy/helm/jumpstarter/values.yaml @@ -94,6 +94,13 @@ jumpstarter-controller: # claim: "sub" # prefix: "" + authorizationConfig: | + apiVersion: jumpstarter.dev/v1alpha1 + kind: AuthorizationConfiguration + type: CEL + cel: + expression: "self.spec.username == user.username" + grpc: hostname: "" routerHostname: "" diff --git a/internal/authorization/cel.go b/internal/authorization/cel.go new file mode 100644 index 00000000..88861092 --- /dev/null +++ b/internal/authorization/cel.go @@ -0,0 +1,128 @@ +package authorization + +import ( + "context" + "fmt" + + celgo "github.com/google/cel-go/cel" + jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/authorization/cel" + "k8s.io/apiserver/pkg/cel/environment" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type CELAuthorizer struct { + reader client.Reader + prefix string + program celgo.Program +} +type Expression struct { + Expression string +} + +func (v *Expression) GetExpression() string { + return v.Expression +} + +func (v *Expression) ReturnTypes() []*celgo.Type { + return []*celgo.Type{celgo.BoolType} +} + +func NewCELAuthorizer(reader client.Reader, prefix string, expression string) (authorizer.Authorizer, error) { + env, err := environment.MustBaseEnvSet( + environment.DefaultCompatibilityVersion(), + false, + ).Extend(environment.VersionedOptions{ + IntroducedVersion: environment.DefaultCompatibilityVersion(), + EnvOptions: []celgo.EnvOption{ + celgo.Variable("kind", celgo.StringType), + celgo.Variable("self", celgo.DynType), + celgo.Variable("user", celgo.DynType), + }, + }) + if err != nil { + return nil, err + } + + compiler := cel.NewCompiler(env) + + compiled, err := compiler.CompileCELExpression(&Expression{ + Expression: expression, + }) + if err != nil { + return nil, err + } + + return &CELAuthorizer{ + reader: reader, + prefix: prefix, + program: compiled.Program, + }, nil +} + +func (b *CELAuthorizer) Authorize( + ctx context.Context, + attributes authorizer.Attributes, +) (authorizer.Decision, string, error) { + var self map[string]interface{} + var err error + + switch attributes.GetResource() { + case "Exporter": + var e jumpstarterdevv1alpha1.Exporter + if err := b.reader.Get(ctx, client.ObjectKey{ + Namespace: attributes.GetNamespace(), + Name: attributes.GetName(), + }, &e); err != nil { + return authorizer.DecisionDeny, "failed to get exporter", err + } + self, err = runtime.DefaultUnstructuredConverter.ToUnstructured(&e) + if err != nil { + return authorizer.DecisionDeny, "failed to serialize exporter", err + } + self["spec"].(map[string]any)["username"] = e.Username(b.prefix) + case "Client": + var c jumpstarterdevv1alpha1.Client + if err := b.reader.Get(ctx, client.ObjectKey{ + Namespace: attributes.GetNamespace(), + Name: attributes.GetName(), + }, &c); err != nil { + return authorizer.DecisionDeny, "failed to get client", err + } + self, err = runtime.DefaultUnstructuredConverter.ToUnstructured(&c) + if err != nil { + return authorizer.DecisionDeny, "failed to serialize client", err + } + self["spec"].(map[string]any)["username"] = c.Username(b.prefix) + default: + return authorizer.DecisionDeny, "invalid object kind", nil + } + + user := attributes.GetUser() + value, _, err := b.program.Eval(map[string]any{ + "kind": attributes.GetResource(), + "self": self, + "user": map[string]any{ + "username": user.GetName(), + "uid": user.GetUID(), + "groups": user.GetGroups(), + "extra": user.GetExtra(), + }, + }) + if err != nil { + return authorizer.DecisionDeny, "failed to evaluate expression", err + } + + result, ok := value.Value().(bool) + if !ok { + return authorizer.DecisionDeny, "failed to evaluate expression", fmt.Errorf("result type mismatch") + } + + if result { + return authorizer.DecisionAllow, "", nil + } else { + return authorizer.DecisionDeny, "permission denied", nil + } +} diff --git a/internal/authorization/config.go b/internal/authorization/config.go new file mode 100644 index 00000000..ae1a3a0e --- /dev/null +++ b/internal/authorization/config.go @@ -0,0 +1,42 @@ +package authorization + +import ( + "context" + "fmt" + + jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter-controller/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func LoadAuthorizationConfiguration( + ctx context.Context, + scheme *runtime.Scheme, + configuration []byte, + reader client.Reader, + prefix string, +) (authorizer.Authorizer, error) { + var authorizationConfiguration jumpstarterdevv1alpha1.AuthorizationConfiguration + if err := runtime.DecodeInto( + serializer.NewCodecFactory(scheme, serializer.EnableStrict). + UniversalDecoder(jumpstarterdevv1alpha1.GroupVersion), + configuration, + &authorizationConfiguration, + ); err != nil { + return nil, err + } + + switch authorizationConfiguration.Type { + case "Basic": + return NewBasicAuthorizer(reader, prefix), nil + case "CEL": + if authorizationConfiguration.CEL == nil { + return nil, fmt.Errorf("CEL authorizer configuration missing") + } + return NewCELAuthorizer(reader, prefix, authorizationConfiguration.CEL.Expression) + default: + return nil, fmt.Errorf("unsupported authorizer type: %s", authorizationConfiguration.Type) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index f16255c0..3a773e84 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,10 +4,12 @@ import ( "context" "fmt" + "github.com/jumpstarter-dev/jumpstarter-controller/internal/authentication" + "github.com/jumpstarter-dev/jumpstarter-controller/internal/authorization" "github.com/jumpstarter-dev/jumpstarter-controller/internal/oidc" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authorization/authorizer" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -18,18 +20,18 @@ func LoadConfiguration( key client.ObjectKey, signer *oidc.Signer, certificateAuthority string, -) (authenticator.Token, error) { +) (authentication.ContextAuthenticator, authorizer.Authorizer, error) { var configmap corev1.ConfigMap if err := client.Get(ctx, key, &configmap); err != nil { - return nil, err + return nil, nil, err } rawAuthenticationConfiguration, ok := configmap.Data["authentication"] if !ok { - return nil, fmt.Errorf("LoadConfiguration: missing authentication section") + return nil, nil, fmt.Errorf("LoadConfiguration: missing authentication section") } - authenticator, err := oidc.LoadAuthenticationConfiguration( + authn, err := oidc.LoadAuthenticationConfiguration( ctx, scheme, []byte(rawAuthenticationConfiguration), @@ -37,8 +39,24 @@ func LoadConfiguration( certificateAuthority, ) if err != nil { - return nil, err + return nil, nil, err } - return authenticator, nil + rawAuthorizationConfiguration, ok := configmap.Data["authorization"] + if !ok { + return nil, nil, fmt.Errorf("LoadConfiguration: missing authorization section") + } + + authz, err := authorization.LoadAuthorizationConfiguration( + ctx, + scheme, + []byte(rawAuthorizationConfiguration), + client, + signer.Prefix(), + ) + if err != nil { + return nil, nil, err + } + + return authentication.NewBearerTokenAuthenticator(authn), authz, nil }