**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.
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestEmbeddedCleanupScriptMatchesDocs(t *testing.T) {
|
|
docs, err := os.ReadFile("../../docs/solutions/macos/scripts/delete-duplicate-scep-certificates.sh")
|
|
require.NoError(t, err, "the public Okta CA cleanup script must exist; if you moved it, update the test path")
|
|
require.Equal(t, string(docs), deleteDuplicateOktaSCEPScript,
|
|
"embedded_scripts/delete-duplicate-scep-certificates.sh has drifted from the docs copy; keep them in sync")
|
|
}
|
|
|
|
func TestBuildOktaCACleanupScript(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
username string
|
|
wantOK bool
|
|
}{
|
|
{"valid simple", "alice", true},
|
|
{"valid with dot", "alice.smith", true},
|
|
{"valid with underscore prefix", "_servicedesk", true},
|
|
{"valid with dash", "alice-smith", true},
|
|
{"valid mixed", "Alice.Smith-1", true},
|
|
{"empty", "", false},
|
|
{"starts with dash", "-alice", false},
|
|
{"starts with dot", ".alice", false},
|
|
{"contains space", "alice smith", false},
|
|
{"contains slash", "alice/smith", false},
|
|
{"contains single quote", "ali'ce", false},
|
|
{"contains backtick", "ali`ce", false},
|
|
{"contains semicolon", "alice;rm -rf /", false},
|
|
{"contains shell metachar dollar", "ali$ce", false},
|
|
{"too long", strings.Repeat("a", 32), false},
|
|
{"exactly 31 chars", strings.Repeat("a", 31), true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, ok := buildOktaCACleanupScript(c.username)
|
|
require.Equal(t, c.wantOK, ok)
|
|
if !c.wantOK {
|
|
assert.Empty(t, got)
|
|
return
|
|
}
|
|
assert.Contains(t, got, "set -- -y -u '"+c.username+"' "+shellSingleQuote(fleet.ConditionalAccessOktaCertificateCN))
|
|
assert.Contains(t, got, deleteDuplicateOktaSCEPScript,
|
|
"wrapped script must contain the embedded cleanup script verbatim")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShellSingleQuote(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": "''",
|
|
"foo": "'foo'",
|
|
"foo bar": "'foo bar'",
|
|
"al'ce": `'al'"'"'ce'`,
|
|
"$danger`": "'$danger`'",
|
|
}
|
|
for in, want := range cases {
|
|
t.Run(in, func(t *testing.T) {
|
|
require.Equal(t, want, shellSingleQuote(in))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaybeRunOktaCACleanupScript(t *testing.T) {
|
|
ctx := context.Background()
|
|
const (
|
|
hostUUID = "host-uuid"
|
|
commandUUID = "cmd-uuid"
|
|
hostID = uint(99)
|
|
shortName = "alice"
|
|
)
|
|
probeErrBoom := errors.New("boom")
|
|
|
|
cases := []struct {
|
|
name string
|
|
target fleet.OktaCACleanupTarget
|
|
targetOK bool
|
|
targetErr error
|
|
wantErr bool
|
|
wantEnqueue bool
|
|
wantInvalidLog bool
|
|
}{
|
|
{
|
|
name: "okta CA profile + valid user: enqueues",
|
|
target: fleet.OktaCACleanupTarget{HostID: hostID, UserShortName: shortName},
|
|
targetOK: true,
|
|
wantEnqueue: true,
|
|
},
|
|
{
|
|
name: "not okta CA profile: no enqueue",
|
|
targetOK: false,
|
|
},
|
|
{
|
|
name: "lookup error: propagates",
|
|
targetErr: probeErrBoom,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid username: skipped without error",
|
|
target: fleet.OktaCACleanupTarget{HostID: hostID, UserShortName: "ali ce"},
|
|
targetOK: true,
|
|
wantInvalidLog: true,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
ds.OktaCACleanupTargetForInstallCommandFunc = func(_ context.Context, hUUID, cmdUUID string) (fleet.OktaCACleanupTarget, bool, error) {
|
|
require.Equal(t, hostUUID, hUUID)
|
|
require.Equal(t, commandUUID, cmdUUID)
|
|
return c.target, c.targetOK, c.targetErr
|
|
}
|
|
ds.NewInternalHostScriptExecutionRequestFunc = func(_ context.Context, req *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
|
|
require.Equal(t, hostID, req.HostID)
|
|
require.Contains(t, req.ScriptContents, "set -- -y -u '"+shortName+"' '"+fleet.ConditionalAccessOktaCertificateCN+"'")
|
|
require.Contains(t, req.ScriptContents, deleteDuplicateOktaSCEPScript)
|
|
require.False(t, req.SyncRequest)
|
|
require.Nil(t, req.UserID)
|
|
return &fleet.HostScriptResult{}, nil
|
|
}
|
|
|
|
svc := &MDMAppleCheckinAndCommandService{
|
|
ds: ds,
|
|
logger: slog.New(slog.DiscardHandler),
|
|
}
|
|
err := svc.maybeRunOktaCACleanupScript(ctx, hostUUID, commandUUID)
|
|
if c.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, c.wantEnqueue, ds.NewInternalHostScriptExecutionRequestFuncInvoked)
|
|
})
|
|
}
|
|
}
|