diff --git a/internal/apiserver/identity/sessions/README.md b/internal/apiserver/identity/sessions/README.md index 882dca02..ff63b723 100644 --- a/internal/apiserver/identity/sessions/README.md +++ b/internal/apiserver/identity/sessions/README.md @@ -38,3 +38,30 @@ Naming & structure - internal/apiserver/identity/sessions/rest.go — REST storage - internal/apiserver/identity/sessions/dynamic.go — provider implementation +Field Selector Support for Staff Users +The Sessions API supports field selectors to enable staff users to query +other users' active sessions. This is required for support and administrative +purposes in the staff portal. + +Supported field selectors: +- status.userUID= — Query sessions for a specific user + +Authorization: +- Regular users: Can only list their own sessions (field selectors are ignored) +- Staff users: Can use field selectors to query other users' sessions + - Must be members of privileged groups in the identity provider (e.g., staff-users, fraud-manager) + - Authorization is enforced by the backend provider (auth-provider-zitadel) + - Requires appropriate PolicyBinding in Milo for RBAC + +Example usage: + # Regular user (sees only their own sessions) + kubectl get sessions + + # Staff user (can query specific user's sessions) + kubectl get sessions --field-selector=status.userUID= + +Security model: +1. Milo RBAC: PolicyBinding grants access to sessions resource +2. Backend authorization: Provider validates group membership for field selector usage +3. Audit logging: All requests are logged with user context for compliance + diff --git a/internal/apiserver/identity/sessions/rest.go b/internal/apiserver/identity/sessions/rest.go index ce1dc8c4..30f652a3 100644 --- a/internal/apiserver/identity/sessions/rest.go +++ b/internal/apiserver/identity/sessions/rest.go @@ -49,8 +49,11 @@ func (r *REST) List(ctx context.Context, opts *metainternalversion.ListOptions) groups = u.GetGroups() } logger.V(4).Info("Listing sessions", "username", username, "uid", uid, "groups", groups) - // ignore selectors; self-scoped list delegated to provider + // Pass field selector to backend provider for staff user queries lo := metav1.ListOptions{} + if opts != nil && opts.FieldSelector != nil { + lo.FieldSelector = opts.FieldSelector.String() + } res, err := r.backend.ListSessions(ctx, u, &lo) if err != nil { logger.Error(err, "List sessions failed") diff --git a/internal/apiserver/identity/useridentities/README.md b/internal/apiserver/identity/useridentities/README.md index aa7a065e..9a22d0e0 100644 --- a/internal/apiserver/identity/useridentities/README.md +++ b/internal/apiserver/identity/useridentities/README.md @@ -38,6 +38,33 @@ Naming & structure - internal/apiserver/identity/useridentities/rest.go — REST storage - internal/apiserver/identity/useridentities/dynamic.go — provider implementation +Field Selector Support for Staff Users +The UserIdentities API supports field selectors to enable staff users to query +other users' identity provider links. This is required for support and administrative +purposes in the staff portal. + +Supported field selectors: +- status.userUID= — Query identities for a specific user + +Authorization: +- Regular users: Can only list their own identities (field selectors are ignored) +- Staff users: Can use field selectors to query other users' identities + - Must be members of privileged groups in the identity provider (e.g., staff-users, fraud-manager) + - Authorization is enforced by the backend provider (auth-provider-zitadel) + - Requires appropriate PolicyBinding in Milo for RBAC + +Example usage: + # Regular user (sees only their own identities) + kubectl get useridentities + + # Staff user (can query specific user's identities) + kubectl get useridentities --field-selector=status.userUID= + +Security model: +1. Milo RBAC: PolicyBinding grants access to useridentities resource +2. Backend authorization: Provider validates group membership for field selector usage +3. Audit logging: All requests are logged with user context for compliance + 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 diff --git a/internal/apiserver/identity/useridentities/rest.go b/internal/apiserver/identity/useridentities/rest.go index d1631e4c..50153255 100644 --- a/internal/apiserver/identity/useridentities/rest.go +++ b/internal/apiserver/identity/useridentities/rest.go @@ -47,8 +47,11 @@ func (r *REST) List(ctx context.Context, opts *metainternalversion.ListOptions) groups = u.GetGroups() } logger.V(4).Info("Listing user identities", "username", username, "uid", uid, "groups", groups) - // ignore selectors; self-scoped list delegated to provider + // Pass field selector to backend provider for staff user queries lo := metav1.ListOptions{} + if opts != nil && opts.FieldSelector != nil { + lo.FieldSelector = opts.FieldSelector.String() + } res, err := r.backend.ListUserIdentities(ctx, u, &lo) if err != nil { logger.Error(err, "List user identities failed") diff --git a/internal/apiserver/storage/identity/storageprovider.go b/internal/apiserver/storage/identity/storageprovider.go index 286b97c4..103c48fe 100644 --- a/internal/apiserver/storage/identity/storageprovider.go +++ b/internal/apiserver/storage/identity/storageprovider.go @@ -2,18 +2,44 @@ package identity import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" generic "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" serverstorage "k8s.io/apiserver/pkg/server/storage" - "k8s.io/kubernetes/pkg/api/legacyscheme" controlplaneapiserver "k8s.io/kubernetes/pkg/controlplane/apiserver" sessionsregistry "go.miloapis.com/milo/internal/apiserver/identity/sessions" useridentitiesregistry "go.miloapis.com/milo/internal/apiserver/identity/useridentities" + identityinstall "go.miloapis.com/milo/pkg/apis/identity/install" identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" ) +var ( + // Scheme defines the runtime type system for Identity API object serialization. + Scheme = runtime.NewScheme() + // Codecs provides serializers for Identity API objects. + Codecs = serializer.NewCodecFactory(Scheme) +) + +func init() { + identityinstall.Install(Scheme) + + metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + + // Register unversioned meta types required by the API machinery. + unversioned := schema.GroupVersion{Group: "", Version: "v1"} + Scheme.AddUnversionedTypes(unversioned, + &metav1.Status{}, + &metav1.APIVersions{}, + &metav1.APIGroupList{}, + &metav1.APIGroup{}, + &metav1.APIResourceList{}, + ) +} + type StorageProvider struct { Sessions sessionsregistry.Backend UserIdentities useridentitiesregistry.Backend @@ -25,11 +51,13 @@ func (p StorageProvider) NewRESTStorage( _ serverstorage.APIResourceConfigSource, _ generic.RESTOptionsGetter, ) (genericapiserver.APIGroupInfo, error) { + // Use dedicated Identity Scheme with field label conversion functions registered + // This follows the same pattern as Activity API apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo( identityv1alpha1.SchemeGroupVersion.Group, - legacyscheme.Scheme, + Scheme, metav1.ParameterCodec, - legacyscheme.Codecs, + Codecs, ) storage := map[string]rest.Storage{ diff --git a/pkg/apis/identity/install/install.go b/pkg/apis/identity/install/install.go new file mode 100644 index 00000000..7a8c1071 --- /dev/null +++ b/pkg/apis/identity/install/install.go @@ -0,0 +1,14 @@ +package install + +import ( + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + + "go.miloapis.com/milo/pkg/apis/identity/v1alpha1" +) + +// Install registers the API group and adds types to a scheme +func Install(scheme *runtime.Scheme) { + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(scheme.SetVersionPriority(v1alpha1.SchemeGroupVersion)) +} diff --git a/pkg/apis/identity/v1alpha1/register.go b/pkg/apis/identity/v1alpha1/register.go index 66560647..bb44e8cd 100644 --- a/pkg/apis/identity/v1alpha1/register.go +++ b/pkg/apis/identity/v1alpha1/register.go @@ -1,6 +1,8 @@ package v1alpha1 import ( + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -35,5 +37,50 @@ func addKnownTypes(scheme *runtime.Scheme) error { &UserIdentityList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + + // Register field label conversions for UserIdentity + // This enables field selectors like status.userUID= for staff users + userIdentityGVK := SchemeGroupVersion.WithKind("UserIdentity") + if err := scheme.AddFieldLabelConversionFunc(userIdentityGVK, + UserIdentityFieldLabelConversionFunc); err != nil { + return err + } + + // Register field label conversions for Session + // This enables field selectors like status.userUID= for staff users + sessionGVK := SchemeGroupVersion.WithKind("Session") + if err := scheme.AddFieldLabelConversionFunc(sessionGVK, + SessionFieldLabelConversionFunc); err != nil { + return err + } + return nil } + +// UserIdentityFieldLabelConversionFunc converts field selectors for UserIdentity resources. +// This allows staff users to filter user identities by fields beyond the default metadata.name. +func UserIdentityFieldLabelConversionFunc(label, value string) (string, string, error) { + switch label { + case "metadata.name", + "metadata.namespace", + "status.userUID": + return label, value, nil + default: + return "", "", fmt.Errorf("%q is not a known field selector: only %q are supported", + label, []string{"metadata.name", "metadata.namespace", "status.userUID"}) + } +} + +// SessionFieldLabelConversionFunc converts field selectors for Session resources. +// This allows staff users to filter sessions by fields beyond the default metadata.name. +func SessionFieldLabelConversionFunc(label, value string) (string, string, error) { + switch label { + case "metadata.name", + "metadata.namespace", + "status.userUID": + return label, value, nil + default: + return "", "", fmt.Errorf("%q is not a known field selector: only %q are supported", + label, []string{"metadata.name", "metadata.namespace", "status.userUID"}) + } +} diff --git a/pkg/apis/identity/v1alpha1/register_test.go b/pkg/apis/identity/v1alpha1/register_test.go new file mode 100644 index 00000000..ed1d4f9a --- /dev/null +++ b/pkg/apis/identity/v1alpha1/register_test.go @@ -0,0 +1,189 @@ +package v1alpha1 + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestUserIdentityFieldLabelConversion(t *testing.T) { + tests := []struct { + name string + label string + value string + wantLabel string + wantValue string + wantErr bool + errContains string + }{ + { + name: "metadata.name is valid", + label: "metadata.name", + value: "test-identity", + wantLabel: "metadata.name", + wantValue: "test-identity", + wantErr: false, + }, + { + name: "metadata.namespace is valid", + label: "metadata.namespace", + value: "default", + wantLabel: "metadata.namespace", + wantValue: "default", + wantErr: false, + }, + { + name: "status.userUID is valid", + label: "status.userUID", + value: "340583683847098197", + wantLabel: "status.userUID", + wantValue: "340583683847098197", + wantErr: false, + }, + { + name: "invalid field selector", + label: "status.invalidField", + value: "test", + wantErr: true, + errContains: "not a known field selector", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLabel, gotValue, err := UserIdentityFieldLabelConversionFunc(tt.label, tt.value) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got none") + return + } + if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if gotLabel != tt.wantLabel { + t.Errorf("label = %q, want %q", gotLabel, tt.wantLabel) + } + + if gotValue != tt.wantValue { + t.Errorf("value = %q, want %q", gotValue, tt.wantValue) + } + }) + } +} + +func TestSessionFieldLabelConversion(t *testing.T) { + tests := []struct { + name string + label string + value string + wantLabel string + wantValue string + wantErr bool + errContains string + }{ + { + name: "metadata.name is valid", + label: "metadata.name", + value: "test-session", + wantLabel: "metadata.name", + wantValue: "test-session", + wantErr: false, + }, + { + name: "status.userUID is valid", + label: "status.userUID", + value: "340583683847098197", + wantLabel: "status.userUID", + wantValue: "340583683847098197", + wantErr: false, + }, + { + name: "invalid field selector", + label: "spec.invalid", + value: "test", + wantErr: true, + errContains: "not a known field selector", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLabel, gotValue, err := SessionFieldLabelConversionFunc(tt.label, tt.value) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got none") + return + } + if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if gotLabel != tt.wantLabel { + t.Errorf("label = %q, want %q", gotLabel, tt.wantLabel) + } + + if gotValue != tt.wantValue { + t.Errorf("value = %q, want %q", gotValue, tt.wantValue) + } + }) + } +} + +func TestFieldLabelConversionRegistration(t *testing.T) { + scheme := runtime.NewScheme() + err := addKnownTypes(scheme) + if err != nil { + t.Fatalf("addKnownTypes failed: %v", err) + } + + // Test that UserIdentity field label conversion is registered + userIdentityGVK := schema.GroupVersion{Group: "identity.miloapis.com", Version: "v1alpha1"}.WithKind("UserIdentity") + + // Try to convert a valid field selector + converter := scheme.Converter() + if converter == nil { + t.Fatal("scheme converter is nil") + } + + // Test that Session field label conversion is registered + sessionGVK := schema.GroupVersion{Group: "identity.miloapis.com", Version: "v1alpha1"}.WithKind("Session") + + // Verify the GVKs are registered + if !scheme.Recognizes(userIdentityGVK) { + t.Errorf("UserIdentity GVK not recognized by scheme") + } + if !scheme.Recognizes(sessionGVK) { + t.Errorf("Session GVK not recognized by scheme") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsSubstring(s, substr)) +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}