Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions cmd/milo/controller-manager/controllermanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
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.
122 changes: 120 additions & 2 deletions internal/webhooks/iam/v1alpha1/user_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}).
Expand All @@ -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
Expand Down Expand Up @@ -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)
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")
}
Loading