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

575 lines
22 KiB
Go

package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"testing"
"github.com/distribution/distribution/v3"
dockercfg "github.com/distribution/distribution/v3/configuration"
dcontext "github.com/distribution/distribution/v3/context"
"github.com/distribution/distribution/v3/registry/auth"
authenticationapi "k8s.io/api/authentication/v1"
authorizationapi "k8s.io/api/authorization/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
restclient "k8s.io/client-go/rest"
"github.com/openshift/image-registry/pkg/dockerregistry/server/client"
"github.com/openshift/image-registry/pkg/dockerregistry/server/configuration"
"github.com/openshift/image-registry/pkg/origin-common/clientcmd"
"github.com/openshift/image-registry/pkg/testutil"
)
var (
scheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(scheme)
)
func init() {
authorizationapi.AddToScheme(scheme)
authenticationapi.AddToScheme(scheme)
}
func sarResponse(ns string, allowed bool, reason string) *authorizationapi.SelfSubjectAccessReview {
resp := &authorizationapi.SelfSubjectAccessReview{}
resp.Namespace = ns
resp.Status = authorizationapi.SubjectAccessReviewStatus{Allowed: allowed, Reason: reason}
return resp
}
// TestVerifyImageStreamAccess mocks openshift http request/response and
// tests invalid/valid/scoped openshift tokens.
func TestVerifyImageStreamAccess(t *testing.T) {
tests := []struct {
openshiftResponse response
expectedError error
}{
{
// Test invalid openshift bearer token
openshiftResponse: response{401, "Unauthorized"},
expectedError: ErrOpenShiftAccessDenied,
},
{
// Test valid openshift bearer token but token *not* scoped for create operation
openshiftResponse: response{
200,
runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("foo", false, "not authorized!")),
},
expectedError: ErrOpenShiftAccessDenied,
},
{
// Test valid openshift bearer token and token scoped for create operation
openshiftResponse: response{
200,
runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("foo", true, "authorized!")),
},
expectedError: nil,
},
}
for _, test := range tests {
ctx := context.Background()
ctx = testutil.WithTestLogger(ctx, t)
server, _ := simulateOpenShiftMaster([]response{test.openshiftResponse})
cfg := clientcmd.NewConfig()
cfg.SkipEnv = true
cfg.KubernetesAddr.Set(server.URL)
cfg.CommonConfig = restclient.Config{
BearerToken: "magic bearer token",
Host: server.URL,
}
osclient, err := client.NewRegistryClient(cfg).Client()
if err != nil {
t.Fatal(err)
}
err = verifyImageStreamAccess(ctx, "foo", "bar", "create", osclient, osclient)
if err == nil || test.expectedError == nil {
if err != test.expectedError {
t.Fatalf("verifyImageStreamAccess did not get expected error - got %s - expected %s", err, test.expectedError)
}
} else if err.Error() != test.expectedError.Error() {
t.Fatalf("verifyImageStreamAccess did not get expected error - got %s - expected %s", err, test.expectedError)
}
server.Close()
}
}
// TestAccessController tests complete integration of the v2 registry auth package.
func TestAccessController(t *testing.T) {
const addr = "https://openshift-example.com/osapi"
authConfig := &configuration.Auth{
Realm: "myrealm",
TokenRealm: "http://tokenrealm.com",
}
tests := map[string]struct {
authConfig *configuration.Auth
access []auth.Access
basicToken string
bearerToken string
openshiftResponses []response
expectedError error
expectedChallenge bool
expectedHeaders http.Header
expectedRepoErr string
expectedActions []string
}{
"no token": {
access: []auth.Access{},
basicToken: "",
expectedError: ErrTokenRequired,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Bearer realm="http://tokenrealm.com/openshift/token"`}},
},
"no token, autodetected tokenrealm": {
authConfig: &configuration.Auth{
Realm: "myrealm",
TokenRealm: "",
},
access: []auth.Access{},
basicToken: "",
expectedError: ErrTokenRequired,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Bearer realm="https://openshift-example.com/openshift/token"`}},
},
"invalid registry token": {
access: []auth.Access{{
Resource: auth.Resource{Type: "repository"},
}},
basicToken: "ab-cd-ef-gh",
expectedError: ErrTokenInvalid,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="failed to decode credentials"`}},
},
"invalid openshift basic password": {
access: []auth.Access{{
Resource: auth.Resource{Type: "repository"},
}},
basicToken: "abcdefgh",
expectedError: ErrTokenInvalid,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="failed to decode credentials"`}},
},
"valid openshift token but invalid namespace": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "bar",
},
Action: "pull",
}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
},
expectedError: distribution.ErrRepositoryNameInvalid{
Name: "bar",
Reason: fmt.Errorf("it must be of the format <project>/<name>"),
},
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
},
},
"registry token but does not involve any repository operation": {
access: []auth.Access{{}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
},
expectedError: ErrUnsupportedResource,
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
},
},
"registry token but does not involve any known action": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "blah",
}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
},
expectedError: ErrUnsupportedAction,
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
},
},
"docker login with invalid openshift creds": {
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{{403, ""}},
expectedError: ErrOpenShiftAccessDenied,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}},
expectedActions: []string{"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)"},
},
"docker login with valid openshift creds": {
basicToken: "dXNyMTphd2Vzb21l",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
},
expectedError: nil,
expectedChallenge: false,
expectedActions: []string{"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)"},
},
"error running subject access review": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "pull",
}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{500, "Uh oh"},
},
expectedError: errors.New("an error on the server (\"unknown\") has prevented the request from succeeding (post selfsubjectaccessreviews.authorization.k8s.io)"),
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
"valid openshift token but token not scoped for the given repo operation": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "pull",
}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("foo", false, "not"))},
},
expectedError: ErrOpenShiftAccessDenied,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}},
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
"partially valid openshift token": {
// Check all the different resource-type/verb combinations we allow to make sure they validate and continue to validate remaining Resource requests
access: []auth.Access{
{Resource: auth.Resource{Type: "repository", Name: "foo/aaa"}, Action: "pull"},
{Resource: auth.Resource{Type: "repository", Name: "bar/bbb"}, Action: "push"},
{Resource: auth.Resource{Type: "admin"}, Action: "prune"},
{Resource: auth.Resource{Type: "repository", Name: "baz/ccc"}, Action: "push"},
},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("foo", true, "authorized!"))},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("bar", true, "authorized!"))},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("", true, "authorized!"))},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("baz", false, "no!"))},
},
expectedError: ErrOpenShiftAccessDenied,
expectedChallenge: true,
expectedHeaders: http.Header{"Www-Authenticate": []string{`Basic realm=myrealm,error="access denied"`}},
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
"deferred cross-mount error": {
// cross-mount push requests check pull/push access on the target repo and pull access on the source repo.
// we expect the access check failure for fromrepo/bbb to be added to the context as a deferred error,
// which our blobstore will look for and prevent a cross mount from.
access: []auth.Access{
{Resource: auth.Resource{Type: "repository", Name: "pushrepo/aaa"}, Action: "pull"},
{Resource: auth.Resource{Type: "repository", Name: "pushrepo/aaa"}, Action: "push"},
{Resource: auth.Resource{Type: "repository", Name: "fromrepo/bbb"}, Action: "pull"},
},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("pushrepo", true, "authorized!"))},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("pushrepo", true, "authorized!"))},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("fromrepo", false, "no!"))},
},
expectedError: nil,
expectedChallenge: false,
expectedRepoErr: "fromrepo/bbb",
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
"valid openshift token": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "pull",
}},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("", true, "authorized!"))},
},
expectedError: nil,
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
"valid anonymous token": {
access: []auth.Access{{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "pull",
}},
bearerToken: "anonymous",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("foo", true, "authorized!"))},
},
expectedError: nil,
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=)",
},
},
"pruning": {
access: []auth.Access{
{
Resource: auth.Resource{
Type: "admin",
},
Action: "prune",
},
{
Resource: auth.Resource{
Type: "repository",
Name: "foo/bar",
},
Action: "delete",
},
},
basicToken: "b3BlbnNoaWZ0OmF3ZXNvbWU=",
openshiftResponses: []response{
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authenticationapi.SchemeGroupVersion), &authenticationapi.SelfSubjectReview{Status: authenticationapi.SelfSubjectReviewStatus{UserInfo: authenticationapi.UserInfo{Username: "usr1"}}})},
{200, runtime.EncodeOrDie(codecs.LegacyCodec(authorizationapi.SchemeGroupVersion), sarResponse("", true, "authorized!"))},
},
expectedError: nil,
expectedChallenge: false,
expectedActions: []string{
"POST /apis/authentication.k8s.io/v1/selfsubjectreviews (Authorization=Bearer awesome)",
"POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews (Authorization=Bearer awesome)",
},
},
}
for k, test := range tests {
t.Run(k, func(t *testing.T) {
reqURL, err := url.Parse(addr)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest("GET", addr, nil)
if err != nil {
t.Fatalf("%s: %v", k, err)
}
// Simulate a secure request to the specified server
req.Host = reqURL.Host
req.TLS = &tls.ConnectionState{ServerName: reqURL.Host}
if len(test.basicToken) > 0 {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", test.basicToken))
}
if len(test.bearerToken) > 0 {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", test.bearerToken))
}
ctx := context.Background()
ctx = testutil.WithTestLogger(ctx, t)
server, actions := simulateOpenShiftMaster(test.openshiftResponses)
cfg := clientcmd.NewConfig()
cfg.SkipEnv = true
cfg.KubernetesAddr.Set(server.URL)
cfg.CommonConfig = restclient.Config{
Host: server.URL,
TLSClientConfig: restclient.TLSClientConfig{Insecure: true},
}
config := &dockercfg.Configuration{}
app := &App{
ctx: ctx,
registryClient: client.NewRegistryClient(cfg),
config: &configuration.Configuration{
Server: &configuration.Server{
Addr: "localhost:5000",
},
Auth: test.authConfig,
},
}
if app.config.Auth == nil {
app.config.Auth = authConfig
}
if err := configuration.InitExtraConfig(config, app.config); err != nil {
t.Fatal(err)
}
accessController, err := app.Auth(nil)
if err != nil {
t.Fatal(err)
}
ctx = dcontext.WithRequest(ctx, req)
authCtx, err := accessController.Authorized(ctx, test.access...)
server.Close()
expectedActions := test.expectedActions
if expectedActions == nil {
expectedActions = []string{}
}
if !reflect.DeepEqual(actions, &expectedActions) {
t.Fatalf("expected: %#v, got: %#v", &expectedActions, actions)
}
if err == nil || test.expectedError == nil {
if err != test.expectedError {
t.Fatalf("accessController did not get expected error - got %#+v - expected %v", err, test.expectedError)
}
if authCtx == nil {
t.Fatalf("expected auth context but got nil")
}
if !authPerformed(authCtx) {
t.Fatalf("expected AuthPerformed to be true")
}
deferredErrors, hasDeferred := deferredErrorsFrom(authCtx)
if len(test.expectedRepoErr) > 0 {
if !hasDeferred || deferredErrors[test.expectedRepoErr] == nil {
t.Fatalf("expected deferred error for repo %s, got none", test.expectedRepoErr)
}
} else {
if hasDeferred && len(deferredErrors) > 0 {
t.Fatalf("didn't expect deferred errors, got %#v", deferredErrors)
}
}
} else {
challengeErr, isChallenge := err.(auth.Challenge)
if test.expectedChallenge != isChallenge {
t.Fatalf("expected challenge=%v, accessController returned challenge=%v", test.expectedChallenge, isChallenge)
}
if isChallenge {
recorder := httptest.NewRecorder()
challengeErr.SetHeaders(req, recorder)
if !reflect.DeepEqual(recorder.HeaderMap, test.expectedHeaders) {
t.Fatalf("expected headers %#v, got %#v", test.expectedHeaders, recorder.HeaderMap)
}
}
if err.Error() != test.expectedError.Error() {
t.Fatalf("accessController did not get expected error - got %+v - expected %s", err, test.expectedError)
}
if authCtx != nil {
t.Fatalf("expected nil auth context but got %s", authCtx)
}
}
})
}
}
type response struct {
code int
body string
}
func simulateOpenShiftMaster(responses []response) (*httptest.Server, *[]string) {
i := 0
actions := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := response{500, "No response registered"}
if i < len(responses) {
response = responses[i]
}
i++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.code)
fmt.Fprintln(w, response.body)
actions = append(actions, fmt.Sprintf(`%s %s (Authorization=%s)`, r.Method, r.URL.Path, r.Header.Get("Authorization")))
}))
return server, &actions
}
func TestSARStatus(t *testing.T) {
testCases := []struct {
sar *authorizationapi.SelfSubjectAccessReview
output string
}{
{
sar: &authorizationapi.SelfSubjectAccessReview{
Status: authorizationapi.SubjectAccessReviewStatus{},
},
output: "no opinion",
},
{
sar: &authorizationapi.SelfSubjectAccessReview{
Status: authorizationapi.SubjectAccessReviewStatus{
Allowed: true,
},
},
output: "allowed",
},
{
sar: &authorizationapi.SelfSubjectAccessReview{
Status: authorizationapi.SubjectAccessReviewStatus{
Denied: true,
},
},
output: "denied",
},
{
sar: &authorizationapi.SelfSubjectAccessReview{
Status: authorizationapi.SubjectAccessReviewStatus{
Denied: true,
EvaluationError: "webhook failed closed",
},
},
output: "denied: webhook failed closed",
},
{
sar: &authorizationapi.SelfSubjectAccessReview{
Status: authorizationapi.SubjectAccessReviewStatus{
Reason: "Error",
EvaluationError: "no user on request.",
},
},
output: "no opinion (Error): no user on request.",
},
}
for _, tc := range testCases {
s := sarStatus(tc.sar)
if s != tc.output {
t.Errorf("got %q, want %q", s, tc.output)
}
}
}