diff --git a/cmd/milo/controller-manager/controllermanager.go b/cmd/milo/controller-manager/controllermanager.go
index db08a78e0..dc5d0630f 100644
--- a/cmd/milo/controller-manager/controllermanager.go
+++ b/cmd/milo/controller-manager/controllermanager.go
@@ -143,6 +143,9 @@ var (
// EmailDomainJoiningRecheckInterval is how often an organization offering
// membership by email domain has its project Domains checked again.
EmailDomainJoiningRecheckInterval time.Duration
+ // OrganizationDomainRecordPrefix is the label in front of the domain in the
+ // TXT record name an OrganizationDomain asks its owners to add.
+ OrganizationDomainRecordPrefix string
// UserWaitlistPendingEmailTemplate is the template for the waitlist pending email.
UserWaitlistPendingEmailTemplate string
@@ -334,6 +337,7 @@ func NewCommand() *cobra.Command {
fs.StringVar(&AcceptInvitationRoleName, "accept-invitation-role-name", "iam.miloapis.com-acceptinvitation", "The name of the role that will be used to grant accept invitation permissions.")
fs.StringVar(&UserInvitationEmailTemplate, "user-invitation-email-template", "emailtemplates.notification.miloapis.com-userinvitationemailtemplate", "The name of the template that will be used to send the user invitation email.")
fs.DurationVar(&EmailDomainJoiningRecheckInterval, "email-domain-joining-recheck-interval", 10*time.Minute, "How often an organization that offers membership by email domain has its project Domains checked again. Only used when the EmailDomainJoining feature gate is enabled.")
+ fs.StringVar(&OrganizationDomainRecordPrefix, "organization-domain-record-prefix", resourcemanagercontroller.DefaultOrganizationDomainRecordPrefix, "The label in front of the domain in the DNS TXT record name that verifies an OrganizationDomain, such as _milo-verification for _milo-verification.acme.com. Only used when the EmailDomainJoining feature gate is enabled.")
fs.StringVar(&OrganizationMemberJoinedEmailTemplate, "organization-member-joined-email-template", "emailtemplates.notification.miloapis.com-organizationmemberjoinedemailtemplate", "The name of the template that will be used to tell organization owners that someone joined. Only used when the MemberJoinedEmails feature gate is enabled; set to an empty string to send no such emails.")
fs.StringVar(&UserWaitlistPendingEmailTemplate, "user-waitlist-pending-email-template", "emailtemplates.notification.miloapis.com-userwaitlistemailtemplate", "The name of the template that will be used to send the waitlist pending email.")
fs.StringVar(&UserWaitlistApprovedEmailTemplate, "user-waitlist-approved-email-template", "emailtemplates.notification.miloapis.com-userwelcomeemailtemplate", "The name of the template that will be used to send the waitlist approved email.")
@@ -809,6 +813,15 @@ func Run(ctx context.Context, c *config.CompletedConfig, opts *Options) error {
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
+ organizationDomainCtrl := resourcemanagercontroller.OrganizationDomainController{
+ Client: ctrl.GetClient(),
+ RecordPrefix: OrganizationDomainRecordPrefix,
+ }
+ if err := organizationDomainCtrl.SetupWithManager(ctrl); err != nil {
+ logger.Error(err, "Error setting up organization domain controller")
+ klog.FlushAndExit(klog.ExitFlushTimeout, 1)
+ }
+
noteCtrl := notescontroller.NoteController{
Client: ctrl.GetClient(),
CreatorEditorRoleName: NoteCreatorEditorRoleName,
diff --git a/cmd/milo/controller-manager/webhooks.go b/cmd/milo/controller-manager/webhooks.go
index 06d6f39df..e4ca754d9 100644
--- a/cmd/milo/controller-manager/webhooks.go
+++ b/cmd/milo/controller-manager/webhooks.go
@@ -94,6 +94,9 @@ func registerCoreControlPlaneWebhooksWithoutNotes(mgr controllerruntime.Manager)
if err := resourcemanagerv1alpha1webhook.SetupOrganizationMembershipWebhooksWithManager(mgr, OrganizationOwnerRoleName, OrganizationOwnerRoleNamespace); err != nil {
return fmt.Errorf("setting up organizationmembership webhook: %w", err)
}
+ if err := resourcemanagerv1alpha1webhook.SetupOrganizationDomainWebhooksWithManager(mgr); err != nil {
+ return fmt.Errorf("setting up organizationdomain webhook: %w", err)
+ }
if err := iamv1alpha1webhook.SetupUserWebhooksWithManager(mgr, SystemNamespace, "iam-user-self-manage"); err != nil {
return fmt.Errorf("setting up user webhook: %w", err)
}
diff --git a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml
index 4c80ed9de..cf44ce199 100644
--- a/config/controller-manager/overlays/core-control-plane/rbac/role.yaml
+++ b/config/controller-manager/overlays/core-control-plane/rbac/role.yaml
@@ -295,16 +295,16 @@ rules:
- apiGroups:
- resourcemanager.miloapis.com
resources:
- - organizationmemberships
+ - organizationdomains
+ - projectsuspensions
verbs:
- - create
- - delete
- get
- list
- watch
- apiGroups:
- resourcemanager.miloapis.com
resources:
+ - organizationdomains/status
- organizationmemberships/status
- organizations/status
- projects/status
@@ -312,6 +312,16 @@ rules:
- get
- patch
- update
+- apiGroups:
+ - resourcemanager.miloapis.com
+ resources:
+ - organizationmemberships
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - watch
- apiGroups:
- resourcemanager.miloapis.com
resources:
@@ -340,11 +350,3 @@ rules:
- patch
- update
- watch
-- apiGroups:
- - resourcemanager.miloapis.com
- resources:
- - projectsuspensions
- verbs:
- - get
- - list
- - watch
diff --git a/config/crd/bases/resourcemanager/kustomization.yaml b/config/crd/bases/resourcemanager/kustomization.yaml
index d67ee33af..0a3ee3712 100644
--- a/config/crd/bases/resourcemanager/kustomization.yaml
+++ b/config/crd/bases/resourcemanager/kustomization.yaml
@@ -2,3 +2,4 @@ resources:
- resourcemanager.miloapis.com_organizations.yaml
- resourcemanager.miloapis.com_projects.yaml
- resourcemanager.miloapis.com_organizationmemberships.yaml
+- resourcemanager.miloapis.com_organizationdomains.yaml
diff --git a/config/crd/bases/resourcemanager/resourcemanager.miloapis.com_organizationdomains.yaml b/config/crd/bases/resourcemanager/resourcemanager.miloapis.com_organizationdomains.yaml
new file mode 100644
index 000000000..a6a729283
--- /dev/null
+++ b/config/crd/bases/resourcemanager/resourcemanager.miloapis.com_organizationdomains.yaml
@@ -0,0 +1,199 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.22.0
+ discovery.miloapis.com/parent-contexts: Organization
+ name: organizationdomains.resourcemanager.miloapis.com
+spec:
+ group: resourcemanager.miloapis.com
+ names:
+ kind: OrganizationDomain
+ listKind: OrganizationDomainList
+ plural: organizationdomains
+ singular: organizationdomain
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.domainName
+ name: Domain
+ type: string
+ - jsonPath: .status.conditions[?(@.type=='Verified')].status
+ name: Verified
+ type: string
+ - jsonPath: .status.verification.dnsRecord.name
+ name: Record
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: "OrganizationDomain proves that an organization controls a domain
+ name, such\nas the domain of its company's email addresses. It belongs to
+ the\norganization rather than to one of its projects, so the domain doesn't
+ have\nto be added to a project to be used.\n\nMilo publishes a DNS TXT record
+ in status.verification.dnsRecord. Once the\nrecord is found at the domain,
+ the Verified condition becomes True and stays\nTrue. The record can then
+ be removed.\n\nThe resource is named after its domain, so an organization
+ has at most one\nfor each domain. Verified domains, and their subdomains,
+ can be used for\nemail domain joining.\n\nExample:\n\n\tapiVersion: resourcemanager.miloapis.com/v1alpha1\n\tkind:
+ OrganizationDomain\n\tmetadata:\n\t name: acme.com\n\t namespace: organization-acme-corp\n\tspec:\n\t
+ \ domainName: acme.com"
+ 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: OrganizationDomainSpec defines the domain an organization
+ wants to verify.
+ properties:
+ domainName:
+ description: |-
+ DomainName is the domain to verify, in lower case, such as acme.com or
+ eu.acme.com. It must match metadata.name and can't be changed.
+ maxLength: 253
+ minLength: 3
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)+$
+ type: string
+ x-kubernetes-validations:
+ - message: domainName is immutable
+ rule: self == oldSelf
+ required:
+ - domainName
+ type: object
+ status:
+ description: OrganizationDomainStatus reports how verification is going.
+ properties:
+ conditions:
+ default:
+ - lastTransitionTime: "1970-01-01T00:00:00Z"
+ message: Waiting for the control plane to publish a verification
+ record
+ reason: Pending
+ status: Unknown
+ type: Verified
+ description: Conditions holds the Verified condition.
+ 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
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ verification:
+ description: |-
+ Verification holds the DNS record to add to the domain. It is cleared
+ once the domain is verified.
+ properties:
+ dnsRecord:
+ description: DNSRecord is the TXT record to add at the domain's
+ DNS provider.
+ properties:
+ content:
+ description: Content is the value the record must hold.
+ type: string
+ name:
+ description: |-
+ Name is the fully qualified record name, such as
+ _milo-verification.acme.com.
+ type: string
+ type:
+ description: Type is the record type. It is always TXT.
+ type: string
+ required:
+ - content
+ - name
+ - type
+ type: object
+ lastAttemptTime:
+ description: LastAttemptTime is when Milo last looked for the
+ record.
+ format: date-time
+ type: string
+ nextAttemptTime:
+ description: NextAttemptTime is when Milo will look for the record
+ again.
+ format: date-time
+ type: string
+ required:
+ - dnsRecord
+ type: object
+ type: object
+ required:
+ - spec
+ type: object
+ selectableFields:
+ - jsonPath: .spec.domainName
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/protected-resources/resourcemanager/kustomization.yaml b/config/protected-resources/resourcemanager/kustomization.yaml
index 1a41de899..9dfb89b38 100644
--- a/config/protected-resources/resourcemanager/kustomization.yaml
+++ b/config/protected-resources/resourcemanager/kustomization.yaml
@@ -3,6 +3,7 @@ kind: Kustomization
resources:
- organization.yaml
+ - organizationdomain.yaml
- organizationmembership.yaml
- project.yaml
- projectsuspension.yaml
diff --git a/config/protected-resources/resourcemanager/organizationdomain.yaml b/config/protected-resources/resourcemanager/organizationdomain.yaml
new file mode 100644
index 000000000..bf89d0e6a
--- /dev/null
+++ b/config/protected-resources/resourcemanager/organizationdomain.yaml
@@ -0,0 +1,27 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: resourcemanager.miloapis.com-organizationdomain
+spec:
+ serviceRef:
+ name: "resourcemanager.miloapis.com"
+ kind: OrganizationDomain
+ plural: organizationdomains
+ singular: organizationdomain
+ subresources:
+ - name: status
+ permissions:
+ - get
+ - update
+ - patch
+ permissions:
+ - list
+ - get
+ - create
+ - update
+ - delete
+ - patch
+ - watch
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Organization
diff --git a/config/roles/kustomization.yaml b/config/roles/kustomization.yaml
index 3626f14a9..76b874f54 100644
--- a/config/roles/kustomization.yaml
+++ b/config/roles/kustomization.yaml
@@ -49,6 +49,9 @@ resources:
- notification-contact-group-enrollment-policy-reader.yaml
- notification-contact-group-enrollment-policy-editor.yaml
- notification-contact-group-enrollment-policy-admin.yaml
+ - organizationdomain-reader.yaml
+ - organizationdomain-editor.yaml
+ - organizationdomain-admin.yaml
- organizationmembership-reader.yaml
- organizationmembership-editor.yaml
- organizationmembership-admin.yaml
diff --git a/config/roles/organization-admin.yaml b/config/roles/organization-admin.yaml
index e9bf58777..e8301072e 100644
--- a/config/roles/organization-admin.yaml
+++ b/config/roles/organization-admin.yaml
@@ -9,6 +9,7 @@ spec:
launchStage: Beta
inheritedRoles:
- name: organizationmembership-admin
+ - name: organizationdomain-admin
- name: resourcemanager.miloapis.com-organization-viewer
includedPermissions:
- resourcemanager.miloapis.com/organizations.create
diff --git a/config/roles/organization-viewer.yaml b/config/roles/organization-viewer.yaml
index cb107261d..6aee96fbe 100644
--- a/config/roles/organization-viewer.yaml
+++ b/config/roles/organization-viewer.yaml
@@ -9,6 +9,7 @@ spec:
launchStage: Beta
inheritedRoles:
- name: organizationmembership-reader
+ - name: organizationdomain-reader
includedPermissions:
- resourcemanager.miloapis.com/organizations.get
- resourcemanager.miloapis.com/organizations/status.get
diff --git a/config/roles/organizationdomain-admin.yaml b/config/roles/organizationdomain-admin.yaml
new file mode 100644
index 000000000..26c1943c1
--- /dev/null
+++ b/config/roles/organizationdomain-admin.yaml
@@ -0,0 +1,11 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: organizationdomain-admin
+ annotations:
+ kubernetes.io/display-name: Organization Domain Admin
+ kubernetes.io/description: "Full access to an organization's domains"
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: organizationdomain-editor
diff --git a/config/roles/organizationdomain-editor.yaml b/config/roles/organizationdomain-editor.yaml
new file mode 100644
index 000000000..86dbba28a
--- /dev/null
+++ b/config/roles/organizationdomain-editor.yaml
@@ -0,0 +1,16 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: organizationdomain-editor
+ annotations:
+ kubernetes.io/display-name: Organization Domain Editor
+ kubernetes.io/description: "Add and remove an organization's domains"
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: organizationdomain-reader
+ includedPermissions:
+ - resourcemanager.miloapis.com/organizationdomains.create
+ - resourcemanager.miloapis.com/organizationdomains.update
+ - resourcemanager.miloapis.com/organizationdomains.patch
+ - resourcemanager.miloapis.com/organizationdomains.delete
diff --git a/config/roles/organizationdomain-reader.yaml b/config/roles/organizationdomain-reader.yaml
new file mode 100644
index 000000000..4fd64cdff
--- /dev/null
+++ b/config/roles/organizationdomain-reader.yaml
@@ -0,0 +1,14 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: organizationdomain-reader
+ annotations:
+ kubernetes.io/display-name: Organization Domain Reader
+ kubernetes.io/description: "View an organization's domains and their verification records"
+spec:
+ launchStage: Beta
+ includedPermissions:
+ - resourcemanager.miloapis.com/organizationdomains.get
+ - resourcemanager.miloapis.com/organizationdomains/status.get
+ - resourcemanager.miloapis.com/organizationdomains.list
+ - resourcemanager.miloapis.com/organizationdomains.watch
diff --git a/config/roles/resourcemanager-admin.yaml b/config/roles/resourcemanager-admin.yaml
index 687f871c4..4c47f59d4 100644
--- a/config/roles/resourcemanager-admin.yaml
+++ b/config/roles/resourcemanager-admin.yaml
@@ -9,3 +9,4 @@ spec:
launchStage: Beta
inheritedRoles:
- name: resourcemanager-editor
+ - name: organizationdomain-admin
diff --git a/config/roles/resourcemanager-reader.yaml b/config/roles/resourcemanager-reader.yaml
index d3a16f6bc..fe446acfe 100644
--- a/config/roles/resourcemanager-reader.yaml
+++ b/config/roles/resourcemanager-reader.yaml
@@ -7,6 +7,8 @@ metadata:
kubernetes.io/description: View access to organizations and projects
spec:
launchStage: Beta
+ inheritedRoles:
+ - name: organizationdomain-reader
includedPermissions:
# Read-only permissions for ResourceManager resources
- resourcemanager.miloapis.com/organizations.get
diff --git a/config/samples/resourcemanager/v1alpha1/organizationdomain.yaml b/config/samples/resourcemanager/v1alpha1/organizationdomain.yaml
new file mode 100644
index 000000000..8e78e9125
--- /dev/null
+++ b/config/samples/resourcemanager/v1alpha1/organizationdomain.yaml
@@ -0,0 +1,10 @@
+# An organization proves it controls acme.com. Milo publishes the TXT record to
+# add in status.verification.dnsRecord and sets the Verified condition once it
+# finds the record.
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: acme.com
+ namespace: organization-acme-corp
+spec:
+ domainName: acme.com
diff --git a/config/services/activity/policies/resourcemanager/kustomization.yaml b/config/services/activity/policies/resourcemanager/kustomization.yaml
index d2e7d773d..2a8f6ef7e 100644
--- a/config/services/activity/policies/resourcemanager/kustomization.yaml
+++ b/config/services/activity/policies/resourcemanager/kustomization.yaml
@@ -7,4 +7,5 @@ resources:
- organization-policy.yaml
- project-policy.yaml
- organizationmembership-policy.yaml
+ - organizationdomain-policy.yaml
- projectsuspension-policy.yaml
diff --git a/config/services/activity/policies/resourcemanager/organizationdomain-policy.yaml b/config/services/activity/policies/resourcemanager/organizationdomain-policy.yaml
new file mode 100644
index 000000000..4733f831b
--- /dev/null
+++ b/config/services/activity/policies/resourcemanager/organizationdomain-policy.yaml
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+
+# ActivityPolicy for OrganizationDomain resources.
+# Defines how OrganizationDomain API operations appear in activity timelines.
+#
+# An OrganizationDomain is named after its domain, so audit.objectRef.name is
+# the domain for every verb, including delete, where audit.requestObject is a
+# DeleteOptions. No eventRules: the controller sets the Verified condition but
+# does not emit Kubernetes Events.
+#
+# All rules exclude system actors so controller reconciliation doesn't
+# generate activity noise.
+apiVersion: activity.miloapis.com/v1alpha1
+kind: ActivityPolicy
+metadata:
+ name: resourcemanager.miloapis.com-organizationdomain
+spec:
+ resource:
+ apiGroup: resourcemanager.miloapis.com
+ kind: OrganizationDomain
+
+ auditRules:
+ - name: create
+ match: "!audit.user.username.startsWith('system:') && audit.verb == 'create'"
+ summary: "{{ actor }} added the domain {{ audit.objectRef.name }} to the organization"
+
+ - name: delete
+ match: "!audit.user.username.startsWith('system:') && audit.verb == 'delete'"
+ summary: "{{ actor }} removed the domain {{ audit.objectRef.name }} from the organization"
+
+ - name: update
+ match: "!audit.user.username.startsWith('system:') && audit.verb in ['update', 'patch'] && !has(audit.objectRef.subresource)"
+ summary: "{{ actor }} updated the domain {{ link(audit.objectRef.name, audit.objectRef) }}"
diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml
index 86007b432..552adb4f3 100644
--- a/config/webhook/manifests.yaml
+++ b/config/webhook/manifests.yaml
@@ -504,6 +504,28 @@ webhooks:
resources:
- organizations
sideEffects: NoneOnDryRun
+- admissionReviewVersions:
+ - v1
+ - v1beta1
+ clientConfig:
+ service:
+ name: milo-controller-manager
+ namespace: milo-system
+ path: /validate-resourcemanager-miloapis-com-v1alpha1-organizationdomain
+ port: 9443
+ failurePolicy: Fail
+ name: vorganizationdomain.datum.net
+ rules:
+ - apiGroups:
+ - resourcemanager.miloapis.com
+ apiVersions:
+ - v1alpha1
+ operations:
+ - CREATE
+ - UPDATE
+ resources:
+ - organizationdomains
+ sideEffects: None
- admissionReviewVersions:
- v1
- v1beta1
diff --git a/docs/api/resourcemanager.md b/docs/api/resourcemanager.md
index 55da211ad..89a71a111 100644
--- a/docs/api/resourcemanager.md
+++ b/docs/api/resourcemanager.md
@@ -8,6 +8,8 @@ Packages:
Resource Types:
+- [OrganizationDomain](#organizationdomain)
+
- [OrganizationMembership](#organizationmembership)
- [Organization](#organization)
@@ -19,6 +21,312 @@ Resource Types:
+## OrganizationDomain
+[↩ Parent](#resourcemanagermiloapiscomv1alpha1 )
+
+
+
+
+
+
+OrganizationDomain proves that an organization controls a domain name, such
+as the domain of its company's email addresses. It belongs to the
+organization rather than to one of its projects, so the domain doesn't have
+to be added to a project to be used.
+
+Milo publishes a DNS TXT record in status.verification.dnsRecord. Once the
+record is found at the domain, the Verified condition becomes True and stays
+True. The record can then be removed.
+
+The resource is named after its domain, so an organization has at most one
+for each domain. Verified domains, and their subdomains, can be used for
+email domain joining.
+
+Example:
+
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: OrganizationDomain
+ metadata:
+ name: acme.com
+ namespace: organization-acme-corp
+ spec:
+ domainName: acme.com
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | apiVersion |
+ string |
+ resourcemanager.miloapis.com/v1alpha1 |
+ true |
+
+
+ | kind |
+ string |
+ OrganizationDomain |
+ true |
+
+
+ | metadata |
+ object |
+ Refer to the Kubernetes API documentation for the fields of the `metadata` field. |
+ true |
+
+ | spec |
+ object |
+
+ OrganizationDomainSpec defines the domain an organization wants to verify.
+ |
+ true |
+
+ | status |
+ object |
+
+ OrganizationDomainStatus reports how verification is going.
+ |
+ false |
+
+
+
+
+### OrganizationDomain.spec
+[↩ Parent](#organizationdomain)
+
+
+
+OrganizationDomainSpec defines the domain an organization wants to verify.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | domainName |
+ string |
+
+ DomainName is the domain to verify, in lower case, such as acme.com or
+eu.acme.com. It must match metadata.name and can't be changed.
+
+ Validations:self == oldSelf: domainName is immutable
+ |
+ true |
+
+
+
+
+### OrganizationDomain.status
+[↩ Parent](#organizationdomain)
+
+
+
+OrganizationDomainStatus reports how verification is going.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | conditions |
+ []object |
+
+ Conditions holds the Verified condition.
+
+ Default: [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for the control plane to publish a verification record reason:Pending status:Unknown type:Verified]]
+ |
+ false |
+
+ | verification |
+ object |
+
+ Verification holds the DNS record to add to the domain. It is cleared
+once the domain is verified.
+ |
+ false |
+
+
+
+
+### OrganizationDomain.status.conditions[index]
+[↩ Parent](#organizationdomainstatus)
+
+
+
+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 |
+
+
+
+
+### OrganizationDomain.status.verification
+[↩ Parent](#organizationdomainstatus)
+
+
+
+Verification holds the DNS record to add to the domain. It is cleared
+once the domain is verified.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | dnsRecord |
+ object |
+
+ DNSRecord is the TXT record to add at the domain's DNS provider.
+ |
+ true |
+
+ | lastAttemptTime |
+ string |
+
+ LastAttemptTime is when Milo last looked for the record.
+
+ Format: date-time
+ |
+ false |
+
+ | nextAttemptTime |
+ string |
+
+ NextAttemptTime is when Milo will look for the record again.
+
+ Format: date-time
+ |
+ false |
+
+
+
+
+### OrganizationDomain.status.verification.dnsRecord
+[↩ Parent](#organizationdomainstatusverification)
+
+
+
+DNSRecord is the TXT record to add at the domain's DNS provider.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | content |
+ string |
+
+ Content is the value the record must hold.
+ |
+ true |
+
+ | name |
+ string |
+
+ Name is the fully qualified record name, such as
+_milo-verification.acme.com.
+ |
+ true |
+
+ | type |
+ string |
+
+ Type is the record type. It is always TXT.
+ |
+ true |
+
+
+
## OrganizationMembership
[↩ Parent](#resourcemanagermiloapiscomv1alpha1 )
diff --git a/go.mod b/go.mod
index 7303a36a6..48e3b24fd 100644
--- a/go.mod
+++ b/go.mod
@@ -158,7 +158,7 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
- golang.org/x/net v0.62.0 // indirect
+ golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.47.0 // indirect
diff --git a/internal/controllers/iam/emaildomainjoining_controller.go b/internal/controllers/iam/emaildomainjoining_controller.go
index 3d45d2732..728756c92 100644
--- a/internal/controllers/iam/emaildomainjoining_controller.go
+++ b/internal/controllers/iam/emaildomainjoining_controller.go
@@ -37,8 +37,9 @@ const (
// EmailDomainJoiningController offers membership of an organization to every
// User at one of its spec.emailDomainJoining domains, as UserInvitations
-// labelled emaildomain.JoinMethodLabel. It offers only domains that a
-// verified Domain in one of the organization's projects covers, reports that
+// labelled emaildomain.JoinMethodLabel. It offers only domains that one of
+// the organization's verified OrganizationDomains, or a verified Domain in one
+// of its projects, covers, reports that
// in the EmailDomainsVerified condition, and removes pending offers for
// domains that are no longer listed or verified.
//
@@ -59,6 +60,7 @@ type EmailDomainJoiningController struct {
// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizations/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationmemberships,verbs=get;list;watch
// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=projects,verbs=get;list;watch
+// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationdomains,verbs=get;list;watch
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=users,verbs=get;list;watch
// +kubebuilder:rbac:groups=iam.miloapis.com,resources=userinvitations,verbs=get;list;watch;create;delete
@@ -270,6 +272,11 @@ func (r *EmailDomainJoiningController) SetupWithManager(mgr ctrl.Manager) error
handler.EnqueueRequestsFromMapFunc(r.findOrganizationsForUser),
builder.WithPredicates(userEmailChangedPredicate),
).
+ Watches(
+ &resourcemanagerv1alpha1.OrganizationDomain{},
+ handler.EnqueueRequestsFromMapFunc(organizationForDomain),
+ builder.WithPredicates(organizationDomainVerifiedChangedPredicate),
+ ).
Named("emaildomainjoining").
Complete(r)
}
@@ -298,6 +305,34 @@ func (r *EmailDomainJoiningController) findOrganizationsForUser(ctx context.Cont
return requests
}
+// organizationForDomain enqueues the organization whose namespace holds the
+// OrganizationDomain.
+func organizationForDomain(_ context.Context, obj client.Object) []reconcile.Request {
+ org, ok := strings.CutPrefix(obj.GetNamespace(), "organization-")
+ if !ok || org == "" {
+ return nil
+ }
+ return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: org}}}
+}
+
+// organizationDomainVerifiedChangedPredicate passes OrganizationDomains that
+// are created or deleted, or whose Verified condition changed.
+var organizationDomainVerifiedChangedPredicate = predicate.Funcs{
+ CreateFunc: func(e event.CreateEvent) bool { return true },
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldDomain, okOld := e.ObjectOld.(*resourcemanagerv1alpha1.OrganizationDomain)
+ newDomain, okNew := e.ObjectNew.(*resourcemanagerv1alpha1.OrganizationDomain)
+ if !okOld || !okNew {
+ return false
+ }
+ return meta.IsStatusConditionTrue(oldDomain.Status.Conditions, resourcemanagerv1alpha1.OrganizationDomainConditionVerified) !=
+ meta.IsStatusConditionTrue(newDomain.Status.Conditions, resourcemanagerv1alpha1.OrganizationDomainConditionVerified) ||
+ (oldDomain.DeletionTimestamp == nil) != (newDomain.DeletionTimestamp == nil)
+ },
+ DeleteFunc: func(e event.DeleteEvent) bool { return true },
+ GenericFunc: func(e event.GenericEvent) bool { return false },
+}
+
// userEmailChangedPredicate passes new Users and Users whose email changed.
var userEmailChangedPredicate = predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return true },
diff --git a/internal/controllers/iam/emaildomainjoining_controller_test.go b/internal/controllers/iam/emaildomainjoining_controller_test.go
index 303e4bc64..0edb1095e 100644
--- a/internal/controllers/iam/emaildomainjoining_controller_test.go
+++ b/internal/controllers/iam/emaildomainjoining_controller_test.go
@@ -15,6 +15,7 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/event"
"go.miloapis.com/milo/internal/emaildomain"
iamv1alpha1 "go.miloapis.com/milo/pkg/apis/iam/v1alpha1"
@@ -299,3 +300,45 @@ func TestEmailDomainJoiningController_GateOff(t *testing.T) {
t.Error("offer created with the feature gate off")
}
}
+
+func TestOrganizationForDomain(t *testing.T) {
+ for namespace, want := range map[string]string{
+ "organization-acme": "acme",
+ "organization-": "",
+ "default": "",
+ } {
+ domain := &resourcemanagerv1alpha1.OrganizationDomain{ObjectMeta: metav1.ObjectMeta{Name: "acme.com", Namespace: namespace}}
+ requests := organizationForDomain(context.Background(), domain)
+ got := ""
+ if len(requests) == 1 {
+ got = requests[0].Name
+ }
+ if got != want || len(requests) > 1 {
+ t.Errorf("organizationForDomain(%q) = %v, want %q", namespace, requests, want)
+ }
+ }
+}
+
+func TestOrganizationDomainVerifiedChangedPredicate(t *testing.T) {
+ domain := func(verified metav1.ConditionStatus) *resourcemanagerv1alpha1.OrganizationDomain {
+ return &resourcemanagerv1alpha1.OrganizationDomain{
+ Status: resourcemanagerv1alpha1.OrganizationDomainStatus{Conditions: []metav1.Condition{{
+ Type: resourcemanagerv1alpha1.OrganizationDomainConditionVerified,
+ Status: verified,
+ }}},
+ }
+ }
+ if !organizationDomainVerifiedChangedPredicate.Update(event.UpdateEvent{
+ ObjectOld: domain(metav1.ConditionFalse), ObjectNew: domain(metav1.ConditionTrue),
+ }) {
+ t.Error("a domain becoming verified should reconcile its organization")
+ }
+ if organizationDomainVerifiedChangedPredicate.Update(event.UpdateEvent{
+ ObjectOld: domain(metav1.ConditionFalse), ObjectNew: domain(metav1.ConditionFalse),
+ }) {
+ t.Error("another failed lookup shouldn't reconcile the organization")
+ }
+ if !organizationDomainVerifiedChangedPredicate.Delete(event.DeleteEvent{Object: domain(metav1.ConditionTrue)}) {
+ t.Error("removing a domain should reconcile its organization")
+ }
+}
diff --git a/internal/controllers/resourcemanager/organization_domain_controller.go b/internal/controllers/resourcemanager/organization_domain_controller.go
new file mode 100644
index 000000000..9d61fe008
--- /dev/null
+++ b/internal/controllers/resourcemanager/organization_domain_controller.go
@@ -0,0 +1,209 @@
+package resourcemanager
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net"
+ "slices"
+ "strings"
+ "time"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+
+ resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
+ "go.miloapis.com/milo/pkg/features"
+)
+
+const (
+ // DefaultOrganizationDomainRecordPrefix is the label in front of the domain
+ // in an OrganizationDomain's TXT record name.
+ DefaultOrganizationDomainRecordPrefix = "_milo-verification"
+
+ organizationDomainLookupTimeout = 5 * time.Second
+)
+
+// OrganizationDomainController verifies OrganizationDomains. It publishes a
+// TXT record for each one, looks for that record at the domain, and sets the
+// Verified condition once it finds it. A verified domain stays verified, so
+// the record can be removed afterwards.
+type OrganizationDomainController struct {
+ Client client.Client
+
+ // RecordPrefix is the label in front of the domain in the TXT record name.
+ // Defaults to DefaultOrganizationDomainRecordPrefix.
+ RecordPrefix string
+
+ // LookupTXT looks up TXT records. Defaults to net.DefaultResolver.
+ LookupTXT func(ctx context.Context, name string) ([]string, error)
+
+ // Now returns the current time. Defaults to time.Now.
+ Now func() time.Time
+}
+
+// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationdomains,verbs=get;list;watch
+// +kubebuilder:rbac:groups=resourcemanager.miloapis.com,resources=organizationdomains/status,verbs=get;update;patch
+
+func (r *OrganizationDomainController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ if !utilfeature.DefaultFeatureGate.Enabled(features.EmailDomainJoining) {
+ return ctrl.Result{}, nil
+ }
+ log := logf.FromContext(ctx).WithName("organizationdomain-reconciler")
+
+ domain := &resourcemanagerv1alpha1.OrganizationDomain{}
+ if err := r.Client.Get(ctx, req.NamespacedName, domain); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if domain.DeletionTimestamp != nil ||
+ apimeta.IsStatusConditionTrue(domain.Status.Conditions, resourcemanagerv1alpha1.OrganizationDomainConditionVerified) {
+ return ctrl.Result{}, nil
+ }
+
+ now := r.now()
+ status := domain.Status.DeepCopy()
+ recordName := r.recordName(domain.Spec.DomainName)
+
+ // Publish the record to add. Keep an existing token so a record the owner
+ // already added stays valid.
+ if status.Verification == nil || status.Verification.DNSRecord.Content == "" {
+ token, err := newVerificationToken()
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ status.Verification = &resourcemanagerv1alpha1.OrganizationDomainVerification{
+ DNSRecord: resourcemanagerv1alpha1.OrganizationDomainDNSRecord{Content: token},
+ }
+ }
+ status.Verification.DNSRecord.Name = recordName
+ status.Verification.DNSRecord.Type = "TXT"
+
+ // Wait for the next attempt unless the record has just been published.
+ published := domain.Status.Verification != nil &&
+ domain.Status.Verification.DNSRecord == status.Verification.DNSRecord
+ if next := status.Verification.NextAttemptTime; published && next != nil && next.After(now) {
+ return ctrl.Result{RequeueAfter: next.Sub(now)}, nil
+ }
+
+ condition := r.check(ctx, status.Verification.DNSRecord)
+ condition.ObservedGeneration = domain.Generation
+ apimeta.SetStatusCondition(&status.Conditions, condition)
+
+ result := ctrl.Result{}
+ if condition.Status == metav1.ConditionTrue {
+ status.Verification = nil
+ log.Info("Verified OrganizationDomain", "domain", domain.Spec.DomainName)
+ } else {
+ interval := verificationInterval(now.Sub(domain.CreationTimestamp.Time))
+ status.Verification.LastAttemptTime = &metav1.Time{Time: now}
+ status.Verification.NextAttemptTime = &metav1.Time{Time: now.Add(interval)}
+ result.RequeueAfter = interval
+ }
+
+ domain.Status = *status
+ if err := r.Client.Status().Update(ctx, domain); err != nil {
+ if apierrors.IsConflict(err) {
+ return ctrl.Result{Requeue: true}, nil
+ }
+ return ctrl.Result{}, fmt.Errorf("failed to update OrganizationDomain status: %w", err)
+ }
+ return result, nil
+}
+
+// check looks for the TXT record and returns the Verified condition.
+func (r *OrganizationDomainController) check(ctx context.Context, record resourcemanagerv1alpha1.OrganizationDomainDNSRecord) metav1.Condition {
+ condition := metav1.Condition{
+ Type: resourcemanagerv1alpha1.OrganizationDomainConditionVerified,
+ Status: metav1.ConditionFalse,
+ }
+
+ lookupCtx, cancel := context.WithTimeout(ctx, organizationDomainLookupTimeout)
+ defer cancel()
+ values, err := r.lookupTXT(lookupCtx, record.Name)
+
+ var dnsErr *net.DNSError
+ switch {
+ case err == nil && slices.ContainsFunc(values, func(v string) bool { return strings.TrimSpace(v) == record.Content }):
+ condition.Status = metav1.ConditionTrue
+ condition.Reason = resourcemanagerv1alpha1.OrganizationDomainReasonVerified
+ condition.Message = fmt.Sprintf("Found the TXT record %s", record.Name)
+ case (err == nil && len(values) == 0) || (errors.As(err, &dnsErr) && dnsErr.IsNotFound):
+ condition.Reason = resourcemanagerv1alpha1.OrganizationDomainReasonRecordNotFound
+ condition.Message = fmt.Sprintf(
+ "Add a TXT record named %s with the value %s at your DNS provider. "+
+ "Milo checks for it regularly, and new records can take a while to appear.",
+ record.Name, record.Content)
+ case err == nil:
+ condition.Reason = resourcemanagerv1alpha1.OrganizationDomainReasonRecordMismatch
+ condition.Message = fmt.Sprintf(
+ "The TXT record %s doesn't hold the value %s yet. Add a TXT record with that value; "+
+ "other values at the same name can stay.",
+ record.Name, record.Content)
+ default:
+ condition.Reason = resourcemanagerv1alpha1.OrganizationDomainReasonLookupFailed
+ condition.Message = fmt.Sprintf("Couldn't look up the TXT record %s, so Milo will try again: %v", record.Name, err)
+ }
+ return condition
+}
+
+func (r *OrganizationDomainController) recordName(domainName string) string {
+ prefix := r.RecordPrefix
+ if prefix == "" {
+ prefix = DefaultOrganizationDomainRecordPrefix
+ }
+ return prefix + "." + domainName
+}
+
+func (r *OrganizationDomainController) lookupTXT(ctx context.Context, name string) ([]string, error) {
+ if r.LookupTXT != nil {
+ return r.LookupTXT(ctx, name)
+ }
+ return net.DefaultResolver.LookupTXT(ctx, name)
+}
+
+func (r *OrganizationDomainController) now() time.Time {
+ if r.Now != nil {
+ return r.Now()
+ }
+ return time.Now()
+}
+
+// verificationInterval is how long to wait before looking for the record
+// again. Owners usually add the record soon after adding the domain, so look
+// often at first and back off for domains that stay unverified.
+func verificationInterval(age time.Duration) time.Duration {
+ switch {
+ case age < time.Hour:
+ return time.Minute
+ case age < 24*time.Hour:
+ return 5 * time.Minute
+ case age < 7*24*time.Hour:
+ return 30 * time.Minute
+ default:
+ return 6 * time.Hour
+ }
+}
+
+func newVerificationToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("failed to generate verification token: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
+
+func (r *OrganizationDomainController) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&resourcemanagerv1alpha1.OrganizationDomain{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
+ Named("organization-domain").
+ Complete(r)
+}
diff --git a/internal/controllers/resourcemanager/organization_domain_controller_test.go b/internal/controllers/resourcemanager/organization_domain_controller_test.go
new file mode 100644
index 000000000..0063ef908
--- /dev/null
+++ b/internal/controllers/resourcemanager/organization_domain_controller_test.go
@@ -0,0 +1,249 @@
+package resourcemanager
+
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ featuregatetesting "k8s.io/component-base/featuregate/testing"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
+ "go.miloapis.com/milo/pkg/features"
+)
+
+var organizationDomainKey = client.ObjectKey{Namespace: "organization-acme", Name: "acme.com"}
+
+// dnsZone fakes the TXT records at each name. A name that isn't listed
+// doesn't exist.
+type dnsZone struct {
+ records map[string][]string
+ err error
+ lookups int
+}
+
+func (z *dnsZone) lookupTXT(_ context.Context, name string) ([]string, error) {
+ z.lookups++
+ if z.err != nil {
+ return nil, z.err
+ }
+ values, ok := z.records[name]
+ if !ok {
+ return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
+ }
+ return values, nil
+}
+
+func newOrganizationDomainTest(t *testing.T, zone *dnsZone, now time.Time, objs ...client.Object) (*OrganizationDomainController, client.Client) {
+ t.Helper()
+ featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.EmailDomainJoining, true)
+
+ scheme := runtime.NewScheme()
+ utilruntime.Must(resourcemanagerv1alpha1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(objs...).
+ WithStatusSubresource(&resourcemanagerv1alpha1.OrganizationDomain{}).
+ Build()
+ return &OrganizationDomainController{
+ Client: c,
+ LookupTXT: zone.lookupTXT,
+ Now: func() time.Time { return now },
+ }, c
+}
+
+func newOrganizationDomain(created time.Time) *resourcemanagerv1alpha1.OrganizationDomain {
+ return &resourcemanagerv1alpha1.OrganizationDomain{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: organizationDomainKey.Name,
+ Namespace: organizationDomainKey.Namespace,
+ CreationTimestamp: metav1.NewTime(created),
+ },
+ Spec: resourcemanagerv1alpha1.OrganizationDomainSpec{DomainName: "acme.com"},
+ }
+}
+
+func reconcileOrganizationDomain(t *testing.T, r *OrganizationDomainController, c client.Client) (ctrl.Result, *resourcemanagerv1alpha1.OrganizationDomain) {
+ t.Helper()
+ result, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: organizationDomainKey})
+ if err != nil {
+ t.Fatalf("Reconcile: %v", err)
+ }
+ got := &resourcemanagerv1alpha1.OrganizationDomain{}
+ if err := c.Get(context.Background(), organizationDomainKey, got); err != nil {
+ t.Fatalf("get OrganizationDomain: %v", err)
+ }
+ return result, got
+}
+
+func verifiedCondition(t *testing.T, d *resourcemanagerv1alpha1.OrganizationDomain) *metav1.Condition {
+ t.Helper()
+ c := apimeta.FindStatusCondition(d.Status.Conditions, resourcemanagerv1alpha1.OrganizationDomainConditionVerified)
+ if c == nil {
+ t.Fatal("no Verified condition")
+ }
+ return c
+}
+
+func TestOrganizationDomainController_PublishesRecordAndLooksForIt(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ zone := &dnsZone{}
+ r, c := newOrganizationDomainTest(t, zone, now, newOrganizationDomain(now))
+
+ result, got := reconcileOrganizationDomain(t, r, c)
+
+ v := got.Status.Verification
+ if v == nil {
+ t.Fatal("no verification record published")
+ }
+ if v.DNSRecord.Name != "_milo-verification.acme.com" || v.DNSRecord.Type != "TXT" || len(v.DNSRecord.Content) != 32 {
+ t.Errorf("record = %+v, want a 32 character TXT value at _milo-verification.acme.com", v.DNSRecord)
+ }
+ cond := verifiedCondition(t, got)
+ if cond.Status != metav1.ConditionFalse || cond.Reason != resourcemanagerv1alpha1.OrganizationDomainReasonRecordNotFound {
+ t.Errorf("condition = %s/%s, want False/RecordNotFound", cond.Status, cond.Reason)
+ }
+ if !strings.Contains(cond.Message, v.DNSRecord.Name) || !strings.Contains(cond.Message, v.DNSRecord.Content) {
+ t.Errorf("message %q doesn't say which record to add", cond.Message)
+ }
+ if zone.lookups != 1 {
+ t.Errorf("lookups = %d, want 1", zone.lookups)
+ }
+ if result.RequeueAfter != time.Minute {
+ t.Errorf("RequeueAfter = %v, want 1m", result.RequeueAfter)
+ }
+ if v.NextAttemptTime == nil || !v.NextAttemptTime.Time.Equal(now.Add(time.Minute)) {
+ t.Errorf("NextAttemptTime = %v, want %v", v.NextAttemptTime, now.Add(time.Minute))
+ }
+}
+
+func TestOrganizationDomainController_VerifiesOnceTheRecordExists(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ zone := &dnsZone{}
+ r, c := newOrganizationDomainTest(t, zone, now, newOrganizationDomain(now))
+ _, pending := reconcileOrganizationDomain(t, r, c)
+ token := pending.Status.Verification.DNSRecord.Content
+
+ // The owner adds the record next to an unrelated one; the next attempt is due.
+ zone.records = map[string][]string{"_milo-verification.acme.com": {"something-else", " " + token + " "}}
+ r.Now = func() time.Time { return now.Add(time.Minute) }
+
+ result, got := reconcileOrganizationDomain(t, r, c)
+
+ cond := verifiedCondition(t, got)
+ if cond.Status != metav1.ConditionTrue || cond.Reason != resourcemanagerv1alpha1.OrganizationDomainReasonVerified {
+ t.Errorf("condition = %s/%s, want True/Verified", cond.Status, cond.Reason)
+ }
+ if got.Status.Verification != nil {
+ t.Errorf("verification = %+v, want it cleared once verified", got.Status.Verification)
+ }
+ if result.RequeueAfter != 0 {
+ t.Errorf("RequeueAfter = %v, want none once verified", result.RequeueAfter)
+ }
+
+ // A verified domain stays verified without looking again.
+ zone.records = nil
+ lookups := zone.lookups
+ _, got = reconcileOrganizationDomain(t, r, c)
+ if verifiedCondition(t, got).Status != metav1.ConditionTrue || zone.lookups != lookups {
+ t.Errorf("a verified domain was checked again (lookups %d -> %d)", lookups, zone.lookups)
+ }
+}
+
+func TestOrganizationDomainController_WaitsForTheNextAttempt(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ zone := &dnsZone{}
+ r, c := newOrganizationDomainTest(t, zone, now, newOrganizationDomain(now))
+ _, first := reconcileOrganizationDomain(t, r, c)
+
+ r.Now = func() time.Time { return now.Add(20 * time.Second) }
+ result, got := reconcileOrganizationDomain(t, r, c)
+
+ if zone.lookups != 1 {
+ t.Errorf("lookups = %d, want 1: the next attempt isn't due", zone.lookups)
+ }
+ if result.RequeueAfter != 40*time.Second {
+ t.Errorf("RequeueAfter = %v, want 40s", result.RequeueAfter)
+ }
+ if got.Status.Verification.DNSRecord != first.Status.Verification.DNSRecord {
+ t.Errorf("record changed from %+v to %+v", first.Status.Verification.DNSRecord, got.Status.Verification.DNSRecord)
+ }
+}
+
+func TestOrganizationDomainController_ReportsWhyTheRecordWasNotAccepted(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ zone *dnsZone
+ wantReason string
+ }{
+ {
+ name: "other values at the record name",
+ zone: &dnsZone{records: map[string][]string{"_milo-verification.acme.com": {"wrong"}}},
+ wantReason: resourcemanagerv1alpha1.OrganizationDomainReasonRecordMismatch,
+ },
+ {
+ name: "lookup failure",
+ zone: &dnsZone{err: errors.New("i/o timeout")},
+ wantReason: resourcemanagerv1alpha1.OrganizationDomainReasonLookupFailed,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ r, c := newOrganizationDomainTest(t, tc.zone, now, newOrganizationDomain(now))
+ _, got := reconcileOrganizationDomain(t, r, c)
+ cond := verifiedCondition(t, got)
+ if cond.Status != metav1.ConditionFalse || cond.Reason != tc.wantReason {
+ t.Errorf("condition = %s/%s, want False/%s", cond.Status, cond.Reason, tc.wantReason)
+ }
+ })
+ }
+}
+
+func TestOrganizationDomainController_UsesTheConfiguredRecordPrefix(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ r, c := newOrganizationDomainTest(t, &dnsZone{}, now, newOrganizationDomain(now))
+ r.RecordPrefix = "_datum-verification"
+
+ _, got := reconcileOrganizationDomain(t, r, c)
+
+ if name := got.Status.Verification.DNSRecord.Name; name != "_datum-verification.acme.com" {
+ t.Errorf("record name = %q, want _datum-verification.acme.com", name)
+ }
+}
+
+func TestOrganizationDomainController_DoesNothingWithTheGateOff(t *testing.T) {
+ now := time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)
+ zone := &dnsZone{}
+ r, c := newOrganizationDomainTest(t, zone, now, newOrganizationDomain(now))
+ featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.EmailDomainJoining, false)
+
+ _, got := reconcileOrganizationDomain(t, r, c)
+
+ if got.Status.Verification != nil || zone.lookups != 0 {
+ t.Errorf("verification = %+v, lookups = %d; want nothing done", got.Status.Verification, zone.lookups)
+ }
+}
+
+func TestVerificationInterval(t *testing.T) {
+ for age, want := range map[time.Duration]time.Duration{
+ 0: time.Minute,
+ 59 * time.Minute: time.Minute,
+ 2 * time.Hour: 5 * time.Minute,
+ 3 * 24 * time.Hour: 30 * time.Minute,
+ 30 * 24 * time.Hour: 6 * time.Hour,
+ } {
+ if got := verificationInterval(age); got != want {
+ t.Errorf("verificationInterval(%v) = %v, want %v", age, got, want)
+ }
+ }
+}
diff --git a/internal/emaildomain/emaildomain.go b/internal/emaildomain/emaildomain.go
index 4f9809d5e..1bfd915a3 100644
--- a/internal/emaildomain/emaildomain.go
+++ b/internal/emaildomain/emaildomain.go
@@ -77,10 +77,10 @@ func Covers(verified []string, emailDomain string) bool {
// Instructions tells an owner how to verify the given email domains.
func Instructions(domains []string) string {
return fmt.Sprintf(
- "no project in this organization has a verified Domain for %s. "+
- "Add the domain as a Domain in any project in this organization, "+
- "verify it with the DNS TXT record or HTTP token shown on that Domain, "+
- "and turn on email domain joining once the Domain shows as Verified",
+ "this organization hasn't verified %s. "+
+ "Add the domain to the organization's domains and add the DNS TXT record it shows, "+
+ "or verify it as a Domain in one of the organization's projects, "+
+ "then turn on email domain joining once the domain shows as Verified",
strings.Join(domains, ", "),
)
}
@@ -126,12 +126,17 @@ func NewProjectDomainLister(base *rest.Config) DomainLister {
}
}
-// VerifiedDomains returns the names of the verified Domains in the
-// organization's ready projects. A project without the Domain API has none.
-// A project that cannot be read is skipped, and its error is returned only
-// when no verified Domain was found, so one broken project can't hide another
-// project's verified Domain.
+// VerifiedDomains returns the names of the organization's verified
+// OrganizationDomains and of the verified Domains in its ready projects.
+// A project without the Domain API has none. A project that cannot be read is
+// skipped, and its error is returned only when no verified domain was found,
+// so one broken project can't hide another verified domain.
func (v *Verifier) VerifiedDomains(ctx context.Context, organization string) ([]string, error) {
+ verified, err := v.verifiedOrganizationDomains(ctx, organization)
+ if err != nil {
+ return nil, err
+ }
+
var projects resourcemanagerv1alpha1.ProjectList
if err := v.Client.List(ctx, &projects, client.MatchingLabels{
resourcemanagerv1alpha1.OrganizationNameLabel: organization,
@@ -139,7 +144,6 @@ func (v *Verifier) VerifiedDomains(ctx context.Context, organization string) ([]
return nil, fmt.Errorf("list projects of organization %q: %w", organization, err)
}
- var verified []string
var errs []error
for i := range projects.Items {
project := &projects.Items[i]
@@ -174,6 +178,31 @@ func (v *Verifier) VerifiedDomains(ctx context.Context, organization string) ([]
return verified, nil
}
+// verifiedOrganizationDomains returns the names of the organization's
+// verified OrganizationDomains.
+func (v *Verifier) verifiedOrganizationDomains(ctx context.Context, organization string) ([]string, error) {
+ var domains resourcemanagerv1alpha1.OrganizationDomainList
+ if err := v.Client.List(ctx, &domains,
+ client.InNamespace(resourcemanagerv1alpha1.OrganizationNamespace(organization)),
+ ); err != nil {
+ if meta.IsNoMatchError(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("list OrganizationDomains of organization %q: %w", organization, err)
+ }
+
+ var verified []string
+ for i := range domains.Items {
+ d := &domains.Items[i]
+ if d.DeletionTimestamp != nil ||
+ !meta.IsStatusConditionTrue(d.Status.Conditions, resourcemanagerv1alpha1.OrganizationDomainConditionVerified) {
+ continue
+ }
+ verified = append(verified, strings.ToLower(d.Spec.DomainName))
+ }
+ return verified, nil
+}
+
func isVerified(d unstructured.Unstructured) bool {
conditions, _, _ := unstructured.NestedSlice(d.Object, "status", "conditions")
for _, c := range conditions {
diff --git a/internal/emaildomain/emaildomain_test.go b/internal/emaildomain/emaildomain_test.go
index 284e5a05f..23ac1409b 100644
--- a/internal/emaildomain/emaildomain_test.go
+++ b/internal/emaildomain/emaildomain_test.go
@@ -140,6 +140,23 @@ func TestVerifiedDomains(t *testing.T) {
failures: map[string]error{"broken": errors.New("connection refused")},
want: []string{"example.com"},
},
+ {
+ name: "includes the organization's verified OrganizationDomains",
+ projects: []client.Object{
+ organizationDomain("acme.com", "organization-org", metav1.ConditionTrue),
+ organizationDomain("pending.com", "organization-org", metav1.ConditionFalse),
+ organizationDomain("theirs.com", "organization-other", metav1.ConditionTrue),
+ project("p1", "org", true),
+ },
+ domains: map[string][]unstructured.Unstructured{"p1": {domain("example.com", metav1.ConditionTrue)}},
+ want: []string{"acme.com", "example.com"},
+ },
+ {
+ name: "a verified OrganizationDomain doesn't depend on any project being readable",
+ projects: []client.Object{organizationDomain("acme.com", "organization-org", metav1.ConditionTrue), project("broken", "org", true)},
+ failures: map[string]error{"broken": errors.New("connection refused")},
+ want: []string{"acme.com"},
+ },
{
name: "an unreadable project is an error when nothing is verified",
projects: []client.Object{project("broken", "org", true)},
@@ -168,3 +185,16 @@ func TestVerifiedDomains(t *testing.T) {
})
}
}
+
+func organizationDomain(name, namespace string, verified metav1.ConditionStatus) *resourcemanagerv1alpha1.OrganizationDomain {
+ return &resourcemanagerv1alpha1.OrganizationDomain{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+ Spec: resourcemanagerv1alpha1.OrganizationDomainSpec{DomainName: name},
+ Status: resourcemanagerv1alpha1.OrganizationDomainStatus{
+ Conditions: []metav1.Condition{{
+ Type: resourcemanagerv1alpha1.OrganizationDomainConditionVerified,
+ Status: verified,
+ }},
+ },
+ }
+}
diff --git a/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook.go b/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook.go
new file mode 100644
index 000000000..22cfd74d5
--- /dev/null
+++ b/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook.go
@@ -0,0 +1,132 @@
+package v1alpha1
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "golang.org/x/net/publicsuffix"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ ctrl "sigs.k8s.io/controller-runtime"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+ "go.miloapis.com/milo/internal/emaildomain"
+ resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
+ "go.miloapis.com/milo/pkg/features"
+ "go.miloapis.com/milo/pkg/webhook"
+)
+
+var organizationdomainlog = logf.Log.WithName("organizationdomain-resource")
+
+const organizationNamespacePrefix = "organization-"
+
+// +kubebuilder:webhook:path=/validate-resourcemanager-miloapis-com-v1alpha1-organizationdomain,mutating=false,failurePolicy=fail,sideEffects=None,groups=resourcemanager.miloapis.com,resources=organizationdomains,verbs=create;update,versions=v1alpha1,name=vorganizationdomain.datum.net,admissionReviewVersions={v1,v1beta1},serviceName=milo-controller-manager,servicePort=9443,serviceNamespace=milo-system
+
+// SetupOrganizationDomainWebhooksWithManager sets up OrganizationDomain webhooks.
+func SetupOrganizationDomainWebhooksWithManager(mgr ctrl.Manager) error {
+ organizationdomainlog.Info("Setting up resourcemanager.miloapis.com organizationdomain webhooks")
+
+ return ctrl.NewWebhookManagedBy(mgr, &resourcemanagerv1alpha1.OrganizationDomain{}).
+ WithValidator(&OrganizationDomainValidator{}).
+ Complete()
+}
+
+// OrganizationDomainValidator validates OrganizationDomains. A domain must live
+// in an organization's namespace, be named after itself, and be one an
+// organization can own: not a public suffix such as co.uk, and not a public
+// email provider such as gmail.com.
+type OrganizationDomainValidator struct{}
+
+func (v *OrganizationDomainValidator) ValidateCreate(ctx context.Context, domain *resourcemanagerv1alpha1.OrganizationDomain) (admission.Warnings, error) {
+ var errs field.ErrorList
+ if !utilfeature.DefaultFeatureGate.Enabled(features.EmailDomainJoining) {
+ errs = append(errs, field.Forbidden(field.NewPath("spec"), "organization domains are not available yet"))
+ return nil, invalidOrganizationDomain(domain, errs)
+ }
+
+ orgErrs, err := validateOrganizationDomainNamespace(ctx, domain)
+ if err != nil {
+ return nil, err
+ }
+ errs = append(errs, orgErrs...)
+ errs = append(errs, validateOrganizationDomainName(domain)...)
+ return nil, invalidOrganizationDomain(domain, errs)
+}
+
+func (v *OrganizationDomainValidator) ValidateUpdate(ctx context.Context, _, domain *resourcemanagerv1alpha1.OrganizationDomain) (admission.Warnings, error) {
+ // The CRD keeps spec.domainName from changing, so only the namespace needs
+ // checking against the request organization.
+ errs, err := validateOrganizationDomainNamespace(ctx, domain)
+ if err != nil {
+ return nil, err
+ }
+ return nil, invalidOrganizationDomain(domain, errs)
+}
+
+func (v *OrganizationDomainValidator) ValidateDelete(context.Context, *resourcemanagerv1alpha1.OrganizationDomain) (admission.Warnings, error) {
+ return nil, nil
+}
+
+// validateOrganizationDomainNamespace checks that the domain lives in an
+// organization's namespace and, for a request made in an organization's
+// context, in that organization's namespace.
+func validateOrganizationDomainNamespace(ctx context.Context, domain *resourcemanagerv1alpha1.OrganizationDomain) (field.ErrorList, error) {
+ fldPath := field.NewPath("metadata", "namespace")
+ orgID, isOrgContext, err := webhook.OrgContextFromRequest(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if isOrgContext {
+ if want := resourcemanagerv1alpha1.OrganizationNamespace(orgID); domain.Namespace != want {
+ return field.ErrorList{field.Invalid(fldPath, domain.Namespace,
+ fmt.Sprintf("an organization's domains must be in its namespace, %s", want))}, nil
+ }
+ return nil, nil
+ }
+ if !strings.HasPrefix(domain.Namespace, organizationNamespacePrefix) ||
+ domain.Namespace == organizationNamespacePrefix {
+ return field.ErrorList{field.Invalid(fldPath, domain.Namespace,
+ "an organization's domains must be in its namespace, organization-")}, nil
+ }
+ return nil, nil
+}
+
+// validateOrganizationDomainName checks the domain name an organization wants
+// to verify.
+func validateOrganizationDomainName(domain *resourcemanagerv1alpha1.OrganizationDomain) field.ErrorList {
+ var errs field.ErrorList
+ name := domain.Spec.DomainName
+ namePath := field.NewPath("spec", "domainName")
+
+ if domain.Name != name {
+ errs = append(errs, field.Invalid(field.NewPath("metadata", "name"), domain.Name,
+ fmt.Sprintf("an OrganizationDomain must be named after its domain; name it %s", name)))
+ }
+ if name != strings.ToLower(name) {
+ errs = append(errs, field.Invalid(namePath, name, "use lower case, such as acme.com"))
+ return errs
+ }
+ if _, err := publicsuffix.EffectiveTLDPlusOne(name); err != nil {
+ errs = append(errs, field.Invalid(namePath, name, fmt.Sprintf(
+ "%s is a public suffix that anyone can register names under. Use a domain your organization owns, such as acme.com",
+ name)))
+ return errs
+ }
+ if emaildomain.IsPublicProvider(name) {
+ errs = append(errs, field.Invalid(namePath, name, fmt.Sprintf(
+ "%s is a public email provider, where anyone can get an address. Use a domain your organization owns, such as your company's domain",
+ name)))
+ }
+ return errs
+}
+
+func invalidOrganizationDomain(domain *resourcemanagerv1alpha1.OrganizationDomain, errs field.ErrorList) error {
+ if len(errs) == 0 {
+ return nil
+ }
+ return apierrors.NewInvalid(
+ resourcemanagerv1alpha1.GroupVersion.WithKind("OrganizationDomain").GroupKind(), domain.Name, errs)
+}
diff --git a/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook_test.go b/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook_test.go
new file mode 100644
index 000000000..e8f992231
--- /dev/null
+++ b/internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook_test.go
@@ -0,0 +1,114 @@
+package v1alpha1
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ utilfeature "k8s.io/apiserver/pkg/util/feature"
+ featuregatetesting "k8s.io/component-base/featuregate/testing"
+
+ resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1"
+ "go.miloapis.com/milo/pkg/features"
+)
+
+func organizationDomainFor(namespace, name, domainName string) *resourcemanagerv1alpha1.OrganizationDomain {
+ return &resourcemanagerv1alpha1.OrganizationDomain{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+ Spec: resourcemanagerv1alpha1.OrganizationDomainSpec{DomainName: domainName},
+ }
+}
+
+func TestOrganizationDomainValidator_ValidateCreate(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ ctx context.Context
+ gateOff bool
+ domain *resourcemanagerv1alpha1.OrganizationDomain
+ wantErr string
+ }{
+ {
+ name: "an organization's domain",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "acme.com", "acme.com"),
+ },
+ {
+ name: "a subdomain in the organization's own context",
+ ctx: orgContextRequest("acme"),
+ domain: organizationDomainFor("organization-acme", "eu.acme.com", "eu.acme.com"),
+ },
+ {
+ name: "a site under a private public suffix",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "acme.github.io", "acme.github.io"),
+ },
+ {
+ name: "the gate is off",
+ ctx: membershipValidationContext(),
+ gateOff: true,
+ domain: organizationDomainFor("organization-acme", "acme.com", "acme.com"),
+ wantErr: "organization domains are not available yet",
+ },
+ {
+ name: "another organization's namespace",
+ ctx: orgContextRequest("acme"),
+ domain: organizationDomainFor("organization-other", "acme.com", "acme.com"),
+ wantErr: "must be in its namespace, organization-acme",
+ },
+ {
+ name: "a namespace that isn't an organization's",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("default", "acme.com", "acme.com"),
+ wantErr: "must be in its namespace",
+ },
+ {
+ name: "a name that isn't the domain",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "company", "acme.com"),
+ wantErr: "name it acme.com",
+ },
+ {
+ name: "a public suffix",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "co.uk", "co.uk"),
+ wantErr: "co.uk is a public suffix",
+ },
+ {
+ name: "a public email provider",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "gmail.com", "gmail.com"),
+ wantErr: "gmail.com is a public email provider",
+ },
+ {
+ name: "upper case",
+ ctx: membershipValidationContext(),
+ domain: organizationDomainFor("organization-acme", "Acme.com", "Acme.com"),
+ wantErr: "use lower case",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.EmailDomainJoining, !tc.gateOff)
+
+ _, err := (&OrganizationDomainValidator{}).ValidateCreate(tc.ctx, tc.domain)
+
+ if tc.wantErr == "" {
+ if err != nil {
+ t.Fatalf("ValidateCreate: %v", err)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("ValidateCreate error = %v, want it to contain %q", err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestOrganizationDomainValidator_ValidateUpdateChecksTheOrganization(t *testing.T) {
+ domain := organizationDomainFor("organization-other", "acme.com", "acme.com")
+ _, err := (&OrganizationDomainValidator{}).ValidateUpdate(orgContextRequest("acme"), domain, domain)
+ if err == nil || !strings.Contains(err.Error(), "organization-acme") {
+ t.Fatalf("ValidateUpdate error = %v, want a namespace error", err)
+ }
+}
diff --git a/pkg/apis/resourcemanager/v1alpha1/organizationdomain_types.go b/pkg/apis/resourcemanager/v1alpha1/organizationdomain_types.go
new file mode 100644
index 000000000..9f1a0cb4a
--- /dev/null
+++ b/pkg/apis/resourcemanager/v1alpha1/organizationdomain_types.go
@@ -0,0 +1,139 @@
+package v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+
+// OrganizationDomain proves that an organization controls a domain name, such
+// as the domain of its company's email addresses. It belongs to the
+// organization rather than to one of its projects, so the domain doesn't have
+// to be added to a project to be used.
+//
+// Milo publishes a DNS TXT record in status.verification.dnsRecord. Once the
+// record is found at the domain, the Verified condition becomes True and stays
+// True. The record can then be removed.
+//
+// The resource is named after its domain, so an organization has at most one
+// for each domain. Verified domains, and their subdomains, can be used for
+// email domain joining.
+//
+// Example:
+//
+// apiVersion: resourcemanager.miloapis.com/v1alpha1
+// kind: OrganizationDomain
+// metadata:
+// name: acme.com
+// namespace: organization-acme-corp
+// spec:
+// domainName: acme.com
+//
+// +k8s:openapi-gen=true
+// +kubebuilder:printcolumn:name="Domain",type="string",JSONPath=".spec.domainName"
+// +kubebuilder:printcolumn:name="Verified",type="string",JSONPath=".status.conditions[?(@.type=='Verified')].status"
+// +kubebuilder:printcolumn:name="Record",type="string",JSONPath=".status.verification.dnsRecord.name",priority=1
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+// +kubebuilder:resource:path=organizationdomains,scope=Namespaced,singular=organizationdomain
+// +kubebuilder:selectablefield:JSONPath=".spec.domainName"
+// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Organization"
+type OrganizationDomain struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // +kubebuilder:validation:Required
+ Spec OrganizationDomainSpec `json:"spec"`
+ Status OrganizationDomainStatus `json:"status,omitempty"`
+}
+
+// OrganizationDomainSpec defines the domain an organization wants to verify.
+type OrganizationDomainSpec struct {
+ // DomainName is the domain to verify, in lower case, such as acme.com or
+ // eu.acme.com. It must match metadata.name and can't be changed.
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=3
+ // +kubebuilder:validation:MaxLength=253
+ // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)+$`
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="domainName is immutable"
+ DomainName string `json:"domainName"`
+}
+
+// OrganizationDomainStatus reports how verification is going.
+type OrganizationDomainStatus struct {
+ // Verification holds the DNS record to add to the domain. It is cleared
+ // once the domain is verified.
+ //
+ // +kubebuilder:validation:Optional
+ Verification *OrganizationDomainVerification `json:"verification,omitempty"`
+
+ // Conditions holds the Verified condition.
+ //
+ // +kubebuilder:default={{type: "Verified", status: "Unknown", reason: "Pending", message: "Waiting for the control plane to publish a verification record", lastTransitionTime: "1970-01-01T00:00:00Z"}}
+ // +kubebuilder:validation:Optional
+ // +listType=map
+ // +listMapKey=type
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// OrganizationDomainVerification describes how to prove control of a domain.
+type OrganizationDomainVerification struct {
+ // DNSRecord is the TXT record to add at the domain's DNS provider.
+ DNSRecord OrganizationDomainDNSRecord `json:"dnsRecord"`
+
+ // LastAttemptTime is when Milo last looked for the record.
+ //
+ // +kubebuilder:validation:Optional
+ LastAttemptTime *metav1.Time `json:"lastAttemptTime,omitempty"`
+
+ // NextAttemptTime is when Milo will look for the record again.
+ //
+ // +kubebuilder:validation:Optional
+ NextAttemptTime *metav1.Time `json:"nextAttemptTime,omitempty"`
+}
+
+// OrganizationDomainDNSRecord is a DNS record that proves control of a domain.
+type OrganizationDomainDNSRecord struct {
+ // Name is the fully qualified record name, such as
+ // _milo-verification.acme.com.
+ Name string `json:"name"`
+
+ // Type is the record type. It is always TXT.
+ Type string `json:"type"`
+
+ // Content is the value the record must hold.
+ Content string `json:"content"`
+}
+
+const (
+ // OrganizationDomainConditionVerified is True once Milo has found the
+ // verification record at the domain.
+ OrganizationDomainConditionVerified = "Verified"
+
+ // OrganizationDomainReasonPending means the record hasn't been published
+ // yet or hasn't been looked for.
+ OrganizationDomainReasonPending = "Pending"
+ // OrganizationDomainReasonRecordNotFound means the domain has no TXT
+ // record at the record name yet.
+ OrganizationDomainReasonRecordNotFound = "RecordNotFound"
+ // OrganizationDomainReasonRecordMismatch means TXT records exist at the
+ // record name, but none holds the expected content.
+ OrganizationDomainReasonRecordMismatch = "RecordMismatch"
+ // OrganizationDomainReasonLookupFailed means the DNS lookup failed for a
+ // reason other than the record not existing.
+ OrganizationDomainReasonLookupFailed = "LookupFailed"
+ // OrganizationDomainReasonVerified means the record was found.
+ OrganizationDomainReasonVerified = "Verified"
+)
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +kubebuilder:object:root=true
+
+// OrganizationDomainList contains a list of OrganizationDomain.
+type OrganizationDomainList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []OrganizationDomain `json:"items"`
+}
diff --git a/pkg/apis/resourcemanager/v1alpha1/register.go b/pkg/apis/resourcemanager/v1alpha1/register.go
index 29b8d3db9..50154f619 100644
--- a/pkg/apis/resourcemanager/v1alpha1/register.go
+++ b/pkg/apis/resourcemanager/v1alpha1/register.go
@@ -34,6 +34,8 @@ func addKnownTypes(scheme *runtime.Scheme) error {
&OrganizationList{},
&OrganizationMembership{},
&OrganizationMembershipList{},
+ &OrganizationDomain{},
+ &OrganizationDomainList{},
&ProjectSuspension{},
&ProjectSuspensionList{},
)
diff --git a/pkg/apis/resourcemanager/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/resourcemanager/v1alpha1/zz_generated.deepcopy.go
index c017464ff..835c4006c 100644
--- a/pkg/apis/resourcemanager/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/resourcemanager/v1alpha1/zz_generated.deepcopy.go
@@ -110,6 +110,146 @@ func (in *OrganizationContactInfo) DeepCopy() *OrganizationContactInfo {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrganizationDomain) DeepCopyInto(out *OrganizationDomain) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationDomain.
+func (in *OrganizationDomain) DeepCopy() *OrganizationDomain {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomain)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OrganizationDomain) 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 *OrganizationDomainDNSRecord) DeepCopyInto(out *OrganizationDomainDNSRecord) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationDomainDNSRecord.
+func (in *OrganizationDomainDNSRecord) DeepCopy() *OrganizationDomainDNSRecord {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomainDNSRecord)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrganizationDomainList) DeepCopyInto(out *OrganizationDomainList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]OrganizationDomain, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationDomainList.
+func (in *OrganizationDomainList) DeepCopy() *OrganizationDomainList {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomainList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OrganizationDomainList) 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 *OrganizationDomainSpec) DeepCopyInto(out *OrganizationDomainSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationDomainSpec.
+func (in *OrganizationDomainSpec) DeepCopy() *OrganizationDomainSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomainSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrganizationDomainStatus) DeepCopyInto(out *OrganizationDomainStatus) {
+ *out = *in
+ if in.Verification != nil {
+ in, out := &in.Verification, &out.Verification
+ *out = new(OrganizationDomainVerification)
+ (*in).DeepCopyInto(*out)
+ }
+ 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 OrganizationDomainStatus.
+func (in *OrganizationDomainStatus) DeepCopy() *OrganizationDomainStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomainStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OrganizationDomainVerification) DeepCopyInto(out *OrganizationDomainVerification) {
+ *out = *in
+ out.DNSRecord = in.DNSRecord
+ if in.LastAttemptTime != nil {
+ in, out := &in.LastAttemptTime, &out.LastAttemptTime
+ *out = (*in).DeepCopy()
+ }
+ if in.NextAttemptTime != nil {
+ in, out := &in.NextAttemptTime, &out.NextAttemptTime
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationDomainVerification.
+func (in *OrganizationDomainVerification) DeepCopy() *OrganizationDomainVerification {
+ if in == nil {
+ return nil
+ }
+ out := new(OrganizationDomainVerification)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OrganizationEmailDomainJoining) DeepCopyInto(out *OrganizationEmailDomainJoining) {
*out = *in
diff --git a/pkg/features/features.go b/pkg/features/features.go
index 41c20f893..cb4aeda89 100644
--- a/pkg/features/features.go
+++ b/pkg/features/features.go
@@ -93,9 +93,11 @@ const (
MemberJoinedEmails featuregate.Feature = "MemberJoinedEmails"
// EmailDomainJoining lets organization owners offer membership to everyone
- // at an email domain that a Domain in one of the organization's projects
- // verifies (Organization spec.emailDomainJoining). Offers are
- // UserInvitations, and accepting one requires a verified email address.
+ // at an email domain the organization has verified, either as an
+ // OrganizationDomain or as a Domain in one of its projects (Organization
+ // spec.emailDomainJoining). Offers are UserInvitations, and accepting one
+ // requires a verified email address. The gate also turns on verifying
+ // OrganizationDomains.
//
// owner: @datum-cloud/platform
// alpha: v0.1.0
diff --git a/test/admission/projectsuspension/05-test-user-rbac.yaml b/test/admission/projectsuspension/05-test-user-rbac.yaml
index 111704317..7fd2b8640 100644
--- a/test/admission/projectsuspension/05-test-user-rbac.yaml
+++ b/test/admission/projectsuspension/05-test-user-rbac.yaml
@@ -1,7 +1,7 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
- name: test-user-admin
+ name: test-user-admin-projectsuspension-admission
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
diff --git a/test/resource-management/organization-domain/01-organization.yaml b/test/resource-management/organization-domain/01-organization.yaml
new file mode 100644
index 000000000..dff087927
--- /dev/null
+++ b/test/resource-management/organization-domain/01-organization.yaml
@@ -0,0 +1,10 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: Organization
+metadata:
+ name: od-acme
+ labels:
+ test.miloapis.com/scenario: "organization-domain"
+ annotations:
+ kubernetes.io/display-name: "Organization Domain Test Organization"
+spec:
+ type: Standard
diff --git a/test/resource-management/organization-domain/02-rbac-domain-manager.yaml b/test/resource-management/organization-domain/02-rbac-domain-manager.yaml
new file mode 100644
index 000000000..9f191a63e
--- /dev/null
+++ b/test/resource-management/organization-domain/02-rbac-domain-manager.yaml
@@ -0,0 +1,28 @@
+# Lets test-user (system:authenticated, NOT system:masters) manage
+# OrganizationDomains through the organization's control plane, so the webhook
+# sees the organization's parent context the way an owner's request would.
+# This test environment authorizes with Kubernetes RBAC rather than Milo's IAM
+# roles, so it doesn't exercise which IAM roles grant these permissions.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: organization-domain-manager
+ namespace: organization-od-acme
+rules:
+ - apiGroups: ["resourcemanager.miloapis.com"]
+ resources: ["organizationdomains"]
+ verbs: ["create", "get", "list", "watch", "patch", "update", "delete"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: organization-domain-manager
+ namespace: organization-od-acme
+subjects:
+ - kind: User
+ name: test-user
+ apiGroup: rbac.authorization.k8s.io
+roleRef:
+ kind: Role
+ name: organization-domain-manager
+ apiGroup: rbac.authorization.k8s.io
diff --git a/test/resource-management/organization-domain/03-organization-joining.yaml b/test/resource-management/organization-domain/03-organization-joining.yaml
new file mode 100644
index 000000000..dd1fd8ee8
--- /dev/null
+++ b/test/resource-management/organization-domain/03-organization-joining.yaml
@@ -0,0 +1,16 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: Organization
+metadata:
+ name: od-acme
+ labels:
+ test.miloapis.com/scenario: "organization-domain"
+ annotations:
+ kubernetes.io/display-name: "Organization Domain Test Organization"
+spec:
+ type: Standard
+ emailDomainJoining:
+ domains:
+ - od-acme.example.com
+ roles:
+ - name: resourcemanager.miloapis.com-organization-viewer
+ namespace: milo-system
diff --git a/test/resource-management/organization-domain/04-users.yaml b/test/resource-management/organization-domain/04-users.yaml
new file mode 100644
index 000000000..aa1679e0d
--- /dev/null
+++ b/test/resource-management/organization-domain/04-users.yaml
@@ -0,0 +1,21 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: User
+metadata:
+ name: od-acme-person
+ labels:
+ test.miloapis.com/scenario: "organization-domain"
+spec:
+ email: person@od-acme.example.com
+ givenName: Person
+ familyName: Acme
+---
+apiVersion: iam.miloapis.com/v1alpha1
+kind: User
+metadata:
+ name: od-outsider
+ labels:
+ test.miloapis.com/scenario: "organization-domain"
+spec:
+ email: outsider@od-elsewhere.example.org
+ givenName: Outsider
+ familyName: Elsewhere
diff --git a/test/resource-management/organization-domain/README.md b/test/resource-management/organization-domain/README.md
new file mode 100644
index 000000000..0127e69be
--- /dev/null
+++ b/test/resource-management/organization-domain/README.md
@@ -0,0 +1,143 @@
+# Test: `organization-domain`
+
+Tests OrganizationDomain end to end: an organization proving it controls a
+domain without adding it to one of its projects, and email domain joining
+accepting a domain verified that way.
+
+The domain-manager steps run as test-user through the organization's
+control plane, so the webhook sees the organization's parent context the
+way an owner's request would. Setup and verification run as the admin.
+
+This test verifies:
+- The webhook rejects public email providers, public suffixes, and an
+ OrganizationDomain that isn't named after its domain.
+- The controller publishes a TXT record to add and reports that the domain
+ isn't verified yet.
+- spec.domainName can't be changed.
+- Email domain joining rejects the domain while it's unverified.
+- Once the domain is verified, email domain joining accepts it and offers
+ membership to a user at the domain, but not to a user elsewhere.
+- Removing the domain pauses joining and withdraws the pending offer.
+
+CI can't publish a real DNS record, so the admin marks the domain verified
+through the status subresource. The controller never checks a verified
+domain again, so it keeps that status. Unit tests cover finding the record.
+
+
+## Steps
+
+| # | Name | Bindings | Try | Catch | Finally | Cleanup |
+|:-:|---|:-:|:-:|:-:|:-:|:-:|
+| 1 | [setup-organization](#step-setup-organization) | 0 | 3 | 0 | 0 | 0 |
+| 2 | [reject-domains-an-organization-cannot-own](#step-reject-domains-an-organization-cannot-own) | 0 | 3 | 0 | 0 | 0 |
+| 3 | [add-domain-and-publish-record](#step-add-domain-and-publish-record) | 0 | 2 | 0 | 0 | 0 |
+| 4 | [domain-name-is-immutable](#step-domain-name-is-immutable) | 0 | 1 | 0 | 0 | 0 |
+| 5 | [joining-rejects-unverified-domain](#step-joining-rejects-unverified-domain) | 0 | 1 | 0 | 0 | 0 |
+| 6 | [mark-domain-verified](#step-mark-domain-verified) | 0 | 2 | 0 | 0 | 0 |
+| 7 | [joining-accepts-verified-domain](#step-joining-accepts-verified-domain) | 0 | 2 | 0 | 0 | 0 |
+| 8 | [offer-membership-at-the-domain](#step-offer-membership-at-the-domain) | 0 | 3 | 0 | 0 | 0 |
+| 9 | [remove-domain-withdraws-offer](#step-remove-domain-withdraws-offer) | 0 | 3 | 0 | 0 | 0 |
+
+### Step: `setup-organization`
+
+Create the organization, wait for its namespace, and let test-user manage its domains
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `wait` | 0 | 0 | *No description* |
+| 3 | `apply` | 0 | 0 | *No description* |
+
+### Step: `reject-domains-an-organization-cannot-own`
+
+The webhook rejects a public email provider, a public suffix, and a name that isn't the domain
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `create` | 0 | 0 | *No description* |
+| 2 | `create` | 0 | 0 | *No description* |
+| 3 | `create` | 0 | 0 | *No description* |
+
+### Step: `add-domain-and-publish-record`
+
+Adding a domain publishes the TXT record to add, and the domain stays unverified until it exists
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `create` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `domain-name-is-immutable`
+
+Changing spec.domainName is rejected
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `patch` | 0 | 0 | *No description* |
+
+### Step: `joining-rejects-unverified-domain`
+
+Email domain joining rejects a domain the organization hasn't verified
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+
+### Step: `mark-domain-verified`
+
+Stand in for DNS by marking the domain verified through the status subresource
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `script` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `joining-accepts-verified-domain`
+
+Email domain joining accepts the verified domain and reports it as verified
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+
+### Step: `offer-membership-at-the-domain`
+
+A user at the domain gets an offer; a user elsewhere doesn't
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `apply` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+| 3 | `error` | 0 | 0 | *No description* |
+
+### Step: `remove-domain-withdraws-offer`
+
+Removing the domain pauses joining and withdraws the pending offer
+
+#### Try
+
+| # | Operation | Bindings | Outputs | Description |
+|:-:|---|:-:|:-:|---|
+| 1 | `delete` | 0 | 0 | *No description* |
+| 2 | `assert` | 0 | 0 | *No description* |
+| 3 | `error` | 0 | 0 | *No description* |
+
+---
+
diff --git a/test/resource-management/organization-domain/chainsaw-test.yaml b/test/resource-management/organization-domain/chainsaw-test.yaml
new file mode 100644
index 000000000..02fd24643
--- /dev/null
+++ b/test/resource-management/organization-domain/chainsaw-test.yaml
@@ -0,0 +1,223 @@
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: organization-domain
+spec:
+ description: |
+ Tests OrganizationDomain end to end: an organization proving it controls a
+ domain without adding it to one of its projects, and email domain joining
+ accepting a domain verified that way.
+
+ The domain-manager steps run as test-user through the organization's
+ control plane, so the webhook sees the organization's parent context the
+ way an owner's request would. Setup and verification run as the admin.
+
+ This test verifies:
+ - The webhook rejects public email providers, public suffixes, and an
+ OrganizationDomain that isn't named after its domain.
+ - The controller publishes a TXT record to add and reports that the domain
+ isn't verified yet.
+ - spec.domainName can't be changed.
+ - Email domain joining rejects the domain while it's unverified.
+ - Once the domain is verified, email domain joining accepts it and offers
+ membership to a user at the domain, but not to a user elsewhere.
+ - Removing the domain pauses joining and withdraws the pending offer.
+
+ CI can't publish a real DNS record, so the admin marks the domain verified
+ through the status subresource. The controller never checks a verified
+ domain again, so it keeps that status. Unit tests cover finding the record.
+
+ clusters:
+ main:
+ kubeconfig: kubeconfig-main
+ orgUser:
+ kubeconfig: kubeconfig-org-user
+
+ steps:
+ - name: setup-organization
+ description: Create the organization, wait for its namespace, and let test-user manage its domains
+ cluster: main
+ try:
+ - apply:
+ file: 01-organization.yaml
+ - wait:
+ apiVersion: v1
+ kind: Namespace
+ name: organization-od-acme
+ timeout: 30s
+ for:
+ jsonPath:
+ path: '{.status.phase}'
+ value: Active
+ - apply:
+ file: 02-rbac-domain-manager.yaml
+
+ - name: reject-domains-an-organization-cannot-own
+ description: The webhook rejects a public email provider, a public suffix, and a name that isn't the domain
+ cluster: orgUser
+ try:
+ - create:
+ file: domain-public-provider.yaml
+ expect:
+ - check:
+ ($error != null): true
+ (contains($error, 'gmail.com is a public email provider')): true
+ - create:
+ file: domain-public-suffix.yaml
+ expect:
+ - check:
+ ($error != null): true
+ (contains($error, 'co.uk is a public suffix')): true
+ - create:
+ file: domain-wrong-name.yaml
+ expect:
+ - check:
+ ($error != null): true
+ (contains($error, 'name it od-acme.example.com')): true
+
+ - name: add-domain-and-publish-record
+ description: Adding a domain publishes the TXT record to add, and the domain stays unverified until it exists
+ cluster: orgUser
+ try:
+ - create:
+ file: domain-acme.yaml
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: OrganizationDomain
+ metadata:
+ name: od-acme.example.com
+ namespace: organization-od-acme
+ status:
+ verification:
+ dnsRecord:
+ name: _milo-verification.od-acme.example.com
+ type: TXT
+ (length(content)): 32
+ (conditions[?type == 'Verified']):
+ - status: 'False'
+ (contains(['RecordNotFound', 'LookupFailed'], reason)): true
+
+ - name: domain-name-is-immutable
+ description: Changing spec.domainName is rejected
+ cluster: orgUser
+ try:
+ - patch:
+ file: domain-acme-renamed.yaml
+ expect:
+ - check:
+ ($error != null): true
+ (contains($error, 'domainName is immutable')): true
+
+ - name: joining-rejects-unverified-domain
+ description: Email domain joining rejects a domain the organization hasn't verified
+ cluster: main
+ try:
+ - apply:
+ file: 03-organization-joining.yaml
+ expect:
+ - check:
+ ($error != null): true
+ (contains($error, 'verified od-acme.example.com')): true
+
+ - name: mark-domain-verified
+ description: Stand in for DNS by marking the domain verified through the status subresource
+ cluster: main
+ try:
+ - script:
+ content: |
+ kubectl --kubeconfig kubeconfig-main patch organizationdomain od-acme.example.com \
+ -n organization-od-acme --subresource=status --type=merge \
+ -p '{"status":{"verification":null,"conditions":[{"type":"Verified","status":"True","reason":"Verified","message":"Marked verified by the e2e test","lastTransitionTime":"2026-01-01T00:00:00Z"}]}}'
+ - assert:
+ timeout: 30s
+ resource:
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: OrganizationDomain
+ metadata:
+ name: od-acme.example.com
+ namespace: organization-od-acme
+ status:
+ (conditions[?type == 'Verified']):
+ - status: 'True'
+
+ - name: joining-accepts-verified-domain
+ description: Email domain joining accepts the verified domain and reports it as verified
+ cluster: main
+ try:
+ - apply:
+ file: 03-organization-joining.yaml
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: Organization
+ metadata:
+ name: od-acme
+ status:
+ (conditions[?type == 'EmailDomainsVerified']):
+ - status: 'True'
+ reason: Verified
+
+ - name: offer-membership-at-the-domain
+ description: A user at the domain gets an offer; a user elsewhere doesn't
+ cluster: main
+ try:
+ - apply:
+ file: 04-users.yaml
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: iam.miloapis.com/v1alpha1
+ kind: UserInvitation
+ metadata:
+ name: email-domain-od-acme-person
+ namespace: organization-od-acme
+ labels:
+ iam.miloapis.com/join-method: EmailDomain
+ iam.miloapis.com/email-domain: od-acme.example.com
+ spec:
+ email: person@od-acme.example.com
+ organizationRef:
+ name: od-acme
+ state: Pending
+ - error:
+ resource:
+ apiVersion: iam.miloapis.com/v1alpha1
+ kind: UserInvitation
+ metadata:
+ name: email-domain-od-outsider
+ namespace: organization-od-acme
+
+ - name: remove-domain-withdraws-offer
+ description: Removing the domain pauses joining and withdraws the pending offer
+ cluster: orgUser
+ try:
+ - delete:
+ ref:
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: OrganizationDomain
+ name: od-acme.example.com
+ namespace: organization-od-acme
+ - assert:
+ cluster: main
+ timeout: 60s
+ resource:
+ apiVersion: resourcemanager.miloapis.com/v1alpha1
+ kind: Organization
+ metadata:
+ name: od-acme
+ status:
+ (conditions[?type == 'EmailDomainsVerified']):
+ - status: 'False'
+ reason: DomainNotVerified
+ - error:
+ cluster: main
+ timeout: 60s
+ resource:
+ apiVersion: iam.miloapis.com/v1alpha1
+ kind: UserInvitation
+ metadata:
+ name: email-domain-od-acme-person
+ namespace: organization-od-acme
diff --git a/test/resource-management/organization-domain/domain-acme-renamed.yaml b/test/resource-management/organization-domain/domain-acme-renamed.yaml
new file mode 100644
index 000000000..7552dce2e
--- /dev/null
+++ b/test/resource-management/organization-domain/domain-acme-renamed.yaml
@@ -0,0 +1,7 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: od-acme.example.com
+ namespace: organization-od-acme
+spec:
+ domainName: other.example.com
diff --git a/test/resource-management/organization-domain/domain-acme.yaml b/test/resource-management/organization-domain/domain-acme.yaml
new file mode 100644
index 000000000..e434147f5
--- /dev/null
+++ b/test/resource-management/organization-domain/domain-acme.yaml
@@ -0,0 +1,7 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: od-acme.example.com
+ namespace: organization-od-acme
+spec:
+ domainName: od-acme.example.com
diff --git a/test/resource-management/organization-domain/domain-public-provider.yaml b/test/resource-management/organization-domain/domain-public-provider.yaml
new file mode 100644
index 000000000..46514cfa0
--- /dev/null
+++ b/test/resource-management/organization-domain/domain-public-provider.yaml
@@ -0,0 +1,7 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: gmail.com
+ namespace: organization-od-acme
+spec:
+ domainName: gmail.com
diff --git a/test/resource-management/organization-domain/domain-public-suffix.yaml b/test/resource-management/organization-domain/domain-public-suffix.yaml
new file mode 100644
index 000000000..b1965c545
--- /dev/null
+++ b/test/resource-management/organization-domain/domain-public-suffix.yaml
@@ -0,0 +1,7 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: co.uk
+ namespace: organization-od-acme
+spec:
+ domainName: co.uk
diff --git a/test/resource-management/organization-domain/domain-wrong-name.yaml b/test/resource-management/organization-domain/domain-wrong-name.yaml
new file mode 100644
index 000000000..0fe2c4de3
--- /dev/null
+++ b/test/resource-management/organization-domain/domain-wrong-name.yaml
@@ -0,0 +1,7 @@
+apiVersion: resourcemanager.miloapis.com/v1alpha1
+kind: OrganizationDomain
+metadata:
+ name: company-domain
+ namespace: organization-od-acme
+spec:
+ domainName: od-acme.example.com
diff --git a/test/resource-management/organization-domain/kubeconfig-main b/test/resource-management/organization-domain/kubeconfig-main
new file mode 100644
index 000000000..c771bb28f
--- /dev/null
+++ b/test/resource-management/organization-domain/kubeconfig-main
@@ -0,0 +1,18 @@
+apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://localhost:30443
+ name: milo-main
+contexts:
+- context:
+ cluster: milo-main
+ user: test-admin
+ name: milo-main
+current-context: milo-main
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+ user:
+ token: test-admin-token
diff --git a/test/resource-management/organization-domain/kubeconfig-org-user b/test/resource-management/organization-domain/kubeconfig-org-user
new file mode 100644
index 000000000..88927f050
--- /dev/null
+++ b/test/resource-management/organization-domain/kubeconfig-org-user
@@ -0,0 +1,18 @@
+apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://localhost:30443/apis/resourcemanager.miloapis.com/v1alpha1/organizations/od-acme/control-plane
+ name: org-od-acme
+contexts:
+- context:
+ cluster: org-od-acme
+ user: test-user
+ name: org-od-acme
+current-context: org-od-acme
+kind: Config
+preferences: {}
+users:
+- name: test-user
+ user:
+ token: test-user-token
diff --git a/test/resource-management/project-suspension-lifecycle/07-test-user-rbac.yaml b/test/resource-management/project-suspension-lifecycle/07-test-user-rbac.yaml
index 111704317..898b8544a 100644
--- a/test/resource-management/project-suspension-lifecycle/07-test-user-rbac.yaml
+++ b/test/resource-management/project-suspension-lifecycle/07-test-user-rbac.yaml
@@ -1,7 +1,7 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
- name: test-user-admin
+ name: test-user-admin-suspension-lifecycle
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole