**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.
84 lines
3.2 KiB
Go
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
|
|
}
|