1
0
mirror of https://github.com/openshift/image-registry.git synced 2026-02-05 09:45:55 +01:00
Files
Flavian Missi 018bd4544a pkg/dockerregistry/server: use SelfAccessReview api instead of users
the users api is specific to openshift, and is not available on every
openshift cluster, i.e when OIDC is configured with external users.
2024-06-14 14:43:41 +02:00

577 lines
16 KiB
Go

package server
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
dcontext "github.com/distribution/distribution/v3/context"
registryauth "github.com/distribution/distribution/v3/registry/auth"
authnv1 "k8s.io/api/authentication/v1"
authorizationapi "k8s.io/api/authorization/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
imageapi "github.com/openshift/api/image/v1"
"github.com/openshift/library-go/pkg/apiserver/httprequest"
"github.com/openshift/image-registry/pkg/dockerregistry/server/audit"
"github.com/openshift/image-registry/pkg/dockerregistry/server/client"
"github.com/openshift/image-registry/pkg/dockerregistry/server/configuration"
)
type deferredErrors map[string]error
func (d deferredErrors) Add(ref string, err error) {
d[ref] = err
}
func (d deferredErrors) Get(ref string) (error, bool) {
err, exists := d[ref]
return err, exists
}
func (d deferredErrors) Empty() bool {
return len(d) == 0
}
const (
defaultUserName = "anonymous"
)
// WithUserInfoLogger creates a new context with provided user infomation.
func WithUserInfoLogger(ctx context.Context, username, userid string) context.Context {
ctx = context.WithValue(ctx, audit.AuditUserEntry, username)
if len(userid) > 0 {
ctx = context.WithValue(ctx, audit.AuditUserIDEntry, userid)
}
return dcontext.WithLogger(ctx, dcontext.GetLogger(ctx,
audit.AuditUserEntry,
audit.AuditUserIDEntry,
))
}
type AccessController struct {
realm string
tokenRealm *url.URL
registryClient client.RegistryClient
auditLog bool
metricsConfig configuration.Metrics
}
var _ registryauth.AccessController = &AccessController{}
type authChallenge struct {
realm string
err error
}
var _ registryauth.Challenge = &authChallenge{}
type tokenAuthChallenge struct {
realm string
service string
err error
}
var _ registryauth.Challenge = &tokenAuthChallenge{}
// Errors used and exported by this package.
var (
// Challenging errors
ErrTokenRequired = errors.New("authorization header required")
ErrTokenInvalid = errors.New("failed to decode credentials")
ErrOpenShiftAccessDenied = errors.New("access denied")
// Non-challenging errors
ErrNamespaceRequired = errors.New("repository namespace required")
ErrUnsupportedAction = errors.New("unsupported action")
ErrUnsupportedResource = errors.New("unsupported resource")
)
func (app *App) Auth(options map[string]interface{}) (registryauth.AccessController, error) {
tokenRealm, err := configuration.TokenRealm(app.config.Auth.TokenRealm)
if err != nil {
return nil, err
}
return &AccessController{
realm: app.config.Auth.Realm,
tokenRealm: tokenRealm,
registryClient: app.registryClient,
metricsConfig: app.config.Metrics,
auditLog: app.config.Audit.Enabled,
}, nil
}
// Error returns the internal error string for this authChallenge.
func (ac *authChallenge) Error() string {
return ac.err.Error()
}
// SetHeaders sets the basic challenge header on the response.
func (ac *authChallenge) SetHeaders(req *http.Request, w http.ResponseWriter) {
// WWW-Authenticate response challenge header.
// See https://tools.ietf.org/html/rfc6750#section-3
str := fmt.Sprintf("Basic realm=%s", ac.realm)
if ac.err != nil {
str = fmt.Sprintf("%s,error=%q", str, ac.Error())
}
w.Header().Set("WWW-Authenticate", str)
}
// Error returns the internal error string for this authChallenge.
func (ac *tokenAuthChallenge) Error() string {
return ac.err.Error()
}
// SetHeaders sets the bearer challenge header on the response.
func (ac *tokenAuthChallenge) SetHeaders(req *http.Request, w http.ResponseWriter) {
// WWW-Authenticate response challenge header.
// See https://docs.docker.com/registry/spec/auth/token/#/how-to-authenticate and https://tools.ietf.org/html/rfc6750#section-3
str := fmt.Sprintf("Bearer realm=%q", ac.realm)
if ac.service != "" {
str += fmt.Sprintf(",service=%q", ac.service)
}
w.Header().Set("WWW-Authenticate", str)
}
// wrapErr wraps errors related to authorization in an authChallenge error that will present a WWW-Authenticate challenge response
func (ac *AccessController) wrapErr(ctx context.Context, err error) error {
switch err {
case ErrTokenRequired:
// Challenge for errors that involve missing tokens
if ac.tokenRealm == nil {
// Send the basic challenge if we don't have a place to redirect
return &authChallenge{realm: ac.realm, err: err}
}
if len(ac.tokenRealm.Scheme) > 0 && len(ac.tokenRealm.Host) > 0 {
// Redirect to token auth if we've been given an absolute URL
return &tokenAuthChallenge{realm: ac.tokenRealm.String(), err: err}
}
// Auto-detect scheme/host from request
req, reqErr := dcontext.GetRequest(ctx)
if reqErr != nil {
return reqErr
}
scheme, host := httprequest.SchemeHost(req)
tokenRealmCopy := *ac.tokenRealm
if len(tokenRealmCopy.Scheme) == 0 {
tokenRealmCopy.Scheme = scheme
}
if len(tokenRealmCopy.Host) == 0 {
tokenRealmCopy.Host = host
}
return &tokenAuthChallenge{realm: tokenRealmCopy.String(), err: err}
case ErrTokenInvalid, ErrOpenShiftAccessDenied:
// Challenge for errors that involve tokens or access denied
return &authChallenge{realm: ac.realm, err: err}
default:
// By default, just return the error, this gets surfaced as a bad request / internal error, but no challenge
return err
}
}
// Authorized handles checking whether the given request is authorized
// for actions on resources allowed by openshift.
// Sources of access records:
//
// origin/pkg/cmd/dockerregistry/dockerregistry.go#Execute
// distribution/distribution/registry/handlers/app.go#appendAccessRecords
func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...registryauth.Access) (context.Context, error) {
req, err := dcontext.GetRequest(ctx)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
bearerToken, err := getOpenShiftAPIToken(req)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
irClient, err := ac.registryClient.Client()
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
osClient, err := ac.registryClient.ClientFromToken(bearerToken)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
// In case of docker login, hits endpoint /v2
if len(bearerToken) > 0 && !isMetricsBearerToken(ac.metricsConfig, bearerToken) {
user, userid, err := verifyOpenShiftUser(ctx, osClient)
if err != nil {
if kerrors.IsUnauthorized(err) || kerrors.IsForbidden(err) {
return nil, ac.wrapErr(ctx, ErrOpenShiftAccessDenied)
}
return nil, ac.wrapErr(ctx, err)
}
ctx = WithUserInfoLogger(ctx, user, userid)
} else {
ctx = WithUserInfoLogger(ctx, defaultUserName, "")
}
if ac.auditLog {
// TODO: setup own log formatter.
ctx = audit.WithLogger(ctx, audit.GetLogger(ctx))
}
// pushChecks remembers which ns/name pairs had push access checks done
pushChecks := map[string]bool{}
// possibleCrossMountErrors holds errors which may be related to cross mount errors
possibleCrossMountErrors := deferredErrors{}
verifiedPrune := false
// Validate all requested accessRecords
// Only return failure errors from this loop. Success should continue to validate all records
for _, access := range accessRecords {
dcontext.GetLogger(ctx).Debugf("Origin auth: checking for access to %s:%s:%s", access.Resource.Type, access.Resource.Name, access.Action)
switch access.Resource.Type {
case "repository":
imageStreamNS, imageStreamName, err := getNamespaceName(access.Resource.Name)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
verb := ""
switch access.Action {
case "push":
verb = "update"
pushChecks[imageStreamNS+"/"+imageStreamName] = true
case "pull":
verb = "get"
case "delete":
if strings.Contains(req.URL.Path, "/blobs/uploads/") {
verb = "update"
} else {
if !verifiedPrune {
if err := verifyPruneAccess(ctx, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
verifiedPrune = true
}
continue
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
if err := verifyImageStreamAccess(ctx, imageStreamNS, imageStreamName, verb, osClient, irClient); err != nil {
if access.Action != "pull" {
return nil, ac.wrapErr(ctx, err)
}
possibleCrossMountErrors.Add(imageStreamNS+"/"+imageStreamName, ac.wrapErr(ctx, err))
}
case "signature":
namespace, name, err := getNamespaceName(access.Resource.Name)
if err != nil {
return nil, ac.wrapErr(ctx, err)
}
switch access.Action {
case "get":
if err := verifyImageStreamAccess(ctx, namespace, name, access.Action, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
case "put":
if err := verifyImageSignatureAccess(ctx, namespace, name, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
case "metrics":
switch access.Action {
case "get":
if err := verifyMetricsAccess(ctx, ac.metricsConfig, bearerToken, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
case "admin":
switch access.Action {
case "prune":
if verifiedPrune {
continue
}
if err := verifyPruneAccess(ctx, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
verifiedPrune = true
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
case "registry":
switch access.Resource.Name {
case "catalog":
if access.Action != "*" {
return nil, ac.wrapErr(ctx, ErrUnsupportedAction)
}
if err := verifyCatalogAccess(ctx, osClient, irClient); err != nil {
return nil, ac.wrapErr(ctx, err)
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedResource)
}
default:
return nil, ac.wrapErr(ctx, ErrUnsupportedResource)
}
}
// deal with any possible cross-mount errors
for namespaceAndName, err := range possibleCrossMountErrors {
// If we have no push requests, this can't be a cross-mount request, so error
if len(pushChecks) == 0 {
return nil, err
}
// If we also requested a push to this ns/name, this isn't a cross-mount request, so error
if pushChecks[namespaceAndName] {
return nil, err
}
}
// Conditionally add auth errors we want to handle later to the context
if !possibleCrossMountErrors.Empty() {
dcontext.GetLogger(ctx).Debugf("Origin auth: deferring errors: %#v", possibleCrossMountErrors)
ctx = withDeferredErrors(ctx, possibleCrossMountErrors)
}
// Always add a marker to the context so we know auth was run
ctx = withAuthPerformed(ctx)
return withUserClient(ctx, osClient), nil
}
func getOpenShiftAPIToken(req *http.Request) (string, error) {
token := ""
authParts := strings.SplitN(req.Header.Get("Authorization"), " ", 2)
if len(authParts) != 2 {
return "", ErrTokenRequired
}
switch strings.ToLower(authParts[0]) {
case "bearer":
// This is either a direct API token, or a token issued by our docker token handler
token = authParts[1]
// Recognize the token issued to anonymous users by our docker token handler
if token == anonymousToken {
token = ""
}
case "basic":
_, password, ok := req.BasicAuth()
if !ok || len(password) == 0 {
return "", ErrTokenInvalid
}
token = password
default:
return "", ErrTokenRequired
}
return token, nil
}
func verifyOpenShiftUser(ctx context.Context, c client.SelfSubjectReviews) (string, string, error) {
ssr := &authnv1.SelfSubjectReview{}
response, err := c.SelfSubjectReviews().Create(ctx, ssr, metav1.CreateOptions{})
if err != nil {
dcontext.GetLogger(ctx).Errorf("Self subject review failed with error: %s", err)
return "", "", err
}
userInfo := response.Status.UserInfo
return userInfo.Username, userInfo.UID, nil
}
func sarStatus(sar *authorizationapi.SelfSubjectAccessReview) string {
var b strings.Builder
if sar.Status.Allowed {
b.WriteString("allowed")
} else if sar.Status.Denied {
b.WriteString("denied")
} else {
b.WriteString("no opinion")
}
if sar.Status.Reason != "" {
b.WriteString(" (")
b.WriteString(sar.Status.Reason)
b.WriteString(")")
}
if sar.Status.EvaluationError != "" {
b.WriteString(": ")
b.WriteString(sar.Status.EvaluationError)
}
return b.String()
}
func verifyWithSAR(
ctx context.Context,
attrs *authorizationapi.ResourceAttributes,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
response, err := remoteClient.SelfSubjectAccessReviews().Create(ctx, &authorizationapi.SelfSubjectAccessReview{
Spec: authorizationapi.SelfSubjectAccessReviewSpec{
ResourceAttributes: attrs,
},
}, metav1.CreateOptions{})
if err != nil {
dcontext.GetLogger(ctx).Errorf("OpenShift client error: %s", err)
if kerrors.IsForbidden(err) {
return verifyWithAnonSAR(ctx, attrs, internalClient)
}
if kerrors.IsUnauthorized(err) {
return ErrOpenShiftAccessDenied
}
return err
}
if !response.Status.Allowed {
dcontext.GetLogger(ctx).Errorf("OpenShift access denied: %s", sarStatus(response))
return ErrOpenShiftAccessDenied
}
return nil
}
func verifyWithAnonSAR(
ctx context.Context,
attrs *authorizationapi.ResourceAttributes,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
response, err := internalClient.SubjectAccessReviews().Create(ctx, &authorizationapi.SubjectAccessReview{
Spec: authorizationapi.SubjectAccessReviewSpec{
User: "system:anonymous",
Groups: []string{"system:unauthenticated"},
ResourceAttributes: attrs,
},
}, metav1.CreateOptions{})
if err != nil {
dcontext.GetLogger(ctx).Errorf("OpenShift internal client error: %s", err)
if kerrors.IsUnauthorized(err) || kerrors.IsForbidden(err) {
return ErrOpenShiftAccessDenied
}
return err
}
if !response.Status.Allowed {
return ErrOpenShiftAccessDenied
}
return nil
}
func verifyWithGlobalSAR(
ctx context.Context,
resource, subresource, verb string,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithSAR(ctx, &authorizationapi.ResourceAttributes{
Verb: verb,
Group: imageapi.GroupName,
Resource: resource,
Subresource: subresource,
}, remoteClient, internalClient)
}
func verifyWithLocalSAR(
ctx context.Context,
resource, namespace, name, verb string,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithSAR(ctx, &authorizationapi.ResourceAttributes{
Namespace: namespace,
Verb: verb,
Group: imageapi.GroupName,
Resource: resource,
Name: name,
}, remoteClient, internalClient)
}
func verifyImageStreamAccess(
ctx context.Context,
namespace, imageRepo, verb string,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithLocalSAR(ctx, "imagestreams/layers", namespace, imageRepo, verb, remoteClient, internalClient)
}
func verifyImageSignatureAccess(
ctx context.Context,
namespace, imageRepo string,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithLocalSAR(ctx, "imagesignatures", namespace, imageRepo, "create", remoteClient, internalClient)
}
func verifyPruneAccess(
ctx context.Context,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithGlobalSAR(ctx, "images", "", "delete", remoteClient, internalClient)
}
func verifyCatalogAccess(
ctx context.Context,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
return verifyWithGlobalSAR(ctx, "imagestreams", "", "list", remoteClient, internalClient)
}
func verifyMetricsAccess(
ctx context.Context,
metrics configuration.Metrics,
token string,
remoteClient client.SelfSubjectAccessReviewsNamespacer,
internalClient client.SubjectAccessReviewsNamespacer,
) error {
if !metrics.Enabled {
return ErrOpenShiftAccessDenied
}
if len(metrics.Secret) > 0 {
if metrics.Secret != token {
return ErrOpenShiftAccessDenied
}
return nil
}
if err := verifyWithGlobalSAR(ctx, "registry", "metrics", "get", remoteClient, internalClient); err != nil {
return err
}
return nil
}
func isMetricsBearerToken(metrics configuration.Metrics, token string) bool {
if metrics.Enabled {
return metrics.Secret == token
}
return false
}