diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go index 11a8d56b..4807ac29 100644 --- a/cmd/milo/controller-manager/controllermanager.go +++ b/cmd/milo/controller-manager/controllermanager.go @@ -86,10 +86,12 @@ import ( quotacontroller "go.miloapis.com/milo/internal/quota/controllers" crmv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/crm/v1alpha1" iamv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/iam/v1alpha1" + identityv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/identity/v1alpha1" notificationv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/notification/v1alpha1" resourcemanagerv1alpha1webhook "go.miloapis.com/milo/internal/webhooks/resourcemanager/v1alpha1" crmv1alpha1 "go.miloapis.com/milo/pkg/apis/crm/v1alpha1" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" + identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" infrastructurev1alpha1 "go.miloapis.com/milo/pkg/apis/infrastructure/v1alpha1" notificationv1alpha1 "go.miloapis.com/milo/pkg/apis/notification/v1alpha1" quotav1alpha1 "go.miloapis.com/milo/pkg/apis/quota/v1alpha1" @@ -169,6 +171,7 @@ func init() { utilruntime.Must(resourcemanagerv1alpha1.AddToScheme(Scheme)) utilruntime.Must(infrastructurev1alpha1.AddToScheme(Scheme)) utilruntime.Must(iamv1alpha1.AddToScheme(Scheme)) + utilruntime.Must(identityv1alpha1.AddToScheme(Scheme)) utilruntime.Must(notificationv1alpha1.AddToScheme(Scheme)) utilruntime.Must(crmv1alpha1.AddToScheme(Scheme)) utilruntime.Must(quotav1alpha1.AddToScheme(Scheme)) @@ -495,6 +498,10 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error { logger.Error(err, "Error setting up userdeactivation webhook") klog.FlushAndExit(klog.ExitFlushTimeout, 1) } + if err := identityv1alpha1webhook.SetupUserIdentityWebhooksWithManager(ctrl); err != nil { + logger.Error(err, "Error setting up useridentity webhook") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } if err := notificationv1alpha1webhook.SetupEmailTemplateWebhooksWithManager(ctrl, SystemNamespace); err != nil { logger.Error(err, "Error setting up emailtemplate webhook") klog.FlushAndExit(klog.ExitFlushTimeout, 1) diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 2ffeb58a..697ad5aa 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -281,6 +281,7 @@ webhooks: - v1alpha1 operations: - CREATE + - UPDATE resources: - users sideEffects: NoneOnDryRun @@ -326,6 +327,27 @@ webhooks: resources: - userinvitations sideEffects: None +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: milo-controller-manager + namespace: milo-system + path: /validate-identity-miloapis-com-v1alpha1-useridentity + port: 9443 + failurePolicy: Fail + name: vuseridentity.identity.miloapis.com + rules: + - apiGroups: + - identity.miloapis.com + apiVersions: + - v1alpha1 + operations: + - DELETE + resources: + - useridentities + sideEffects: None - admissionReviewVersions: - v1 - v1beta1 diff --git a/internal/apiserver/identity/useridentities/README.md b/internal/apiserver/identity/useridentities/README.md index 1d85eea8..aa7a065e 100644 --- a/internal/apiserver/identity/useridentities/README.md +++ b/internal/apiserver/identity/useridentities/README.md @@ -38,7 +38,13 @@ Naming & structure - internal/apiserver/identity/useridentities/rest.go — REST storage - internal/apiserver/identity/useridentities/dynamic.go — provider implementation -Read-only resource +Read-only resource and admission webhook for deletion Unlike sessions, useridentities is a read-only resource. Users cannot create, update, or delete user identities through the Kubernetes API. Identity linking and unlinking is managed through the external identity provider (e.g., Zitadel). + +If a user attempts to delete a UserIdentity via the Kubernetes API, the operation will be explicitly rejected by an admission webhook, which returns an error similar to: + + deleting UserIdentity resources is not currently supported. Identity provider links must be managed through the authentication provider (e.g., Zitadel). Automatic email synchronization logic is required before deletion can be enabled + +This error response ensures deletions are consistently blocked at the API layer, clarifying current support and intended usage. diff --git a/internal/webhooks/iam/v1alpha1/user_webhook.go b/internal/webhooks/iam/v1alpha1/user_webhook.go index eeadf62e..62eb5288 100644 --- a/internal/webhooks/iam/v1alpha1/user_webhook.go +++ b/internal/webhooks/iam/v1alpha1/user_webhook.go @@ -4,20 +4,25 @@ import ( "context" "fmt" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1" + identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" ) // log is for logging in this package. var userlog = logf.Log.WithName("user-resource") -// +kubebuilder:webhook:path=/validate-iam-miloapis-com-v1alpha1-user,mutating=false,failurePolicy=fail,sideEffects=NoneOnDryRun,groups=iam.miloapis.com,resources=users,verbs=create,versions=v1alpha1,name=vuser.iam.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system +// +kubebuilder:webhook:path=/validate-iam-miloapis-com-v1alpha1-user,mutating=false,failurePolicy=fail,sideEffects=NoneOnDryRun,groups=iam.miloapis.com,resources=users,verbs=create;update,versions=v1alpha1,name=vuser.iam.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system // SetupWebhooksWithManager sets up all iam.miloapis.com webhooks func SetupUserWebhooksWithManager(mgr ctrl.Manager, systemNamespace string, userSelfManageRoleName string) error { @@ -27,6 +32,8 @@ func SetupUserWebhooksWithManager(mgr ctrl.Manager, systemNamespace string, user For(&iamv1alpha1.User{}). WithValidator(&UserValidator{ client: mgr.GetClient(), + restConfig: mgr.GetConfig(), + scheme: mgr.GetScheme(), systemNamespace: systemNamespace, userSelfManageRoleName: userSelfManageRoleName, }). @@ -36,6 +43,8 @@ func SetupUserWebhooksWithManager(mgr ctrl.Manager, systemNamespace string, user // UserValidator validates Users type UserValidator struct { client client.Client + restConfig *rest.Config + scheme *runtime.Scheme decoder admission.Decoder systemNamespace string userSelfManageRoleName string @@ -74,13 +83,122 @@ func (v *UserValidator) ValidateCreate(ctx context.Context, obj runtime.Object) } func (v *UserValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { - return nil, nil + oldUser := oldObj.(*iamv1alpha1.User) + newUser := newObj.(*iamv1alpha1.User) + + // If email hasn't changed, allow the update + if oldUser.Spec.Email == newUser.Spec.Email { + return nil, nil + } + + userlog.Info("Email change detected", + "user", newUser.Name, + "oldEmail", oldUser.Spec.Email, + "newEmail", newUser.Spec.Email) + + // Get the requesting user from admission request + req, err := admission.RequestFromContext(ctx) + if err != nil { + userlog.Error(err, "Failed to get request from context") + return nil, errors.NewInternalError(fmt.Errorf("failed to get request from context: %w", err)) + } + + // Only allow users to update their own email (self-service only) + // This ensures that the UserIdentity list will be scoped to the correct user + // Note: req.UserInfo.UID is the Milo user ID, which matches the User resource name + if req.UserInfo.UID != newUser.Name { + userlog.Info("Rejecting email update from different user", + "requestingUser", req.UserInfo.UID, + "targetUser", newUser.Name) + return nil, errors.NewForbidden( + schema.GroupResource{Group: iamv1alpha1.SchemeGroupVersion.Group, Resource: "users"}, + newUser.Name, + fmt.Errorf("cannot update email address of another user. Email updates are restricted to self-service only")) + } + + // Get UserIdentities for the requesting user using impersonated client + // UserIdentity is a dynamic REST API that requires proper user context + impersonatedClient, err := v.createImpersonatedClient(req.UserInfo) + if err != nil { + userlog.Error(err, "Failed to create impersonated client", "user", newUser.Name) + return nil, errors.NewInternalError(fmt.Errorf("failed to create impersonated client: %w", err)) + } + + identityList := &identityv1alpha1.UserIdentityList{} + if err := impersonatedClient.List(ctx, identityList); err != nil { + userlog.Error(err, "Failed to list user identities", "user", newUser.Name) + return nil, errors.NewInternalError(fmt.Errorf("failed to list user identities: %w", err)) + } + + // If no identities are linked, reject the change + if len(identityList.Items) == 0 { + userlog.Info("User has no linked identities, rejecting email change", "user", newUser.Name) + return nil, errors.NewBadRequest( + "cannot change email: no verified identity providers linked to this account. " + + "Please link an identity provider (GitHub, Google, etc.) first") + } + + // Validate that the new email exists in any of the linked identities + // The email is stored in the Username field of UserIdentity + for _, identity := range identityList.Items { + if identity.Status.Username == newUser.Spec.Email { + userlog.Info("Email validated against identity provider", + "email", newUser.Spec.Email, + "provider", identity.Status.ProviderName, + "providerID", identity.Status.ProviderID) + return nil, nil // Email is valid + } + } + + // Build list of available emails for error message + availableEmails := make([]string, 0, len(identityList.Items)) + for _, identity := range identityList.Items { + if identity.Status.Username != "" { + availableEmails = append(availableEmails, identity.Status.Username) + } + } + + // Email not found in any linked identity + userlog.Info("Email not found in linked identities", + "requestedEmail", newUser.Spec.Email, + "availableEmails", availableEmails) + + return nil, errors.NewBadRequest( + fmt.Sprintf( + "email %q is not linked to any verified identity provider. "+ + "Available verified emails: %v", + newUser.Spec.Email, + availableEmails)) } func (v *UserValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { return nil, nil } +// createImpersonatedClient creates a client that impersonates the requesting user +// This is necessary for UserIdentity API calls which require proper user context +func (v *UserValidator) createImpersonatedClient(userInfo authenticationv1.UserInfo) (client.Client, error) { + // Convert Extra from authenticationv1.ExtraValue to map[string][]string + extra := make(map[string][]string) + for k, v := range userInfo.Extra { + extra[k] = []string(v) + } + + // Create a copy of the REST config with impersonation + impersonatedConfig := rest.CopyConfig(v.restConfig) + impersonatedConfig.Impersonate = rest.ImpersonationConfig{ + UserName: userInfo.Username, + UID: userInfo.UID, + Groups: userInfo.Groups, + Extra: extra, + } + + // Create a new client with the impersonated config + return client.New(impersonatedConfig, client.Options{ + Scheme: v.scheme, + }) +} + // createSelfManagePolicyBinding creates a PolicyBinding for the organization owner func (v *UserValidator) createSelfManagePolicyBinding(ctx context.Context, user *iamv1alpha1.User) error { userlog.Info("Attempting to create PolicyBinding for new user", "user", user.Name) diff --git a/internal/webhooks/identity/v1alpha1/useridentity_webhook.go b/internal/webhooks/identity/v1alpha1/useridentity_webhook.go new file mode 100644 index 00000000..962f41b9 --- /dev/null +++ b/internal/webhooks/identity/v1alpha1/useridentity_webhook.go @@ -0,0 +1,48 @@ +package v1alpha1 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" +) + +var useridentitylog = logf.Log.WithName("useridentity-resource") + +// +kubebuilder:webhook:path=/validate-identity-miloapis-com-v1alpha1-useridentity,mutating=false,failurePolicy=fail,sideEffects=None,groups=identity.miloapis.com,resources=useridentities,verbs=delete,versions=v1alpha1,name=vuseridentity.identity.miloapis.com,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system + +// SetupUserIdentityWebhooksWithManager sets up the webhooks for UserIdentity +func SetupUserIdentityWebhooksWithManager(mgr ctrl.Manager) error { + useridentitylog.Info("Setting up identity.miloapis.com useridentity webhooks") + + return ctrl.NewWebhookManagedBy(mgr). + For(&identityv1alpha1.UserIdentity{}). + WithValidator(&UserIdentityValidator{}). + Complete() +} + +// UserIdentityValidator validates UserIdentity resources +type UserIdentityValidator struct{} + +func (v *UserIdentityValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *UserIdentityValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +func (v *UserIdentityValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + userIdentity := obj.(*identityv1alpha1.UserIdentity) + useridentitylog.Info("Blocking UserIdentity deletion", "name", userIdentity.Name) + + return nil, fmt.Errorf( + "deleting UserIdentity resources is not currently supported. " + + "Identity provider links must be managed through the authentication provider (e.g., Zitadel). " + + "Automatic email synchronization logic is required before deletion can be enabled") +}