Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/milo/controller-manager/controllermanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ 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"
Expand Down Expand Up @@ -495,6 +496,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)
Expand Down
22 changes: 22 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ webhooks:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- users
sideEffects: NoneOnDryRun
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion internal/apiserver/identity/useridentities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 70 additions & 2 deletions internal/webhooks/iam/v1alpha1/user_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import (
"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 {
Expand Down Expand Up @@ -74,7 +75,74 @@ 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 all UserIdentities and filter by userUID
// Note: UserIdentity is a dynamic REST API (not cached), so we cannot use field selectors
// We must list all and filter manually
allIdentities := &identityv1alpha1.UserIdentityList{}
if err := v.client.List(ctx, allIdentities); err != nil {
userlog.Error(err, "Failed to list user identities", "user", newUser.Name)
return nil, fmt.Errorf("failed to list user identities: %w", err)
}

// Filter identities for this user
identityList := &identityv1alpha1.UserIdentityList{}
for _, identity := range allIdentities.Items {
if identity.Status.UserUID == string(newUser.GetUID()) {
identityList.Items = append(identityList.Items, identity)
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this verification/filtering is not strictly necessary, as the userIdentity list method seems to use the context in order to fetch the correct user identities.

https://github.com/datum-cloud/auth-provider-zitadel/blob/b75874a5d479912a07c2851e19449c3923dcf8dd/internal/apiserver/identity/useridentities/rest.go#L39

However, is good to have this extra validation.

// 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, fmt.Errorf(
"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, fmt.Errorf(
"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) {
Expand Down
48 changes: 48 additions & 0 deletions internal/webhooks/identity/v1alpha1/useridentity_webhook.go
Original file line number Diff line number Diff line change
@@ -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")
}