feat: Add support for Entra Application#5232
Conversation
theunrepentantgeek
left a comment
There was a problem hiding this comment.
Deep Adversarial Code Review
Three independent review perspectives (Advocate, Skeptic, Architect) analyzed this PR. Findings synthesized below, posted as inline comments at relevant locations.
| Priority | Count | Summary |
|---|---|---|
| 🔴 Critical | 1 | Nil pointer panic on external deletion |
| 🟠 High | 4 | OData injection, can't clear fields, FEATURE.md, dead field |
| 🟡 Medium | 3 | Status from pre-PATCH, missing configmap save, DisplayName validation |
| 🔵 Low | 3 | Extract shared reconciler, decouple SDK, cosmetic consistency |
Verified robust: Annotation persistence on partial create failure ✅, Delete path ✅, Webhook defaulting ✅
The FEATURE.md file is an implementation plan that belongs in the PR description or issue, not in the repository root alongside README.md and CONTRIBUTING.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CredentialProvider field is declared on both EntraApplicationReconciler and EntraSecurityGroupReconciler but never populated by their constructors and never referenced anywhere. The credential flow goes through EntraClientFactory → EntraClientCache.GetConnection() → the provider, making this field unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace reconcile.Result{} with ctrl.Result{} in both the Application
and SecurityGroup reconcilers for consistency. They are the same
underlying type, but all other return sites use ctrl.Result{}.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extract the duplicated isNotFound method from both EntraApplicationReconciler and EntraSecurityGroupReconciler into a shared package-level function in errors.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
loadApplicationByID returns (nil, nil) for 404 errors, not an error. The isNotFound(err) check was dead code since err is always nil for a 404. The nil Applicationable would reach AssignToApplication and panic. Fix by checking for nil result after the error check instead of checking isNotFound on the error. Apply same fix to SecurityGroup reconciler which has the same pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Display names containing single quotes (e.g., "O'Brien's App") produce malformed OData filters, causing 400 errors from the Graph API. Escape single quotes by doubling them per OData conventions. Fix applied to both Application and SecurityGroup reconcilers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The PATCH return value was discarded and status was populated from the locally-mutated model. If the Graph API normalizes, rejects, or augments any field, status would diverge from reality. Now use the server response from PATCH to populate status, ensuring it reflects the authoritative server state. Applied to both Application and SecurityGroup reconcilers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The create() path saves ConfigMaps but update() did not, meaning ConfigMaps would not be refreshed after an update until the next UpdateStatus() cycle. Add the call for symmetry with create(). Applied to both Application and SecurityGroup reconcilers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously, nil/empty guards meant that removing a field from the spec (e.g., clearing identifierUris, tags, web, spa, or publicClient) would never send the empty value to the API - the field would persist unchanged in Entra. Now all fields are always set so that clearing a value in the K8s spec correctly propagates to the Graph API. Same fix applied to sub-type assignment methods (Web, Spa, PublicClient). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new hand-written Entra (Azure AD) Application resource to ASO v2 (non-ARM), including controller reconciliation via Microsoft Graph, webhooks, samples, docs, and record/replay tests to validate CRUD behavior.
Changes:
- Introduces
entra.azure.com/v1 ApplicationAPI type, webhook defaulting/validation, and controller registration. - Implements a Microsoft Graph-based reconciler for Applications (adopt/create/update/delete) and updates SecurityGroup reconciler behavior.
- Adds samples, docs reference entries, and test recordings + a new CRUD test for Entra Applications.
Reviewed changes
Copilot reviewed 15 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| v2/tools/generator/internal/astbuilder/comments.go | Minor linter directive formatting adjustment. |
| v2/samples/entra/v1/v1_application.yaml | New sample manifest for Entra Application. |
| v2/internal/testsamples/recordings/Test_Samples_CreationAndDeletion/Test_Entra_v1_CreationAndDeletion.yaml | Updated sample-test VCR recording to include Application interactions. |
| v2/internal/reconcilers/entra/odata.go | Adds OData string escaping helper for Graph filters. |
| v2/internal/reconcilers/entra/errors.go | Adds shared Graph “not found” detection helper. |
| v2/internal/reconcilers/entra/entra_securitygroup_reconciler.go | Refactors not-found handling + adds status/resource export behavior changes. |
| v2/internal/reconcilers/entra/entra_application_reconciler.go | New reconciler implementing CRUD/adoption for Entra Applications + configmap export. |
| v2/internal/controllers/recordings/Test_Entra_SecurityGroup_v1_CRUD.yaml | Updated SecurityGroup controller VCR recording. |
| v2/internal/controllers/recordings/Test_Entra_Application_v1_CRUD.yaml | New Application controller VCR recording. |
| v2/internal/controllers/entra_application_v1_test.go | New controller CRUD test for Entra Application + configmap assertions. |
| v2/internal/controllers/controller_resources.go | Registers Application storage type and webhook. |
| v2/azure-arm/entra.yaml | Marks Application as supported from v2.20.0 in Entra group config. |
| v2/api/entra/v1/zz_generated.deepcopy.go | Generated deepcopy updates for new Application types. |
| v2/api/entra/v1/webhook/application_webhook_types.go | Adds defaulting + validator webhook for Application. |
| v2/api/entra/v1/application_types.go | Defines Application spec/status and Graph model assignment helpers. |
| docs/hugo/content/reference/entra/_index.md | Documents Application availability and links to sample. |
| docs/hugo/content/reference/_index.md | Adds Application to the global reference index. |
Files not reviewed (1)
- v2/api/entra/v1/zz_generated.deepcopy.go: Generated file
| - chunked | ||
| content_length: -1 | ||
| uncompressed: true | ||
| body: '{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#applications/$entity","id":"12bffd98-e5b5-4a9e-a8c1-25ac9b532f27","deletedDateTime":null,"appId":"e9cd4cdd-db0c-4d5e-a4f1-3b189514f799","applicationTemplateId":null,"disabledByMicrosoftStatus":null,"createdByAppId":"04b07795-8ddb-461a-bbee-02f9e1bf7b46","createdDateTime":"2001-02-03T04:05:06Z","displayName":"ASO Test Application","description":null,"groupMembershipClaims":null,"identifierUris":[],"isDeviceOnlyAuthSupported":null,"isDisabled":null,"isFallbackPublicClient":null,"nativeAuthenticationApisEnabled":null,"notes":null,"publisherDomain":"bevanarpsoutlook.onmicrosoft.com","serviceManagementReference":null,"signInAudience":"AzureADMyOrg","tags":[],"tokenEncryptionKeyId":null,"uniqueName":null,"samlMetadataUrl":null,"defaultRedirectUri":null,"certification":null,"optionalClaims":null,"servicePrincipalLockConfiguration":null,"requestSignatureVerification":null,"addIns":[],"api":{"acceptMappedClaims":null,"knownClientApplications":[],"requestedAccessTokenVersion":null,"oauth2PermissionScopes":[],"preAuthorizedApplications":[]},"appRoles":[],"info":{"logoUrl":null,"marketingUrl":null,"privacyStatementUrl":null,"supportUrl":null,"termsOfServiceUrl":null},"keyCredentials":[],"parentalControlSettings":{"countriesBlockedForMinors":[],"legalAgeGroupRule":"Allow"},"passwordCredentials":[],"publicClient":{"redirectUris":[]},"requiredResourceAccess":[],"verifiedPublisher":{"displayName":null,"verifiedPublisherId":null,"addedDateTime":null},"web":{"homePageUrl":null,"logoutUrl":null,"redirectUris":[],"implicitGrantSettings":{"enableAccessTokenIssuance":false,"enableIdTokenIssuance":false},"redirectUriSettings":[]},"spa":{"redirectUris":[]}}' |
| Content-Type: | ||
| - application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8 | ||
| Location: | ||
| - https://graph.microsoft.com/v1.0/9b0c84c7-21c4-4c92-ae62-d9c1eb53333f/directoryObjects/12bffd98-e5b5-4a9e-a8c1-25ac9b532f27/Microsoft.DirectoryServices.Application |
| app, err := r.asApplication(obj) | ||
| if err != nil { | ||
| return ctrl.Result{}, eris.Wrapf(err, "creating or updating application %s", app.Name) | ||
| } |
| app, err := r.asApplication(obj) | ||
| if err != nil { | ||
| return ctrl.Result{}, eris.Wrapf(err, "deleting application %s", app.Name) | ||
| } |
| app, err := r.asApplication(obj) | ||
| if err != nil { | ||
| return eris.Wrapf(err, "updating status of application %s", app.Name) | ||
| } |
| if result == nil { | ||
| // Didn't get a result back from the patch, load the group again to get the latest state | ||
| log.V(Status).Info("No result returned from update, reloading group to get latest state") | ||
|
|
||
| result, err = r.loadGroupByID(ctx, id, client.Client()) | ||
| if err != nil { | ||
| return ctrl.Result{}, eris.Wrapf(err, "getting group by ID %s after update returned no result", id) | ||
| } | ||
| } |
| groupable, err := r.loadGroupByID(ctx, id, client.Client()) | ||
| if err != nil { | ||
| // If the group doesn't exist, nothing to do as we're probably in the midst of deleting it | ||
| if r.isNotFound(err) { | ||
| if isNotFound(err) { | ||
| return nil |
| model.SetGroupMembershipClaims(spec.GroupMembershipClaims) | ||
| } | ||
|
|
||
| type ApplicationStatus struct { |
There was a problem hiding this comment.
How did you decide what to put in status, versus what not to?
| } | ||
|
|
||
| func (status *ApplicationStatus) AssignFromApplication(model models.Applicationable) { | ||
| if model != nil { |
There was a problem hiding this comment.
minor, style:
you could avoid nesting by bailing out early if model != nil
if model != nil {
return
}
| app, err := r.asApplication(obj) | ||
| if err != nil { | ||
| return ctrl.Result{}, eris.Wrapf(err, "creating or updating application %s", app.Name) | ||
| } |
| ) | ||
|
|
||
| // EntraSecurityGroupReconciler reconciles an Entra security group. | ||
| // TODO: Factor out common code shared with other Entra resources into entraGenericReconciler |
There was a problem hiding this comment.
Is this something that makes sense to do as part of this PR?
There was a problem hiding this comment.
| app, err := r.asApplication(obj) | ||
| if err != nil { | ||
| return ctrl.Result{}, eris.Wrapf(err, "creating or updating application %s", app.Name) | ||
| } |
| } | ||
|
|
||
| var displayName string | ||
| if app.Spec.DisplayName != nil { |
There was a problem hiding this comment.
displayName = to.FromPtr() would work here, though see comment below I dont even think you need/want this naem at all?
| } | ||
|
|
||
| if result == nil { | ||
| // Didn't get a result back from the patch, load the application again to get the latest state |
There was a problem hiding this comment.
Can you expand... is this expected? I don't fully understand what "no response from patch" means in terms of if it was expected or what ends up happening (which is that we load it and then return?)
| // Try to find the application by DisplayName | ||
| displayName := app.Spec.DisplayName | ||
| if to.Value(displayName) == "" { | ||
| // Can't adopt without a display name |
There was a problem hiding this comment.
Is this an error condition? Or success?
There was a problem hiding this comment.
Seems unexpected, should this be an error?
|
|
||
| // saveAssociatedKubernetesResources retrieves Kubernetes resources to create and saves them to Kubernetes. | ||
| // If there are no resources to save this method is a no-op. | ||
| func (r *EntraApplicationReconciler) saveAssociatedKubernetesResources( |
There was a problem hiding this comment.
Duplicate from EntraSecurityGroupReconciler? Wondering if we can combine some of this w/ like an interface that exposes operatorSpec.ConfigMaps? Similar to what we do with the generic reconciler?
| return ctrl.Result{}, eris.Wrapf(err, "getting group by ID %s", id) | ||
| } | ||
|
|
||
| if g == nil { |
What this PR does
Adds support for Entra Application as a new resource type.
How does this PR make you feel?
Fixes #5217