Skip to content

feat: add optional activeAccount to UserValidation - #123

Merged
ytsarev merged 3 commits into
mainfrom
account-enabled
Aug 14, 2026
Merged

feat: add optional activeAccount to UserValidation#123
ytsarev merged 3 commits into
mainfrom
account-enabled

Conversation

@ytsarev

@ytsarev ytsarev commented Aug 12, 2026

Copy link
Copy Markdown
Member

Description of your changes

UserValidation only proved that a user exists in Entra ID, so a disabled (for
example offboarded) account passed validation like an active one. This adds an
opt-in check that the account is also enabled.

activeAccount: true additionally requires the accountEnabled attribute to be
true and omits disabled users from the result written to the target:

input:
  apiVersion: msgraph.fn.crossplane.io/v1alpha1
  kind: Input
  queryType: UserValidation
  usersRef: context.[apiextensions.crossplane.io/environment].users
  target: "status.validatedUsers"
  activeAccount: true

accountEnabled is added to the $select projection, because Microsoft Graph
returns it only when explicitly selected, and is filtered client side. Filtering
server side via and accountEnabled eq true would make "disabled" and "does not
exist" indistinguishable, since both come back as an empty collection, and would
leave accountEnabled either constantly true or absent in the output.

The value is reported for every validated user regardless of the flag, so the
account state stays visible to downstream consumers. A user for which Graph
returns no accountEnabled value is treated as disabled: when the state cannot
be confirmed, activeAccount excludes the user.

Notes for reviewers

  • The result map gains accountEnabled even when the flag is unset. In
    Operation mode this registers as a one-time drift on the first reconcile after
    upgrade (hasQueryResultDriftedFromTarget compares with reflect.DeepEqual),
    and consumers whose XRD constrains the target with a structural item schema
    need to add the field or the API server prunes it.
  • The flag filters the result of a query. It does not purge a target held static
    by skipQueryWhenTargetHasData or queryInterval, so adopters with an
    existing target must clear it once. Documented in README.md and
    example/e2e/README.md.
  • Nil handling is fail closed by design: an unconfirmed account state must not
    pass a gating check.
  • Scope is UserValidation only. GroupMembership user members are unchanged.
  • Result-map building is extracted into buildUserResults, which keeps
    validateUsers under the gocyclo ceiling and makes the filter unit testable
    without a Graph client, following the existing processMember precedent.

Testing

  • TestBuildUserResults covers the filter, including the fail-closed
    unconfirmed-state case and the empty/nil edges, plus a new TestRunFunction
    case proving the field is parsed off the manifest and reaches the query.
  • Verified against a live Entra ID tenant with one enabled and one deliberately
    disabled account, via crossplane render and end-to-end on a kind cluster
    running the packaged build: without the flag the disabled account passes
    validation and is reported as accountEnabled: false, with the flag it is
    excluded, and the filtering holds across forced reconciles.
  • example/e2e/ gains an activeAccount scenario (two Compositions identical
    except for the flag) so the difference is observable in one cluster.

I have:

UserValidation only proved that a user exists in Entra ID, so a disabled
(for example offboarded) account passed validation exactly like an active
one.

Add an optional activeAccount flag to the Input. When set, UserValidation
additionally requires the accountEnabled attribute to be true and omits
disabled users from the result written to the target.

Microsoft Graph returns accountEnabled only when it is explicitly
selected, so it is added to the $select projection and filtered client
side. Filtering server side via "and accountEnabled eq true" would make
"disabled" and "does not exist" indistinguishable (both come back as an
empty collection) and would leave accountEnabled either constantly true
or absent in the output.

accountEnabled is reported for every validated user regardless of the
flag, so the account state stays visible to downstream consumers. A user
for which Graph returns no accountEnabled value is treated as disabled:
when the state cannot be confirmed, activeAccount excludes the user.
Excluded users are logged at debug level, consistent with how a
non-existent user is silently omitted today.

The result-map building is extracted into buildUserResults so the added
branch stays under the gocyclo ceiling and is unit testable without a
Graph client, following the existing processMember precedent.

