From 2cbd3a214859fc0798c47823615f1064e19dea7c Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:03:19 +0000 Subject: [PATCH 1/3] feat: Let organizations verify domains without a project An organization could prove it owned a domain only by adding it as a Domain in one of its projects. Email domain joining then depended on that project, and the company's email domain sat in a project it served nothing from. OrganizationDomain is a new resource in the organization's namespace, named after its domain. Milo publishes a random DNS TXT value for it, looks the record up (often at first, then backing off), and sets Verified once it finds it. A verified domain stays verified. - The webhook rejects public suffixes, public email providers, upper case, names that aren't the domain, and other organizations' namespaces. - Email domain joining accepts a domain an OrganizationDomain or a project Domain verifies, and reconciles the organization when an OrganizationDomain becomes verified or is removed. - Owners can add and remove domains through resourcemanager-admin; readers and organization viewers can see them. - --organization-domain-record-prefix sets the TXT record name's label, _milo-verification by default. - Everything is behind the EmailDomainJoining feature gate. --- .../controller-manager/controllermanager.go | 13 + cmd/milo/controller-manager/webhooks.go | 3 + .../core-control-plane/rbac/role.yaml | 24 +- .../bases/resourcemanager/kustomization.yaml | 1 + ...ager.miloapis.com_organizationdomains.yaml | 199 +++++++++++ .../resourcemanager/kustomization.yaml | 1 + .../resourcemanager/organizationdomain.yaml | 27 ++ config/roles/kustomization.yaml | 3 + config/roles/organization-admin.yaml | 1 + config/roles/organization-viewer.yaml | 1 + config/roles/organizationdomain-admin.yaml | 11 + config/roles/organizationdomain-editor.yaml | 16 + config/roles/organizationdomain-reader.yaml | 14 + config/roles/resourcemanager-admin.yaml | 1 + config/roles/resourcemanager-reader.yaml | 2 + .../v1alpha1/organizationdomain.yaml | 10 + .../resourcemanager/kustomization.yaml | 1 + .../organizationdomain-policy.yaml | 33 ++ config/webhook/manifests.yaml | 22 ++ docs/api/resourcemanager.md | 308 ++++++++++++++++++ go.mod | 2 +- .../iam/emaildomainjoining_controller.go | 39 ++- .../iam/emaildomainjoining_controller_test.go | 43 +++ .../organization_domain_controller.go | 209 ++++++++++++ .../organization_domain_controller_test.go | 249 ++++++++++++++ internal/emaildomain/emaildomain.go | 49 ++- internal/emaildomain/emaildomain_test.go | 30 ++ .../v1alpha1/organizationdomain_webhook.go | 132 ++++++++ .../organizationdomain_webhook_test.go | 114 +++++++ .../v1alpha1/organizationdomain_types.go | 139 ++++++++ pkg/apis/resourcemanager/v1alpha1/register.go | 2 + .../v1alpha1/zz_generated.deepcopy.go | 140 ++++++++ pkg/features/features.go | 8 +- 33 files changed, 1820 insertions(+), 27 deletions(-) create mode 100644 config/crd/bases/resourcemanager/resourcemanager.miloapis.com_organizationdomains.yaml create mode 100644 config/protected-resources/resourcemanager/organizationdomain.yaml create mode 100644 config/roles/organizationdomain-admin.yaml create mode 100644 config/roles/organizationdomain-editor.yaml create mode 100644 config/roles/organizationdomain-reader.yaml create mode 100644 config/samples/resourcemanager/v1alpha1/organizationdomain.yaml create mode 100644 config/services/activity/policies/resourcemanager/organizationdomain-policy.yaml create mode 100644 internal/controllers/resourcemanager/organization_domain_controller.go create mode 100644 internal/controllers/resourcemanager/organization_domain_controller_test.go create mode 100644 internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook.go create mode 100644 internal/webhooks/resourcemanager/v1alpha1/organizationdomain_webhook_test.go create mode 100644 pkg/apis/resourcemanager/v1alpha1/organizationdomain_types.go 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringresourcemanager.miloapis.com/v1alpha1true
kindstringOrganizationDomaintrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + OrganizationDomainSpec defines the domain an organization wants to verify.
+
true
statusobject + OrganizationDomainStatus reports how verification is going.
+
false
+ + +### OrganizationDomain.spec +[↩ Parent](#organizationdomain) + + + +OrganizationDomainSpec defines the domain an organization wants to verify. + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
domainNamestring + 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. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    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
    verificationobject + 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + 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
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + 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
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + 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. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    dnsRecordobject + DNSRecord is the TXT record to add at the domain's DNS provider.
    +
    true
    lastAttemptTimestring + LastAttemptTime is when Milo last looked for the record.
    +
    + Format: date-time
    +
    false
    nextAttemptTimestring + 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. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    contentstring + Content is the value the record must hold.
    +
    true
    namestring + Name is the fully qualified record name, such as +_milo-verification.acme.com.
    +
    true
    typestring + Type is the record type. It is always TXT.
    +
    true
    + ## OrganizationMembership [↩ Parent](#resourcemanagermiloapiscomv1alpha1 ) diff --git a/go.mod b/go.mod index 0e6c762e2..0c6ac86d0 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 From 56ed214f854f639cd15089317afeb698efc112ec Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:47:22 +0000 Subject: [PATCH 2/3] test: Cover OrganizationDomain end to end Adds a Chainsaw test for the webhook's rejections, the published TXT record, domainName immutability, and email domain joining with an organization-verified domain: rejected while unverified, accepted once verified, an offer for a user at the domain only, and the offer withdrawn when the domain is removed. The admin marks the domain verified through the status subresource, since CI can't publish a DNS record. --- .../organization-domain/01-organization.yaml | 10 + .../02-rbac-domain-manager.yaml | 28 +++ .../03-organization-joining.yaml | 16 ++ .../organization-domain/04-users.yaml | 21 ++ .../organization-domain/README.md | 143 +++++++++++ .../organization-domain/chainsaw-test.yaml | 223 ++++++++++++++++++ .../domain-acme-renamed.yaml | 7 + .../organization-domain/domain-acme.yaml | 7 + .../domain-public-provider.yaml | 7 + .../domain-public-suffix.yaml | 7 + .../domain-wrong-name.yaml | 7 + .../organization-domain/kubeconfig-main | 18 ++ .../organization-domain/kubeconfig-org-user | 18 ++ 13 files changed, 512 insertions(+) create mode 100644 test/resource-management/organization-domain/01-organization.yaml create mode 100644 test/resource-management/organization-domain/02-rbac-domain-manager.yaml create mode 100644 test/resource-management/organization-domain/03-organization-joining.yaml create mode 100644 test/resource-management/organization-domain/04-users.yaml create mode 100644 test/resource-management/organization-domain/README.md create mode 100644 test/resource-management/organization-domain/chainsaw-test.yaml create mode 100644 test/resource-management/organization-domain/domain-acme-renamed.yaml create mode 100644 test/resource-management/organization-domain/domain-acme.yaml create mode 100644 test/resource-management/organization-domain/domain-public-provider.yaml create mode 100644 test/resource-management/organization-domain/domain-public-suffix.yaml create mode 100644 test/resource-management/organization-domain/domain-wrong-name.yaml create mode 100644 test/resource-management/organization-domain/kubeconfig-main create mode 100644 test/resource-management/organization-domain/kubeconfig-org-user 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 From c5f6a75b6e3b7df2f2d5e1baf3f0a025c96ba693 Mon Sep 17 00:00:00 2001 From: Matt Jenkinson <75292329+mattdjenkinson@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:00:55 +0000 Subject: [PATCH 3/3] test: Stop two suspension tests sharing a ClusterRoleBinding project-suspension-lifecycle and the projectsuspension admission test both created the cluster-scoped test-user-admin binding and deleted it on cleanup. When they overlapped, the first to finish removed the other's access, and project-suspension-lifecycle failed with test-user forbidden to create namespaces. Give each test its own binding. --- test/admission/projectsuspension/05-test-user-rbac.yaml | 2 +- .../project-suspension-lifecycle/07-test-user-rbac.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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