Files
fleet/server/service/conditional_access_cleanup.go
Dante Catalfamo 49db931ffb Auto-clean duplicate Okta CA SCEP cert after profile install (#46172)
**Related issue:** Resolves #42757

## Summary

Resending or renewing the Okta conditional access profile leaves an
orphaned SCEP certificate in the per-user macOS keychain, accumulating
duplicates with every renewal. This PR auto-runs an existing
keychain-cleanup script after a successful `InstallProfile` ack for the
Okta CA profile, so admins no longer have to find and run the script
manually.

## Root cause

Investigation in the issue thread isolated the trigger:

- The Okta CA `.mobileconfig` bundles `com.apple.security.scep` with
`com.apple.security.identitypreference` in a single profile (macOS
rejects the alternative — `Identity payload not found in same profile as
identity preference payload`).
- The Identity Preference payload creates a keychain-resident preference
item that keeps the *old* cert pinned across profile replacement, even
though the rewritten Identity Preference now points to the fresh SCEP
enrollment.
- EAP-TLS Wi-Fi profiles renew cleanly because they reference the cert
via SystemConfiguration (`PayloadCertificateUUID`), not the keychain —
so this isn't a generic SCEP-bundling issue.

The team decision in the issue (`@sharon-fdm`) was to delete the
duplicate certificate rather than restructure the profile. A standalone
cleanup script already shipped at
`docs/solutions/macos/scripts/delete-duplicate-scep-certificates.sh` and
was linked from the Okta CA guide; admins had to find and run it.

## Approach

Hook the existing Apple MDM `InstallProfile` ack path in
`MDMAppleCheckinAndCommandService.CommandAndReportResults`, parallel to
the existing ACME `CertificateList` follow-up. When the ack is for the
Okta CA profile and status is `verifying`, enqueue an internal host
script run that executes the cleanup script targeting the host's
per-user MDM enrollment short name.

Key properties:

- **Single hook, three paths covered.** Admin "Resend" nulls the profile
status and the reconciliation cron re-enqueues an `InstallProfile`; the
SCEP renewal cron also re-issues `InstallProfile`. Both flow through the
same ack handler this hook attaches to.
- **Idempotent.** The cleanup script no-ops when only one matching cert
is present, so triggering on initial installs (not just renewals) is
safe and removes the need to distinguish "is this a renewal".
- **Tightly gated.** Single indexed lookup keyed on `(host_uuid,
command_uuid, profile_identifier, platform='darwin')`. Other
SCEP-bearing profiles do not trigger the script. No work happens for
hosts with no per-user enrollment.
- **Internal-script semantics** (matches lock/unlock/wipe prior art).
Runs even when scripts are globally disabled. Does not appear in the
user-facing host activity feed.
- **Failure-isolated.** Enqueue errors are logged but do not break the
ack path; the renewal itself is what matters.
- **Defense in depth on the shell call.** The macOS short name is
validated against a strict regex (`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`, ≤31
chars) before being interpolated, and POSIX single-quote-escaped on the
way through.

## Files

**New**
- `server/service/conditional_access_cleanup.go` — `//go:embed` of the
cleanup script, the hook helper `maybeRunOktaCACleanupScript`, the
validated shell-wrapper builder, and the POSIX single-quote escape
helper.
- `server/service/conditional_access_cleanup_test.go` — unit coverage
for username validation, shell escaping, the routing decisions of the
hook helper (mock-based), and an embed-sync assertion against the docs
copy.
-
`server/service/embedded_scripts/delete-duplicate-scep-certificates.sh`
— embed source-of-truth copy, byte-for-byte equal to the public
`docs/solutions/macos/scripts/` script.
- `changes/42757-okta-conditional-access-duplicate-scep-cert-cleanup` —
user-visible changes note.

**Datastore**
- `server/datastore/mysql/mdm.go` —
`OktaCACleanupTargetForInstallCommand`: single SQL lookup that returns
`(host_id, user_short_name, ok)` for the new hook. Returns `ok=false`
for non-Okta profiles, non-darwin hosts, or hosts without a user-channel
enrollment.
- `server/datastore/mysql/scripts.go` —
`NewInternalHostScriptExecutionRequest`: thin wrapper that routes
through the existing internal-script codepath (`isInternal=true`) used
by lock/unlock/wipe. Refactored the existing public method to share an
internal helper.

**Interface / mocks**
- `server/fleet/conditional_access_idp.go` — exported
`ConditionalAccessOktaProfileIdentifier`,
`ConditionalAccessOktaCertificateCN`, and the new `OktaCACleanupTarget`
struct, so both the template-render path and the SQL lookup can
reference the same source of truth.
- `server/fleet/datastore.go` — `OktaCACleanupTargetForInstallCommand`
and `NewInternalHostScriptExecutionRequest` added to the `Datastore`
interface.
- `server/mock/datastore_mock.go` — regenerated (additions only).

**Wiring**
- `server/service/apple_mdm.go` — call into
`maybeRunOktaCACleanupScript` from the InstallProfile
`MDMDeliveryVerifying` branch, alongside the existing ACME
`maybeQueueCertificateListForACMEProfile` follow-up. Warns on error
rather than failing the ack.
- `server/service/conditional_access_idp.go` — use the new
`fleet.ConditionalAccessOktaCertificateCN` constant when rendering the
profile template, eliminating the magic string duplication.

**Tests touched**
- `server/datastore/mysql/mdm_test.go` — integration test
`testOktaCACleanupTargetForInstallCommand` covering the happy path,
non-Okta profile, device-only enrollment, and unknown command.
- `server/datastore/mysql/scripts_test.go` —
`testNewInternalHostScriptExecutionRequest` confirming the internal flag
is set correctly and the new entry only appears under the internal-only
listing filter.
- `server/service/apple_mdm_test.go` — added the new mock stub for
`OktaCACleanupTargetForInstallCommandFunc` to
`TestMDMCommandAndReportResultsProfileHandling` so the existing test
continues to pass with the new hook in the codepath.
- `server/service/conditional_access_idp_test.go` — the rendered-profile
assertion now also pins on the shared
`ConditionalAccessOktaProfileIdentifier` and
`ConditionalAccessOktaCertificateCN` constants so the template can't
drift from the SQL lookup.
2026-06-05 16:20:32 -04:00

84 lines
3.2 KiB
Go

package service
import (
"context"
_ "embed"
"fmt"
"regexp"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// deleteDuplicateOktaSCEPScript is the macOS shell script that removes
// orphaned duplicate SCEP certificates left in the per-user keychain after
// the Okta conditional access profile is reinstalled or renewed. The
// canonical, customer-facing copy lives at
// docs/solutions/macos/scripts/delete-duplicate-scep-certificates.sh; the
// embed_sync test asserts the two stay byte-identical.
//
//go:embed embedded_scripts/delete-duplicate-scep-certificates.sh
var deleteDuplicateOktaSCEPScript string
// macOS short names are 1-31 chars of ASCII letters, digits, and a small
// set of punctuation. We're conservative here because the value is
// interpolated into a shell command line; single-quote escaping covers the
// rest. Reject anything weird so a malformed nano_users row cannot reach
// the shell.
var validMacOSShortNameRE = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]*$`)
// buildOktaCACleanupScript wraps the embedded cleanup script with the
// positional arguments it expects (auto-confirm, target user, certificate
// CN). Returns ok=false when the username fails validation, in which case
// the caller should log and skip rather than dispatch a malformed script.
func buildOktaCACleanupScript(username string) (string, bool) {
if len(username) == 0 || len(username) > 31 || !validMacOSShortNameRE.MatchString(username) {
return "", false
}
return fmt.Sprintf("#!/bin/bash\nset -- -y -u %s %s\n%s",
shellSingleQuote(username),
shellSingleQuote(fleet.ConditionalAccessOktaCertificateCN),
deleteDuplicateOktaSCEPScript,
), true
}
// shellSingleQuote returns s wrapped in single quotes, escaping any
// embedded single quotes via the POSIX 'foo'"'"'bar' idiom.
func shellSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
}
// maybeRunOktaCACleanupScript schedules the Okta conditional access
// keychain-cleanup script on the host after a successful InstallProfile
// ack for the Okta CA profile. It silently no-ops when the command does
// not apply to the Okta CA profile, when no per-user MDM enrollment short
// name is on record, or when the short name fails validation. Errors are
// returned to the caller for logging; the caller must not fail the ack
// path on them.
func (svc *MDMAppleCheckinAndCommandService) maybeRunOktaCACleanupScript(ctx context.Context, hostUUID, commandUUID string) error {
target, ok, err := svc.ds.OktaCACleanupTargetForInstallCommand(ctx, hostUUID, commandUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "look up Okta CA cleanup target")
}
if !ok {
return nil
}
script, ok := buildOktaCACleanupScript(target.UserShortName)
if !ok {
svc.logger.DebugContext(ctx, "skip Okta CA keychain cleanup: invalid macOS username",
"host_uuid", hostUUID, "command_uuid", commandUUID)
return nil
}
if _, err := svc.ds.NewInternalHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: target.HostID,
ScriptContents: script,
SyncRequest: false,
}); err != nil {
return ctxerr.Wrap(ctx, err, "enqueue Okta CA keychain cleanup script")
}
return nil
}