From 475d8d54f0756e147775c28874de0859804e875c Mon Sep 17 00:00:00 2001 From: Basanth Jenu H B Date: Tue, 2 Nov 2021 08:39:44 +0530 Subject: [PATCH] feat: Adds SSO control via individual namespaces. Fixes #6916 (#6990) Signed-off-by: bjenuhb --- cmd/argo/commands/server.go | 20 +++ docs/cli/argo_server.md | 1 + .../argo-server-clusterole.yaml | 3 + manifests/install.yaml | 3 + manifests/namespace-install.yaml | 3 + .../argo-server-rbac/argo-server-role.yaml | 3 + manifests/quick-start-minimal.yaml | 3 + manifests/quick-start-mysql.yaml | 3 + manifests/quick-start-postgres.yaml | 3 + .../workflow-controller-configmap.yaml | 2 +- pkg/apiclient/argo-kube-client.go | 2 +- server/apiserver/argoserver.go | 17 +- server/artifacts/artifact_server.go | 69 +++++---- server/artifacts/artifact_server_test.go | 4 +- server/auth/authorizing_server_stream.go | 44 ++++++ server/auth/gatekeeper.go | 138 ++++++++++++----- server/auth/gatekeeper_test.go | 146 ++++++++++++++++-- server/auth/mocks/Gatekeeper.go | 23 +++ server/cache/cache.go | 26 ++++ server/cache/cache_test.go | 93 +++++++++++ server/types/namespaces.go | 11 ++ 21 files changed, 530 insertions(+), 87 deletions(-) create mode 100644 server/auth/authorizing_server_stream.go create mode 100644 server/cache/cache.go create mode 100644 server/cache/cache_test.go create mode 100644 server/types/namespaces.go diff --git a/cmd/argo/commands/server.go b/cmd/argo/commands/server.go index c291bc12e638..6e21c17428e9 100644 --- a/cmd/argo/commands/server.go +++ b/cmd/argo/commands/server.go @@ -46,6 +46,7 @@ func NewServerCommand() *cobra.Command { htst bool namespaced bool // --namespaced managedNamespace string // --managed-namespace + ssoNamespace string enableOpenBrowser bool eventOperationQueueSize int eventWorkerCount int @@ -124,11 +125,29 @@ See %s`, help.ArgoServer), log.Warn("You are running without client authentication. Learn how to enable client authentication: https://argoproj.github.io/argo-workflows/argo-server-auth-mode/") } + if namespaced { + // Case 1: If ssoNamespace is not specified, default it to installation namespace + if ssoNamespace == "" { + ssoNamespace = namespace + } + // Case 2: If ssoNamespace is not equal to installation or managed namespace, default it to installation namespace + if ssoNamespace != namespace && ssoNamespace != managedNamespace { + log.Warn("--sso-namespace should be equal to --managed-namespace or the installation namespace") + ssoNamespace = namespace + } + } else { + if ssoNamespace != "" { + log.Warn("ignoring --sso-namespace because --namespaced is false") + } + ssoNamespace = namespace + } opts := apiserver.ArgoServerOpts{ BaseHRef: baseHRef, TLSConfig: tlsConfig, HSTS: htst, + Namespaced: namespaced, Namespace: namespace, + SSONameSpace: ssoNamespace, Clients: clients, RestConfig: config, AuthModes: modes, @@ -187,6 +206,7 @@ See %s`, help.ArgoServer), command.Flags().StringVar(&configMap, "configmap", "workflow-controller-configmap", "Name of K8s configmap to retrieve workflow controller configuration") command.Flags().BoolVar(&namespaced, "namespaced", false, "run as namespaced mode") command.Flags().StringVar(&managedNamespace, "managed-namespace", "", "namespace that watches, default to the installation namespace") + command.Flags().StringVar(&ssoNamespace, "sso-namespace", "", "namespace that will be used for SSO RBAC. Defaults to installation namespace. Used only in namespaced mode") command.Flags().BoolVarP(&enableOpenBrowser, "browser", "b", false, "enable automatic launching of the browser [local mode]") command.Flags().IntVar(&eventOperationQueueSize, "event-operation-queue-size", 16, "how many events operations that can be queued at once") command.Flags().IntVar(&eventWorkerCount, "event-worker-count", 4, "how many event workers to run") diff --git a/docs/cli/argo_server.md b/docs/cli/argo_server.md index 365fce076a83..6a9c4520dbe0 100644 --- a/docs/cli/argo_server.md +++ b/docs/cli/argo_server.md @@ -30,6 +30,7 @@ See https://argoproj.github.io/argo-workflows/argo-server/ --managed-namespace string namespace that watches, default to the installation namespace --namespaced run as namespaced mode -p, --port int Port to listen on (default 2746) + --sso-namespace string namespace that will be used for SSO RBAC. Defaults to installation namespace. Used only in namespaced mode --x-frame-options string Set X-Frame-Options header in HTTP responses. (default "DENY") ``` diff --git a/manifests/cluster-install/argo-server-rbac/argo-server-clusterole.yaml b/manifests/cluster-install/argo-server-rbac/argo-server-clusterole.yaml index f954115b005c..74e7849f901a 100644 --- a/manifests/cluster-install/argo-server-rbac/argo-server-clusterole.yaml +++ b/manifests/cluster-install/argo-server-rbac/argo-server-clusterole.yaml @@ -18,6 +18,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -44,6 +46,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/install.yaml b/manifests/install.yaml index d6d909475980..817fa1b7dfc4 100644 --- a/manifests/install.yaml +++ b/manifests/install.yaml @@ -472,6 +472,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -498,6 +500,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/namespace-install.yaml b/manifests/namespace-install.yaml index d4d5baa98bfe..19f3f1eecbc5 100644 --- a/manifests/namespace-install.yaml +++ b/manifests/namespace-install.yaml @@ -377,6 +377,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -403,6 +405,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml b/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml index 546be634146b..0470b138d9d3 100644 --- a/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml +++ b/manifests/namespace-install/argo-server-rbac/argo-server-role.yaml @@ -18,6 +18,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -44,6 +46,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/quick-start-minimal.yaml b/manifests/quick-start-minimal.yaml index 994f8d01e83c..f8ddd2426f17 100644 --- a/manifests/quick-start-minimal.yaml +++ b/manifests/quick-start-minimal.yaml @@ -382,6 +382,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -408,6 +410,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/quick-start-mysql.yaml b/manifests/quick-start-mysql.yaml index 024b32dfe768..515ba1ad2fd2 100644 --- a/manifests/quick-start-mysql.yaml +++ b/manifests/quick-start-mysql.yaml @@ -382,6 +382,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -408,6 +410,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/quick-start-postgres.yaml b/manifests/quick-start-postgres.yaml index 6f6645b4ed59..e07c8534ee3f 100644 --- a/manifests/quick-start-postgres.yaml +++ b/manifests/quick-start-postgres.yaml @@ -382,6 +382,8 @@ rules: verbs: - get - create + - list + - watch - apiGroups: - "" resources: @@ -408,6 +410,7 @@ rules: verbs: - get - list + - watch - apiGroups: - argoproj.io resources: diff --git a/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml b/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml index ed990346869f..088fa2febb32 100644 --- a/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml +++ b/manifests/quick-start/sso/overlays/workflow-controller-configmap.yaml @@ -2,7 +2,7 @@ apiVersion: v1 data: sso: | issuer: http://dex:5556/dex - issuerAlias: http://mydex:5556/dex + issuerAlias: http://dex:5556/dex clientId: name: argo-server-sso key: clientID diff --git a/pkg/apiclient/argo-kube-client.go b/pkg/apiclient/argo-kube-client.go index 1ccb71b6f1ce..8fbbdb39fb27 100644 --- a/pkg/apiclient/argo-kube-client.go +++ b/pkg/apiclient/argo-kube-client.go @@ -71,7 +71,7 @@ func newArgoKubeClient(ctx context.Context, clientConfig clientcmd.ClientConfig, Sensor: sensorInterface, Workflow: wfClient, } - gatekeeper, err := auth.NewGatekeeper(auth.Modes{auth.Server: true}, clients, restConfig, nil, auth.DefaultClientForAuthorization, "unused") + gatekeeper, err := auth.NewGatekeeper(auth.Modes{auth.Server: true}, clients, restConfig, nil, auth.DefaultClientForAuthorization, "unused", "unused", false, nil) if err != nil { return nil, nil, err } diff --git a/server/apiserver/argoserver.go b/server/apiserver/argoserver.go index 5758c4609b05..a3d8fb03fcd7 100644 --- a/server/apiserver/argoserver.go +++ b/server/apiserver/argoserver.go @@ -20,6 +20,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/rest" "k8s.io/utils/env" @@ -42,6 +43,7 @@ import ( "github.com/argoproj/argo-workflows/v3/server/auth" "github.com/argoproj/argo-workflows/v3/server/auth/sso" "github.com/argoproj/argo-workflows/v3/server/auth/webhook" + "github.com/argoproj/argo-workflows/v3/server/cache" "github.com/argoproj/argo-workflows/v3/server/clusterworkflowtemplate" "github.com/argoproj/argo-workflows/v3/server/cronworkflow" "github.com/argoproj/argo-workflows/v3/server/event" @@ -81,11 +83,13 @@ type argoServer struct { eventAsyncDispatch bool xframeOptions string accessControlAllowOrigin string + cache *cache.ResourceCache } type ArgoServerOpts struct { BaseHRef string TLSConfig *tls.Config + Namespaced bool Namespace string Clients *types.Clients RestConfig *rest.Config @@ -93,6 +97,7 @@ type ArgoServerOpts struct { // config map name ConfigName string ManagedNamespace string + SSONameSpace string HSTS bool EventOperationQueueSize int EventWorkerCount int @@ -109,8 +114,16 @@ func init() { } } +func getResourceCacheNamespace(opts ArgoServerOpts) string { + if opts.Namespaced { + return opts.SSONameSpace + } + return v1.NamespaceAll +} + func NewArgoServer(ctx context.Context, opts ArgoServerOpts) (*argoServer, error) { configController := config.NewController(opts.Namespace, opts.ConfigName, opts.Clients.Kubernetes, emptyConfigFunc) + var resourceCache *cache.ResourceCache = nil ssoIf := sso.NullSSO if opts.AuthModes[auth.SSO] { c, err := configController.Get(ctx) @@ -121,11 +134,12 @@ func NewArgoServer(ctx context.Context, opts ArgoServerOpts) (*argoServer, error if err != nil { return nil, err } + resourceCache = cache.NewResourceCache(opts.Clients.Kubernetes, ctx, getResourceCacheNamespace(opts)) log.Info("SSO enabled") } else { log.Info("SSO disabled") } - gatekeeper, err := auth.NewGatekeeper(opts.AuthModes, opts.Clients, opts.RestConfig, ssoIf, auth.DefaultClientForAuthorization, opts.Namespace) + gatekeeper, err := auth.NewGatekeeper(opts.AuthModes, opts.Clients, opts.RestConfig, ssoIf, auth.DefaultClientForAuthorization, opts.Namespace, opts.SSONameSpace, opts.Namespaced, resourceCache) if err != nil { return nil, err } @@ -145,6 +159,7 @@ func NewArgoServer(ctx context.Context, opts ArgoServerOpts) (*argoServer, error eventAsyncDispatch: opts.EventAsyncDispatch, xframeOptions: opts.XFrameOptions, accessControlAllowOrigin: opts.AccessControlAllowOrigin, + cache: resourceCache, }, nil } diff --git a/server/artifacts/artifact_server.go b/server/artifacts/artifact_server.go index 6e5e14daa933..1ad681b47b6b 100644 --- a/server/artifacts/artifact_server.go +++ b/server/artifacts/artifact_server.go @@ -21,6 +21,7 @@ import ( "github.com/argoproj/argo-workflows/v3/persist/sqldb" wfv1 "github.com/argoproj/argo-workflows/v3/pkg/apis/workflow/v1alpha1" "github.com/argoproj/argo-workflows/v3/server/auth" + "github.com/argoproj/argo-workflows/v3/server/types" "github.com/argoproj/argo-workflows/v3/util/instanceid" "github.com/argoproj/argo-workflows/v3/workflow/artifactrepositories" artifact "github.com/argoproj/argo-workflows/v3/workflow/artifacts" @@ -53,18 +54,17 @@ func (a *ArtifactServer) GetInputArtifact(w http.ResponseWriter, r *http.Request } func (a *ArtifactServer) getArtifact(w http.ResponseWriter, r *http.Request, isInput bool) { - ctx, err := a.gateKeeping(r) + requestPath := strings.SplitN(r.URL.Path, "/", 6) + namespace := requestPath[2] + workflowName := requestPath[3] + nodeId := requestPath[4] + artifactName := requestPath[5] + + ctx, err := a.gateKeeping(r, types.NamespaceHolder(namespace)) if err != nil { - w.WriteHeader(401) - _, _ = w.Write([]byte(err.Error())) + a.unauthorizedError(err, w) return } - path := strings.SplitN(r.URL.Path, "/", 6) - - namespace := path[2] - workflowName := path[3] - nodeId := path[4] - artifactName := path[5] log.WithFields(log.Fields{"namespace": namespace, "workflowName": workflowName, "nodeId": nodeId, "artifactName": artifactName, "isInput": isInput}).Info("Download artifact") @@ -91,27 +91,33 @@ func (a *ArtifactServer) GetInputArtifactByUID(w http.ResponseWriter, r *http.Re } func (a *ArtifactServer) getArtifactByUID(w http.ResponseWriter, r *http.Request, isInput bool) { - ctx, err := a.gateKeeping(r) + requestPath := strings.SplitN(r.URL.Path, "/", 6) + + uid := requestPath[2] + nodeId := requestPath[3] + artifactName := requestPath[4] + + // We need to know the namespace before we can do gate keeping + wf, err := a.wfArchive.GetWorkflow(uid) if err != nil { - w.WriteHeader(401) - _, _ = w.Write([]byte(err.Error())) + a.serverInternalError(err, w) return } - path := strings.SplitN(r.URL.Path, "/", 6) - - uid := path[2] - nodeId := path[3] - artifactName := path[4] - - log.WithFields(log.Fields{"uid": uid, "nodeId": nodeId, "artifactName": artifactName, "isInput": isInput}).Info("Download artifact") + ctx, err := a.gateKeeping(r, types.NamespaceHolder(wf.GetNamespace())) + if err != nil { + a.unauthorizedError(err, w) + return + } - wf, err := a.getWorkflowByUID(ctx, uid) + // return 401 if the client does not have permission to get wf + err = a.validateAccess(ctx, wf) if err != nil { - a.serverInternalError(err, w) + a.unauthorizedError(err, w) return } + log.WithFields(log.Fields{"uid": uid, "nodeId": nodeId, "artifactName": artifactName, "isInput": isInput}).Info("Download artifact") err = a.returnArtifact(ctx, w, r, wf, nodeId, artifactName, isInput) if err != nil { @@ -120,7 +126,7 @@ func (a *ArtifactServer) getArtifactByUID(w http.ResponseWriter, r *http.Request } } -func (a *ArtifactServer) gateKeeping(r *http.Request) (context.Context, error) { +func (a *ArtifactServer) gateKeeping(r *http.Request, ns types.NamespacedRequest) (context.Context, error) { token := r.Header.Get("Authorization") if token == "" { cookie, err := r.Cookie("authorization") @@ -133,7 +139,12 @@ func (a *ArtifactServer) gateKeeping(r *http.Request) (context.Context, error) { } } ctx := metadata.NewIncomingContext(r.Context(), metadata.MD{"authorization": []string{token}}) - return a.gatekeeper.Context(ctx) + return a.gatekeeper.ContextWithRequest(ctx, ns) +} + +func (a *ArtifactServer) unauthorizedError(err error, w http.ResponseWriter) { + w.WriteHeader(401) + _, _ = w.Write([]byte(err.Error())) } func (a *ArtifactServer) serverInternalError(err error, w http.ResponseWriter) { @@ -229,17 +240,13 @@ func (a *ArtifactServer) getWorkflowAndValidate(ctx context.Context, namespace s return wf, nil } -func (a *ArtifactServer) getWorkflowByUID(ctx context.Context, uid string) (*wfv1.Workflow, error) { - wf, err := a.wfArchive.GetWorkflow(uid) - if err != nil { - return nil, err - } +func (a *ArtifactServer) validateAccess(ctx context.Context, wf *wfv1.Workflow) error { allowed, err := auth.CanI(ctx, "get", "workflows", wf.Namespace, wf.Name) if err != nil { - return nil, err + return err } if !allowed { - return nil, status.Error(codes.PermissionDenied, "permission denied") + return status.Error(codes.PermissionDenied, "permission denied") } - return wf, nil + return nil } diff --git a/server/artifacts/artifact_server_test.go b/server/artifacts/artifact_server_test.go index 9a5137f8375f..7e80592d59a9 100644 --- a/server/artifacts/artifact_server_test.go +++ b/server/artifacts/artifact_server_test.go @@ -118,7 +118,7 @@ func newServer() *ArtifactServer { ObjectMeta: metav1.ObjectMeta{Namespace: "my-ns", Name: "your-wf"}, }) ctx := context.WithValue(context.WithValue(context.Background(), auth.KubeKey, kube), auth.WfKey, argo) - gatekeeper.On("Context", mock.Anything).Return(ctx, nil) + gatekeeper.On("ContextWithRequest", mock.Anything, mock.Anything).Return(ctx, nil) a := &sqldbmocks.WorkflowArchive{} a.On("GetWorkflow", "my-uuid").Return(wf, nil) @@ -229,5 +229,5 @@ func TestArtifactServer_GetOutputArtifactByUID(t *testing.T) { r.URL = mustParse("/artifacts/my-uuid/my-node/my-artifact") w := &testhttp.TestResponseWriter{} s.GetOutputArtifactByUID(w, r) - assert.Equal(t, 500, w.StatusCode) + assert.Equal(t, 401, w.StatusCode) } diff --git a/server/auth/authorizing_server_stream.go b/server/auth/authorizing_server_stream.go new file mode 100644 index 000000000000..7d3c07010512 --- /dev/null +++ b/server/auth/authorizing_server_stream.go @@ -0,0 +1,44 @@ +package auth + +import ( + "context" + + "google.golang.org/grpc" +) + +// authorizingServerStream is a thin wrapper around grpc.ServerStream that allows modifying context and do RBAC via gatekeeper. +type authorizingServerStream struct { + grpc.ServerStream + ctx context.Context + Gatekeeper +} + +func NewAuthorizingServerStream(ss grpc.ServerStream, gk Gatekeeper) *authorizingServerStream { + return &authorizingServerStream{ + ServerStream: ss, + ctx: ss.Context(), + Gatekeeper: gk, + } +} + +func (l *authorizingServerStream) Context() context.Context { + return l.ctx +} + +func (l *authorizingServerStream) SendMsg(m interface{}) error { + return l.ServerStream.SendMsg(m) +} + +// RecvMsg is overridden so that we can understand the request and use it for RBAC +func (l *authorizingServerStream) RecvMsg(m interface{}) error { + err := l.ServerStream.RecvMsg(m) + if err != nil { + return err + } + ctx, err := l.Gatekeeper.ContextWithRequest(l.ctx, m) + if err != nil { + return err + } + l.ctx = ctx + return nil +} diff --git a/server/auth/gatekeeper.go b/server/auth/gatekeeper.go index d58364163c93..fdf730368196 100644 --- a/server/auth/gatekeeper.go +++ b/server/auth/gatekeeper.go @@ -4,20 +4,20 @@ import ( "context" "fmt" "net/http" + "os" "sort" "strconv" "github.com/antonmedv/expr" eventsource "github.com/argoproj/argo-events/pkg/client/eventsource/clientset/versioned" sensor "github.com/argoproj/argo-events/pkg/client/sensor/clientset/versioned" - grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -26,6 +26,7 @@ import ( "github.com/argoproj/argo-workflows/v3/server/auth/serviceaccount" "github.com/argoproj/argo-workflows/v3/server/auth/sso" "github.com/argoproj/argo-workflows/v3/server/auth/types" + "github.com/argoproj/argo-workflows/v3/server/cache" servertypes "github.com/argoproj/argo-workflows/v3/server/types" jsonutil "github.com/argoproj/argo-workflows/v3/util/json" "github.com/argoproj/argo-workflows/v3/util/kubeconfig" @@ -46,6 +47,7 @@ const ( //go:generate mockery --name=Gatekeeper type Gatekeeper interface { + ContextWithRequest(ctx context.Context, req interface{}) (context.Context, error) Context(ctx context.Context) (context.Context, error) UnaryServerInterceptor() grpc.UnaryServerInterceptor StreamServerInterceptor() grpc.StreamServerInterceptor @@ -61,19 +63,33 @@ type gatekeeper struct { ssoIf sso.Interface clientForAuthorization ClientForAuthorization // The namespace the server is installed in. - namespace string + namespace string + ssoNamespace string + namespaced bool + cache *cache.ResourceCache } -func NewGatekeeper(modes Modes, clients *servertypes.Clients, restConfig *rest.Config, ssoIf sso.Interface, clientForAuthorization ClientForAuthorization, namespace string) (Gatekeeper, error) { +func NewGatekeeper(modes Modes, clients *servertypes.Clients, restConfig *rest.Config, ssoIf sso.Interface, clientForAuthorization ClientForAuthorization, namespace string, ssoNamespace string, namespaced bool, cache *cache.ResourceCache) (Gatekeeper, error) { if len(modes) == 0 { return nil, fmt.Errorf("must specify at least one auth mode") } - return &gatekeeper{modes, clients, restConfig, ssoIf, clientForAuthorization, namespace}, nil + return &gatekeeper{ + modes, + clients, + restConfig, + ssoIf, + clientForAuthorization, + namespace, + ssoNamespace, + namespaced, + cache, + }, nil + } func (s *gatekeeper) UnaryServerInterceptor() grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { - ctx, err = s.Context(ctx) + ctx, err = s.ContextWithRequest(ctx, req) if err != nil { return nil, err } @@ -83,18 +99,12 @@ func (s *gatekeeper) UnaryServerInterceptor() grpc.UnaryServerInterceptor { func (s *gatekeeper) StreamServerInterceptor() grpc.StreamServerInterceptor { return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - ctx, err := s.Context(ss.Context()) - if err != nil { - return err - } - wrapped := grpc_middleware.WrapServerStream(ss) - wrapped.WrappedContext = ctx - return handler(srv, wrapped) + return handler(srv, NewAuthorizingServerStream(ss, s)) } } -func (s *gatekeeper) Context(ctx context.Context) (context.Context, error) { - clients, claims, err := s.getClients(ctx) +func (s *gatekeeper) ContextWithRequest(ctx context.Context, req interface{}) (context.Context, error) { + clients, claims, err := s.getClients(ctx, req) if err != nil { return nil, err } @@ -107,6 +117,10 @@ func (s *gatekeeper) Context(ctx context.Context) (context.Context, error) { return ctx, nil } +func (s *gatekeeper) Context(ctx context.Context) (context.Context, error) { + return s.ContextWithRequest(ctx, nil) +} + func GetDynamicClient(ctx context.Context) dynamic.Interface { return ctx.Value(DynamicKey).(dynamic.Interface) } @@ -150,7 +164,7 @@ func getAuthHeader(md metadata.MD) string { return "" } -func (s gatekeeper) getClients(ctx context.Context) (*servertypes.Clients, *types.Claims, error) { +func (s gatekeeper) getClients(ctx context.Context, req interface{}) (*servertypes.Clients, *types.Claims, error) { md, _ := metadata.FromIncomingContext(ctx) authorization := getAuthHeader(md) mode, valid := s.Modes.GetMode(authorization) @@ -174,7 +188,7 @@ func (s gatekeeper) getClients(ctx context.Context) (*servertypes.Clients, *type return nil, nil, status.Error(codes.Unauthenticated, err.Error()) } if s.ssoIf.IsRBACEnabled() { - clients, err := s.rbacAuthorization(ctx, claims) + clients, err := s.rbacAuthorization(claims, req) if err != nil { log.WithError(err).Error("failed to perform RBAC authorization") return nil, nil, status.Error(codes.PermissionDenied, "not allowed") @@ -190,23 +204,35 @@ func (s gatekeeper) getClients(ctx context.Context) (*servertypes.Clients, *type } } -func (s *gatekeeper) rbacAuthorization(ctx context.Context, claims *types.Claims) (*servertypes.Clients, error) { - list, err := s.clients.Kubernetes.CoreV1().ServiceAccounts(s.namespace).List(ctx, metav1.ListOptions{}) +func getNamespace(req interface{}) string { + if req == nil { + return "" + } + namespacedRequest, ok := req.(servertypes.NamespacedRequest) + if !ok { + return "" + } + return namespacedRequest.GetNamespace() +} + +func precedence(serviceAccount *corev1.ServiceAccount) int { + i, _ := strconv.Atoi(serviceAccount.Annotations[common.AnnotationKeyRBACRulePrecedence]) + return i +} + +func (s *gatekeeper) getServiceAccount(claims *types.Claims, namespace string) (*corev1.ServiceAccount, error) { + list, err := s.cache.ServiceAccountLister.ServiceAccounts(namespace).List(labels.Everything()) if err != nil { return nil, fmt.Errorf("failed to list SSO RBAC service accounts: %w", err) } - var serviceAccounts []corev1.ServiceAccount - for _, serviceAccount := range list.Items { + var serviceAccounts []*corev1.ServiceAccount + for _, serviceAccount := range list { _, ok := serviceAccount.Annotations[common.AnnotationKeyRBACRule] if !ok { continue } serviceAccounts = append(serviceAccounts, serviceAccount) } - precedence := func(serviceAccount corev1.ServiceAccount) int { - i, _ := strconv.Atoi(serviceAccount.Annotations[common.AnnotationKeyRBACRulePrecedence]) - return i - } sort.Slice(serviceAccounts, func(i, j int) bool { return precedence(serviceAccounts[i]) > precedence(serviceAccounts[j]) }) for _, serviceAccount := range serviceAccounts { rule := serviceAccount.Annotations[common.AnnotationKeyRBACRule] @@ -225,31 +251,59 @@ func (s *gatekeeper) rbacAuthorization(ctx context.Context, claims *types.Claims if !allow { continue } - authorization, err := s.authorizationForServiceAccount(ctx, serviceAccount.Name) - if err != nil { - return nil, err - } - _, clients, err := s.clientForAuthorization(authorization) - if err != nil { - return nil, err - } - claims.ServiceAccountName = serviceAccount.Name - // important! write an audit entry (i.e. log entry) so we know which user performed an operation - log.WithFields(addClaimsLogFields(claims, log.Fields{"serviceAccount": serviceAccount.Name})).Info("selected SSO RBAC service account for user") - return clients, nil + return serviceAccount, nil } return nil, fmt.Errorf("no service account rule matches") } -func (s *gatekeeper) authorizationForServiceAccount(ctx context.Context, serviceAccountName string) (string, error) { - serviceAccount, err := s.clients.Kubernetes.CoreV1().ServiceAccounts(s.namespace).Get(ctx, serviceAccountName, metav1.GetOptions{}) +func (s *gatekeeper) canDelegateRBACToRequestNamespace(req interface{}) bool { + if s.namespaced || os.Getenv("SSO_DELEGATE_RBAC_TO_NAMESPACE") != "true" { + return false + } + namespace := getNamespace(req) + return len(namespace) != 0 && s.ssoNamespace != namespace +} + +func (s *gatekeeper) getClientsForServiceAccount(claims *types.Claims, serviceAccount *corev1.ServiceAccount) (*servertypes.Clients, error) { + authorization, err := s.authorizationForServiceAccount(serviceAccount) if err != nil { - return "", fmt.Errorf("failed to get service account: %w", err) + return nil, err } + _, clients, err := s.clientForAuthorization(authorization) + if err != nil { + return nil, err + } + claims.ServiceAccountName = serviceAccount.Name + return clients, nil +} + +func (s *gatekeeper) rbacAuthorization(claims *types.Claims, req interface{}) (*servertypes.Clients, error) { + ssoDelegationAllowed, ssoDelegated := false, false + loginAccount, err := s.getServiceAccount(claims, s.ssoNamespace) + if err != nil { + return nil, err + } + delegatedAccount := loginAccount + if s.canDelegateRBACToRequestNamespace(req) { + ssoDelegationAllowed = true + namespaceAccount, err := s.getServiceAccount(claims, getNamespace(req)) + if err != nil { + log.WithError(err).Info("Error while SSO Delegation") + } else if precedence(namespaceAccount) > precedence(loginAccount) { + delegatedAccount = namespaceAccount + ssoDelegated = true + } + } + // important! write an audit entry (i.e. log entry) so we know which user performed an operation + log.WithFields(log.Fields{"serviceAccount": delegatedAccount.Name, "loginServiceAccount": loginAccount.Name, "subject": claims.Subject, "ssoDelegationAllowed": ssoDelegationAllowed, "ssoDelegated": ssoDelegated}).Info("selected SSO RBAC service account for user") + return s.getClientsForServiceAccount(claims, delegatedAccount) +} + +func (s *gatekeeper) authorizationForServiceAccount(serviceAccount *corev1.ServiceAccount) (string, error) { if len(serviceAccount.Secrets) == 0 { - return "", fmt.Errorf("expected at least one secret for SSO RBAC service account: %w", err) + return "", fmt.Errorf("expected at least one secret for SSO RBAC service account: %s", serviceAccount.GetName()) } - secret, err := s.clients.Kubernetes.CoreV1().Secrets(s.namespace).Get(ctx, serviceAccount.Secrets[0].Name, metav1.GetOptions{}) + secret, err := s.cache.SecretLister.Secrets(serviceAccount.GetNamespace()).Get(serviceAccount.Secrets[0].Name) if err != nil { return "", fmt.Errorf("failed to get service account secret: %w", err) } diff --git a/server/auth/gatekeeper_test.go b/server/auth/gatekeeper_test.go index ff71e9551e75..b7277529d1d2 100644 --- a/server/auth/gatekeeper_test.go +++ b/server/auth/gatekeeper_test.go @@ -19,6 +19,7 @@ import ( fakewfclientset "github.com/argoproj/argo-workflows/v3/pkg/client/clientset/versioned/fake" ssomocks "github.com/argoproj/argo-workflows/v3/server/auth/sso/mocks" "github.com/argoproj/argo-workflows/v3/server/auth/types" + "github.com/argoproj/argo-workflows/v3/server/cache" servertypes "github.com/argoproj/argo-workflows/v3/server/types" "github.com/argoproj/argo-workflows/v3/workflow/common" ) @@ -49,37 +50,86 @@ func TestServer_GetWFClient(t *testing.T) { }, Secrets: []corev1.ObjectReference{{Name: "my-secret"}}, }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user1-sa", Namespace: "user1-ns", + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'my-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "2", + }, + }, + Secrets: []corev1.ObjectReference{{Name: "user-secret"}}, + }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user2-sa", Namespace: "user2-ns", + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'my-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "0", + }, + }, + Secrets: []corev1.ObjectReference{{Name: "user-secret"}}, + }, + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user3-sa", Namespace: "user3-ns", + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'my-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "1", + }, + }, + Secrets: []corev1.ObjectReference{{Name: "user-secret"}}, + }, &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "my-secret", Namespace: "my-ns"}, Data: map[string][]byte{ "token": {}, }, }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user-secret", Namespace: "user1-ns"}, + Data: map[string][]byte{ + "token": {}, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user-secret", Namespace: "user2-ns"}, + Data: map[string][]byte{ + "token": {}, + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user-secret", Namespace: "user3-ns"}, + Data: map[string][]byte{ + "token": {}, + }, + }, ) + resourceCache := cache.NewResourceCache(kubeClient, context.TODO(), corev1.NamespaceAll) var clientForAuthorization ClientForAuthorization = func(authorization string) (*rest.Config, *servertypes.Clients, error) { return &rest.Config{}, &servertypes.Clients{Workflow: &fakewfclientset.Clientset{}, Kubernetes: &kubefake.Clientset{}}, nil } clients := &servertypes.Clients{Workflow: wfClient, Kubernetes: kubeClient} t.Run("None", func(t *testing.T) { - _, err := NewGatekeeper(Modes{}, clients, nil, nil, clientForAuthorization, "") + _, err := NewGatekeeper(Modes{}, clients, nil, nil, clientForAuthorization, "", "", true, resourceCache) assert.Error(t, err) }) t.Run("Invalid", func(t *testing.T) { - g, err := NewGatekeeper(Modes{Client: true}, clients, nil, nil, clientForAuthorization, "") + g, err := NewGatekeeper(Modes{Client: true}, clients, nil, nil, clientForAuthorization, "", "", true, resourceCache) if assert.NoError(t, err) { _, err := g.Context(x("invalid")) assert.Error(t, err) } }) t.Run("NotAllowed", func(t *testing.T) { - g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, nil, clientForAuthorization, "") + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, nil, clientForAuthorization, "", "", true, resourceCache) if assert.NoError(t, err) { _, err := g.Context(x("Bearer ")) assert.Error(t, err) } }) t.Run("Client", func(t *testing.T) { - g, err := NewGatekeeper(Modes{Client: true}, clients, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "") + g, err := NewGatekeeper(Modes{Client: true}, clients, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "", "", true, resourceCache) assert.NoError(t, err) ctx, err := g.Context(x("Bearer ")) if assert.NoError(t, err) { @@ -89,7 +139,7 @@ func TestServer_GetWFClient(t *testing.T) { } }) t.Run("Server", func(t *testing.T) { - g, err := NewGatekeeper(Modes{Server: true}, clients, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "") + g, err := NewGatekeeper(Modes{Server: true}, clients, &rest.Config{Username: "my-username"}, nil, clientForAuthorization, "", "", true, resourceCache) assert.NoError(t, err) ctx, err := g.Context(x("")) if assert.NoError(t, err) { @@ -102,7 +152,7 @@ func TestServer_GetWFClient(t *testing.T) { ssoIf := &ssomocks.Interface{} ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Claims: jwt.Claims{Subject: "my-sub"}}, nil) ssoIf.On("IsRBACEnabled").Return(false) - g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns") + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", true, resourceCache) if assert.NoError(t, err) { ctx, err := g.Context(x("Bearer v2:whatever")) if assert.NoError(t, err) { @@ -121,7 +171,7 @@ func TestServer_GetWFClient(t *testing.T) { ssoIf := &ssomocks.Interface{} ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil) ssoIf.On("IsRBACEnabled").Return(true) - g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns") + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", true, resourceCache) if assert.NoError(t, err) { ctx, err := g.Context(x("Bearer v2:whatever")) if assert.NoError(t, err) { @@ -135,11 +185,89 @@ func TestServer_GetWFClient(t *testing.T) { } } }) + t.Run("SSO+RBAC, Namespace delegation ON, precedence=2, Delagated", func(t *testing.T) { + os.Setenv("SSO_DELEGATE_RBAC_TO_NAMESPACE", "true") + ssoIf := &ssomocks.Interface{} + ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil) + ssoIf.On("IsRBACEnabled").Return(true) + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", false, resourceCache) + if assert.NoError(t, err) { + ctx, err := g.ContextWithRequest(x("Bearer v2:whatever"), servertypes.NamespaceHolder("user1-ns")) + if assert.NoError(t, err) { + assert.NotEqual(t, clients, GetWfClient(ctx)) + assert.NotEqual(t, kubeClient, GetKubeClient(ctx)) + if assert.NotNil(t, GetClaims(ctx)) { + assert.Equal(t, []string{"my-group", "other-group"}, GetClaims(ctx).Groups) + assert.Equal(t, "user1-sa", GetClaims(ctx).ServiceAccountName) + } + assert.Equal(t, "user1-sa", hook.LastEntry().Data["serviceAccount"]) + } + } + os.Unsetenv("SSO_DELEGATE_RBAC_TO_NAMESPACE") + }) + t.Run("SSO+RBAC, Namespace delegation OFF, precedence=2, Not Delegated", func(t *testing.T) { + ssoIf := &ssomocks.Interface{} + ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil) + ssoIf.On("IsRBACEnabled").Return(true) + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", true, resourceCache) + if assert.NoError(t, err) { + ctx, err := g.ContextWithRequest(x("Bearer v2:whatever"), servertypes.NamespaceHolder("user1-ns")) + if assert.NoError(t, err) { + assert.NotEqual(t, clients, GetWfClient(ctx)) + assert.NotEqual(t, kubeClient, GetKubeClient(ctx)) + if assert.NotNil(t, GetClaims(ctx)) { + assert.Equal(t, []string{"my-group", "other-group"}, GetClaims(ctx).Groups) + assert.Equal(t, "my-sa", GetClaims(ctx).ServiceAccountName) + } + assert.Equal(t, "my-sa", hook.LastEntry().Data["serviceAccount"]) + } + } + }) + t.Run("SSO+RBAC, Namespace delegation ON, precedence=0, Not delegated", func(t *testing.T) { + os.Setenv("SSO_DELEGATE_RBAC_TO_NAMESPACE", "true") + ssoIf := &ssomocks.Interface{} + ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil) + ssoIf.On("IsRBACEnabled").Return(true) + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", false, resourceCache) + if assert.NoError(t, err) { + ctx, err := g.ContextWithRequest(x("Bearer v2:whatever"), servertypes.NamespaceHolder("user2-ns")) + if assert.NoError(t, err) { + assert.NotEqual(t, clients, GetWfClient(ctx)) + assert.NotEqual(t, kubeClient, GetKubeClient(ctx)) + if assert.NotNil(t, GetClaims(ctx)) { + assert.Equal(t, []string{"my-group", "other-group"}, GetClaims(ctx).Groups) + assert.Equal(t, "my-sa", GetClaims(ctx).ServiceAccountName) + } + assert.Equal(t, "my-sa", hook.LastEntry().Data["serviceAccount"]) + } + } + os.Unsetenv("SSO_DELEGATE_RBAC_TO_NAMESPACE") + }) + t.Run("SSO+RBAC, Namespace delegation ON, precedence=1, Not delegated", func(t *testing.T) { + os.Setenv("SSO_DELEGATE_RBAC_TO_NAMESPACE", "true") + ssoIf := &ssomocks.Interface{} + ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"my-group", "other-group"}}, nil) + ssoIf.On("IsRBACEnabled").Return(true) + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", false, resourceCache) + if assert.NoError(t, err) { + ctx, err := g.ContextWithRequest(x("Bearer v2:whatever"), servertypes.NamespaceHolder("user3-ns")) + if assert.NoError(t, err) { + assert.NotEqual(t, clients, GetWfClient(ctx)) + assert.NotEqual(t, kubeClient, GetKubeClient(ctx)) + if assert.NotNil(t, GetClaims(ctx)) { + assert.Equal(t, []string{"my-group", "other-group"}, GetClaims(ctx).Groups) + assert.Equal(t, "my-sa", GetClaims(ctx).ServiceAccountName) + } + assert.Equal(t, "my-sa", hook.LastEntry().Data["serviceAccount"]) + } + } + os.Unsetenv("SSO_DELEGATE_RBAC_TO_NAMESPACE") + }) t.Run("SSO+RBAC,precedence=0", func(t *testing.T) { ssoIf := &ssomocks.Interface{} ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{Groups: []string{"other-group"}}, nil) ssoIf.On("IsRBACEnabled").Return(true) - g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns") + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", true, resourceCache) if assert.NoError(t, err) { ctx, err := g.Context(x("Bearer v2:whatever")) if assert.NoError(t, err) { @@ -152,7 +280,7 @@ func TestServer_GetWFClient(t *testing.T) { ssoIf := &ssomocks.Interface{} ssoIf.On("Authorize", mock.Anything, mock.Anything).Return(&types.Claims{}, nil) ssoIf.On("IsRBACEnabled").Return(true) - g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns") + g, err := NewGatekeeper(Modes{SSO: true}, clients, nil, ssoIf, clientForAuthorization, "my-ns", "my-ns", true, resourceCache) if assert.NoError(t, err) { _, err := g.Context(x("Bearer v2:whatever")) assert.EqualError(t, err, "rpc error: code = PermissionDenied desc = not allowed") diff --git a/server/auth/mocks/Gatekeeper.go b/server/auth/mocks/Gatekeeper.go index fdc707addcc4..18d8851178c5 100644 --- a/server/auth/mocks/Gatekeeper.go +++ b/server/auth/mocks/Gatekeeper.go @@ -38,6 +38,29 @@ func (_m *Gatekeeper) Context(ctx context.Context) (context.Context, error) { return r0, r1 } +// ContextWithRequest provides a mock function with given fields: ctx, req +func (_m *Gatekeeper) ContextWithRequest(ctx context.Context, req interface{}) (context.Context, error) { + ret := _m.Called(ctx, req) + + var r0 context.Context + if rf, ok := ret.Get(0).(func(context.Context, interface{}) context.Context); ok { + r0 = rf(ctx, req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(context.Context) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, interface{}) error); ok { + r1 = rf(ctx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // StreamServerInterceptor provides a mock function with given fields: func (_m *Gatekeeper) StreamServerInterceptor() grpc.StreamServerInterceptor { ret := _m.Called() diff --git a/server/cache/cache.go b/server/cache/cache.go new file mode 100644 index 000000000000..cd5540efb03f --- /dev/null +++ b/server/cache/cache.go @@ -0,0 +1,26 @@ +package cache + +import ( + "context" + "time" + + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + v1 "k8s.io/client-go/listers/core/v1" +) + +type ResourceCache struct { + v1.ServiceAccountLister + v1.SecretLister +} + +func NewResourceCache(client kubernetes.Interface, ctx context.Context, namespace string) *ResourceCache { + informerFactory := informers.NewSharedInformerFactoryWithOptions(client, time.Minute*20, informers.WithNamespace(namespace)) + cache := &ResourceCache{ + ServiceAccountLister: informerFactory.Core().V1().ServiceAccounts().Lister(), + SecretLister: informerFactory.Core().V1().Secrets().Lister(), + } + informerFactory.Start(ctx.Done()) + informerFactory.WaitForCacheSync(ctx.Done()) + return cache +} diff --git a/server/cache/cache_test.go b/server/cache/cache_test.go new file mode 100644 index 000000000000..42407d5391f5 --- /dev/null +++ b/server/cache/cache_test.go @@ -0,0 +1,93 @@ +package cache + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + kubefake "k8s.io/client-go/kubernetes/fake" + + "github.com/argoproj/argo-workflows/v3/workflow/common" +) + +func checkServiceAccountExists(saList []*v1.ServiceAccount, name string) bool { + for _, sa := range saList { + if sa.Name == name { + return true + } + } + return false +} + +func TestServer_K8sUtilsCache(t *testing.T) { + _ = os.Setenv("KUBECONFIG", "/dev/null") + defer func() { _ = os.Unsetenv("KUBECONFIG") }() + saLabels := make(map[string]string) + saLabels["hello"] = "world" + + secretLabels := make(map[string]string) + secretLabels["hi"] = "world" + kubeClient := kubefake.NewSimpleClientset( + &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sa1", Namespace: "ns1", + Labels: saLabels, + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'other-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "0", + }, + }, + Secrets: []v1.ObjectReference{{Name: "my-secret"}}, + }, + &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sa2", Namespace: "ns1", + Labels: saLabels, + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'my-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "1", + }, + }, + Secrets: []v1.ObjectReference{{Name: "my-secret"}}, + }, + &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sa3", Namespace: "ns2", + Labels: saLabels, + Annotations: map[string]string{ + common.AnnotationKeyRBACRule: "'my-group' in groups", + common.AnnotationKeyRBACRulePrecedence: "2", + }, + }, + Secrets: []v1.ObjectReference{{Name: "user-secret"}}, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "s1", + Namespace: "ns1", + Labels: secretLabels, + }, + Data: map[string][]byte{ + "token": {}, + }, + }) + cache := NewResourceCache(kubeClient, context.TODO(), v1.NamespaceAll) + + t.Run("List Service Accounts in different namespaces", func(t *testing.T) { + sa, _ := cache.ServiceAccountLister.ServiceAccounts("ns1").List(labels.Everything()) + assert.Equal(t, 2, len(sa)) + assert.True(t, checkServiceAccountExists(sa, "sa1")) + assert.True(t, checkServiceAccountExists(sa, "sa2")) + + sa, _ = cache.ServiceAccountLister.ServiceAccounts("ns2").List(labels.Everything()) + assert.Equal(t, 1, len(sa)) + assert.True(t, checkServiceAccountExists(sa, "sa3")) + + secrets, _ := cache.SecretLister.Secrets("ns1").List(labels.Everything()) + assert.Equal(t, 1, len(secrets)) + }) +} diff --git a/server/types/namespaces.go b/server/types/namespaces.go new file mode 100644 index 000000000000..08bb436533b6 --- /dev/null +++ b/server/types/namespaces.go @@ -0,0 +1,11 @@ +package types + +type NamespacedRequest interface { + GetNamespace() string +} + +type NamespaceHolder string + +func (n NamespaceHolder) GetNamespace() string { + return string(n) +}