diff --git a/apis/concierge/identity/doc.go.tmpl b/apis/concierge/identity/doc.go.tmpl new file mode 100644 index 00000000..6d821566 --- /dev/null +++ b/apis/concierge/identity/doc.go.tmpl @@ -0,0 +1,8 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// +k8s:deepcopy-gen=package +// +groupName=identity.concierge.pinniped.dev + +// Package identity is the internal version of the Pinniped identity API. +package identity diff --git a/apis/concierge/identity/register.go.tmpl b/apis/concierge/identity/register.go.tmpl new file mode 100644 index 00000000..ad0fe3ab --- /dev/null +++ b/apis/concierge/identity/register.go.tmpl @@ -0,0 +1,38 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = "identity.concierge.pinniped.dev" + +// SchemeGroupVersion is group version used to register these objects. +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal} + +// Kind takes an unqualified kind and returns back a Group qualified GroupKind. +func Kind(kind string) schema.GroupKind { + return SchemeGroupVersion.WithKind(kind).GroupKind() +} + +// Resource takes an unqualified resource and returns back a Group qualified GroupResource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &WhoAmIRequest{}, + &WhoAmIRequestList{}, + ) + return nil +} diff --git a/apis/concierge/identity/types_userinfo.go.tmpl b/apis/concierge/identity/types_userinfo.go.tmpl new file mode 100644 index 00000000..fdd5b258 --- /dev/null +++ b/apis/concierge/identity/types_userinfo.go.tmpl @@ -0,0 +1,37 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import "fmt" + +// KubernetesUserInfo represents the current authenticated user, exactly as Kubernetes understands it. +// Copied from the Kubernetes token review API. +type KubernetesUserInfo struct { + // User is the UserInfo associated with the current user. + User UserInfo + // Audiences are audience identifiers chosen by the authenticator. + Audiences []string +} + +// UserInfo holds the information about the user needed to implement the +// user.Info interface. +type UserInfo struct { + // The name that uniquely identifies this user among all active users. + Username string + // A unique value that identifies this user across time. If this user is + // deleted and another user by the same name is added, they will have + // different UIDs. + UID string + // The names of groups this user is a part of. + Groups []string + // Any additional information provided by the authenticator. + Extra map[string]ExtraValue +} + +// ExtraValue masks the value so protobuf can generate +type ExtraValue []string + +func (t ExtraValue) String() string { + return fmt.Sprintf("%v", []string(t)) +} diff --git a/apis/concierge/identity/types_whoami.go.tmpl b/apis/concierge/identity/types_whoami.go.tmpl new file mode 100644 index 00000000..133a9a8e --- /dev/null +++ b/apis/concierge/identity/types_whoami.go.tmpl @@ -0,0 +1,40 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WhoAmIRequest submits a request to echo back the current authenticated user. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type WhoAmIRequest struct { + metav1.TypeMeta + metav1.ObjectMeta + + Spec WhoAmIRequestSpec + Status WhoAmIRequestStatus +} + +type WhoAmIRequestSpec struct { + // empty for now but we may add some config here in the future + // any such config must be safe in the context of an unauthenticated user +} + +type WhoAmIRequestStatus struct { + // The current authenticated user, exactly as Kubernetes understands it. + KubernetesUserInfo KubernetesUserInfo + + // We may add concierge specific information here in the future. +} + +// WhoAmIRequestList is a list of WhoAmIRequest objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type WhoAmIRequestList struct { + metav1.TypeMeta + metav1.ListMeta + + // Items is a list of WhoAmIRequest + Items []WhoAmIRequest +} diff --git a/apis/concierge/identity/v1alpha1/conversion.go.tmpl b/apis/concierge/identity/v1alpha1/conversion.go.tmpl new file mode 100644 index 00000000..e7e86b85 --- /dev/null +++ b/apis/concierge/identity/v1alpha1/conversion.go.tmpl @@ -0,0 +1,4 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 diff --git a/apis/concierge/identity/v1alpha1/defaults.go.tmpl b/apis/concierge/identity/v1alpha1/defaults.go.tmpl new file mode 100644 index 00000000..8953e608 --- /dev/null +++ b/apis/concierge/identity/v1alpha1/defaults.go.tmpl @@ -0,0 +1,12 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +func addDefaultingFuncs(scheme *runtime.Scheme) error { + return RegisterDefaults(scheme) +} diff --git a/apis/concierge/identity/v1alpha1/doc.go.tmpl b/apis/concierge/identity/v1alpha1/doc.go.tmpl new file mode 100644 index 00000000..d5464c0c --- /dev/null +++ b/apis/concierge/identity/v1alpha1/doc.go.tmpl @@ -0,0 +1,11 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen=package +// +k8s:conversion-gen=go.pinniped.dev/GENERATED_PKG/apis/concierge/identity +// +k8s:defaulter-gen=TypeMeta +// +groupName=identity.concierge.pinniped.dev + +// Package v1alpha1 is the v1alpha1 version of the Pinniped identity API. +package v1alpha1 diff --git a/apis/concierge/identity/v1alpha1/register.go.tmpl b/apis/concierge/identity/v1alpha1/register.go.tmpl new file mode 100644 index 00000000..09ecfad8 --- /dev/null +++ b/apis/concierge/identity/v1alpha1/register.go.tmpl @@ -0,0 +1,43 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = "identity.concierge.pinniped.dev" + +// SchemeGroupVersion is group version used to register these objects. +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} + +var ( + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes, addDefaultingFuncs) +} + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &WhoAmIRequest{}, + &WhoAmIRequestList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/apis/concierge/identity/v1alpha1/types_userinfo.go.tmpl b/apis/concierge/identity/v1alpha1/types_userinfo.go.tmpl new file mode 100644 index 00000000..dc15fd36 --- /dev/null +++ b/apis/concierge/identity/v1alpha1/types_userinfo.go.tmpl @@ -0,0 +1,41 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import "fmt" + +// KubernetesUserInfo represents the current authenticated user, exactly as Kubernetes understands it. +// Copied from the Kubernetes token review API. +type KubernetesUserInfo struct { + // User is the UserInfo associated with the current user. + User UserInfo `json:"user"` + // Audiences are audience identifiers chosen by the authenticator. + // +optional + Audiences []string `json:"audiences,omitempty"` +} + +// UserInfo holds the information about the user needed to implement the +// user.Info interface. +type UserInfo struct { + // The name that uniquely identifies this user among all active users. + Username string `json:"username"` + // A unique value that identifies this user across time. If this user is + // deleted and another user by the same name is added, they will have + // different UIDs. + // +optional + UID string `json:"uid,omitempty"` + // The names of groups this user is a part of. + // +optional + Groups []string `json:"groups,omitempty"` + // Any additional information provided by the authenticator. + // +optional + Extra map[string]ExtraValue `json:"extra,omitempty"` +} + +// ExtraValue masks the value so protobuf can generate +type ExtraValue []string + +func (t ExtraValue) String() string { + return fmt.Sprintf("%v", []string(t)) +} diff --git a/apis/concierge/identity/v1alpha1/types_whoami.go.tmpl b/apis/concierge/identity/v1alpha1/types_whoami.go.tmpl new file mode 100644 index 00000000..b9ecadb4 --- /dev/null +++ b/apis/concierge/identity/v1alpha1/types_whoami.go.tmpl @@ -0,0 +1,43 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WhoAmIRequest submits a request to echo back the current authenticated user. +// +genclient +// +genclient:nonNamespaced +// +genclient:onlyVerbs=create +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type WhoAmIRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec WhoAmIRequestSpec `json:"spec,omitempty"` + Status WhoAmIRequestStatus `json:"status,omitempty"` +} + +type WhoAmIRequestSpec struct { + // empty for now but we may add some config here in the future + // any such config must be safe in the context of an unauthenticated user +} + +type WhoAmIRequestStatus struct { + // The current authenticated user, exactly as Kubernetes understands it. + KubernetesUserInfo KubernetesUserInfo `json:"kubernetesUserInfo"` + + // We may add concierge specific information here in the future. +} + +// WhoAmIRequestList is a list of WhoAmIRequest objects. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type WhoAmIRequestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + // Items is a list of WhoAmIRequest + Items []WhoAmIRequest `json:"items"` +} diff --git a/apis/concierge/identity/validation/validation.go.tmpl b/apis/concierge/identity/validation/validation.go.tmpl new file mode 100644 index 00000000..05eb0746 --- /dev/null +++ b/apis/concierge/identity/validation/validation.go.tmpl @@ -0,0 +1,14 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + identityapi "go.pinniped.dev/GENERATED_PKG/apis/concierge/identity" +) + +func ValidateWhoAmIRequest(whoAmIRequest *identityapi.WhoAmIRequest) field.ErrorList { + return nil // add validation for spec here if we expand it +} diff --git a/apis/concierge/login/v1alpha1/types_token.go.tmpl b/apis/concierge/login/v1alpha1/types_token.go.tmpl index 66b744f3..53e25645 100644 --- a/apis/concierge/login/v1alpha1/types_token.go.tmpl +++ b/apis/concierge/login/v1alpha1/types_token.go.tmpl @@ -31,6 +31,7 @@ type TokenCredentialRequestStatus struct { // TokenCredentialRequest submits an IDP-specific credential to Pinniped in exchange for a cluster-specific credential. // +genclient // +genclient:nonNamespaced +// +genclient:onlyVerbs=create // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type TokenCredentialRequest struct { metav1.TypeMeta `json:",inline"` diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go index 536a2e17..90c72f8f 100644 --- a/cmd/pinniped/cmd/kubeconfig.go +++ b/cmd/pinniped/cmd/kubeconfig.go @@ -108,7 +108,7 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command { f.StringVar(&namespace, "concierge-namespace", "pinniped-concierge", "Namespace in which the concierge was installed") f.StringVar(&flags.concierge.authenticatorType, "concierge-authenticator-type", "", "Concierge authenticator type (e.g., 'webhook', 'jwt') (default: autodiscover)") f.StringVar(&flags.concierge.authenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name (default: autodiscover)") - f.StringVar(&flags.concierge.apiGroupSuffix, "concierge-api-group-suffix", "pinniped.dev", "Concierge API group suffix") + f.StringVar(&flags.concierge.apiGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") f.StringVar(&flags.oidc.issuer, "oidc-issuer", "", "OpenID Connect issuer URL (default: autodiscover)") f.StringVar(&flags.oidc.clientID, "oidc-client-id", "pinniped-cli", "OpenID Connect client ID (default: autodiscover)") diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 07cb3560..f9f258b2 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -22,6 +22,7 @@ import ( clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "k8s.io/klog/v2/klogr" + "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" @@ -93,7 +94,7 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command { cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") - cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", "pinniped.dev", "Concierge API group suffix") + cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") mustMarkHidden(cmd, "debug-session-cache") mustMarkRequired(cmd, "issuer") diff --git a/cmd/pinniped/cmd/login_static.go b/cmd/pinniped/cmd/login_static.go index 863afc28..6b391d19 100644 --- a/cmd/pinniped/cmd/login_static.go +++ b/cmd/pinniped/cmd/login_static.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" ) @@ -67,7 +68,7 @@ func staticLoginCommand(deps staticLoginDeps) *cobra.Command { cmd.Flags().StringVar(&flags.conciergeAuthenticatorName, "concierge-authenticator-name", "", "Concierge authenticator name") cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Pinniped concierge endpoint") cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the concierge") - cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", "pinniped.dev", "Concierge API group suffix") + cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix") cmd.RunE = func(cmd *cobra.Command, args []string) error { return runStaticLogin(cmd.OutOrStdout(), deps, flags) } mustMarkDeprecated(cmd, "concierge-namespace", "not needed anymore") diff --git a/deploy/concierge/deployment.yaml b/deploy/concierge/deployment.yaml index 535cf1f1..58a5098b 100644 --- a/deploy/concierge/deployment.yaml +++ b/deploy/concierge/deployment.yaml @@ -204,3 +204,19 @@ spec: name: #@ defaultResourceNameWithSuffix("api") namespace: #@ namespace() port: 443 +--- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: #@ pinnipedDevAPIGroupWithPrefix("v1alpha1.identity.concierge") + labels: #@ labels() +spec: + version: v1alpha1 + group: #@ pinnipedDevAPIGroupWithPrefix("identity.concierge") + groupPriorityMinimum: 9900 + versionPriority: 15 + #! caBundle: Do not include this key here. Starts out null, will be updated/owned by the golang code. + service: + name: #@ defaultResourceNameWithSuffix("api") + namespace: #@ namespace() + port: 443 diff --git a/deploy/concierge/rbac.yaml b/deploy/concierge/rbac.yaml index e74ae281..427af70e 100644 --- a/deploy/concierge/rbac.yaml +++ b/deploy/concierge/rbac.yaml @@ -133,18 +133,22 @@ roleRef: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: #@ defaultResourceNameWithSuffix("create-token-credential-requests") + name: #@ defaultResourceNameWithSuffix("pre-authn-apis") labels: #@ labels() rules: - apiGroups: - #@ pinnipedDevAPIGroupWithPrefix("login.concierge") resources: [ tokencredentialrequests ] - verbs: [ create ] + verbs: [ create, list ] + - apiGroups: + - #@ pinnipedDevAPIGroupWithPrefix("identity.concierge") + resources: [ whoamirequests ] + verbs: [ create, list ] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: - name: #@ defaultResourceNameWithSuffix("create-token-credential-requests") + name: #@ defaultResourceNameWithSuffix("pre-authn-apis") labels: #@ labels() subjects: - kind: Group @@ -155,7 +159,7 @@ subjects: apiGroup: rbac.authorization.k8s.io roleRef: kind: ClusterRole - name: #@ defaultResourceNameWithSuffix("create-token-credential-requests") + name: #@ defaultResourceNameWithSuffix("pre-authn-apis") apiGroup: rbac.authorization.k8s.io #! Give permissions for subjectaccessreviews, tokenreview that is needed by aggregated api servers diff --git a/hack/lib/update-codegen.sh b/hack/lib/update-codegen.sh index f9697dc3..94353561 100755 --- a/hack/lib/update-codegen.sh +++ b/hack/lib/update-codegen.sh @@ -112,7 +112,7 @@ echo "generating API-related code for our public API groups..." deepcopy \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/apis" \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/apis" \ - "supervisor/config:v1alpha1 supervisor/idp:v1alpha1 concierge/config:v1alpha1 concierge/authentication:v1alpha1 concierge/login:v1alpha1" \ + "supervisor/config:v1alpha1 supervisor/idp:v1alpha1 concierge/config:v1alpha1 concierge/authentication:v1alpha1 concierge/login:v1alpha1 concierge/identity:v1alpha1" \ --go-header-file "${ROOT}/hack/boilerplate.go.txt" 2>&1 | sed "s|^|gen-api > |" ) @@ -124,7 +124,7 @@ echo "generating API-related code for our internal API groups..." "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/client/concierge" \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/apis" \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/apis" \ - "concierge/login:v1alpha1" \ + "concierge/login:v1alpha1 concierge/identity:v1alpha1" \ --go-header-file "${ROOT}/hack/boilerplate.go.txt" 2>&1 | sed "s|^|gen-int-api > |" ) @@ -140,7 +140,7 @@ echo "generating client code for our public API groups..." client,lister,informer \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/client/concierge" \ "${BASE_PKG}/generated/${KUBE_MINOR_VERSION}/apis" \ - "concierge/config:v1alpha1 concierge/authentication:v1alpha1 concierge/login:v1alpha1" \ + "concierge/config:v1alpha1 concierge/authentication:v1alpha1 concierge/login:v1alpha1 concierge/identity:v1alpha1" \ --go-header-file "${ROOT}/hack/boilerplate.go.txt" 2>&1 | sed "s|^|gen-client > |" ) (cd client && diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index 51017066..e5fc8da9 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -10,12 +10,14 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/errors" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/client-go/pkg/version" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/registry/credentialrequest" + "go.pinniped.dev/internal/registry/whoamirequest" ) type Config struct { @@ -29,7 +31,8 @@ type ExtraConfig struct { StartControllersPostStartHook func(ctx context.Context) Scheme *runtime.Scheme NegotiatedSerializer runtime.NegotiatedSerializer - GroupVersion schema.GroupVersion + LoginConciergeGroupVersion schema.GroupVersion + IdentityConciergeGroupVersion schema.GroupVersion } type PinnipedServer struct { @@ -70,17 +73,35 @@ func (c completedConfig) New() (*PinnipedServer, error) { GenericAPIServer: genericServer, } - gvr := c.ExtraConfig.GroupVersion.WithResource("tokencredentialrequests") - storage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, gvr.GroupResource()) - if err := s.GenericAPIServer.InstallAPIGroup(&genericapiserver.APIGroupInfo{ - PrioritizedVersions: []schema.GroupVersion{gvr.GroupVersion()}, - VersionedResourcesStorageMap: map[string]map[string]rest.Storage{gvr.Version: {gvr.Resource: storage}}, - OptionsExternalVersion: &schema.GroupVersion{Version: "v1"}, - Scheme: c.ExtraConfig.Scheme, - ParameterCodec: metav1.ParameterCodec, - NegotiatedSerializer: c.ExtraConfig.NegotiatedSerializer, - }); err != nil { - return nil, fmt.Errorf("could not install API group %s: %w", gvr.String(), err) + var errs []error //nolint: prealloc + for _, f := range []func() (schema.GroupVersionResource, rest.Storage){ + func() (schema.GroupVersionResource, rest.Storage) { + tokenCredReqGVR := c.ExtraConfig.LoginConciergeGroupVersion.WithResource("tokencredentialrequests") + tokenCredStorage := credentialrequest.NewREST(c.ExtraConfig.Authenticator, c.ExtraConfig.Issuer, tokenCredReqGVR.GroupResource()) + return tokenCredReqGVR, tokenCredStorage + }, + func() (schema.GroupVersionResource, rest.Storage) { + whoAmIReqGVR := c.ExtraConfig.IdentityConciergeGroupVersion.WithResource("whoamirequests") + whoAmIStorage := whoamirequest.NewREST(whoAmIReqGVR.GroupResource()) + return whoAmIReqGVR, whoAmIStorage + }, + } { + gvr, storage := f() + errs = append(errs, + s.GenericAPIServer.InstallAPIGroup( + &genericapiserver.APIGroupInfo{ + PrioritizedVersions: []schema.GroupVersion{gvr.GroupVersion()}, + VersionedResourcesStorageMap: map[string]map[string]rest.Storage{gvr.Version: {gvr.Resource: storage}}, + OptionsExternalVersion: &schema.GroupVersion{Version: "v1"}, + Scheme: c.ExtraConfig.Scheme, + ParameterCodec: metav1.ParameterCodec, + NegotiatedSerializer: c.ExtraConfig.NegotiatedSerializer, + }, + ), + ) + } + if err := errors.NewAggregate(errs); err != nil { + return nil, fmt.Errorf("could not install API groups: %w", err) } s.GenericAPIServer.AddPostStartHookOrDie("start-controllers", diff --git a/internal/concierge/server/server.go b/internal/concierge/server/server.go index eab45c97..77769fca 100644 --- a/internal/concierge/server/server.go +++ b/internal/concierge/server/server.go @@ -19,6 +19,8 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" genericoptions "k8s.io/apiserver/pkg/server/options" + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" "go.pinniped.dev/internal/certauthority/dynamiccertauthority" @@ -174,7 +176,7 @@ func getAggregatedAPIServerConfig( startControllersPostStartHook func(context.Context), apiGroupSuffix string, ) (*apiserver.Config, error) { - scheme, groupVersion := getAggregatedAPIServerScheme(apiGroupSuffix) + scheme, loginConciergeGroupVersion, identityConciergeGroupVersion := getAggregatedAPIServerScheme(apiGroupSuffix) codecs := serializer.NewCodecFactory(scheme) // this is unused for now but it is a safe value that we could use in the future @@ -182,7 +184,7 @@ func getAggregatedAPIServerConfig( recommendedOptions := genericoptions.NewRecommendedOptions( defaultEtcdPathPrefix, - codecs.LegacyCodec(groupVersion), + codecs.LegacyCodec(loginConciergeGroupVersion, identityConciergeGroupVersion), ) recommendedOptions.Etcd = nil // turn off etcd storage because we don't need it yet recommendedOptions.SecureServing.ServerCert.GeneratedCert = dynamicCertProvider @@ -210,13 +212,14 @@ func getAggregatedAPIServerConfig( StartControllersPostStartHook: startControllersPostStartHook, Scheme: scheme, NegotiatedSerializer: codecs, - GroupVersion: groupVersion, + LoginConciergeGroupVersion: loginConciergeGroupVersion, + IdentityConciergeGroupVersion: identityConciergeGroupVersion, }, } return apiServerConfig, nil } -func getAggregatedAPIServerScheme(apiGroupSuffix string) (*runtime.Scheme, schema.GroupVersion) { +func getAggregatedAPIServerScheme(apiGroupSuffix string) (_ *runtime.Scheme, login, identity schema.GroupVersion) { // standard set up of the server side scheme scheme := runtime.NewScheme() @@ -224,48 +227,30 @@ func getAggregatedAPIServerScheme(apiGroupSuffix string) (*runtime.Scheme, schem metav1.AddToGroupVersion(scheme, metav1.Unversioned) // nothing fancy is required if using the standard group suffix - if apiGroupSuffix == "pinniped.dev" { - utilruntime.Must(loginv1alpha1.AddToScheme(scheme)) - utilruntime.Must(loginapi.AddToScheme(scheme)) - return scheme, loginv1alpha1.SchemeGroupVersion + if apiGroupSuffix == groupsuffix.PinnipedDefaultSuffix { + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.AddToScheme, + loginapi.AddToScheme, + identityv1alpha1.AddToScheme, + identityapi.AddToScheme, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) + return scheme, loginv1alpha1.SchemeGroupVersion, identityv1alpha1.SchemeGroupVersion } - loginConciergeAPIGroup, ok := groupsuffix.Replace(loginv1alpha1.GroupName, apiGroupSuffix) - if !ok { - panic(fmt.Errorf("cannot make api group from %s/%s", loginv1alpha1.GroupName, apiGroupSuffix)) // static input, impossible case - } + loginConciergeGroupData, identityConciergeGroupData := groupsuffix.ConciergeAggregatedGroups(apiGroupSuffix) - // we need a temporary place to register our types to avoid double registering them - tmpScheme := runtime.NewScheme() - utilruntime.Must(loginv1alpha1.AddToScheme(tmpScheme)) - utilruntime.Must(loginapi.AddToScheme(tmpScheme)) + addToSchemeAtNewGroup(scheme, loginv1alpha1.GroupName, loginConciergeGroupData.Group, loginv1alpha1.AddToScheme, loginapi.AddToScheme) + addToSchemeAtNewGroup(scheme, identityv1alpha1.GroupName, identityConciergeGroupData.Group, identityv1alpha1.AddToScheme, identityapi.AddToScheme) - for gvk := range tmpScheme.AllKnownTypes() { - if gvk.GroupVersion() == metav1.Unversioned { - continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore - } - - if gvk.Group != loginv1alpha1.GroupName { - panic("tmp scheme has types not in the aggregated API group: " + gvk.Group) // programmer error - } - - obj, err := tmpScheme.New(gvk) - if err != nil { - panic(err) // programmer error, scheme internal code is broken - } - newGVK := schema.GroupVersionKind{ - Group: loginConciergeAPIGroup, - Version: gvk.Version, - Kind: gvk.Kind, - } - - // register the existing type but with the new group in the correct scheme - scheme.AddKnownTypeWithName(newGVK, obj) - } - - // manually register conversions and defaulting into the correct scheme since we cannot directly call loginv1alpha1.AddToScheme - utilruntime.Must(loginv1alpha1.RegisterConversions(scheme)) - utilruntime.Must(loginv1alpha1.RegisterDefaults(scheme)) + // manually register conversions and defaulting into the correct scheme since we cannot directly call AddToScheme + schemeBuilder := runtime.NewSchemeBuilder( + loginv1alpha1.RegisterConversions, + loginv1alpha1.RegisterDefaults, + identityv1alpha1.RegisterConversions, + identityv1alpha1.RegisterDefaults, + ) + utilruntime.Must(schemeBuilder.AddToScheme(scheme)) // we do not want to return errors from the scheme and instead would prefer to defer // to the REST storage layer for consistency. The simplest way to do this is to force @@ -306,5 +291,35 @@ func getAggregatedAPIServerScheme(apiGroupSuffix string) (*runtime.Scheme, schem credentialRequest.Spec.Authenticator.APIGroup = &restoredGroup }) - return scheme, schema.GroupVersion{Group: loginConciergeAPIGroup, Version: loginv1alpha1.SchemeGroupVersion.Version} + return scheme, schema.GroupVersion(loginConciergeGroupData), schema.GroupVersion(identityConciergeGroupData) +} + +func addToSchemeAtNewGroup(scheme *runtime.Scheme, oldGroup, newGroup string, funcs ...func(*runtime.Scheme) error) { + // we need a temporary place to register our types to avoid double registering them + tmpScheme := runtime.NewScheme() + schemeBuilder := runtime.NewSchemeBuilder(funcs...) + utilruntime.Must(schemeBuilder.AddToScheme(tmpScheme)) + + for gvk := range tmpScheme.AllKnownTypes() { + if gvk.GroupVersion() == metav1.Unversioned { + continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore + } + + if gvk.Group != oldGroup { + panic(fmt.Errorf("tmp scheme has type not in the old aggregated API group %s: %s", oldGroup, gvk)) // programmer error + } + + obj, err := tmpScheme.New(gvk) + if err != nil { + panic(err) // programmer error, scheme internal code is broken + } + newGVK := schema.GroupVersionKind{ + Group: newGroup, + Version: gvk.Version, + Kind: gvk.Kind, + } + + // register the existing type but with the new group in the correct scheme + scheme.AddKnownTypeWithName(newGVK, obj) + } } diff --git a/internal/concierge/server/server_test.go b/internal/concierge/server/server_test.go index e37afb96..4eab0245 100644 --- a/internal/concierge/server/server_test.go +++ b/internal/concierge/server/server_test.go @@ -18,6 +18,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" ) @@ -99,24 +101,40 @@ func TestCommand(t *testing.T) { func Test_getAggregatedAPIServerScheme(t *testing.T) { // the standard group - regularGV := schema.GroupVersion{ + regularLoginGV := schema.GroupVersion{ Group: "login.concierge.pinniped.dev", Version: "v1alpha1", } - regularGVInternal := schema.GroupVersion{ + regularLoginGVInternal := schema.GroupVersion{ Group: "login.concierge.pinniped.dev", Version: runtime.APIVersionInternal, } + regularIdentityGV := schema.GroupVersion{ + Group: "identity.concierge.pinniped.dev", + Version: "v1alpha1", + } + regularIdentityGVInternal := schema.GroupVersion{ + Group: "identity.concierge.pinniped.dev", + Version: runtime.APIVersionInternal, + } // the canonical other group - otherGV := schema.GroupVersion{ + otherLoginGV := schema.GroupVersion{ Group: "login.concierge.walrus.tld", Version: "v1alpha1", } - otherGVInternal := schema.GroupVersion{ + otherLoginGVInternal := schema.GroupVersion{ Group: "login.concierge.walrus.tld", Version: runtime.APIVersionInternal, } + otherIdentityGV := schema.GroupVersion{ + Group: "identity.concierge.walrus.tld", + Version: "v1alpha1", + } + otherIdentityGVInternal := schema.GroupVersion{ + Group: "identity.concierge.walrus.tld", + Version: runtime.APIVersionInternal, + } // kube's core internal internalGV := schema.GroupVersion{ @@ -125,10 +143,11 @@ func Test_getAggregatedAPIServerScheme(t *testing.T) { } tests := []struct { - name string - apiGroupSuffix string - want map[schema.GroupVersionKind]reflect.Type - wantGroupVersion schema.GroupVersion + name string + apiGroupSuffix string + want map[schema.GroupVersionKind]reflect.Type + wantLoginGroupVersion schema.GroupVersion + wantIdentityGroupVersion schema.GroupVersion }{ { name: "regular api group", @@ -136,22 +155,39 @@ func Test_getAggregatedAPIServerScheme(t *testing.T) { want: map[schema.GroupVersionKind]reflect.Type{ // all the types that are in the aggregated API group - regularGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(), - regularGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(), + regularLoginGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(), + regularLoginGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(), - regularGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(), - regularGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(), + regularLoginGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(), + regularLoginGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(), - regularGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), - regularGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), - regularGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), - regularGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), - regularGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), - regularGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), - regularGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), - regularGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + regularIdentityGV.WithKind("WhoAmIRequest"): reflect.TypeOf(&identityv1alpha1.WhoAmIRequest{}).Elem(), + regularIdentityGV.WithKind("WhoAmIRequestList"): reflect.TypeOf(&identityv1alpha1.WhoAmIRequestList{}).Elem(), - regularGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), + regularIdentityGVInternal.WithKind("WhoAmIRequest"): reflect.TypeOf(&identityapi.WhoAmIRequest{}).Elem(), + regularIdentityGVInternal.WithKind("WhoAmIRequestList"): reflect.TypeOf(&identityapi.WhoAmIRequestList{}).Elem(), + + regularLoginGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), + regularLoginGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), + regularLoginGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), + regularLoginGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), + regularLoginGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), + regularLoginGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), + regularLoginGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), + regularLoginGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + + regularIdentityGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), + regularIdentityGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), + regularIdentityGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), + regularIdentityGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), + regularIdentityGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), + regularIdentityGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), + regularIdentityGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), + regularIdentityGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + + regularLoginGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), + + regularIdentityGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), // the types below this line do not really matter to us because they are in the core group @@ -171,7 +207,8 @@ func Test_getAggregatedAPIServerScheme(t *testing.T) { metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), }, - wantGroupVersion: regularGV, + wantLoginGroupVersion: regularLoginGV, + wantIdentityGroupVersion: regularIdentityGV, }, { name: "other api group", @@ -179,22 +216,39 @@ func Test_getAggregatedAPIServerScheme(t *testing.T) { want: map[schema.GroupVersionKind]reflect.Type{ // all the types that are in the aggregated API group - otherGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(), - otherGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(), + otherLoginGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(), + otherLoginGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(), - otherGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(), - otherGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(), + otherLoginGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(), + otherLoginGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(), - otherGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), - otherGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), - otherGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), - otherGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), - otherGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), - otherGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), - otherGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), - otherGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + otherIdentityGV.WithKind("WhoAmIRequest"): reflect.TypeOf(&identityv1alpha1.WhoAmIRequest{}).Elem(), + otherIdentityGV.WithKind("WhoAmIRequestList"): reflect.TypeOf(&identityv1alpha1.WhoAmIRequestList{}).Elem(), - otherGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), + otherIdentityGVInternal.WithKind("WhoAmIRequest"): reflect.TypeOf(&identityapi.WhoAmIRequest{}).Elem(), + otherIdentityGVInternal.WithKind("WhoAmIRequestList"): reflect.TypeOf(&identityapi.WhoAmIRequestList{}).Elem(), + + otherLoginGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), + otherLoginGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), + otherLoginGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), + otherLoginGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), + otherLoginGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), + otherLoginGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), + otherLoginGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), + otherLoginGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + + otherIdentityGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(), + otherIdentityGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(), + otherIdentityGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(), + otherIdentityGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(), + otherIdentityGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(), + otherIdentityGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(), + otherIdentityGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), + otherIdentityGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), + + otherLoginGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), + + otherIdentityGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(), // the types below this line do not really matter to us because they are in the core group @@ -214,15 +268,17 @@ func Test_getAggregatedAPIServerScheme(t *testing.T) { metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(), metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(), }, - wantGroupVersion: otherGV, + wantLoginGroupVersion: otherLoginGV, + wantIdentityGroupVersion: otherIdentityGV, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - scheme, gv := getAggregatedAPIServerScheme(tt.apiGroupSuffix) + scheme, loginGV, identityGV := getAggregatedAPIServerScheme(tt.apiGroupSuffix) require.Equal(t, tt.want, scheme.AllKnownTypes()) - require.Equal(t, tt.wantGroupVersion, gv) + require.Equal(t, tt.wantLoginGroupVersion, loginGV) + require.Equal(t, tt.wantIdentityGroupVersion, identityGV) // make a credential request like a client would send authenticationConciergeAPIGroup := "authentication.concierge." + tt.apiGroupSuffix diff --git a/internal/config/concierge/config.go b/internal/config/concierge/config.go index 1cfa11b6..88d8fe06 100644 --- a/internal/config/concierge/config.go +++ b/internal/config/concierge/config.go @@ -79,7 +79,7 @@ func maybeSetAPIDefaults(apiConfig *APIConfigSpec) { func maybeSetAPIGroupSuffixDefault(apiGroupSuffix **string) { if *apiGroupSuffix == nil { - *apiGroupSuffix = stringPtr("pinniped.dev") + *apiGroupSuffix = stringPtr(groupsuffix.PinnipedDefaultSuffix) } } diff --git a/internal/config/supervisor/config.go b/internal/config/supervisor/config.go index f6dabb04..24668f54 100644 --- a/internal/config/supervisor/config.go +++ b/internal/config/supervisor/config.go @@ -54,7 +54,7 @@ func FromPath(path string) (*Config, error) { func maybeSetAPIGroupSuffixDefault(apiGroupSuffix **string) { if *apiGroupSuffix == nil { - *apiGroupSuffix = stringPtr("pinniped.dev") + *apiGroupSuffix = stringPtr(groupsuffix.PinnipedDefaultSuffix) } } diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index ee6fa140..902ed43b 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -15,7 +15,6 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/klog/v2/klogr" - loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" pinnipedclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions" "go.pinniped.dev/internal/apiserviceref" @@ -85,18 +84,14 @@ type Config struct { // Prepare the controllers and their informers and return a function that will start them when called. //nolint:funlen // Eh, fair, it is a really long function...but it is wiring the world...so... func PrepareControllers(c *Config) (func(ctx context.Context), error) { - groupName, ok := groupsuffix.Replace(loginv1alpha1.GroupName, c.APIGroupSuffix) - if !ok { - return nil, fmt.Errorf("cannot make api group from %s/%s", loginv1alpha1.GroupName, c.APIGroupSuffix) - } - apiServiceName := loginv1alpha1.SchemeGroupVersion.Version + "." + groupName + loginConciergeGroupData, identityConciergeGroupData := groupsuffix.ConciergeAggregatedGroups(c.APIGroupSuffix) dref, _, err := deploymentref.New(c.ServerInstallationInfo) if err != nil { return nil, fmt.Errorf("cannot create deployment ref: %w", err) } - apiServiceRef, err := apiserviceref.New(apiServiceName) + apiServiceRef, err := apiserviceref.New(loginConciergeGroupData.APIServiceName()) if err != nil { return nil, fmt.Errorf("cannot create API service ref: %w", err) } @@ -163,7 +158,18 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) { apicerts.NewAPIServiceUpdaterController( c.ServerInstallationInfo.Namespace, c.NamesConfig.ServingCertificateSecret, - apiServiceName, + loginConciergeGroupData.APIServiceName(), + client.Aggregation, + informers.installationNamespaceK8s.Core().V1().Secrets(), + controllerlib.WithInformer, + ), + singletonWorker, + ). + WithController( + apicerts.NewAPIServiceUpdaterController( + c.ServerInstallationInfo.Namespace, + c.NamesConfig.ServingCertificateSecret, + identityConciergeGroupData.APIServiceName(), client.Aggregation, informers.installationNamespaceK8s.Core().V1().Secrets(), controllerlib.WithInformer, diff --git a/internal/groupsuffix/groupdata.go b/internal/groupsuffix/groupdata.go new file mode 100644 index 00000000..bac7ee4c --- /dev/null +++ b/internal/groupsuffix/groupdata.go @@ -0,0 +1,34 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package groupsuffix + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" + loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" +) + +type GroupData schema.GroupVersion + +func (d GroupData) APIServiceName() string { + return d.Version + "." + d.Group +} + +func ConciergeAggregatedGroups(apiGroupSuffix string) (login, identity GroupData) { + loginConciergeAPIGroup, ok1 := Replace(loginv1alpha1.GroupName, apiGroupSuffix) + identityConciergeAPIGroup, ok2 := Replace(identityv1alpha1.GroupName, apiGroupSuffix) + + if valid := ok1 && ok2; !valid { + panic("static group input is invalid") + } + + return GroupData{ + Group: loginConciergeAPIGroup, + Version: loginv1alpha1.SchemeGroupVersion.Version, + }, GroupData{ + Group: identityConciergeAPIGroup, + Version: identityv1alpha1.SchemeGroupVersion.Version, + } +} diff --git a/internal/groupsuffix/groupsuffix.go b/internal/groupsuffix/groupsuffix.go index 55bbff3b..c05c3d7a 100644 --- a/internal/groupsuffix/groupsuffix.go +++ b/internal/groupsuffix/groupsuffix.go @@ -20,13 +20,13 @@ import ( ) const ( - pinnipedDefaultSuffix = "pinniped.dev" + PinnipedDefaultSuffix = "pinniped.dev" pinnipedDefaultSuffixWithDot = ".pinniped.dev" ) func New(apiGroupSuffix string) kubeclient.Middleware { // return a no-op middleware by default - if len(apiGroupSuffix) == 0 || apiGroupSuffix == pinnipedDefaultSuffix { + if len(apiGroupSuffix) == 0 || apiGroupSuffix == PinnipedDefaultSuffix { return nil } @@ -161,7 +161,7 @@ func Replace(baseAPIGroup, apiGroupSuffix string) (string, bool) { if !strings.HasSuffix(baseAPIGroup, pinnipedDefaultSuffixWithDot) { return "", false } - return strings.TrimSuffix(baseAPIGroup, pinnipedDefaultSuffix) + apiGroupSuffix, true + return strings.TrimSuffix(baseAPIGroup, PinnipedDefaultSuffix) + apiGroupSuffix, true } // Unreplace is like performing an undo of Replace(). @@ -169,7 +169,7 @@ func Unreplace(baseAPIGroup, apiGroupSuffix string) (string, bool) { if !strings.HasSuffix(baseAPIGroup, "."+apiGroupSuffix) { return "", false } - return strings.TrimSuffix(baseAPIGroup, apiGroupSuffix) + pinnipedDefaultSuffix, true + return strings.TrimSuffix(baseAPIGroup, apiGroupSuffix) + PinnipedDefaultSuffix, true } // Validate validates the provided apiGroupSuffix is usable as an API group suffix. Specifically, it diff --git a/internal/kubeclient/kubeclient_test.go b/internal/kubeclient/kubeclient_test.go index 273942b6..07288238 100644 --- a/internal/kubeclient/kubeclient_test.go +++ b/internal/kubeclient/kubeclient_test.go @@ -21,8 +21,8 @@ import ( "k8s.io/client-go/transport" apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" - loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" - configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + conciergeconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" + supervisorconfigv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" "go.pinniped.dev/internal/testutil/fakekubeapi" ) @@ -46,16 +46,15 @@ var ( }, } - tokenCredentialRequestGVK = loginv1alpha1.SchemeGroupVersion.WithKind("TokenCredentialRequest") - goodTokenCredentialRequest = &loginv1alpha1.TokenCredentialRequest{ + credentialIssuerGVK = conciergeconfigv1alpha1.SchemeGroupVersion.WithKind("CredentialIssuer") + goodCredentialIssuer = &conciergeconfigv1alpha1.CredentialIssuer{ ObjectMeta: metav1.ObjectMeta{ - Name: "good-token-credential-request", - Namespace: "good-namespace", + Name: "good-credential-issuer", }, } - federationDomainGVK = configv1alpha1.SchemeGroupVersion.WithKind("FederationDomain") - goodFederationDomain = &configv1alpha1.FederationDomain{ + federationDomainGVK = supervisorconfigv1alpha1.SchemeGroupVersion.WithKind("FederationDomain") + goodFederationDomain = &supervisorconfigv1alpha1.FederationDomain{ ObjectMeta: metav1.ObjectMeta{ Name: "good-federation-domain", Namespace: "good-namespace", @@ -258,60 +257,60 @@ func TestKubeclient(t *testing.T) { reallyRunTest: func(t *testing.T, c *Client) { // create tokenCredentialRequest, err := c.PinnipedConcierge. - LoginV1alpha1(). - TokenCredentialRequests(). - Create(context.Background(), goodTokenCredentialRequest, metav1.CreateOptions{}) + ConfigV1alpha1(). + CredentialIssuers(). + Create(context.Background(), goodCredentialIssuer, metav1.CreateOptions{}) require.NoError(t, err) - require.Equal(t, goodTokenCredentialRequest, tokenCredentialRequest) + require.Equal(t, goodCredentialIssuer, tokenCredentialRequest) // read tokenCredentialRequest, err = c.PinnipedConcierge. - LoginV1alpha1(). - TokenCredentialRequests(). + ConfigV1alpha1(). + CredentialIssuers(). Get(context.Background(), tokenCredentialRequest.Name, metav1.GetOptions{}) require.NoError(t, err) - require.Equal(t, with(goodTokenCredentialRequest, annotations(), labels()), tokenCredentialRequest) + require.Equal(t, with(goodCredentialIssuer, annotations(), labels()), tokenCredentialRequest) // update - goodTokenCredentialRequestWithAnnotationsAndLabelsAndClusterName := with(goodTokenCredentialRequest, annotations(), labels(), clusterName()).(*loginv1alpha1.TokenCredentialRequest) + goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName := with(goodCredentialIssuer, annotations(), labels(), clusterName()).(*conciergeconfigv1alpha1.CredentialIssuer) tokenCredentialRequest, err = c.PinnipedConcierge. - LoginV1alpha1(). - TokenCredentialRequests(). - Update(context.Background(), goodTokenCredentialRequestWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{}) + ConfigV1alpha1(). + CredentialIssuers(). + Update(context.Background(), goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{}) require.NoError(t, err) - require.Equal(t, goodTokenCredentialRequestWithAnnotationsAndLabelsAndClusterName, tokenCredentialRequest) + require.Equal(t, goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName, tokenCredentialRequest) // delete err = c.PinnipedConcierge. - LoginV1alpha1(). - TokenCredentialRequests(). + ConfigV1alpha1(). + CredentialIssuers(). Delete(context.Background(), tokenCredentialRequest.Name, metav1.DeleteOptions{}) require.NoError(t, err) }, wantMiddlewareReqs: [][]Object{ { - with(goodTokenCredentialRequest, gvk(tokenCredentialRequestGVK)), - with(&metav1.PartialObjectMetadata{}, gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), clusterName(), gvk(tokenCredentialRequestGVK)), - with(&metav1.PartialObjectMetadata{}, gvk(tokenCredentialRequestGVK)), + with(goodCredentialIssuer, gvk(credentialIssuerGVK)), + with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)), + with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)), }, { - with(goodTokenCredentialRequest, annotations(), gvk(tokenCredentialRequestGVK)), - with(&metav1.PartialObjectMetadata{}, gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), clusterName(), gvk(tokenCredentialRequestGVK)), - with(&metav1.PartialObjectMetadata{}, gvk(tokenCredentialRequestGVK)), + with(goodCredentialIssuer, annotations(), gvk(credentialIssuerGVK)), + with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)), + with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)), }, }, wantMiddlewareResps: [][]Object{ { - with(goodTokenCredentialRequest, annotations(), labels(), gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), clusterName(), gvk(tokenCredentialRequestGVK)), + with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)), }, { - with(goodTokenCredentialRequest, emptyAnnotations(), labels(), gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), gvk(tokenCredentialRequestGVK)), - with(goodTokenCredentialRequest, annotations(), labels(), clusterName(), gvk(tokenCredentialRequestGVK)), + with(goodCredentialIssuer, emptyAnnotations(), labels(), gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)), + with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)), }, }, }, @@ -338,7 +337,7 @@ func TestKubeclient(t *testing.T) { require.Equal(t, with(goodFederationDomain, annotations(), labels()), federationDomain) // update - goodFederationDomainWithAnnotationsAndLabelsAndClusterName := with(goodFederationDomain, annotations(), labels(), clusterName()).(*configv1alpha1.FederationDomain) + goodFederationDomainWithAnnotationsAndLabelsAndClusterName := with(goodFederationDomain, annotations(), labels(), clusterName()).(*supervisorconfigv1alpha1.FederationDomain) federationDomain, err = c.PinnipedSupervisor. ConfigV1alpha1(). FederationDomains(federationDomain.Namespace). diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 4f8cc289..a8906676 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -17,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/authentication/user" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/utils/trace" @@ -157,6 +158,11 @@ func validateRequest(ctx context.Context, obj runtime.Object, createValidation r } } + if namespace := genericapirequest.NamespaceValue(ctx); len(namespace) != 0 { + traceValidationFailure(t, "namespace is not allowed") + return nil, apierrors.NewBadRequest(fmt.Sprintf("namespace is not allowed on TokenCredentialRequest: %v", namespace)) + } + // let dynamic admission webhooks have a chance to validate (but not mutate) as well // TODO Since we are an aggregated API, we should investigate to see if the kube API server is already invoking admission hooks for us. // Even if it is, its okay to call it again here. However, if the kube API server is already calling the webhooks and passing diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 80e40a1b..8542b99e 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -284,6 +284,17 @@ func TestCreate(t *testing.T) { `.pinniped.dev "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`) requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`) }) + + it("CreateFailsWhenNamespaceIsNotEmpty", func() { + response, err := NewREST(nil, nil, schema.GroupResource{}).Create( + genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), + validCredentialRequest(), + rest.ValidateAllObjectFunc, + &metav1.CreateOptions{}) + + requireAPIError(t, response, err, apierrors.IsBadRequest, `namespace is not allowed on TokenCredentialRequest: some-ns`) + requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:namespace is not allowed`) + }) }, spec.Sequential()) } diff --git a/internal/registry/whoamirequest/rest.go b/internal/registry/whoamirequest/rest.go new file mode 100644 index 00000000..d7a54e3e --- /dev/null +++ b/internal/registry/whoamirequest/rest.go @@ -0,0 +1,131 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package whoamirequest + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/authentication/authenticator" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" + + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" + identityapivalidation "go.pinniped.dev/generated/latest/apis/concierge/identity/validation" +) + +func NewREST(resource schema.GroupResource) *REST { + return &REST{ + tableConvertor: rest.NewDefaultTableConvertor(resource), + } +} + +type REST struct { + tableConvertor rest.TableConvertor +} + +// Assert that our *REST implements all the optional interfaces that we expect it to implement. +var _ interface { + rest.Creater + rest.NamespaceScopedStrategy + rest.Scoper + rest.Storage + rest.CategoriesProvider + rest.Lister +} = (*REST)(nil) + +func (*REST) New() runtime.Object { + return &identityapi.WhoAmIRequest{} +} + +func (*REST) NewList() runtime.Object { + return &identityapi.WhoAmIRequestList{} +} + +func (*REST) List(_ context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) { + return &identityapi.WhoAmIRequestList{ + ListMeta: metav1.ListMeta{ + ResourceVersion: "0", // this resource version means "from the API server cache" + }, + Items: []identityapi.WhoAmIRequest{}, // avoid sending nil items list + }, nil +} + +func (r *REST) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return r.tableConvertor.ConvertToTable(ctx, obj, tableOptions) +} + +func (*REST) NamespaceScoped() bool { + return false +} + +func (*REST) Categories() []string { + return []string{"pinniped"} +} + +func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + whoAmIRequest, ok := obj.(*identityapi.WhoAmIRequest) + if !ok { + return nil, apierrors.NewBadRequest(fmt.Sprintf("not a WhoAmIRequest: %#v", obj)) + } + + if errs := identityapivalidation.ValidateWhoAmIRequest(whoAmIRequest); len(errs) > 0 { + return nil, apierrors.NewInvalid(identityapi.Kind(whoAmIRequest.Kind), whoAmIRequest.Name, errs) + } + + // just a sanity check, not sure how to honor a dry run on a virtual API + if options != nil { + if len(options.DryRun) != 0 { + errs := field.ErrorList{field.NotSupported(field.NewPath("dryRun"), options.DryRun, nil)} + return nil, apierrors.NewInvalid(identityapi.Kind(whoAmIRequest.Kind), whoAmIRequest.Name, errs) + } + } + + if namespace := genericapirequest.NamespaceValue(ctx); len(namespace) != 0 { + return nil, apierrors.NewBadRequest(fmt.Sprintf("namespace is not allowed on WhoAmIRequest: %v", namespace)) + } + + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } + + userInfo, ok := genericapirequest.UserFrom(ctx) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("no user info on request")) + } + + auds, _ := authenticator.AudiencesFrom(ctx) + + out := &identityapi.WhoAmIRequest{ + Status: identityapi.WhoAmIRequestStatus{ + KubernetesUserInfo: identityapi.KubernetesUserInfo{ + User: identityapi.UserInfo{ + Username: userInfo.GetName(), + UID: userInfo.GetUID(), + Groups: userInfo.GetGroups(), + }, + Audiences: auds, + }, + }, + } + for k, v := range userInfo.GetExtra() { + if out.Status.KubernetesUserInfo.User.Extra == nil { + out.Status.KubernetesUserInfo.User.Extra = map[string]identityapi.ExtraValue{} + } + + // this assumes no one is putting secret data in the extra field + // I think this is a safe assumption since it would leak into audit logs + out.Status.KubernetesUserInfo.User.Extra[k] = v + } + + return out, nil +} diff --git a/internal/registry/whoamirequest/rest_test.go b/internal/registry/whoamirequest/rest_test.go new file mode 100644 index 00000000..28b65041 --- /dev/null +++ b/internal/registry/whoamirequest/rest_test.go @@ -0,0 +1,211 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package whoamirequest + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + "k8s.io/apiserver/pkg/registry/rest" + + identityapi "go.pinniped.dev/generated/latest/apis/concierge/identity" +) + +func TestNew(t *testing.T) { + r := NewREST(schema.GroupResource{Group: "bears", Resource: "panda"}) + require.NotNil(t, r) + require.False(t, r.NamespaceScoped()) + require.Equal(t, []string{"pinniped"}, r.Categories()) + require.IsType(t, &identityapi.WhoAmIRequest{}, r.New()) + require.IsType(t, &identityapi.WhoAmIRequestList{}, r.NewList()) + + ctx := context.Background() + + // check the simple invariants of our no-op list + list, err := r.List(ctx, nil) + require.NoError(t, err) + require.NotNil(t, list) + require.IsType(t, &identityapi.WhoAmIRequestList{}, list) + require.Equal(t, "0", list.(*identityapi.WhoAmIRequestList).ResourceVersion) + require.NotNil(t, list.(*identityapi.WhoAmIRequestList).Items) + require.Len(t, list.(*identityapi.WhoAmIRequestList).Items, 0) + + // make sure we can turn lists into tables if needed + table, err := r.ConvertToTable(ctx, list, nil) + require.NoError(t, err) + require.NotNil(t, table) + require.Equal(t, "0", table.ResourceVersion) + require.Nil(t, table.Rows) + + // exercise group resource - force error by passing a runtime.Object that does not have an embedded object meta + _, err = r.ConvertToTable(ctx, &metav1.APIGroup{}, nil) + require.Error(t, err, "the resource panda.bears does not support being converted to a Table") +} + +func TestCreate(t *testing.T) { + type args struct { + ctx context.Context + obj runtime.Object + createValidation rest.ValidateObjectFunc + options *metav1.CreateOptions + } + tests := []struct { + name string + args args + want runtime.Object + wantErr string + }{ + { + name: "wrong type", + args: args{ + ctx: genericapirequest.NewContext(), + obj: &metav1.Status{}, + createValidation: nil, + options: nil, + }, + want: nil, + wantErr: `not a WhoAmIRequest: &v1.Status{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ListMeta:v1.ListMeta{SelfLink:"", ResourceVersion:"", Continue:"", RemainingItemCount:(*int64)(nil)}, Status:"", Message:"", Reason:"", Details:(*v1.StatusDetails)(nil), Code:0}`, + }, + { + name: "bad options", + args: args{ + ctx: genericapirequest.NewContext(), + obj: &identityapi.WhoAmIRequest{ + TypeMeta: metav1.TypeMeta{ + Kind: "SomeKind", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "some-name", + }, + }, + createValidation: nil, + options: &metav1.CreateOptions{DryRun: []string{"stuff"}}, + }, + want: nil, + wantErr: `SomeKind.identity.concierge.pinniped.dev "some-name" is invalid: dryRun: Unsupported value: []string{"stuff"}`, + }, + { + name: "bad namespace", + args: args{ + ctx: genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"), + obj: &identityapi.WhoAmIRequest{}, + createValidation: nil, + options: nil, + }, + want: nil, + wantErr: `namespace is not allowed on WhoAmIRequest: some-ns`, + }, + { + // if we add fields to spec, we need additional tests to: + // - make sure admission cannot mutate it + // - the input spec fields are validated correctly + name: "create validation failure", + args: args{ + ctx: genericapirequest.NewContext(), + obj: &identityapi.WhoAmIRequest{}, + createValidation: func(ctx context.Context, obj runtime.Object) error { + return errors.New("some-error-here") + }, + options: nil, + }, + want: nil, + wantErr: `some-error-here`, + }, + { + name: "no user info", + args: args{ + ctx: genericapirequest.NewContext(), + obj: &identityapi.WhoAmIRequest{}, + createValidation: nil, + options: nil, + }, + want: nil, + wantErr: `Internal error occurred: no user info on request`, + }, + { + name: "with user info, no auds", + args: args{ + ctx: genericapirequest.WithUser(genericapirequest.NewContext(), &user.DefaultInfo{ + Name: "bond", + UID: "007", + Groups: []string{"agents", "ops"}, + Extra: map[string][]string{ + "fan-of": {"pandas", "twizzlers"}, + "needs": {"sleep"}, + }, + }), + obj: &identityapi.WhoAmIRequest{}, + createValidation: nil, + options: nil, + }, + want: &identityapi.WhoAmIRequest{ + Status: identityapi.WhoAmIRequestStatus{ + KubernetesUserInfo: identityapi.KubernetesUserInfo{ + User: identityapi.UserInfo{ + Username: "bond", + UID: "007", + Groups: []string{"agents", "ops"}, + Extra: map[string]identityapi.ExtraValue{ + "fan-of": {"pandas", "twizzlers"}, + "needs": {"sleep"}, + }, + }, + Audiences: nil, + }, + }, + }, + wantErr: ``, + }, + { + name: "with user info and auds", + args: args{ + ctx: authenticator.WithAudiences( + genericapirequest.WithUser(genericapirequest.NewContext(), &user.DefaultInfo{ + Name: "panda", + }), + authenticator.Audiences{"gitlab", "aws"}, + ), + obj: &identityapi.WhoAmIRequest{}, + createValidation: nil, + options: nil, + }, + want: &identityapi.WhoAmIRequest{ + Status: identityapi.WhoAmIRequestStatus{ + KubernetesUserInfo: identityapi.KubernetesUserInfo{ + User: identityapi.UserInfo{ + Username: "panda", + }, + Audiences: []string{"gitlab", "aws"}, + }, + }, + }, + wantErr: ``, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + r := &REST{} + got, err := r.Create(tt.args.ctx, tt.args.obj, tt.args.createValidation, tt.args.options) + require.Equal(t, tt.wantErr, errString(err)) + require.Equal(t, tt.want, got) + }) + } +} + +func errString(err error) string { + if err == nil { + return "" + } + + return err.Error() +} diff --git a/pkg/conciergeclient/conciergeclient.go b/pkg/conciergeclient/conciergeclient.go index 87198ddf..0e5fa6f7 100644 --- a/pkg/conciergeclient/conciergeclient.go +++ b/pkg/conciergeclient/conciergeclient.go @@ -118,7 +118,7 @@ func WithAPIGroupSuffix(apiGroupSuffix string) Option { // New validates the specified options and returns a newly initialized *Client. func New(opts ...Option) (*Client, error) { - c := Client{apiGroupSuffix: "pinniped.dev"} + c := Client{apiGroupSuffix: groupsuffix.PinnipedDefaultSuffix} for _, opt := range opts { if err := opt(&c); err != nil { return nil, err diff --git a/test/integration/category_test.go b/test/integration/category_test.go index a6957567..e8edc7bd 100644 --- a/test/integration/category_test.go +++ b/test/integration/category_test.go @@ -45,6 +45,8 @@ func TestGetPinnipedCategory(t *testing.T) { require.Contains(t, stdErr.String(), `"kind":"Table"`) require.Contains(t, stdErr.String(), `"resourceVersion":"0"`) + require.Contains(t, stdErr.String(), `/v1alpha1/tokencredentialrequests`) + require.Contains(t, stdErr.String(), `/v1alpha1/whoamirequests`) }) t.Run("list, no special params", func(t *testing.T) { @@ -78,7 +80,7 @@ func TestGetPinnipedCategory(t *testing.T) { require.Contains(t, stdErr.String(), `"resourceVersion":"0"`) }) - t.Run("raw request to see body", func(t *testing.T) { + t.Run("raw request to see body, token cred", func(t *testing.T) { var stdOut, stdErr bytes.Buffer //nolint: gosec // input is part of test env @@ -93,4 +95,20 @@ func TestGetPinnipedCategory(t *testing.T) { require.Contains(t, stdOut.String(), `{"kind":"TokenCredentialRequestList","apiVersion":"login.concierge`+ dotSuffix+`/v1alpha1","metadata":{"resourceVersion":"0"},"items":[]}`) }) + + t.Run("raw request to see body, whoami", func(t *testing.T) { + var stdOut, stdErr bytes.Buffer + + //nolint: gosec // input is part of test env + cmd := exec.Command("kubectl", "get", "--raw", "/apis/identity.concierge"+dotSuffix+"/v1alpha1/whoamirequests") + cmd.Stdout = &stdOut + cmd.Stderr = &stdErr + err := cmd.Run() + require.NoError(t, err, stdErr.String(), stdOut.String()) + require.Empty(t, stdErr.String()) + + require.NotContains(t, stdOut.String(), "MethodNotAllowed") + require.Contains(t, stdOut.String(), `{"kind":"WhoAmIRequestList","apiVersion":"identity.concierge`+ + dotSuffix+`/v1alpha1","metadata":{"resourceVersion":"0"},"items":[]}`) + }) } diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index e0b2cc75..6ffdf0b9 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -303,4 +303,37 @@ func TestE2EFullIntegration(t *testing.T) { expectedGroups = append(expectedGroups, g) } require.Equal(t, expectedGroups, idTokenClaims[oidc.DownstreamGroupsClaim]) + + // confirm we are the right user according to Kube + expectedYAMLGroups := func() string { + var b strings.Builder + for _, g := range env.SupervisorTestUpstream.ExpectedGroups { + b.WriteString("\n") + b.WriteString(` - `) + b.WriteString(g) + } + return b.String() + }() + kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath) + kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...) + kubectlCmd3.Stdin = strings.NewReader(` +apiVersion: identity.concierge.` + env.APIGroupSuffix + `/v1alpha1 +kind: WhoAmIRequest +`) + kubectlOutput3, err := kubectlCmd3.CombinedOutput() + require.NoError(t, err) + require.Equal(t, + `apiVersion: identity.concierge.`+env.APIGroupSuffix+`/v1alpha1 +kind: WhoAmIRequest +metadata: + creationTimestamp: null +spec: {} +status: + kubernetesUserInfo: + user: + groups:`+expectedYAMLGroups+` + - system:authenticated + username: `+env.SupervisorTestUpstream.Username+` +`, + string(kubectlOutput3)) } diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index c7783b81..b7a652a2 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/discovery" "go.pinniped.dev/test/library" @@ -44,6 +45,7 @@ func TestGetAPIResourceList(t *testing.T) { } } loginConciergeGV := makeGV("login", "concierge") + identityConciergeGV := makeGV("identity", "concierge") authenticationConciergeGV := makeGV("authentication", "concierge") configConciergeGV := makeGV("config", "concierge") idpSupervisorGV := makeGV("idp", "supervisor") @@ -79,6 +81,32 @@ func TestGetAPIResourceList(t *testing.T) { }, }, }, + { + group: metav1.APIGroup{ + Name: identityConciergeGV.Group, + Versions: []metav1.GroupVersionForDiscovery{ + { + GroupVersion: identityConciergeGV.String(), + Version: identityConciergeGV.Version, + }, + }, + PreferredVersion: metav1.GroupVersionForDiscovery{ + GroupVersion: identityConciergeGV.String(), + Version: identityConciergeGV.Version, + }, + }, + resourceByVersion: map[string][]metav1.APIResource{ + identityConciergeGV.String(): { + { + Name: "whoamirequests", + Kind: "WhoAmIRequest", + Verbs: []string{"create", "list"}, + Namespaced: false, + Categories: []string{"pinniped"}, + }, + }, + }, + }, { group: metav1.APIGroup{ Name: configSupervisorGV.Group, @@ -280,6 +308,8 @@ func TestGetAPIResourceList(t *testing.T) { t.Run("every API has a status subresource", func(t *testing.T) { t.Parallel() + aggregatedAPIs := sets.NewString("tokencredentialrequests", "whoamirequests") + var regular, status []string for _, r := range resources { @@ -288,8 +318,8 @@ func TestGetAPIResourceList(t *testing.T) { } for _, a := range r.APIResources { - if a.Name == "tokencredentialrequests" { - continue // our special aggregated API with its own magical properties + if aggregatedAPIs.Has(a.Name) { + continue // skip our special aggregated APIs with their own magical properties } if strings.HasSuffix(a.Name, "/status") { diff --git a/test/integration/whoami_test.go b/test/integration/whoami_test.go new file mode 100644 index 00000000..1a6beef4 --- /dev/null +++ b/test/integration/whoami_test.go @@ -0,0 +1,448 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package integration + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "testing" + "time" + + "github.com/stretchr/testify/require" + authenticationv1 "k8s.io/api/authentication/v1" + certificatesv1 "k8s.io/api/certificates/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/cert" + "k8s.io/client-go/util/certificate/csr" + "k8s.io/client-go/util/keyutil" + + identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" + "go.pinniped.dev/test/library" +) + +func TestWhoAmI_Kubeadm(t *testing.T) { + // use the cluster signing key being available as a proxy for this being a kubeadm cluster + // we should add more robust logic around skipping clusters based on vendor + _ = library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + whoAmI, err := library.NewConciergeClientset(t).IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // this user info is based off of the bootstrap cert user created by kubeadm + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "kubernetes-admin", + Groups: []string{ + "system:masters", + "system:authenticated", + }, + }, + }, + }, + }, + whoAmI, + ) +} + +func TestWhoAmI_ServiceAccount_Legacy(t *testing.T) { + _ = library.IntegrationEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + kubeClient := library.NewKubernetesClientset(t).CoreV1() + + ns, err := kubeClient.Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + if t.Failed() { + return + } + err := kubeClient.Namespaces().Delete(ctx, ns.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + sa, err := kubeClient.ServiceAccounts(ns.Name).Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + secret, err := kubeClient.Secrets(ns.Name).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + Annotations: map[string]string{ + corev1.ServiceAccountNameKey: sa.Name, + }, + }, + Type: corev1.SecretTypeServiceAccountToken, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + library.RequireEventuallyWithoutError(t, func() (bool, error) { + secret, err = kubeClient.Secrets(ns.Name).Get(ctx, secret.Name, metav1.GetOptions{}) + if err != nil { + return false, err + } + return len(secret.Data[corev1.ServiceAccountTokenKey]) > 0, nil + }, 30*time.Second, time.Second) + + saConfig := library.NewAnonymousClientRestConfig(t) + saConfig.BearerToken = string(secret.Data[corev1.ServiceAccountTokenKey]) + + whoAmI, err := library.NewKubeclient(t, saConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // legacy service account tokens do not have any extra info + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "system:serviceaccount:" + ns.Name + ":" + sa.Name, + UID: "", // aggregation drops UID: https://github.com/kubernetes/kubernetes/issues/93699 + Groups: []string{ + "system:serviceaccounts", + "system:serviceaccounts:" + ns.Name, + "system:authenticated", + }, + }, + }, + }, + }, + whoAmI, + ) +} + +func TestWhoAmI_ServiceAccount_TokenRequest(t *testing.T) { + _ = library.IntegrationEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + kubeClient := library.NewKubernetesClientset(t).CoreV1() + + ns, err := kubeClient.Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + defer func() { + if t.Failed() { + return + } + err := kubeClient.Namespaces().Delete(ctx, ns.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + sa, err := kubeClient.ServiceAccounts(ns.Name).Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + _, tokenRequestProbeErr := kubeClient.ServiceAccounts(ns.Name).CreateToken(ctx, sa.Name, &authenticationv1.TokenRequest{}, metav1.CreateOptions{}) + if errors.IsNotFound(tokenRequestProbeErr) && tokenRequestProbeErr.Error() == "the server could not find the requested resource" { + return // stop test early since the token request API is not enabled on this cluster - other errors are caught below + } + + pod, err := kubeClient.Pods(ns.Name).Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-whoami-", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "ignored-but-required", + Image: "does-not-matter", + }, + }, + ServiceAccountName: sa.Name, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + tokenRequestBadAudience, err := kubeClient.ServiceAccounts(ns.Name).CreateToken(ctx, sa.Name, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"should-fail-because-wrong-audience"}, // anything that is not an API server audience + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + saBadAudConfig := library.NewAnonymousClientRestConfig(t) + saBadAudConfig.BearerToken = tokenRequestBadAudience.Status.Token + + _, badAudErr := library.NewKubeclient(t, saBadAudConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.True(t, errors.IsUnauthorized(badAudErr), library.Sdump(badAudErr)) + + tokenRequest, err := kubeClient.ServiceAccounts(ns.Name).CreateToken(ctx, sa.Name, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + saTokenReqConfig := library.NewAnonymousClientRestConfig(t) + saTokenReqConfig.BearerToken = tokenRequest.Status.Token + + whoAmITokenReq, err := library.NewKubeclient(t, saTokenReqConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // new service account tokens include the pod info in the extra fields + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "system:serviceaccount:" + ns.Name + ":" + sa.Name, + UID: "", // aggregation drops UID: https://github.com/kubernetes/kubernetes/issues/93699 + Groups: []string{ + "system:serviceaccounts", + "system:serviceaccounts:" + ns.Name, + "system:authenticated", + }, + Extra: map[string]identityv1alpha1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.UID)}, + }, + }, + }, + }, + }, + whoAmITokenReq, + ) +} + +func TestWhoAmI_CSR(t *testing.T) { + // use the cluster signing key being available as a proxy for this not being an EKS cluster + // we should add more robust logic around skipping clusters based on vendor + _ = library.IntegrationEnv(t).WithCapability(library.ClusterSigningKeyIsAvailable) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + kubeClient := library.NewKubernetesClientset(t) + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + der, err := x509.MarshalECPrivateKey(privateKey) + require.NoError(t, err) + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: keyutil.ECPrivateKeyBlockType, Bytes: der}) + + csrPEM, err := cert.MakeCSR(privateKey, &pkix.Name{ + CommonName: "panda-man", + Organization: []string{"living-the-dream", "need-more-sleep"}, + }, nil, nil) + require.NoError(t, err) + + csrName, csrUID, err := csr.RequestCertificate( + kubeClient, + csrPEM, + "", + certificatesv1.KubeAPIServerClientSignerName, + []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth}, + privateKey, + ) + require.NoError(t, err) + + defer func() { + if t.Failed() { + return + } + err := kubeClient.CertificatesV1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{}) + require.NoError(t, err) + }() + + // this is a blind update with no resource version checks, which is only safe during tests + _, err = kubeClient.CertificatesV1().CertificateSigningRequests().UpdateApproval(ctx, csrName, &certificatesv1.CertificateSigningRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: csrName, + }, + Status: certificatesv1.CertificateSigningRequestStatus{ + Conditions: []certificatesv1.CertificateSigningRequestCondition{ + { + Type: certificatesv1.CertificateApproved, + Status: corev1.ConditionTrue, + Reason: "WhoAmICSRTest", + }, + }, + }, + }, metav1.UpdateOptions{}) + require.NoError(t, err) + + crtPEM, err := csr.WaitForCertificate(ctx, kubeClient, csrName, csrUID) + require.NoError(t, err) + + csrConfig := library.NewAnonymousClientRestConfig(t) + csrConfig.CertData = crtPEM + csrConfig.KeyData = keyPEM + + whoAmI, err := library.NewKubeclient(t, csrConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "panda-man", + Groups: []string{ + "need-more-sleep", + "living-the-dream", + "system:authenticated", + }, + }, + }, + }, + }, + whoAmI, + ) +} + +func TestWhoAmI_Anonymous(t *testing.T) { + _ = library.IntegrationEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + anonymousConfig := library.NewAnonymousClientRestConfig(t) + + whoAmI, err := library.NewKubeclient(t, anonymousConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // this also asserts that all users, even unauthenticated ones, can call this API when anonymous is enabled + // this test will need to be skipped when we start running the integration tests against AKS clusters + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "system:anonymous", + Groups: []string{ + "system:unauthenticated", + }, + }, + }, + }, + }, + whoAmI, + ) +} + +func TestWhoAmI_ImpersonateDirectly(t *testing.T) { + _ = library.IntegrationEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + impersonationConfig := library.NewClientConfig(t) + impersonationConfig.Impersonate = rest.ImpersonationConfig{ + UserName: "solaire", + Groups: []string{"astora", "lordran"}, + Extra: map[string][]string{ + "covenant": {"warrior-of-sunlight"}, + "loves": {"sun", "co-op"}, + }, + } + + whoAmI, err := library.NewKubeclient(t, impersonationConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "solaire", + UID: "", // no way to impersonate UID: https://github.com/kubernetes/kubernetes/issues/93699 + Groups: []string{ + "astora", + "lordran", + "system:authenticated", // impersonation will add this implicitly + }, + Extra: map[string]identityv1alpha1.ExtraValue{ + "covenant": {"warrior-of-sunlight"}, + "loves": {"sun", "co-op"}, + }, + }, + }, + }, + }, + whoAmI, + ) + + impersonationAnonymousConfig := library.NewClientConfig(t) + impersonationAnonymousConfig.Impersonate.UserName = "system:anonymous" + + whoAmIAnonymous, err := library.NewKubeclient(t, impersonationAnonymousConfig).PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + require.Equal(t, + &identityv1alpha1.WhoAmIRequest{ + Status: identityv1alpha1.WhoAmIRequestStatus{ + KubernetesUserInfo: identityv1alpha1.KubernetesUserInfo{ + User: identityv1alpha1.UserInfo{ + Username: "system:anonymous", + Groups: []string{ + "system:unauthenticated", // impersonation will add this implicitly + }, + }, + }, + }, + }, + whoAmIAnonymous, + ) +} + +func TestWhoAmI_ImpersonateViaProxy(t *testing.T) { + _ = library.IntegrationEnv(t) + + // TODO: add this test after the impersonation proxy is done + // this should test all forms of auth understood by the proxy (certs, SA token, token cred req, anonymous, etc) + // remember that impersonation does not support UID: https://github.com/kubernetes/kubernetes/issues/93699 +} diff --git a/test/library/client.go b/test/library/client.go index 6d7facb6..8e90c424 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -72,25 +72,25 @@ func NewClientsetWithCertAndKey(t *testing.T, clientCertificateData, clientKeyDa func NewKubernetesClientset(t *testing.T) kubernetes.Interface { t.Helper() - return newKubeclient(t, NewClientConfig(t)).Kubernetes + return NewKubeclient(t, NewClientConfig(t)).Kubernetes } func NewSupervisorClientset(t *testing.T) supervisorclientset.Interface { t.Helper() - return newKubeclient(t, NewClientConfig(t)).PinnipedSupervisor + return NewKubeclient(t, NewClientConfig(t)).PinnipedSupervisor } func NewConciergeClientset(t *testing.T) conciergeclientset.Interface { t.Helper() - return newKubeclient(t, NewClientConfig(t)).PinnipedConcierge + return NewKubeclient(t, NewClientConfig(t)).PinnipedConcierge } func NewAnonymousConciergeClientset(t *testing.T) conciergeclientset.Interface { t.Helper() - return newKubeclient(t, newAnonymousClientRestConfig(t)).PinnipedConcierge + return NewKubeclient(t, NewAnonymousClientRestConfig(t)).PinnipedConcierge } func NewAggregatedClientset(t *testing.T) aggregatorclient.Interface { @@ -118,7 +118,7 @@ func newClientsetWithConfig(t *testing.T, config *rest.Config) kubernetes.Interf } // Returns a rest.Config without any user authentication info. -func newAnonymousClientRestConfig(t *testing.T) *rest.Config { +func NewAnonymousClientRestConfig(t *testing.T) *rest.Config { t.Helper() return rest.AnonymousClientConfig(NewClientConfig(t)) @@ -128,13 +128,13 @@ func newAnonymousClientRestConfig(t *testing.T) *rest.Config { func newAnonymousClientRestConfigWithCertAndKeyAdded(t *testing.T, clientCertificateData, clientKeyData string) *rest.Config { t.Helper() - config := newAnonymousClientRestConfig(t) + config := NewAnonymousClientRestConfig(t) config.CertData = []byte(clientCertificateData) config.KeyData = []byte(clientKeyData) return config } -func newKubeclient(t *testing.T, config *rest.Config) *kubeclient.Client { +func NewKubeclient(t *testing.T, config *rest.Config) *kubeclient.Client { t.Helper() env := IntegrationEnv(t) client, err := kubeclient.New(