Notable for adopters: activeAccount filters the result of a query, it
does not purge a target that is no longer refreshed, so enabling it
alongside skipQueryWhenTargetHasData leaves already-stored disabled users
in place until the target is cleared once. Both READMEs document this.

Signed-off-by: Yury Tsarev <yury@upbound.io>
golangci-lint v2.12.2 (used in CI) reported two goconst findings that the
locally installed v2.11.4 did not:

  fn_test.go:990: string `user-id-1` has 7 occurrences, make it a constant
  fn_test.go:1599: string `Test User` has 8 occurrences, make it a constant

The activeAccount test fixtures and cases pushed both literals over the
configured min-occurrences of 5. Extract them as testUserID1 and
testUserDisplay alongside the existing fixture constants, and use them at
every Go call site. Occurrences inside the raw-string JSON request bodies
are part of a single larger literal and are not counted by goconst, so
they are left as they are.

No behavior change. Verified with golangci-lint v2.12.2 (0 issues) and
go test -count=1 ./... (all pass).

Signed-off-by: Yury Tsarev <yury@upbound.io>
Adds cluster manifests for the activeAccount filter alongside the existing
queryInterval scenario: two Compositions identical except for the flag, and
one XR each, so the difference is observable in a single cluster. The
baseline shows a disabled account passing validation, which is the
behaviour the flag exists to change.

The e2e README is restructured around the two scenarios.

function.yaml gains a DeploymentRuntimeConfig enabling --debug, so the
per-user exclusions and the queryInterval skips are visible in the pod
logs, and pins the tag to v0.8.0-rc2.

The Compositions use placeholder UPNs deliberately: substitute one enabled
and one disabled account from your own directory before running.

Verified against a kind cluster running v0.8.0-rc2: both XRs reach
SYNCED/READY with FunctionSuccess, the baseline stores the disabled user
with accountEnabled false, the activeAccount XR stores only the enabled
user, and the exclusion is logged. All manifests pass kubectl apply
--dry-run=server.

Signed-off-by: Yury Tsarev <yury@upbound.io>
@ytsarev
ytsarev requested a review from a team August 13, 2026 08:25

@turkenf turkenf left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @ytsarev, LGTM 🙌

@jboero

jboero commented Aug 13, 2026

Copy link
Copy Markdown

This looks good to me too but I had Claude take a look too. Is this OK? Seems like possible drift loop.

1. The added field can cause a permanent drift loop, not a one-time one

The PR notes two things separately in "Notes for reviewers":

- the result map gains accountEnabled, which registers as "a one-time drift on the first reconcile after upgrade"
- consumers with a structural XRD item schema "need to add the field or the API server prunes it"

Those two interact, and the combination isn't one-time. hasQueryResultDriftedFromTarget is a straight !reflect.DeepEqual(observedValue, results) (fn.go:1043-1057). If the consumer's XRD prunes accountEnabled, the observed target will never contain the field while results always will, so drift evaluates true on every reconcile, forever.

That matters because drift isn't just an annotation. In shouldSkipQuery (fn.go:1379-1405), queryDriftDetected short-circuits ahead of the skip check:

// We should not skip if Operation annotation is set to "true"
if f.queryDriftDetected(req, inOperation) {
    return false
}

So a permanently-drifting target means skipQueryWhenTargetHasData is permanently bypassed, and every reconcile issues one Graph request per user. For an adopter with a structural XRD and a large users list, upgrading turns the throttling feature off and walks into Graph 429s, with the self-healing loop never converging.

Worth noting the e2e wouldn't have caught this: example/definition.yaml:88-93 uses x-kubernetes-preserve-unknown-fields: true, so nothing gets pruned in the test cluster.

@ytsarev

ytsarev commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

@jboero thanks for the double checking. My understanding is that the target field will have to be forcefully invalidated /cleaned up to propagate updated structure and that's ok for one off operation

@jboero

jboero commented Aug 13, 2026

Copy link
Copy Markdown

OK then if that's a prerequisite fine by me. Well done.

@jboero jboero left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good if we agree the drift loop should be avoided.

@ytsarev
ytsarev merged commit efd7b79 into main Aug 14, 2026
6 checks passed
@ytsarev
ytsarev deleted the account-enabled branch August 14, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants