diff --git a/cmd/milo/apiserver/config.go b/cmd/milo/apiserver/config.go
index 544c2f9e..af823ccd 100644
--- a/cmd/milo/apiserver/config.go
+++ b/cmd/milo/apiserver/config.go
@@ -45,6 +45,7 @@ import (
"go.miloapis.com/milo/internal/apiserver/admission/initializer"
eventsbackend "go.miloapis.com/milo/internal/apiserver/events"
+ machineaccountkeysbackend "go.miloapis.com/milo/internal/apiserver/identity/machineaccountkeys"
sessionsbackend "go.miloapis.com/milo/internal/apiserver/identity/sessions"
useridentitiesbackend "go.miloapis.com/milo/internal/apiserver/identity/useridentities"
identitystorage "go.miloapis.com/milo/internal/apiserver/storage/identity"
@@ -69,9 +70,10 @@ type Config struct {
}
type ExtraConfig struct {
- SessionsProvider SessionsProviderConfig
- UserIdentitiesProvider UserIdentitiesProviderConfig
- EventsProvider EventsProviderConfig
+ SessionsProvider SessionsProviderConfig
+ UserIdentitiesProvider UserIdentitiesProviderConfig
+ MachineAccountKeysProvider MachineAccountKeysProviderConfig
+ EventsProvider EventsProviderConfig
}
// SessionsProviderConfig groups configuration for the sessions backend provider
@@ -107,6 +109,17 @@ type EventsProviderConfig struct {
ForwardExtras []string
}
+// MachineAccountKeysProviderConfig groups configuration for the machineaccountkeys backend provider
+type MachineAccountKeysProviderConfig struct {
+ URL string
+ CAFile string
+ ClientCertFile string
+ ClientKeyFile string
+ TimeoutSeconds int
+ Retries int
+ ForwardExtras []string
+}
+
type completedConfig struct {
Options options.CompletedOptions
@@ -149,9 +162,7 @@ func (c *CompletedConfig) GenericStorageProviders(discovery discovery.DiscoveryI
discoveryrest.StorageProvider{},
}
- if utilfeature.DefaultFeatureGate.Enabled(features.Sessions) || utilfeature.DefaultFeatureGate.Enabled(features.UserIdentities) {
- providers = append(providers, newIdentityStorageProvider(c))
- }
+ providers = append(providers, newIdentityStorageProvider(c))
if utilfeature.DefaultFeatureGate.Enabled(features.EventsProxy) {
providers = append(providers, newEventsV1StorageProvider(eventsBackend))
@@ -201,6 +212,25 @@ func newIdentityStorageProvider(c *CompletedConfig) controlplaneapiserver.RESTSt
provider.UserIdentities = backend
}
+ if utilfeature.DefaultFeatureGate.Enabled(features.MachineAccountKeys) {
+ allow := make(map[string]struct{}, len(c.ExtraConfig.MachineAccountKeysProvider.ForwardExtras))
+ for _, k := range c.ExtraConfig.MachineAccountKeysProvider.ForwardExtras {
+ allow[k] = struct{}{}
+ }
+ cfg := machineaccountkeysbackend.Config{
+ BaseConfig: c.ControlPlane.Generic.LoopbackClientConfig,
+ ProviderURL: c.ExtraConfig.MachineAccountKeysProvider.URL,
+ CAFile: c.ExtraConfig.MachineAccountKeysProvider.CAFile,
+ ClientCertFile: c.ExtraConfig.MachineAccountKeysProvider.ClientCertFile,
+ ClientKeyFile: c.ExtraConfig.MachineAccountKeysProvider.ClientKeyFile,
+ Timeout: time.Duration(c.ExtraConfig.MachineAccountKeysProvider.TimeoutSeconds) * time.Second,
+ Retries: c.ExtraConfig.MachineAccountKeysProvider.Retries,
+ ExtrasAllow: allow,
+ }
+ backend, _ := machineaccountkeysbackend.NewDynamicProvider(cfg)
+ provider.MachineAccountKeys = backend
+ }
+
return provider
}
diff --git a/cmd/milo/apiserver/server.go b/cmd/milo/apiserver/server.go
index 1e2e9648..3c165129 100644
--- a/cmd/milo/apiserver/server.go
+++ b/cmd/milo/apiserver/server.go
@@ -56,25 +56,29 @@ func init() {
}
var (
- SystemNamespace string
- sessionsProviderURL string
- sessionsProviderCAFile string
- sessionsProviderClientCert string
- sessionsProviderClientKey string
- providerTimeoutSeconds int
- providerRetries int
- forwardExtras []string
- userIdentitiesProviderURL string
- userIdentitiesProviderCAFile string
- userIdentitiesProviderClientCert string
- userIdentitiesProviderClientKey string
- eventsProviderURL string
- eventsProviderCAFile string
- eventsProviderClientCert string
- eventsProviderClientKey string
- eventsProviderTimeoutSeconds int
- eventsProviderRetries int
- eventsForwardExtras []string
+ SystemNamespace string
+ sessionsProviderURL string
+ sessionsProviderCAFile string
+ sessionsProviderClientCert string
+ sessionsProviderClientKey string
+ providerTimeoutSeconds int
+ providerRetries int
+ forwardExtras []string
+ userIdentitiesProviderURL string
+ userIdentitiesProviderCAFile string
+ userIdentitiesProviderClientCert string
+ userIdentitiesProviderClientKey string
+ machineAccountKeysProviderURL string
+ machineAccountKeysProviderCAFile string
+ machineAccountKeysProviderClientCert string
+ machineAccountKeysProviderClientKey string
+ eventsProviderURL string
+ eventsProviderCAFile string
+ eventsProviderClientCert string
+ eventsProviderClientKey string
+ eventsProviderTimeoutSeconds int
+ eventsProviderRetries int
+ eventsForwardExtras []string
)
// NewCommand creates a *cobra.Command object with default parameters
@@ -184,6 +188,10 @@ func NewCommand() *cobra.Command {
fs.StringVar(&userIdentitiesProviderCAFile, "useridentities-provider-ca-file", "", "Path to CA file to validate useridentities provider TLS")
fs.StringVar(&userIdentitiesProviderClientCert, "useridentities-provider-client-cert", "", "Client certificate for mTLS to useridentities provider")
fs.StringVar(&userIdentitiesProviderClientKey, "useridentities-provider-client-key", "", "Client private key for mTLS to useridentities provider")
+ fs.StringVar(&machineAccountKeysProviderURL, "machineaccountkeys-provider-url", "", "Direct provider base URL for machineaccountkeys (e.g., https://zitadel-apiserver:8443)")
+ fs.StringVar(&machineAccountKeysProviderCAFile, "machineaccountkeys-provider-ca-file", "", "Path to CA file to validate machineaccountkeys provider TLS")
+ fs.StringVar(&machineAccountKeysProviderClientCert, "machineaccountkeys-provider-client-cert", "", "Client certificate for mTLS to machineaccountkeys provider")
+ fs.StringVar(&machineAccountKeysProviderClientKey, "machineaccountkeys-provider-client-key", "", "Client private key for mTLS to machineaccountkeys provider")
fs.StringVar(&eventsProviderURL, "events-provider-url", "", "Activity API server URL for events storage (e.g., https://activity-apiserver.activity-system.svc:443)")
fs.StringVar(&eventsProviderCAFile, "events-provider-ca-file", "", "Path to CA file to validate Activity provider TLS")
fs.StringVar(&eventsProviderClientCert, "events-provider-client-cert", "", "Client certificate for mTLS to Activity provider")
@@ -253,6 +261,14 @@ func Run(ctx context.Context, opts options.CompletedOptions) error {
config.ExtraConfig.UserIdentitiesProvider.Retries = providerRetries
config.ExtraConfig.UserIdentitiesProvider.ForwardExtras = forwardExtras
+ config.ExtraConfig.MachineAccountKeysProvider.URL = machineAccountKeysProviderURL
+ config.ExtraConfig.MachineAccountKeysProvider.CAFile = machineAccountKeysProviderCAFile
+ config.ExtraConfig.MachineAccountKeysProvider.ClientCertFile = machineAccountKeysProviderClientCert
+ config.ExtraConfig.MachineAccountKeysProvider.ClientKeyFile = machineAccountKeysProviderClientKey
+ config.ExtraConfig.MachineAccountKeysProvider.TimeoutSeconds = providerTimeoutSeconds
+ config.ExtraConfig.MachineAccountKeysProvider.Retries = providerRetries
+ config.ExtraConfig.MachineAccountKeysProvider.ForwardExtras = forwardExtras
+
config.ExtraConfig.EventsProvider.URL = eventsProviderURL
config.ExtraConfig.EventsProvider.CAFile = eventsProviderCAFile
config.ExtraConfig.EventsProvider.ClientCertFile = eventsProviderClientCert
diff --git a/config/apiserver/deployment.yaml b/config/apiserver/deployment.yaml
index b0a38258..e13dd69f 100644
--- a/config/apiserver/deployment.yaml
+++ b/config/apiserver/deployment.yaml
@@ -70,6 +70,11 @@ spec:
- --useridentities-provider-ca-file=$(USERIDENTITIES_PROVIDER_CA_FILE)
- --useridentities-provider-client-cert=$(USERIDENTITIES_PROVIDER_CLIENT_CERT_FILE)
- --useridentities-provider-client-key=$(USERIDENTITIES_PROVIDER_CLIENT_KEY_FILE)
+ # MachineAccountKeys provider configuration
+ - --machineaccountkeys-provider-url=$(MACHINEACCOUNTKEYS_PROVIDER_URL)
+ - --machineaccountkeys-provider-ca-file=$(MACHINEACCOUNTKEYS_PROVIDER_CA_FILE)
+ - --machineaccountkeys-provider-client-cert=$(MACHINEACCOUNTKEYS_PROVIDER_CLIENT_CERT_FILE)
+ - --machineaccountkeys-provider-client-key=$(MACHINEACCOUNTKEYS_PROVIDER_CLIENT_KEY_FILE)
# Events proxy provider configuration (requires EventsProxy feature gate)
- --events-provider-url=$(EVENTS_PROVIDER_URL)
- --events-provider-ca-file=$(EVENTS_PROVIDER_CA_FILE)
@@ -156,6 +161,14 @@ spec:
value: ""
- name: USERIDENTITIES_PROVIDER_CLIENT_KEY_FILE
value: ""
+ - name: MACHINEACCOUNTKEYS_PROVIDER_URL
+ value: ""
+ - name: MACHINEACCOUNTKEYS_PROVIDER_CA_FILE
+ value: ""
+ - name: MACHINEACCOUNTKEYS_PROVIDER_CLIENT_CERT_FILE
+ value: ""
+ - name: MACHINEACCOUNTKEYS_PROVIDER_CLIENT_KEY_FILE
+ value: ""
# Events proxy provider configuration (requires --feature-gates=EventsProxy=true)
- name: EVENTS_PROVIDER_URL
value: ""
diff --git a/config/components/apiserver-audit-logging/audit-policy-configmap.yaml b/config/components/apiserver-audit-logging/audit-policy-configmap.yaml
index c0f2998e..7689fb71 100644
--- a/config/components/apiserver-audit-logging/audit-policy-configmap.yaml
+++ b/config/components/apiserver-audit-logging/audit-policy-configmap.yaml
@@ -142,6 +142,14 @@ data:
- group: "" # core API group
resources: ["secrets", "configmaps"]
+ # Log MachineAccountKey at Metadata level to redact private key from audit logs
+ # The privateKey is only returned in the response body on creation, so we omit
+ # the response to prevent credential leakage in audit logs
+ - level: Metadata
+ resources:
+ - group: "identity.miloapis.com"
+ resources: ["machineaccountkeys"]
+
# Log Milo API resources at RequestResponse level to capture full context
- level: RequestResponse
resources:
diff --git a/config/crd/bases/iam/iam.miloapis.com_machineaccountkeys.yaml b/config/crd/bases/iam/iam.miloapis.com_machineaccountkeys.yaml
deleted file mode 100644
index e8cb7935..00000000
--- a/config/crd/bases/iam/iam.miloapis.com_machineaccountkeys.yaml
+++ /dev/null
@@ -1,153 +0,0 @@
----
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
- annotations:
- controller-gen.kubebuilder.io/version: v0.18.0
- name: machineaccountkeys.iam.miloapis.com
-spec:
- group: iam.miloapis.com
- names:
- kind: MachineAccountKey
- listKind: MachineAccountKeyList
- plural: machineaccountkeys
- singular: machineaccountkey
- scope: Namespaced
- versions:
- - additionalPrinterColumns:
- - jsonPath: .spec.machineAccountName
- name: Machine Account
- type: string
- - jsonPath: .spec.expirationDate
- name: Expiration Date
- type: string
- - jsonPath: .status.conditions[?(@.type=='Ready')].status
- name: Ready
- type: string
- - jsonPath: .metadata.creationTimestamp
- name: Age
- type: date
- name: v1alpha1
- schema:
- openAPIV3Schema:
- description: MachineAccountKey is the Schema for the machineaccountkeys API
- properties:
- apiVersion:
- description: |-
- APIVersion defines the versioned schema of this representation of an object.
- Servers should convert recognized schemas to the latest internal value, and
- may reject unrecognized values.
- More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
- type: string
- kind:
- description: |-
- Kind is a string value representing the REST resource this object represents.
- Servers may infer this from the endpoint the client submits requests to.
- Cannot be updated.
- In CamelCase.
- More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
- type: string
- metadata:
- type: object
- spec:
- description: MachineAccountKeySpec defines the desired state of MachineAccountKey
- properties:
- expirationDate:
- description: |-
- ExpirationDate is the date and time when the MachineAccountKey will expire.
- If not specified, the MachineAccountKey will never expire.
- format: date-time
- type: string
- machineAccountName:
- description: MachineAccountName is the name of the MachineAccount
- that owns this key.
- type: string
- publicKey:
- description: |-
- PublicKey is the public key of the MachineAccountKey.
- If not specified, the MachineAccountKey will be created with an auto-generated public key.
- type: string
- required:
- - machineAccountName
- type: object
- status:
- description: MachineAccountKeyStatus defines the observed state of MachineAccountKey
- properties:
- authProviderKeyId:
- description: |-
- AuthProviderKeyID is the unique identifier for the key in the auth provider.
- This field is populated by the controller after the key is created in the auth provider.
- For example, when using Zitadel, a typical value might be: "326102453042806786"
- type: string
- conditions:
- default:
- - lastTransitionTime: "1970-01-01T00:00:00Z"
- message: Waiting for control plane to reconcile
- reason: Unknown
- status: Unknown
- type: Ready
- description: Conditions provide conditions that represent the current
- status of the MachineAccountKey.
- items:
- description: Condition contains details for one aspect of the current
- state of this API Resource.
- properties:
- lastTransitionTime:
- description: |-
- lastTransitionTime is the last time the condition transitioned from one status to another.
- This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
- format: date-time
- type: string
- message:
- description: |-
- message is a human readable message indicating details about the transition.
- This may be an empty string.
- maxLength: 32768
- type: string
- observedGeneration:
- description: |-
- observedGeneration represents the .metadata.generation that the condition was set based upon.
- For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
- with respect to the current state of the instance.
- format: int64
- minimum: 0
- type: integer
- reason:
- description: |-
- reason contains a programmatic identifier indicating the reason for the condition's last transition.
- Producers of specific condition types may define expected values and meanings for this field,
- and whether the values are considered a guaranteed API.
- The value should be a CamelCase string.
- This field may not be empty.
- maxLength: 1024
- minLength: 1
- pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
- type: string
- status:
- description: status of the condition, one of True, False, Unknown.
- enum:
- - "True"
- - "False"
- - Unknown
- type: string
- type:
- description: type of condition in CamelCase or in foo.example.com/CamelCase.
- maxLength: 316
- pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
- type: string
- required:
- - lastTransitionTime
- - message
- - reason
- - status
- - type
- type: object
- type: array
- type: object
- type: object
- selectableFields:
- - jsonPath: .spec.machineAccountName
- served: true
- storage: true
- subresources:
- status: {}
diff --git a/config/crd/bases/iam/iam.miloapis.com_machineaccounts.yaml b/config/crd/bases/iam/iam.miloapis.com_machineaccounts.yaml
index 1e56cf9d..e3845a59 100644
--- a/config/crd/bases/iam/iam.miloapis.com_machineaccounts.yaml
+++ b/config/crd/bases/iam/iam.miloapis.com_machineaccounts.yaml
@@ -12,7 +12,7 @@ spec:
listKind: MachineAccountList
plural: machineaccounts
singular: machineaccount
- scope: Namespaced
+ scope: Cluster
versions:
- additionalPrinterColumns:
- jsonPath: .status.email
diff --git a/config/crd/bases/iam/kustomization.yaml b/config/crd/bases/iam/kustomization.yaml
index 797c8a51..366abfce 100644
--- a/config/crd/bases/iam/kustomization.yaml
+++ b/config/crd/bases/iam/kustomization.yaml
@@ -7,7 +7,6 @@ resources:
- iam.miloapis.com_protectedresources.yaml
- iam.miloapis.com_users.yaml
- iam.miloapis.com_userinvitations.yaml
-- iam.miloapis.com_machineaccountkeys.yaml
- iam.miloapis.com_userpreferences.yaml
- iam.miloapis.com_userdeactivations.yaml
- iam.miloapis.com_platforminvitations.yaml
diff --git a/config/protected-resources/iam/kustomization.yaml b/config/protected-resources/iam/kustomization.yaml
index 86804e39..69b40ea8 100644
--- a/config/protected-resources/iam/kustomization.yaml
+++ b/config/protected-resources/iam/kustomization.yaml
@@ -14,4 +14,5 @@ resources:
- platformaccessapproval.yaml
- platformaccessrejection.yaml
- platforminvitation.yaml
+ - machineaccount.yaml
diff --git a/config/protected-resources/iam/machineaccount.yaml b/config/protected-resources/iam/machineaccount.yaml
new file mode 100644
index 00000000..154d6c11
--- /dev/null
+++ b/config/protected-resources/iam/machineaccount.yaml
@@ -0,0 +1,21 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: iam.miloapis.com-machineaccount
+spec:
+ serviceRef:
+ name: "iam.miloapis.com"
+ kind: MachineAccount
+ plural: machineaccounts
+ singular: machineaccount
+ permissions:
+ - list
+ - get
+ - create
+ - update
+ - delete
+ - patch
+ - watch
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
diff --git a/config/protected-resources/identity/kustomization.yaml b/config/protected-resources/identity/kustomization.yaml
index 88c6f0ac..600a9041 100644
--- a/config/protected-resources/identity/kustomization.yaml
+++ b/config/protected-resources/identity/kustomization.yaml
@@ -4,3 +4,4 @@ kind: Kustomization
resources:
- session.yaml
- useridentity.yaml
+ - machineaccountkey.yaml
diff --git a/config/protected-resources/identity/machineaccountkey.yaml b/config/protected-resources/identity/machineaccountkey.yaml
new file mode 100644
index 00000000..72baf7a3
--- /dev/null
+++ b/config/protected-resources/identity/machineaccountkey.yaml
@@ -0,0 +1,21 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: identity.miloapis.com-machineaccountkey
+spec:
+ serviceRef:
+ name: "identity.miloapis.com"
+ kind: MachineAccountKey
+ plural: machineaccountkeys
+ singular: machineaccountkey
+ permissions:
+ - list
+ - get
+ - create
+ - update
+ - delete
+ - patch
+ - watch
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
diff --git a/config/resources-metrics/iam/kustomization.yaml b/config/resources-metrics/iam/kustomization.yaml
index 26e37a8a..06e77315 100644
--- a/config/resources-metrics/iam/kustomization.yaml
+++ b/config/resources-metrics/iam/kustomization.yaml
@@ -8,7 +8,6 @@ configMapGenerator:
- groups.yaml
- group_memberships.yaml
- machine_accounts.yaml
- - machine_account_keys.yaml
- policy_bindings.yaml
- roles.yaml
- user_invitations.yaml
diff --git a/config/resources-metrics/identity/kustomization.yaml b/config/resources-metrics/identity/kustomization.yaml
new file mode 100644
index 00000000..de9c25ee
--- /dev/null
+++ b/config/resources-metrics/identity/kustomization.yaml
@@ -0,0 +1,10 @@
+apiVersion: kustomize.config.k8s.io/v1alpha1
+kind: Component
+
+configMapGenerator:
+ - name: milo-identity-resource-metrics
+ files:
+ - machine_account_keys.yaml
+ options:
+ labels:
+ telemetry.datumapis.com/core-resource-metrics-config: "true"
diff --git a/config/resources-metrics/iam/machine_account_keys.yaml b/config/resources-metrics/identity/machine_account_keys.yaml
similarity index 85%
rename from config/resources-metrics/iam/machine_account_keys.yaml
rename to config/resources-metrics/identity/machine_account_keys.yaml
index 9bd7e3f0..9ebbc86f 100644
--- a/config/resources-metrics/iam/machine_account_keys.yaml
+++ b/config/resources-metrics/identity/machine_account_keys.yaml
@@ -2,7 +2,7 @@ kind: CustomResourceStateMetrics
spec:
resources:
- groupVersionKind:
- group: "iam.miloapis.com"
+ group: "identity.miloapis.com"
kind: "MachineAccountKey"
version: "v1alpha1"
labelsFromPath:
@@ -20,4 +20,4 @@ spec:
each:
type: Gauge
gauge:
- path: [metadata, creationTimestamp]
\ No newline at end of file
+ path: [metadata, creationTimestamp]
diff --git a/config/resources-metrics/kustomization.yaml b/config/resources-metrics/kustomization.yaml
index 3284a57e..ebc2a4ae 100644
--- a/config/resources-metrics/kustomization.yaml
+++ b/config/resources-metrics/kustomization.yaml
@@ -3,5 +3,6 @@ kind: Component
components:
- iam/
+ - identity/
- resources-manager/
- infrastructure/
diff --git a/config/roles/iam-editor.yaml b/config/roles/iam-editor.yaml
index f1c084cf..c9488fff 100644
--- a/config/roles/iam-editor.yaml
+++ b/config/roles/iam-editor.yaml
@@ -22,6 +22,10 @@ spec:
- iam.miloapis.com/userinvitations.update
- iam.miloapis.com/userinvitations.patch
- iam.miloapis.com/userinvitations.delete
+ - iam.miloapis.com/machineaccounts.create
+ - iam.miloapis.com/machineaccounts.update
+ - iam.miloapis.com/machineaccounts.patch
+ - iam.miloapis.com/machineaccounts.delete
- iam.miloapis.com/policybindings.create
- iam.miloapis.com/policybindings.update
- iam.miloapis.com/policybindings.patch
diff --git a/config/roles/iam-machine-accounts-admin.yaml b/config/roles/iam-machine-accounts-admin.yaml
new file mode 100644
index 00000000..78ea9fb8
--- /dev/null
+++ b/config/roles/iam-machine-accounts-admin.yaml
@@ -0,0 +1,11 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: iam-machine-accounts-admin
+ annotations:
+ kubernetes.io/display-name: IAM Machine Accounts Admin
+ kubernetes.io/description: "Full access to machine accounts."
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: iam-machine-accounts-editor
diff --git a/config/roles/iam-machine-accounts-editor.yaml b/config/roles/iam-machine-accounts-editor.yaml
new file mode 100644
index 00000000..405d0015
--- /dev/null
+++ b/config/roles/iam-machine-accounts-editor.yaml
@@ -0,0 +1,16 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: iam-machine-accounts-editor
+ annotations:
+ kubernetes.io/display-name: IAM Machine Accounts Editor
+ kubernetes.io/description: "Allows editing machine accounts."
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: iam-machine-accounts-viewer
+ includedPermissions:
+ - iam.miloapis.com/machineaccounts.create
+ - iam.miloapis.com/machineaccounts.update
+ - iam.miloapis.com/machineaccounts.patch
+ - iam.miloapis.com/machineaccounts.delete
diff --git a/config/roles/iam-machine-accounts-viewer.yaml b/config/roles/iam-machine-accounts-viewer.yaml
new file mode 100644
index 00000000..e79a8228
--- /dev/null
+++ b/config/roles/iam-machine-accounts-viewer.yaml
@@ -0,0 +1,13 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: iam-machine-accounts-viewer
+ annotations:
+ kubernetes.io/display-name: IAM Machine Accounts Viewer
+ kubernetes.io/description: "Allows viewing machine accounts."
+spec:
+ launchStage: Beta
+ includedPermissions:
+ - iam.miloapis.com/machineaccounts.get
+ - iam.miloapis.com/machineaccounts.list
+ - iam.miloapis.com/machineaccounts.watch
diff --git a/config/roles/iam-viewer.yaml b/config/roles/iam-viewer.yaml
index 99279153..45d73730 100644
--- a/config/roles/iam-viewer.yaml
+++ b/config/roles/iam-viewer.yaml
@@ -24,6 +24,9 @@ spec:
- iam.miloapis.com/userinvitations.get
- iam.miloapis.com/userinvitations.list
- iam.miloapis.com/userinvitations.watch
+ - iam.miloapis.com/machineaccounts.get
+ - iam.miloapis.com/machineaccounts.list
+ - iam.miloapis.com/machineaccounts.watch
- iam.miloapis.com/protectedresources.get
- iam.miloapis.com/protectedresources.list
- iam.miloapis.com/protectedresources.watch
diff --git a/config/roles/identity-machine-account-keys-admin.yaml b/config/roles/identity-machine-account-keys-admin.yaml
new file mode 100644
index 00000000..7367ca51
--- /dev/null
+++ b/config/roles/identity-machine-account-keys-admin.yaml
@@ -0,0 +1,11 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: identity-machine-account-keys-admin
+ annotations:
+ kubernetes.io/display-name: Identity Machine Account Keys Admin
+ kubernetes.io/description: "Full access to machine account keys."
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: identity-machine-account-keys-editor
diff --git a/config/roles/identity-machine-account-keys-editor.yaml b/config/roles/identity-machine-account-keys-editor.yaml
new file mode 100644
index 00000000..ff0447c4
--- /dev/null
+++ b/config/roles/identity-machine-account-keys-editor.yaml
@@ -0,0 +1,16 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: identity-machine-account-keys-editor
+ annotations:
+ kubernetes.io/display-name: Identity Machine Account Keys Editor
+ kubernetes.io/description: "Allows editing machine account keys."
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: identity-machine-account-keys-viewer
+ includedPermissions:
+ - identity.miloapis.com/machineaccountkeys.create
+ - identity.miloapis.com/machineaccountkeys.update
+ - identity.miloapis.com/machineaccountkeys.patch
+ - identity.miloapis.com/machineaccountkeys.delete
diff --git a/config/roles/identity-machine-account-keys-viewer.yaml b/config/roles/identity-machine-account-keys-viewer.yaml
new file mode 100644
index 00000000..3b21ef6c
--- /dev/null
+++ b/config/roles/identity-machine-account-keys-viewer.yaml
@@ -0,0 +1,13 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: identity-machine-account-keys-viewer
+ annotations:
+ kubernetes.io/display-name: Identity Machine Account Keys Viewer
+ kubernetes.io/description: "Allows viewing machine account keys."
+spec:
+ launchStage: Beta
+ includedPermissions:
+ - identity.miloapis.com/machineaccountkeys.get
+ - identity.miloapis.com/machineaccountkeys.list
+ - identity.miloapis.com/machineaccountkeys.watch
diff --git a/config/roles/kustomization.yaml b/config/roles/kustomization.yaml
index 816d7f8b..73b43978 100644
--- a/config/roles/kustomization.yaml
+++ b/config/roles/kustomization.yaml
@@ -71,3 +71,9 @@ resources:
- notes-creator-editor.yaml
- notes-admin.yaml
- identity-user-session-viewer.yaml
+ - iam-machine-accounts-viewer.yaml
+ - iam-machine-accounts-editor.yaml
+ - iam-machine-accounts-admin.yaml
+ - identity-machine-account-keys-viewer.yaml
+ - identity-machine-account-keys-editor.yaml
+ - identity-machine-account-keys-admin.yaml
diff --git a/config/samples/iam/v1alpha1/machineaccount.yaml b/config/samples/iam/v1alpha1/machineaccount.yaml
index 2b778f6e..9be0a33e 100644
--- a/config/samples/iam/v1alpha1/machineaccount.yaml
+++ b/config/samples/iam/v1alpha1/machineaccount.yaml
@@ -4,10 +4,4 @@ metadata:
name: example-machine-account
namespace: default
spec:
- ownerRef:
- name: "example-project"
- uid: "12345678-1234-1234-1234-123456789012"
- email: "machine-account@example.com"
- description: "A sample machine account for API access"
- accessTokenType: "jwt"
- active: true
\ No newline at end of file
+ state: Active
diff --git a/config/samples/identity/v1alpha1/machineaccountkey.yaml b/config/samples/identity/v1alpha1/machineaccountkey.yaml
new file mode 100644
index 00000000..5d717fb0
--- /dev/null
+++ b/config/samples/identity/v1alpha1/machineaccountkey.yaml
@@ -0,0 +1,11 @@
+apiVersion: identity.miloapis.com/v1alpha1
+kind: MachineAccountKey
+metadata:
+ name: example-machine-account-key-32
+ namespace: default
+spec:
+ machineAccountUserName: example-machine-account
+ # If not specified, the key will never expire.
+ # expirationDate: "2026-03-25T10:18:48Z"
+ # If not specified, an auto-generated public key will be created.
+ expirationDate: "2027-12-31T23:59:59Z"
diff --git a/docs/api/iam.md b/docs/api/iam.md
index 1ee17986..ccc43037 100644
--- a/docs/api/iam.md
+++ b/docs/api/iam.md
@@ -12,8 +12,6 @@ Resource Types:
- [Group](#group)
-- [MachineAccountKey](#machineaccountkey)
-
- [MachineAccount](#machineaccount)
- [PlatformAccessApproval](#platformaccessapproval)
@@ -376,219 +374,6 @@ GroupStatus defines the observed state of Group
-Condition contains details for one aspect of the current state of this API Resource.
-
-
-
-
- | Name |
- Type |
- Description |
- Required |
-
-
-
- | lastTransitionTime |
- string |
-
- lastTransitionTime is the last time the condition transitioned from one status to another.
-This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
-
- Format: date-time
- |
- true |
-
- | message |
- string |
-
- message is a human readable message indicating details about the transition.
-This may be an empty string.
- |
- true |
-
- | reason |
- string |
-
- reason contains a programmatic identifier indicating the reason for the condition's last transition.
-Producers of specific condition types may define expected values and meanings for this field,
-and whether the values are considered a guaranteed API.
-The value should be a CamelCase string.
-This field may not be empty.
- |
- true |
-
- | status |
- enum |
-
- status of the condition, one of True, False, Unknown.
-
- Enum: True, False, Unknown
- |
- true |
-
- | type |
- string |
-
- type of condition in CamelCase or in foo.example.com/CamelCase.
- |
- true |
-
- | observedGeneration |
- integer |
-
- observedGeneration represents the .metadata.generation that the condition was set based upon.
-For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
-with respect to the current state of the instance.
-
- Format: int64
- Minimum: 0
- |
- false |
-
-
-
-## MachineAccountKey
-[↩ Parent](#iammiloapiscomv1alpha1 )
-
-
-
-
-
-
-MachineAccountKey is the Schema for the machineaccountkeys API
-
-
-
-
- | Name |
- Type |
- Description |
- Required |
-
-
-
- | apiVersion |
- string |
- iam.miloapis.com/v1alpha1 |
- true |
-
-
- | kind |
- string |
- MachineAccountKey |
- true |
-
-
- | metadata |
- object |
- Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
- true |
-
- | spec |
- object |
-
- MachineAccountKeySpec defines the desired state of MachineAccountKey
- |
- false |
-
- | status |
- object |
-
- MachineAccountKeyStatus defines the observed state of MachineAccountKey
- |
- false |
-
-
-
-
-### MachineAccountKey.spec
-[↩ Parent](#machineaccountkey)
-
-
-
-MachineAccountKeySpec defines the desired state of MachineAccountKey
-
-
-
-
- | Name |
- Type |
- Description |
- Required |
-
-
-
- | machineAccountName |
- string |
-
- MachineAccountName is the name of the MachineAccount that owns this key.
- |
- true |
-
- | expirationDate |
- string |
-
- ExpirationDate is the date and time when the MachineAccountKey will expire.
-If not specified, the MachineAccountKey will never expire.
-
- Format: date-time
- |
- false |
-
- | publicKey |
- string |
-
- PublicKey is the public key of the MachineAccountKey.
-If not specified, the MachineAccountKey will be created with an auto-generated public key.
- |
- false |
-
-
-
-
-### MachineAccountKey.status
-[↩ Parent](#machineaccountkey)
-
-
-
-MachineAccountKeyStatus defines the observed state of MachineAccountKey
-
-
-
-
- | Name |
- Type |
- Description |
- Required |
-
-
-
- | authProviderKeyId |
- string |
-
- AuthProviderKeyID is the unique identifier for the key in the auth provider.
-This field is populated by the controller after the key is created in the auth provider.
-For example, when using Zitadel, a typical value might be: "326102453042806786"
- |
- false |
-
- | conditions |
- []object |
-
- Conditions provide conditions that represent the current status of the MachineAccountKey.
-
- Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for control plane to reconcile reason:Unknown status:Unknown type:Ready]]
- |
- false |
-
-
-
-
-### MachineAccountKey.status.conditions[index]
-[↩ Parent](#machineaccountkeystatus)
-
-
-
Condition contains details for one aspect of the current state of this API Resource.
diff --git a/docs/api/identity.md b/docs/api/identity.md
index edc68e08..d2cc07db 100644
--- a/docs/api/identity.md
+++ b/docs/api/identity.md
@@ -12,6 +12,7 @@ Package v1alpha1 contains API types for identity-related resources.
- [UserIdentity](#useridentity)
- [Session](#session)
+- [MachineAccountKey](#machineaccountkey)
---
@@ -22,11 +23,13 @@ UserIdentity represents a user's linked identity within an external identity pro
This resource describes the connection between a Milo user and their account in an external authentication provider (e.g., GitHub, Google, Microsoft). It is NOT the identity provider itself, but rather the user's specific identity within that provider.
**Use cases:**
+
- Display all authentication methods linked to a user account in the UI
- Show which external accounts a user has connected
- Provide visibility into federated identity mappings
**Important notes:**
+
- This is a read-only resource for display purposes only
- Identity management (linking/unlinking providers) is handled by the external authentication provider (e.g., Zitadel), not through this API
- No sensitive credentials or tokens are exposed through this resource
@@ -34,7 +37,7 @@ This resource describes the connection between a Milo user and their account in
#### UserIdentityStatus
| Field | Type | Description |
-|-------|------|-------------|
+| :--- | :--- | :--- |
| `userUID` | string | The unique identifier of the Milo user who owns this identity. |
| `providerID` | string | The unique identifier of the external identity provider instance. This is typically an internal ID from the authentication system. |
| `providerName` | string | The human-readable name of the identity provider. Examples: "GitHub", "Google", "Microsoft", "GitLab" |
@@ -49,11 +52,13 @@ Session represents an active user session in the system.
This resource provides information about user authentication sessions, including the provider used for authentication and session metadata.
**Use cases:**
+
- Display active sessions for a user
- Monitor session activity
- Provide session management capabilities in the UI
**Important notes:**
+
- This is a read-only resource
- Session lifecycle is managed by the authentication provider
- No sensitive session tokens are exposed
@@ -61,10 +66,46 @@ This resource provides information about user authentication sessions, including
#### SessionStatus
| Field | Type | Description |
-|-------|------|-------------|
+| :--- | :--- | :--- |
| `userUID` | string | The unique identifier of the user who owns this session. |
| `provider` | string | The authentication provider used for this session. |
| `ip` | string | The IP address from which the session was created (optional). |
| `fingerprintID` | string | A fingerprint identifier for the session (optional). |
| `createdAt` | metav1.Time | The timestamp when the session was created. |
| `expiresAt` | *metav1.Time | The timestamp when the session expires (optional). |
+
+---
+
+### MachineAccountKey
+
+MachineAccountKey represents a credential for a MachineAccount.
+
+This resource allows users to manage API keys for machine-to-machine authentication. When a MachineAccountKey is created, the system generates a private key that is returned in the status only once.
+
+**Use cases:**
+
+- Authenticating external services and automation scripts
+- Managing key rotation and expiration
+- Auditing machine account activity
+
+**Important notes:**
+
+- The `privateKey` is ONLY available in the creation response and is NEVER persisted in the Milo API server.
+- Keys can have an optional expiration date.
+- Each key is associated with a specific `MachineAccount` identified by its email.
+
+#### MachineAccountKeySpec
+
+| Field | Type | Description |
+| :--- | :--- | :--- |
+| `machineAccountUserName` | string | The email address of the MachineAccount that owns this key. |
+| `expirationDate` | metav1.Time | Optional date and time when the key will expire. |
+| `publicKey` | string | Optional public key to be registered. If not provided, one will be auto-generated. |
+
+#### MachineAccountKeyStatus
+
+| Field | Type | Description |
+| :--- | :--- | :--- |
+| `authProviderKeyID` | string | Unique identifier for the key in the authentication provider (e.g. Zitadel ID). |
+| `privateKey` | string | PEM-encoded RSA private key. Only present in the response of a creation event. |
+| `conditions` | []metav1.Condition | Standard Kubernetes conditions for resource status. |
diff --git a/docs/api/notes.md b/docs/api/notes.md
index ba1faf9f..7247a2df 100644
--- a/docs/api/notes.md
+++ b/docs/api/notes.md
@@ -8,14 +8,14 @@ Packages:
Resource Types:
-- [Note](#note)
-
- [ClusterNote](#clusternote)
+- [Note](#note)
+
-## Note
+## ClusterNote
[↩ Parent](#notesmiloapiscomv1alpha1 )
@@ -23,8 +23,8 @@ Resource Types:
-Note is the Schema for the notes API.
-It represents a namespaced note attached to a subject resource.
+ClusterNote is the Schema for the cluster-scoped notes API.
+It represents a note attached to a cluster-scoped subject resource.
@@ -44,7 +44,7 @@ It represents a namespaced note attached to a subject resource.
| kind |
string |
- Note |
+ ClusterNote |
true |
@@ -53,14 +53,14 @@ It represents a namespaced note attached to a subject resource.
| Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
true |
- | spec |
+ spec |
object |
NoteSpec defines the desired state of Note.
|
false |
- | status |
+ status |
object |
NoteStatus defines the observed state of Note
@@ -70,8 +70,8 @@ It represents a namespaced note attached to a subject resource.
|
-### Note.spec
-[↩ Parent](#note)
+### ClusterNote.spec
+[↩ Parent](#clusternote)
@@ -91,12 +91,10 @@ NoteSpec defines the desired state of Note.
string |
Content is the text content of the note.
-
- Validations:MaxLength: 1000
|
true |
- | subjectRef |
+ subjectRef |
object |
Subject is a reference to the subject of the note.
@@ -105,7 +103,7 @@ NoteSpec defines the desired state of Note.
|
true |
- | creatorRef |
+ creatorRef |
object |
CreatorRef is a reference to the user that created the note.
@@ -113,7 +111,7 @@ Defaults to the user that created the note.
Validations:type(oldSelf) == null_type || self == oldSelf: creatorRef type is immutable
|
- true |
+ false |
| followUp |
boolean |
@@ -153,8 +151,8 @@ When true, the note is being actively tracked for further action.
-### Note.spec.subjectRef
-[↩ Parent](#notespec)
+### ClusterNote.spec.subjectRef
+[↩ Parent](#clusternotespec)
@@ -202,8 +200,8 @@ Required for namespace-scoped resources. Omitted for cluster-scoped resources.
-### Note.spec.creatorRef
-[↩ Parent](#notespec)
+### ClusterNote.spec.creatorRef
+[↩ Parent](#clusternotespec)
@@ -230,8 +228,8 @@ Defaults to the user that created the note.
-### Note.status
-[↩ Parent](#note)
+### ClusterNote.status
+[↩ Parent](#clusternote)
@@ -247,7 +245,7 @@ NoteStatus defines the observed state of Note
- | conditions |
+ conditions |
[]object |
Conditions provide conditions that represent the current status of the Note.
@@ -266,8 +264,8 @@ NoteStatus defines the observed state of Note
-### Note.status.conditions[index]
-[↩ Parent](#notestatus)
+### ClusterNote.status.conditions[index]
+[↩ Parent](#clusternotestatus)
@@ -342,7 +340,7 @@ with respect to the current state of the instance.
|
-## ClusterNote
+## Note
[↩ Parent](#notesmiloapiscomv1alpha1 )
@@ -350,8 +348,8 @@ with respect to the current state of the instance.
-ClusterNote is the Schema for the cluster-scoped notes API.
-It represents a note attached to a cluster-scoped subject resource.
+Note is the Schema for the notes API.
+It represents a namespaced note attached to a subject resource.
@@ -371,7 +369,7 @@ It represents a note attached to a cluster-scoped subject resource.
| kind |
string |
- ClusterNote |
+ Note |
true |
@@ -380,14 +378,14 @@ It represents a note attached to a cluster-scoped subject resource.
| Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
true |
- | spec |
+ spec |
object |
NoteSpec defines the desired state of Note.
|
false |
- | status |
+ status |
object |
NoteStatus defines the observed state of Note
@@ -397,8 +395,8 @@ It represents a note attached to a cluster-scoped subject resource.
|
-### ClusterNote.spec
-[↩ Parent](#clusternote)
+### Note.spec
+[↩ Parent](#note)
@@ -418,12 +416,10 @@ NoteSpec defines the desired state of Note.
string |
Content is the text content of the note.
-
- Validations:MaxLength: 1000
|
true |
- | subjectRef |
+ subjectRef |
object |
Subject is a reference to the subject of the note.
@@ -432,7 +428,7 @@ NoteSpec defines the desired state of Note.
|
true |
- | creatorRef |
+ creatorRef |
object |
CreatorRef is a reference to the user that created the note.
@@ -440,7 +436,7 @@ Defaults to the user that created the note.
Validations:type(oldSelf) == null_type || self == oldSelf: creatorRef type is immutable
|
- true |
+ false |
| followUp |
boolean |
@@ -480,8 +476,8 @@ When true, the note is being actively tracked for further action.
-### ClusterNote.spec.subjectRef
-[↩ Parent](#clusternotespec)
+### Note.spec.subjectRef
+[↩ Parent](#notespec)
@@ -529,8 +525,8 @@ Required for namespace-scoped resources. Omitted for cluster-scoped resources.
-### ClusterNote.spec.creatorRef
-[↩ Parent](#clusternotespec)
+### Note.spec.creatorRef
+[↩ Parent](#notespec)
@@ -557,8 +553,8 @@ Defaults to the user that created the note.
-### ClusterNote.status
-[↩ Parent](#clusternote)
+### Note.status
+[↩ Parent](#note)
@@ -574,7 +570,7 @@ NoteStatus defines the observed state of Note
- | conditions |
+ conditions |
[]object |
Conditions provide conditions that represent the current status of the Note.
@@ -593,8 +589,8 @@ NoteStatus defines the observed state of Note
-### ClusterNote.status.conditions[index]
-[↩ Parent](#clusternotestatus)
+### Note.status.conditions[index]
+[↩ Parent](#notestatus)
diff --git a/docs/api/notification.md b/docs/api/notification.md
index 8c1b1137..2c77299d 100644
--- a/docs/api/notification.md
+++ b/docs/api/notification.md
@@ -22,6 +22,8 @@ Resource Types:
- [EmailTemplate](#emailtemplate)
+- [Note](#note)
+
@@ -2208,3 +2210,220 @@ with respect to the current state of the instance.
|
+## Note
+[↩ Parent](#notificationmiloapiscomv1alpha1 )
+
+
+
+
+
+
+Note is the Schema for the notes API.
+It represents a note attached to a contact.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiVersion |
+ string |
+ notification.miloapis.com/v1alpha1 |
+ true |
+
+
+ | kind |
+ string |
+ Note |
+ true |
+
+
+ | metadata |
+ object |
+ Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
+ true |
+
+ | spec |
+ object |
+
+ NoteSpec defines the desired state of Note.
+ |
+ false |
+
+ | status |
+ object |
+
+ NoteStatus defines the observed state of Note.
+ |
+ false |
+
+
+
+
+### Note.spec
+[↩ Parent](#note)
+
+
+
+NoteSpec defines the desired state of Note.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | contactRef |
+ string |
+
+ ContactRef is the name of the Contact this note is attached to.
+ |
+ true |
+
+ | content |
+ string |
+
+ Content is the text content of the note.
+ |
+ true |
+
+ | action |
+ string |
+
+ Action is an optional follow-up action.
+ |
+ false |
+
+ | actionTime |
+ string |
+
+ ActionTime is the timestamp for the follow-up action.
+
+ Format: date-time
+ |
+ false |
+
+ | interactionTime |
+ string |
+
+ InteractionTime is the timestamp of the interaction.
+If not specified, it defaults to the creation timestamp of the note.
+
+ Format: date-time
+ |
+ false |
+
+
+
+
+### Note.status
+[↩ Parent](#note)
+
+
+
+NoteStatus defines the observed state of Note.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | conditions |
+ []object |
+
+ Conditions represent the latest available observations of an object's state
+ |
+ false |
+
+
+
+
+### Note.status.conditions[index]
+[↩ Parent](#notestatus)
+
+
+
+Condition contains details for one aspect of the current state of this API Resource.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | lastTransitionTime |
+ string |
+
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+ |
+ true |
+
+ | message |
+ string |
+
+ message is a human readable message indicating details about the transition.
+This may be an empty string.
+ |
+ true |
+
+ | reason |
+ string |
+
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+Producers of specific condition types may define expected values and meanings for this field,
+and whether the values are considered a guaranteed API.
+The value should be a CamelCase string.
+This field may not be empty.
+ |
+ true |
+
+ | status |
+ enum |
+
+ status of the condition, one of True, False, Unknown.
+
+ Enum: True, False, Unknown
+ |
+ true |
+
+ | type |
+ string |
+
+ type of condition in CamelCase or in foo.example.com/CamelCase.
+ |
+ true |
+
+ | observedGeneration |
+ integer |
+
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+with respect to the current state of the instance.
+
+ Format: int64
+ Minimum: 0
+ |
+ false |
+
+
diff --git a/internal/apiserver/identity/machineaccountkeys/dynamic.go b/internal/apiserver/identity/machineaccountkeys/dynamic.go
new file mode 100644
index 00000000..7205c06c
--- /dev/null
+++ b/internal/apiserver/identity/machineaccountkeys/dynamic.go
@@ -0,0 +1,234 @@
+package machineaccountkeys
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ authuser "k8s.io/apiserver/pkg/authentication/user"
+ apirequest "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/transport"
+)
+
+// Config controls how the provider talks to the remote machineaccountkeys API **always via a remote URL**.
+
+type Config struct {
+ BaseConfig *rest.Config
+
+ ProviderURL string
+
+ CAFile string
+ ClientCertFile string
+ ClientKeyFile string
+
+ Timeout time.Duration
+ Retries int
+ ExtrasAllow map[string]struct{}
+}
+
+// DynamicProvider implements Backend by proxying to a remote auth-provider
+// that serves the machineaccountkeys API (e.g. auth-provider-zitadel).
+type DynamicProvider struct {
+ base *rest.Config
+ gvr schema.GroupVersionResource
+ to time.Duration
+ retries int
+ allowExtras map[string]struct{}
+}
+
+func NewDynamicProvider(cfg Config) (*DynamicProvider, error) {
+ if cfg.ProviderURL == "" {
+ return nil, fmt.Errorf("ProviderURL is required")
+ }
+
+ // Build from scratch
+ base := &rest.Config{}
+ base.Host = cfg.ProviderURL
+
+ var sni string
+ if u, err := url.Parse(cfg.ProviderURL); err == nil {
+ sni = u.Hostname()
+ }
+
+ // Wire TLS from files
+ base.TLSClientConfig = rest.TLSClientConfig{
+ CAFile: cfg.CAFile,
+ CertFile: cfg.ClientCertFile,
+ KeyFile: cfg.ClientKeyFile,
+ // We enforce verification; set Insecure=true only for dev
+ Insecure: false,
+ ServerName: sni,
+ }
+
+ // Respect our explicit timeout
+ if cfg.Timeout > 0 {
+ base.Timeout = cfg.Timeout
+ }
+
+ gvr := identityv1alpha1.SchemeGroupVersion.WithResource("machineaccountkeys")
+
+ return &DynamicProvider{
+ base: base,
+ gvr: gvr,
+ to: cfg.Timeout,
+ retries: max(0, cfg.Retries),
+ allowExtras: cfg.ExtrasAllow,
+ }, nil
+}
+
+// dynForUser creates a per-call client-go dynamic.Interface that forwards identity via X-Remote-*.
+func (b *DynamicProvider) dynForUser(ctx context.Context) (dynamic.Interface, error) {
+ u, ok := apirequest.UserFrom(ctx)
+ if !ok || u == nil {
+ return nil, fmt.Errorf("no user in context")
+ }
+ cfg := rest.CopyConfig(b.base)
+ if b.to > 0 {
+ cfg.Timeout = b.to
+ }
+ prev := cfg.WrapTransport
+ cfg.WrapTransport = func(rt http.RoundTripper) http.RoundTripper {
+ if prev != nil {
+ rt = prev(rt)
+ }
+ return transport.NewAuthProxyRoundTripper(
+ u.GetName(),
+ u.GetUID(),
+ u.GetGroups(),
+ b.filterExtras(u.GetExtra()),
+ rt,
+ )
+ }
+ return dynamic.NewForConfig(cfg)
+}
+
+func (b *DynamicProvider) filterExtras(src map[string][]string) map[string][]string {
+ if len(b.allowExtras) == 0 || len(src) == 0 {
+ return nil
+ }
+ out := make(map[string][]string, len(src))
+ for k, v := range src {
+ if _, ok := b.allowExtras[k]; ok {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// ---- Public API (implements Backend) ----
+
+func (b *DynamicProvider) CreateMachineAccountKey(ctx context.Context, _ authuser.Info, key *identityv1alpha1.MachineAccountKey, opts *metav1.CreateOptions) (*identityv1alpha1.MachineAccountKey, error) {
+ dyn, err := b.dynForUser(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if opts == nil {
+ opts = &metav1.CreateOptions{}
+ }
+
+ // Convert to unstructured for the dynamic client
+ uobj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert MachineAccountKey to unstructured: %w", err)
+ }
+
+ var lastErr error
+ var created *unstructured.Unstructured
+ for i := 0; i <= b.retries; i++ {
+ created, lastErr = dyn.Resource(b.gvr).Create(ctx, &unstructured.Unstructured{Object: uobj}, *opts)
+ if lastErr == nil {
+ break
+ }
+ }
+ if lastErr != nil {
+ return nil, lastErr
+ }
+
+ out := new(identityv1alpha1.MachineAccountKey)
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(created.UnstructuredContent(), out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (b *DynamicProvider) ListMachineAccountKeys(ctx context.Context, _ authuser.Info, opts *metav1.ListOptions) (*identityv1alpha1.MachineAccountKeyList, error) {
+ if opts == nil {
+ opts = &metav1.ListOptions{}
+ }
+ dyn, err := b.dynForUser(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var lastErr error
+ var ul *unstructured.UnstructuredList
+ for i := 0; i <= b.retries; i++ {
+ ul, lastErr = dyn.Resource(b.gvr).List(ctx, *opts)
+ if lastErr == nil {
+ break
+ }
+ }
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ out := new(identityv1alpha1.MachineAccountKeyList)
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(ul.UnstructuredContent(), out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (b *DynamicProvider) GetMachineAccountKey(ctx context.Context, _ authuser.Info, name string) (*identityv1alpha1.MachineAccountKey, error) {
+ dyn, err := b.dynForUser(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var lastErr error
+ var uobj *unstructured.Unstructured
+ for i := 0; i <= b.retries; i++ {
+ uobj, lastErr = dyn.Resource(b.gvr).Get(ctx, name, metav1.GetOptions{})
+ if lastErr == nil {
+ break
+ }
+ }
+ if lastErr != nil {
+ return nil, lastErr
+ }
+ out := new(identityv1alpha1.MachineAccountKey)
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(uobj.UnstructuredContent(), out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (b *DynamicProvider) DeleteMachineAccountKey(ctx context.Context, _ authuser.Info, name string) error {
+ dyn, err := b.dynForUser(ctx)
+ if err != nil {
+ return err
+ }
+ var lastErr error
+ for i := 0; i <= b.retries; i++ {
+ lastErr = dyn.Resource(b.gvr).Delete(ctx, name, metav1.DeleteOptions{})
+ if lastErr == nil {
+ return nil
+ }
+ }
+ return lastErr
+}
+
+// small util
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/internal/apiserver/identity/machineaccountkeys/rest.go b/internal/apiserver/identity/machineaccountkeys/rest.go
new file mode 100644
index 00000000..4c75290e
--- /dev/null
+++ b/internal/apiserver/identity/machineaccountkeys/rest.go
@@ -0,0 +1,168 @@
+package machineaccountkeys
+
+import (
+ "context"
+ "time"
+
+ 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"
+ authuser "k8s.io/apiserver/pkg/authentication/user"
+ apirequest "k8s.io/apiserver/pkg/endpoints/request"
+ "k8s.io/apiserver/pkg/registry/rest"
+ "k8s.io/klog/v2"
+
+ identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1"
+)
+
+// Backend is the interface that the REST handler delegates all operations to.
+// Implementations proxy requests to the auth-provider (e.g. Zitadel) service.
+type Backend interface {
+ CreateMachineAccountKey(ctx context.Context, u authuser.Info, key *identityv1alpha1.MachineAccountKey, opts *metav1.CreateOptions) (*identityv1alpha1.MachineAccountKey, error)
+ ListMachineAccountKeys(ctx context.Context, u authuser.Info, opts *metav1.ListOptions) (*identityv1alpha1.MachineAccountKeyList, error)
+ GetMachineAccountKey(ctx context.Context, u authuser.Info, name string) (*identityv1alpha1.MachineAccountKey, error)
+ DeleteMachineAccountKey(ctx context.Context, u authuser.Info, name string) error
+}
+
+type REST struct {
+ backend Backend
+}
+
+var _ rest.Scoper = &REST{}
+var _ rest.Creater = &REST{} //nolint:misspell
+var _ rest.Lister = &REST{}
+var _ rest.Getter = &REST{}
+var _ rest.GracefulDeleter = &REST{}
+var _ rest.Storage = &REST{}
+var _ rest.SingularNameProvider = &REST{}
+
+func NewREST(b Backend) *REST { return &REST{backend: b} }
+
+func (r *REST) GetSingularName() string { return "machineaccountkey" }
+func (r *REST) NamespaceScoped() bool { return false }
+func (r *REST) New() runtime.Object { return &identityv1alpha1.MachineAccountKey{} }
+func (r *REST) NewList() runtime.Object { return &identityv1alpha1.MachineAccountKeyList{} }
+
+func (r *REST) Create(
+ ctx context.Context,
+ obj runtime.Object,
+ _ rest.ValidateObjectFunc,
+ opts *metav1.CreateOptions,
+) (runtime.Object, error) {
+ logger := klog.FromContext(ctx)
+ u, _ := apirequest.UserFrom(ctx)
+ key, ok := obj.(*identityv1alpha1.MachineAccountKey)
+ if !ok {
+ return nil, apierrors.NewBadRequest("not a MachineAccountKey")
+ }
+ logger.V(4).Info("Creating machine account key", "name", key.Name, "machineAccount", key.Spec.MachineAccountUserName)
+ res, err := r.backend.CreateMachineAccountKey(ctx, u, key, opts)
+ if err != nil {
+ logger.Error(err, "Create machine account key failed", "name", key.Name)
+ return nil, err
+ }
+ logger.V(4).Info("Created machine account key", "name", res.Name, "authProviderKeyID", res.Status.AuthProviderKeyID)
+ return res, nil
+}
+
+func (r *REST) List(ctx context.Context, opts *metainternalversion.ListOptions) (runtime.Object, error) {
+ logger := klog.FromContext(ctx)
+ u, _ := apirequest.UserFrom(ctx)
+ username, uid, groups := "", "", []string(nil)
+ if u != nil {
+ username = u.GetName()
+ uid = u.GetUID()
+ groups = u.GetGroups()
+ }
+ logger.V(4).Info("Listing machine account keys", "username", username, "uid", uid, "groups", groups)
+ lo := metav1.ListOptions{}
+ if opts != nil && opts.FieldSelector != nil && !opts.FieldSelector.Empty() {
+ lo.FieldSelector = opts.FieldSelector.String()
+ }
+ res, err := r.backend.ListMachineAccountKeys(ctx, u, &lo)
+ if err != nil {
+ logger.Error(err, "List machine account keys failed")
+ return nil, err
+ }
+ logger.V(4).Info("Listed machine account keys", "count", len(res.Items))
+ return res, nil
+}
+
+func (r *REST) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
+ logger := klog.FromContext(ctx)
+ u, _ := apirequest.UserFrom(ctx)
+ username, uid := "", ""
+ if u != nil {
+ username = u.GetName()
+ uid = u.GetUID()
+ }
+ logger.V(4).Info("Getting machine account key", "name", name, "username", username, "uid", uid)
+ res, err := r.backend.GetMachineAccountKey(ctx, u, name)
+ if err != nil {
+ logger.Error(err, "Get machine account key failed", "name", name)
+ return nil, err
+ }
+ logger.V(4).Info("Got machine account key", "name", name, "authProviderKeyID", res.Status.AuthProviderKeyID)
+ return res, nil
+}
+
+func (r *REST) Delete(ctx context.Context, name string, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions) (runtime.Object, bool, error) {
+ logger := klog.FromContext(ctx)
+ u, _ := apirequest.UserFrom(ctx)
+ username, uid := "", ""
+ if u != nil {
+ username = u.GetName()
+ uid = u.GetUID()
+ }
+ logger.V(4).Info("Deleting machine account key", "name", name, "username", username, "uid", uid)
+ if err := r.backend.DeleteMachineAccountKey(ctx, u, name); err != nil {
+ logger.Error(err, "Delete machine account key failed", "name", name)
+ return nil, false, err
+ }
+ logger.V(4).Info("Deleted machine account key", "name", name)
+ return &metav1.Status{Status: metav1.StatusSuccess}, true, nil
+}
+
+func (r *REST) Destroy() {}
+
+// ConvertToTable satisfies rest.TableConvertor with a kubectl-friendly table output.
+func (r *REST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
+ table := &metav1.Table{
+ ColumnDefinitions: []metav1.TableColumnDefinition{
+ {Name: "Name", Type: "string"},
+ {Name: "Machine Account", Type: "string"},
+ {Name: "Key ID", Type: "string"},
+ {Name: "Age", Type: "date"},
+ {Name: "Expires", Type: "string"},
+ },
+ }
+
+ appendRow := func(mak *identityv1alpha1.MachineAccountKey) {
+ age := metav1.Now().Rfc3339Copy()
+ if !mak.CreationTimestamp.IsZero() {
+ age = mak.CreationTimestamp
+ }
+ expiresStr := ""
+ if mak.Spec.ExpirationDate != nil {
+ expiresStr = mak.Spec.ExpirationDate.Time.Format(time.RFC3339)
+ }
+ table.Rows = append(table.Rows, metav1.TableRow{
+ Cells: []interface{}{mak.Name, mak.Spec.MachineAccountUserName, mak.Status.AuthProviderKeyID, age.Time.Format(time.RFC3339), expiresStr},
+ Object: runtime.RawExtension{Object: mak},
+ })
+ }
+
+ switch obj := object.(type) {
+ case *identityv1alpha1.MachineAccountKeyList:
+ for i := range obj.Items {
+ appendRow(&obj.Items[i])
+ }
+ case *identityv1alpha1.MachineAccountKey:
+ appendRow(obj)
+ default:
+ return nil, nil
+ }
+
+ return table, nil
+}
diff --git a/internal/apiserver/storage/identity/storageprovider.go b/internal/apiserver/storage/identity/storageprovider.go
index 286b97c4..90544896 100644
--- a/internal/apiserver/storage/identity/storageprovider.go
+++ b/internal/apiserver/storage/identity/storageprovider.go
@@ -9,14 +9,16 @@ import (
"k8s.io/kubernetes/pkg/api/legacyscheme"
controlplaneapiserver "k8s.io/kubernetes/pkg/controlplane/apiserver"
+ machineaccountkeysregistry "go.miloapis.com/milo/internal/apiserver/identity/machineaccountkeys"
sessionsregistry "go.miloapis.com/milo/internal/apiserver/identity/sessions"
useridentitiesregistry "go.miloapis.com/milo/internal/apiserver/identity/useridentities"
identityv1alpha1 "go.miloapis.com/milo/pkg/apis/identity/v1alpha1"
)
type StorageProvider struct {
- Sessions sessionsregistry.Backend
- UserIdentities useridentitiesregistry.Backend
+ Sessions sessionsregistry.Backend
+ UserIdentities useridentitiesregistry.Backend
+ MachineAccountKeys machineaccountkeysregistry.Backend
}
func (p StorageProvider) GroupName() string { return identityv1alpha1.SchemeGroupVersion.Group }
@@ -33,8 +35,9 @@ func (p StorageProvider) NewRESTStorage(
)
storage := map[string]rest.Storage{
- "sessions": sessionsregistry.NewREST(p.Sessions),
- "useridentities": useridentitiesregistry.NewREST(p.UserIdentities),
+ "sessions": sessionsregistry.NewREST(p.Sessions),
+ "useridentities": useridentitiesregistry.NewREST(p.UserIdentities),
+ "machineaccountkeys": machineaccountkeysregistry.NewREST(p.MachineAccountKeys),
}
apiGroupInfo.VersionedResourcesStorageMap = map[string]map[string]rest.Storage{
diff --git a/internal/quota/admission/plugin.go b/internal/quota/admission/plugin.go
index e6ad472c..91c380a0 100644
--- a/internal/quota/admission/plugin.go
+++ b/internal/quota/admission/plugin.go
@@ -54,7 +54,6 @@ var (
},
[]string{"result", "policy_name", "policy_namespace", "resource_group", "resource_kind"},
)
-
)
func init() {
diff --git a/internal/quota/admission/plugin_test.go b/internal/quota/admission/plugin_test.go
index 68111b70..779ac4a1 100644
--- a/internal/quota/admission/plugin_test.go
+++ b/internal/quota/admission/plugin_test.go
@@ -1413,9 +1413,9 @@ func TestStructuredEndpointSliceCELTemplateRendering(t *testing.T) {
plugin.watchManagers.Store("", &testWatchManager{behavior: "grant"})
tests := []struct {
- name string
- epsName string
- object runtime.Object
+ name string
+ epsName string
+ object runtime.Object
}{
{
name: "structured discoveryv1.EndpointSlice",
diff --git a/pkg/apis/iam/v1alpha1/machineaccount_types.go b/pkg/apis/iam/v1alpha1/machineaccount_types.go
index 87cec01b..42b16f77 100644
--- a/pkg/apis/iam/v1alpha1/machineaccount_types.go
+++ b/pkg/apis/iam/v1alpha1/machineaccount_types.go
@@ -15,7 +15,7 @@ import (
// +kubebuilder:printcolumn:name="Access Token Type",type="string",JSONPath=".spec.accessTokenType"
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
-// +kubebuilder:resource:scope=Namespaced
+// +kubebuilder:resource:scope=Cluster
type MachineAccount struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
diff --git a/pkg/apis/iam/v1alpha1/register.go b/pkg/apis/iam/v1alpha1/register.go
index 828278ce..5a00f729 100644
--- a/pkg/apis/iam/v1alpha1/register.go
+++ b/pkg/apis/iam/v1alpha1/register.go
@@ -35,8 +35,6 @@ func addKnownTypes(scheme *runtime.Scheme) error {
&ProtectedResourceList{},
&MachineAccount{},
&MachineAccountList{},
- &MachineAccountKey{},
- &MachineAccountKeyList{},
&UserPreference{},
&UserPreferenceList{},
&UserDeactivation{},
diff --git a/pkg/apis/iam/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/iam/v1alpha1/zz_generated.deepcopy.go
index 0218d4c2..fc6e024d 100644
--- a/pkg/apis/iam/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/iam/v1alpha1/zz_generated.deepcopy.go
@@ -229,106 +229,6 @@ func (in *MachineAccount) DeepCopyObject() runtime.Object {
return nil
}
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *MachineAccountKey) DeepCopyInto(out *MachineAccountKey) {
- *out = *in
- out.TypeMeta = in.TypeMeta
- in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
- in.Spec.DeepCopyInto(&out.Spec)
- in.Status.DeepCopyInto(&out.Status)
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKey.
-func (in *MachineAccountKey) DeepCopy() *MachineAccountKey {
- if in == nil {
- return nil
- }
- out := new(MachineAccountKey)
- in.DeepCopyInto(out)
- return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *MachineAccountKey) DeepCopyObject() runtime.Object {
- if c := in.DeepCopy(); c != nil {
- return c
- }
- return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *MachineAccountKeyList) DeepCopyInto(out *MachineAccountKeyList) {
- *out = *in
- out.TypeMeta = in.TypeMeta
- in.ListMeta.DeepCopyInto(&out.ListMeta)
- if in.Items != nil {
- in, out := &in.Items, &out.Items
- *out = make([]MachineAccountKey, len(*in))
- for i := range *in {
- (*in)[i].DeepCopyInto(&(*out)[i])
- }
- }
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeyList.
-func (in *MachineAccountKeyList) DeepCopy() *MachineAccountKeyList {
- if in == nil {
- return nil
- }
- out := new(MachineAccountKeyList)
- in.DeepCopyInto(out)
- return out
-}
-
-// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
-func (in *MachineAccountKeyList) DeepCopyObject() runtime.Object {
- if c := in.DeepCopy(); c != nil {
- return c
- }
- return nil
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *MachineAccountKeySpec) DeepCopyInto(out *MachineAccountKeySpec) {
- *out = *in
- if in.ExpirationDate != nil {
- in, out := &in.ExpirationDate, &out.ExpirationDate
- *out = (*in).DeepCopy()
- }
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeySpec.
-func (in *MachineAccountKeySpec) DeepCopy() *MachineAccountKeySpec {
- if in == nil {
- return nil
- }
- out := new(MachineAccountKeySpec)
- in.DeepCopyInto(out)
- return out
-}
-
-// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *MachineAccountKeyStatus) DeepCopyInto(out *MachineAccountKeyStatus) {
- *out = *in
- if in.Conditions != nil {
- in, out := &in.Conditions, &out.Conditions
- *out = make([]v1.Condition, len(*in))
- for i := range *in {
- (*in)[i].DeepCopyInto(&(*out)[i])
- }
- }
-}
-
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeyStatus.
-func (in *MachineAccountKeyStatus) DeepCopy() *MachineAccountKeyStatus {
- if in == nil {
- return nil
- }
- out := new(MachineAccountKeyStatus)
- in.DeepCopyInto(out)
- return out
-}
-
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MachineAccountList) DeepCopyInto(out *MachineAccountList) {
*out = *in
diff --git a/pkg/apis/identity/scheme.go b/pkg/apis/identity/scheme.go
index a6714d59..8f4e8878 100644
--- a/pkg/apis/identity/scheme.go
+++ b/pkg/apis/identity/scheme.go
@@ -2,6 +2,7 @@ package identity
import (
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"go.miloapis.com/milo/pkg/apis/identity/v1alpha1"
)
@@ -9,4 +10,22 @@ import (
// Install registers the identity API group versions into the provided scheme.
func Install(scheme *runtime.Scheme) {
v1alpha1.AddToScheme(scheme)
+
+ // Register valid field selectors for MachineAccountKey so the generic API
+ // server passes them through to the REST handler instead of rejecting them.
+ _ = scheme.AddFieldLabelConversionFunc(
+ schema.GroupVersionKind{
+ Group: v1alpha1.SchemeGroupVersion.Group,
+ Version: v1alpha1.SchemeGroupVersion.Version,
+ Kind: "MachineAccountKey",
+ },
+ func(label, value string) (string, string, error) {
+ switch label {
+ case "spec.machineAccountUserName", "metadata.name", "metadata.namespace":
+ return label, value, nil
+ default:
+ return "", "", nil
+ }
+ },
+ )
}
diff --git a/pkg/apis/iam/v1alpha1/machineaccountkey_types.go b/pkg/apis/identity/v1alpha1/machineaccountkey_types.go
similarity index 75%
rename from pkg/apis/iam/v1alpha1/machineaccountkey_types.go
rename to pkg/apis/identity/v1alpha1/machineaccountkey_types.go
index 4c8e0b33..dc272116 100644
--- a/pkg/apis/iam/v1alpha1/machineaccountkey_types.go
+++ b/pkg/apis/identity/v1alpha1/machineaccountkey_types.go
@@ -7,6 +7,7 @@ import (
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:subresource:status
// +kubebuilder:object:root=true
+// +kubebuilder:storageversion
// MachineAccountKey is the Schema for the machineaccountkeys API
// +kubebuilder:printcolumn:name="Machine Account",type="string",JSONPath=".spec.machineAccountName"
@@ -25,9 +26,9 @@ type MachineAccountKey struct {
// MachineAccountKeySpec defines the desired state of MachineAccountKey
type MachineAccountKeySpec struct {
- // MachineAccountName is the name of the MachineAccount that owns this key.
+ // MachineAccountUserName is the email address of the MachineAccount that owns this key.
// +kubebuilder:validation:Required
- MachineAccountName string `json:"machineAccountName"`
+ MachineAccountUserName string `json:"machineAccountUserName"`
// ExpirationDate is the date and time when the MachineAccountKey will expire.
// If not specified, the MachineAccountKey will never expire.
@@ -45,11 +46,25 @@ type MachineAccountKeyStatus struct {
// AuthProviderKeyID is the unique identifier for the key in the auth provider.
// This field is populated by the controller after the key is created in the auth provider.
// For example, when using Zitadel, a typical value might be: "326102453042806786"
- AuthProviderKeyID string `json:"authProviderKeyId,omitempty"`
+ AuthProviderKeyID string `json:"authProviderKeyID,omitempty"`
+
+ // PrivateKey contains the PEM-encoded RSA private key generated during resource
+ // creation. This field is populated only in the creation response and is never
+ // persisted to etcd. Any value present on a GET or LIST response indicates a
+ // bug in the server implementation.
+ //
+ // Note: The private key is NOT logged in API server audit logs. The audit policy
+ // is configured to log MachineAccountKey resources at the Metadata level only,
+ // which redacts the response body containing the private key.
+ //
+ // +kubebuilder:validation:Optional
+ PrivateKey string `json:"privateKey,omitempty"`
// Conditions provide conditions that represent the current status of the MachineAccountKey.
// +kubebuilder:default={{type: "Ready", status: "Unknown", reason: "Unknown", message: "Waiting for control plane to reconcile", lastTransitionTime: "1970-01-01T00:00:00Z"}}
// +kubebuilder:validation:Optional
+ // +listType=map
+ // +listMapKey=type
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
diff --git a/pkg/apis/identity/v1alpha1/register.go b/pkg/apis/identity/v1alpha1/register.go
index 66560647..f838b1eb 100644
--- a/pkg/apis/identity/v1alpha1/register.go
+++ b/pkg/apis/identity/v1alpha1/register.go
@@ -28,12 +28,24 @@ func Resource(resource string) schema.GroupResource {
// Adds the list of known types to Scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
- scheme.AddKnownTypes(SchemeGroupVersion,
+ types := []runtime.Object{
&Session{},
&SessionList{},
&UserIdentity{},
&UserIdentityList{},
+ &MachineAccountKey{},
+ &MachineAccountKeyList{},
+ }
+
+ scheme.AddKnownTypes(SchemeGroupVersion, types...)
+ scheme.AddKnownTypes(schema.GroupVersion{
+ Group: SchemeGroupVersion.Group,
+ Version: runtime.APIVersionInternal,
+ },
+ &MachineAccountKey{},
+ &MachineAccountKeyList{},
)
+
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
diff --git a/pkg/apis/identity/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/identity/v1alpha1/zz_generated.deepcopy.go
index 73588aed..d8b27588 100644
--- a/pkg/apis/identity/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/identity/v1alpha1/zz_generated.deepcopy.go
@@ -5,9 +5,110 @@
package v1alpha1
import (
+ "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MachineAccountKey) DeepCopyInto(out *MachineAccountKey) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKey.
+func (in *MachineAccountKey) DeepCopy() *MachineAccountKey {
+ if in == nil {
+ return nil
+ }
+ out := new(MachineAccountKey)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *MachineAccountKey) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MachineAccountKeyList) DeepCopyInto(out *MachineAccountKeyList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]MachineAccountKey, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeyList.
+func (in *MachineAccountKeyList) DeepCopy() *MachineAccountKeyList {
+ if in == nil {
+ return nil
+ }
+ out := new(MachineAccountKeyList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *MachineAccountKeyList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MachineAccountKeySpec) DeepCopyInto(out *MachineAccountKeySpec) {
+ *out = *in
+ if in.ExpirationDate != nil {
+ in, out := &in.ExpirationDate, &out.ExpirationDate
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeySpec.
+func (in *MachineAccountKeySpec) DeepCopy() *MachineAccountKeySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(MachineAccountKeySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MachineAccountKeyStatus) DeepCopyInto(out *MachineAccountKeyStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MachineAccountKeyStatus.
+func (in *MachineAccountKeyStatus) DeepCopy() *MachineAccountKeyStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(MachineAccountKeyStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Session) DeepCopyInto(out *Session) {
*out = *in
diff --git a/pkg/apis/identity/v1alpha1/zz_generated.openapi.go b/pkg/apis/identity/v1alpha1/zz_generated.openapi.go
index 253a22d2..f473a06e 100644
--- a/pkg/apis/identity/v1alpha1/zz_generated.openapi.go
+++ b/pkg/apis/identity/v1alpha1/zz_generated.openapi.go
@@ -14,12 +14,200 @@ import (
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.Session": schema_pkg_apis_identity_v1alpha1_Session(ref),
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.SessionList": schema_pkg_apis_identity_v1alpha1_SessionList(ref),
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.SessionStatus": schema_pkg_apis_identity_v1alpha1_SessionStatus(ref),
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentity": schema_pkg_apis_identity_v1alpha1_UserIdentity(ref),
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentityList": schema_pkg_apis_identity_v1alpha1_UserIdentityList(ref),
- "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentityStatus": schema_pkg_apis_identity_v1alpha1_UserIdentityStatus(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKey": schema_pkg_apis_identity_v1alpha1_MachineAccountKey(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeyList": schema_pkg_apis_identity_v1alpha1_MachineAccountKeyList(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeySpec": schema_pkg_apis_identity_v1alpha1_MachineAccountKeySpec(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeyStatus": schema_pkg_apis_identity_v1alpha1_MachineAccountKeyStatus(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.Session": schema_pkg_apis_identity_v1alpha1_Session(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.SessionList": schema_pkg_apis_identity_v1alpha1_SessionList(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.SessionStatus": schema_pkg_apis_identity_v1alpha1_SessionStatus(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentity": schema_pkg_apis_identity_v1alpha1_UserIdentity(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentityList": schema_pkg_apis_identity_v1alpha1_UserIdentityList(ref),
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.UserIdentityStatus": schema_pkg_apis_identity_v1alpha1_UserIdentityStatus(ref),
+ }
+}
+
+func schema_pkg_apis_identity_v1alpha1_MachineAccountKey(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "MachineAccountKey is the Schema for the machineaccountkeys API",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeySpec"),
+ },
+ },
+ "status": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeyStatus"),
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeySpec", "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKeyStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
+ }
+}
+
+func schema_pkg_apis_identity_v1alpha1_MachineAccountKeyList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "MachineAccountKeyList contains a list of MachineAccountKey",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKey"),
+ },
+ },
+ },
+ },
+ },
+ },
+ Required: []string{"items"},
+ },
+ },
+ Dependencies: []string{
+ "go.miloapis.com/milo/pkg/apis/identity/v1alpha1.MachineAccountKey", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
+ }
+}
+
+func schema_pkg_apis_identity_v1alpha1_MachineAccountKeySpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "MachineAccountKeySpec defines the desired state of MachineAccountKey",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "machineAccountUserName": {
+ SchemaProps: spec.SchemaProps{
+ Description: "MachineAccountUserName is the email address of the MachineAccount that owns this key.",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "expirationDate": {
+ SchemaProps: spec.SchemaProps{
+ Description: "ExpirationDate is the date and time when the MachineAccountKey will expire. If not specified, the MachineAccountKey will never expire.",
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"),
+ },
+ },
+ "publicKey": {
+ SchemaProps: spec.SchemaProps{
+ Description: "PublicKey is the public key of the MachineAccountKey. If not specified, the MachineAccountKey will be created with an auto-generated public key.",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"machineAccountUserName"},
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.Time"},
+ }
+}
+
+func schema_pkg_apis_identity_v1alpha1_MachineAccountKeyStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "MachineAccountKeyStatus defines the observed state of MachineAccountKey",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "authProviderKeyID": {
+ SchemaProps: spec.SchemaProps{
+ Description: "AuthProviderKeyID is the unique identifier for the key in the auth provider. This field is populated by the controller after the key is created in the auth provider. For example, when using Zitadel, a typical value might be: \"326102453042806786\"",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "privateKey": {
+ SchemaProps: spec.SchemaProps{
+ Description: "PrivateKey contains the PEM-encoded RSA private key generated during resource creation. This field is populated only in the creation response and is never persisted to etcd. Any value present on a GET or LIST response indicates a bug in the server implementation.\n\nNote: private key material will appear in API server audit logs for creation events. This matches the behavior of similar systems (GCP service account keys).",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "conditions": {
+ VendorExtensible: spec.VendorExtensible{
+ Extensions: spec.Extensions{
+ "x-kubernetes-list-map-keys": []interface{}{
+ "type",
+ },
+ "x-kubernetes-list-type": "map",
+ },
+ },
+ SchemaProps: spec.SchemaProps{
+ Description: "Conditions provide conditions that represent the current status of the MachineAccountKey.",
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"),
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"},
}
}
diff --git a/pkg/features/features.go b/pkg/features/features.go
index 89bf6d65..089a058a 100644
--- a/pkg/features/features.go
+++ b/pkg/features/features.go
@@ -47,6 +47,13 @@ const (
// alpha: v0.1.0
// ga: v0.2.0
UserIdentities featuregate.Feature = "UserIdentities"
+
+ // MachineAccountKeys enables the identity.miloapis.com/v1alpha1 MachineAccountKey
+ // virtual API that proxies to an external identity provider for machine account key management.
+ //
+ // owner: @datum-cloud/platform
+ // alpha: v0.1.0
+ MachineAccountKeys featuregate.Feature = "MachineAccountKeys"
)
func init() {
@@ -60,6 +67,10 @@ var defaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
Default: false,
PreRelease: featuregate.Alpha,
},
+ MachineAccountKeys: {
+ Default: false,
+ PreRelease: featuregate.Alpha,
+ },
Sessions: {
Default: true,
PreRelease: featuregate.GA,
diff --git a/test/crm/note-contact-lifecycle/README.md b/test/crm/note-contact-lifecycle/README.md
index 766d3a24..fe422110 100644
--- a/test/crm/note-contact-lifecycle/README.md
+++ b/test/crm/note-contact-lifecycle/README.md
@@ -12,7 +12,7 @@ This test verifies:
| # | Name | Bindings | Try | Catch | Finally | Cleanup |
|:-:|---|:-:|:-:|:-:|:-:|:-:|
-| 1 | [create-user-contact-and-notes](#step-create-user-contact-and-notes) | 0 | 7 | 0 | 0 | 0 |
+| 1 | [create-user-contact-and-notes](#step-create-user-contact-and-notes) | 0 | 5 | 0 | 0 | 0 |
| 2 | [delete-contact-and-verify-contact-note-deletion](#step-delete-contact-and-verify-contact-note-deletion) | 0 | 5 | 0 | 0 | 0 |
| 3 | [delete-user-and-verify-user-note-deletion](#step-delete-user-and-verify-user-note-deletion) | 0 | 3 | 0 | 0 | 0 |
| 4 | [verify-additional-notes-still-exist](#step-verify-additional-notes-still-exist) | 0 | 2 | 0 | 0 | 0 |
@@ -29,9 +29,7 @@ Create IAM User, Notification Contact, and CRM Notes, then verify Note status
| 2 | `wait` | 0 | 0 | *No description* |
| 3 | `apply` | 0 | 0 | *No description* |
| 4 | `apply` | 0 | 0 | *No description* |
-| 5 | `script` | 0 | 0 | *No description* |
-| 6 | `wait` | 0 | 0 | *No description* |
-| 7 | `wait` | 0 | 0 | *No description* |
+| 5 | `assert` | 0 | 0 | *No description* |
### Step: `delete-contact-and-verify-contact-note-deletion`
diff --git a/test/notes/clusternote-multicluster-subject/README.md b/test/notes/clusternote-multicluster-subject/README.md
new file mode 100644
index 00000000..ccb91295
--- /dev/null
+++ b/test/notes/clusternote-multicluster-subject/README.md
@@ -0,0 +1,78 @@
+# Test: `clusternote-multicluster-subject`
+
+Tests that ClusterNotes can reference cluster-scoped subjects (Namespaces)
+in project control planes.
+
+Validates:
+- ClusterNote in project control plane can reference cluster-scoped Namespace
+- Owner reference is correctly set on the ClusterNote
+- ClusterNote is garbage collected when Namespace is deleted
+
+
+## Steps
+
+| # | Name | Bindings | Try | Catch | Finally | Cleanup |
+|:-:|---|:-:|:-:|:-:|:-:|:-:|
+| 1 | [setup-organization](#step-setup-organization) | 0 | 2 | 0 | 0 | 0 |
+| 2 | [create-project](#step-create-project) | 0 | 2 | 0 | 0 | 0 |
+| 3 | [create-namespace-in-project](#step-create-namespace-in-project) | 0 | 2 | 0 | 0 | 0 |
+| 4 | [create-clusternote-referencing-namespace](#step-create-clusternote-referencing-namespace) | 0 | 2 | 0 | 0 | 0 |
+| 5 | [delete-namespace-verify-clusternote-deletion](#step-delete-namespace-verify-clusternote-deletion) | 0 | 2 | 0 | 0 | 0 |
+
+### Step: `setup-organization`
+
+Create test organization
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-project`
+
+Create project in org control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-namespace-in-project`
+
+Create cluster-scoped Namespace in project control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-clusternote-referencing-namespace`
+
+Create ClusterNote referencing the Namespace in project control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `delete-namespace-verify-clusternote-deletion`
+
+Delete Namespace and verify ClusterNote is garbage collected
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `delete` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+---
+
diff --git a/test/notes/note-multicluster-subject/README.md b/test/notes/note-multicluster-subject/README.md
new file mode 100644
index 00000000..06ca26d3
--- /dev/null
+++ b/test/notes/note-multicluster-subject/README.md
@@ -0,0 +1,77 @@
+# Test: `note-multicluster-subject`
+
+Tests that Notes can reference subjects (ConfigMaps) in project control planes.
+
+Validates:
+- Note created in project control plane can reference ConfigMap in same control plane
+- Owner reference is correctly set on the Note
+- Note is garbage collected when ConfigMap is deleted
+
+
+## Steps
+
+| # | Name | Bindings | Try | Catch | Finally | Cleanup |
+|:-:|---|:-:|:-:|:-:|:-:|:-:|
+| 1 | [setup-organization](#step-setup-organization) | 0 | 2 | 0 | 0 | 0 |
+| 2 | [create-project](#step-create-project) | 0 | 2 | 0 | 0 | 0 |
+| 3 | [create-configmap-in-project](#step-create-configmap-in-project) | 0 | 2 | 0 | 0 | 0 |
+| 4 | [create-note-referencing-configmap](#step-create-note-referencing-configmap) | 0 | 2 | 0 | 0 | 0 |
+| 5 | [delete-configmap-verify-note-deletion](#step-delete-configmap-verify-note-deletion) | 0 | 2 | 0 | 0 | 0 |
+
+### Step: `setup-organization`
+
+Create test organization
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-project`
+
+Create project in org control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-configmap-in-project`
+
+Create ConfigMap resource in project control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `create-note-referencing-configmap`
+
+Create Note referencing the ConfigMap in project control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `delete-configmap-verify-note-deletion`
+
+Delete ConfigMap and verify Note is garbage collected
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `delete` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+---
+
diff --git a/test/quota/structured-type-enforcement/README.md b/test/quota/structured-type-enforcement/README.md
new file mode 100644
index 00000000..3a39cb6e
--- /dev/null
+++ b/test/quota/structured-type-enforcement/README.md
@@ -0,0 +1,205 @@
+# Test: `structured-type-enforcement`
+
+Tests quota enforcement for EndpointSlice (a structured/native k8s type)
+in a project control plane.
+
+EndpointSlice is a native Kubernetes type that arrives at the admission
+plugin as a Go struct (*discoveryv1.EndpointSlice), not as
+*unstructured.Unstructured. The admission plugin must convert it to
+unstructured with correct JSON field names (metadata, not objectMeta)
+for CEL template expressions like trigger.metadata.name to work.
+
+This test uses a deterministic claim name template
+"endpointslice-{{ trigger.metadata.name }}" to verify the CEL
+evaluation works correctly for structured types.
+
+
+## Steps
+
+| # | Name | Bindings | Try | Catch | Finally | Cleanup |
+|:-:|---|:-:|:-:|:-:|:-:|:-:|
+| 1 | [setup-resource-registration](#step-setup-resource-registration) | 0 | 2 | 0 | 0 | 0 |
+| 2 | [setup-grant-creation-policy](#step-setup-grant-creation-policy) | 0 | 2 | 0 | 0 | 0 |
+| 3 | [setup-test-organization](#step-setup-test-organization) | 0 | 2 | 0 | 0 | 0 |
+| 4 | [create-project-in-org](#step-create-project-in-org) | 0 | 2 | 0 | 0 | 0 |
+| 5 | [verify-grant-for-project](#step-verify-grant-for-project) | 0 | 1 | 0 | 0 | 0 |
+| 6 | [verify-bucket-pre-created](#step-verify-bucket-pre-created) | 0 | 1 | 0 | 0 | 0 |
+| 7 | [setup-claim-creation-policy](#step-setup-claim-creation-policy) | 0 | 2 | 0 | 0 | 0 |
+| 8 | [create-endpointslice-1](#step-create-endpointslice-1) | 0 | 2 | 2 | 0 | 0 |
+| 9 | [verify-claim-for-endpointslice-1](#step-verify-claim-for-endpointslice-1) | 0 | 1 | 0 | 0 | 0 |
+| 10 | [verify-bucket-usage-1-of-5](#step-verify-bucket-usage-1-of-5) | 0 | 1 | 0 | 0 | 0 |
+| 11 | [create-endpointslice-2](#step-create-endpointslice-2) | 0 | 1 | 0 | 0 | 0 |
+| 12 | [verify-claim-for-endpointslice-2](#step-verify-claim-for-endpointslice-2) | 0 | 1 | 0 | 0 | 0 |
+| 13 | [verify-bucket-usage-2-of-5](#step-verify-bucket-usage-2-of-5) | 0 | 1 | 0 | 0 | 0 |
+| 14 | [delete-endpointslice-1](#step-delete-endpointslice-1) | 0 | 1 | 0 | 0 | 0 |
+| 15 | [verify-bucket-after-deletion](#step-verify-bucket-after-deletion) | 0 | 1 | 0 | 0 | 0 |
+
+### Step: `setup-resource-registration`
+
+Register EndpointSlice resource type for quota tracking
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `setup-grant-creation-policy`
+
+Create GrantCreationPolicy to grant EndpointSlice quota to projects
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `setup-test-organization`
+
+Create test organization
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-project-in-org`
+
+Create project in org control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `verify-grant-for-project`
+
+Confirm grant is created in project control plane
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `wait` | 0 | 0 | *No description* |
+
+### Step: `verify-bucket-pre-created`
+
+Verify AllowanceBucket shows 5 available EndpointSlices
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `assert` | 0 | 0 | *No description* |
+
+### Step: `setup-claim-creation-policy`
+
+Register ClaimCreationPolicy for EndpointSlices with deterministic name
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+
+### Step: `create-endpointslice-1`
+
+Create EndpointSlice in project control plane.
+This is the critical test: EndpointSlice is a native k8s type that
+arrives as a structured Go type in admission. The CEL template
+trigger.metadata.name must resolve correctly.
+
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `sleep` | 0 | 0 | *No description* |
+| 2 | `apply` | 0 | 0 | *No description* |
+
+#### Catch
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `script` | 0 | 0 | *No description* |
+| 2 | `script` | 0 | 0 | *No description* |
+
+### Step: `verify-claim-for-endpointslice-1`
+
+Confirm ResourceClaim with deterministic name is created and granted
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `wait` | 0 | 0 | *No description* |
+
+### Step: `verify-bucket-usage-1-of-5`
+
+Verify bucket shows 1 EndpointSlice allocated
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `assert` | 0 | 0 | *No description* |
+
+### Step: `create-endpointslice-2`
+
+Create second EndpointSlice
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+
+### Step: `verify-claim-for-endpointslice-2`
+
+Confirm second claim with deterministic name is created and granted
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `wait` | 0 | 0 | *No description* |
+
+### Step: `verify-bucket-usage-2-of-5`
+
+Verify bucket shows 2 EndpointSlices allocated
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `assert` | 0 | 0 | *No description* |
+
+### Step: `delete-endpointslice-1`
+
+Delete first EndpointSlice to free quota
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `delete` | 0 | 0 | *No description* |
+
+### Step: `verify-bucket-after-deletion`
+
+Verify bucket shows quota freed after deletion
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `assert` | 0 | 0 | *No description* |
+
+---
+