diff --git a/charts/identity/Chart.yaml b/charts/identity/Chart.yaml index 06bcf73a..038b5cee 100644 --- a/charts/identity/Chart.yaml +++ b/charts/identity/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart for deploying Unikorn's IdP type: application -version: v0.2.10 -appVersion: v0.2.10 +version: v0.2.11 +appVersion: v0.2.11 icon: https://raw.githubusercontent.com/unikorn-cloud/assets/main/images/logos/dark-on-light/icon.png diff --git a/go.mod b/go.mod index 7a298f13..b4b6c0ea 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 - github.com/unikorn-cloud/core v0.1.43 + github.com/unikorn-cloud/core v0.1.49 go.opentelemetry.io/otel v1.24.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 go.opentelemetry.io/otel/sdk v1.24.0 diff --git a/go.sum b/go.sum index 3fec621d..76445e39 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unikorn-cloud/core v0.1.43 h1:QszxVqWaZXIzSlf0qkHa5m8cRnjKGvHjM8iUk0Y3U9A= -github.com/unikorn-cloud/core v0.1.43/go.mod h1:cP39UQN7aSmsfjQuSMsworI4oBIwx4oA4u20CbPpfZw= +github.com/unikorn-cloud/core v0.1.49 h1:ahAxrzvBnBICi+qN/AmTqKRJHpxl958gKVfBO3lz4G8= +github.com/unikorn-cloud/core v0.1.49/go.mod h1:cP39UQN7aSmsfjQuSMsworI4oBIwx4oA4u20CbPpfZw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/pkg/authorization/authorizer.go b/pkg/authorization/authorizer.go new file mode 100644 index 00000000..9e783382 --- /dev/null +++ b/pkg/authorization/authorizer.go @@ -0,0 +1,122 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authorization + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/unikorn-cloud/core/pkg/authorization/constants" + "github.com/unikorn-cloud/core/pkg/authorization/rbac" + identityapi "github.com/unikorn-cloud/identity/pkg/openapi" +) + +var ( + ErrResponse = errors.New("unexpected response") +) + +// IdentityACLGetter grabs an ACL for the user from the identity API. +// Used for any non-identity API. +type IdentityACLGetter struct { + // client and initialized identity client. + client identityapi.ClientWithResponsesInterface + // The organization this user is trying to access. + organizationID string +} + +// Ensure the interface is correctly implemented. +var _ rbac.ACLGetter = &IdentityACLGetter{} + +func NewIdentityACLGetter(client identityapi.ClientWithResponsesInterface, organizationID string) *IdentityACLGetter { + return &IdentityACLGetter{ + client: client, + organizationID: organizationID, + } +} + +// TODO: this is a typed mess! +func convertPermission(in string) constants.Permission { + return constants.Permission(in) +} + +func convertPermissions(in identityapi.AclPermissions) []constants.Permission { + if in == nil { + return nil + } + + out := make([]constants.Permission, len(in)) + + for i, permission := range in { + out[i] = convertPermission(permission) + } + + return out +} + +func convertScope(in *identityapi.AclScope) *rbac.Scope { + out := &rbac.Scope{ + Name: in.Name, + Permissions: convertPermissions(in.Permissions), + } + + return out +} + +func convertScopes(in *identityapi.AclScopes) []*rbac.Scope { + if in == nil { + return nil + } + + in2 := *in + + out := make([]*rbac.Scope, len(*in)) + + for i := range in2 { + out[i] = convertScope(&in2[i]) + } + + return out +} + +func convert(in identityapi.Acl) *rbac.ACL { + out := &rbac.ACL{ + Scopes: convertScopes(in.Scopes), + } + + if in.IsSuperAdmin != nil { + out.IsSuperAdmin = *in.IsSuperAdmin + } + + return out +} + +func (a *IdentityACLGetter) Get(ctx context.Context) (*rbac.ACL, error) { + resp, err := a.client.GetApiV1OrganizationsOrganizationIDAclWithResponse(ctx, a.organizationID) + if err != nil { + return nil, err + } + + if resp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("%w: status code not as expected", ErrResponse) + } + + result := *resp.JSON200 + + return convert(result), nil +} diff --git a/pkg/middleware/authorizer/authorizer.go b/pkg/middleware/authorizer/authorizer.go new file mode 100644 index 00000000..d26fb4b8 --- /dev/null +++ b/pkg/middleware/authorizer/authorizer.go @@ -0,0 +1,244 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authorizer + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net/http" + "strconv" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/getkin/kin-openapi/openapi3filter" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "golang.org/x/oauth2" + + "github.com/unikorn-cloud/core/pkg/authorization/userinfo" + "github.com/unikorn-cloud/core/pkg/server/errors" + "github.com/unikorn-cloud/core/pkg/server/middleware/openapi" + identityclient "github.com/unikorn-cloud/identity/pkg/client" + + corev1 "k8s.io/api/core/v1" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Authorizer provides OpenAPI based authorization middleware. +type Authorizer struct { + client client.Client + namespace string + options *identityclient.Options +} + +var _ openapi.Authorizer = &Authorizer{} + +// NewAuthorizer returns a new authorizer with required parameters. +func NewAuthorizer(client client.Client, namespace string, options *identityclient.Options) *Authorizer { + return &Authorizer{ + client: client, + namespace: namespace, + options: options, + } +} + +// getHTTPAuthenticationScheme grabs the scheme and token from the HTTP +// Authorization header. +func getHTTPAuthenticationScheme(r *http.Request) (string, string, error) { + header := r.Header.Get("Authorization") + if header == "" { + return "", "", errors.OAuth2InvalidRequest("authorization header missing") + } + + parts := strings.Split(header, " ") + if len(parts) != 2 { + return "", "", errors.OAuth2InvalidRequest("authorization header malformed") + } + + return parts[0], parts[1], nil +} + +type propagationFunc func(r *http.Request) + +type propagatingTransport struct { + base http.Transport + f propagationFunc +} + +func newPropagatingTransport(ctx context.Context) *propagatingTransport { + return &propagatingTransport{ + f: func(r *http.Request) { + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(r.Header)) + }, + } +} + +func (t *propagatingTransport) Clone() *propagatingTransport { + return &propagatingTransport{ + f: t.f, + } +} + +func (t *propagatingTransport) CloseIdleConnections() { + t.base.CloseIdleConnections() +} + +func (t *propagatingTransport) RegisterProtocol(scheme string, rt http.RoundTripper) { + t.base.RegisterProtocol(scheme, rt) +} + +func (t *propagatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.f(req) + + return t.base.RoundTrip(req) +} + +// oidcErrorIsUnauthorized tries to convert the error returned by the OIDC library +// into a proper status code, as it doesn't wrap anything useful. +// The error looks like "{code} {text code}: {body}". +func oidcErrorIsUnauthorized(err error) bool { + // Does it look like it contains the colon? + fields := strings.Split(err.Error(), ":") + if len(fields) < 2 { + return false + } + + // What about a number followed by a string? + fields = strings.Split(fields[0], " ") + if len(fields) < 2 { + return false + } + + code, err := strconv.Atoi(fields[0]) + if err != nil { + return false + } + + // Is the number a 403? + return code == http.StatusUnauthorized +} + +func (a *Authorizer) tlsClientConfig(ctx context.Context) (*tls.Config, error) { + if a.options.CASecretName == "" { + //nolint:nilnil + return nil, nil + } + + namespace := a.namespace + + if a.options.CASecretNamespace != "" { + namespace = a.options.CASecretNamespace + } + + secret := &corev1.Secret{} + + if err := a.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: a.options.CASecretName}, secret); err != nil { + return nil, errors.OAuth2ServerError("unable to fetch issuer CA").WithError(err) + } + + if secret.Type != corev1.SecretTypeTLS { + return nil, errors.OAuth2ServerError("issuer CA not of type kubernetes.io/tls") + } + + cert, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return nil, errors.OAuth2ServerError("issuer CA missing tls.crt") + } + + certPool := x509.NewCertPool() + + if ok := certPool.AppendCertsFromPEM(cert); !ok { + return nil, errors.OAuth2InvalidRequest("failed to parse oidc issuer CA cert") + } + + config := &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS13, + } + + return config, nil +} + +// authorizeOAuth2 checks APIs that require and oauth2 bearer token. +func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInfo, error) { + authorizationScheme, rawToken, err := getHTTPAuthenticationScheme(r) + if err != nil { + return "", nil, err + } + + if !strings.EqualFold(authorizationScheme, "bearer") { + return "", nil, errors.OAuth2InvalidRequest("authorization scheme not allowed").WithValues("scheme", authorizationScheme) + } + + // Handle non-public CA certiifcates used in development. + ctx := r.Context() + + tlsClientConfig, err := a.tlsClientConfig(r.Context()) + if err != nil { + return "", nil, err + } + + transport := newPropagatingTransport(ctx) + transport.base.TLSClientConfig = tlsClientConfig + + client := &http.Client{ + Transport: transport, + } + + ctx = oidc.ClientContext(ctx, client) + + // Perform userinfo call against the identity service that will validate the token + // and also return some information about the user. + provider, err := oidc.NewProvider(ctx, a.options.Host) + if err != nil { + return "", nil, errors.OAuth2ServerError("oidc service discovery failed").WithError(err) + } + + token := &oauth2.Token{ + AccessToken: rawToken, + TokenType: authorizationScheme, + } + + ui, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err != nil { + if oidcErrorIsUnauthorized(err) { + return "", nil, errors.OAuth2AccessDenied("token validation failed").WithError(err) + } + + return "", nil, err + } + + uiInternal := &userinfo.UserInfo{} + + if err := ui.Claims(uiInternal); err != nil { + return "", nil, errors.OAuth2ServerError("failed to extrac user information").WithError(err) + } + + return rawToken, uiInternal, nil +} + +// Authorize checks the request against the OpenAPI security scheme. +func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInput) (string, *userinfo.UserInfo, error) { + if authentication.SecurityScheme.Type == "oauth2" { + return a.authorizeOAuth2(authentication.RequestValidationInput.Request) + } + + return "", nil, errors.OAuth2InvalidRequest("authorization scheme unsupported").WithValues("scheme", authentication.SecurityScheme.Type) +}