Android Wi-Fi profile withheld until cert installed on device (#42877)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42405

Demo video: https://www.youtube.com/watch?v=F3nfFvwdj-c

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Android Wi‑Fi configuration profiles that reference client
certificates are withheld until the certificate is installed or reaches
a terminal state.
* Host OS settings now show the specific pending reason in the detail
column when Android profiles are waiting on certificate installation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky 2026-04-07 16:26:09 -05:00 committed by GitHub
parent 6e3648a7d1
commit 36ad83f611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1081 additions and 11 deletions

View file

@ -0,0 +1,3 @@
- Android Wi-Fi configuration profiles (`openNetworkConfiguration` with `ClientCertKeyPairAlias`) are now withheld until the referenced certificate is installed or terminally failed on the device.
- The host OS settings detail column now shows the reason when an Android profile is pending due to a certificate dependency.
- `fleetctl gitops` now processes Android certificates before Android profiles

View file

@ -334,6 +334,10 @@ const OSSettingsErrorCell = ({
};
const isFailed = profile.status === "failed";
const isPending =
profile.status === "pending" ||
profile.status === "delivering" ||
profile.status === "delivered";
const isVerified = profile.status === "verified";
const showResendButton =
canResendProfiles &&
@ -341,7 +345,8 @@ const OSSettingsErrorCell = ({
profile.profile_uuid !== REC_LOCK_SYNTHETIC_PROFILE_UUID;
const showRotateButton =
canRotateRecoveryLockPassword && (isFailed || isVerified);
const value = (isFailed && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;
const value =
((isFailed || isPending) && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;
const tooltip = generateErrorTooltip(value, profile);

View file

@ -90,7 +90,7 @@ const generateTableConfig = (
},
},
{
Header: "Error",
Header: "Details",
disableSortBy: true,
accessor: "detail",
Cell: (cellProps: ITableStringCellProps) => {

View file

@ -10,6 +10,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
)
@ -172,6 +173,49 @@ func (ds *Datastore) GetHostCertificateTemplateRecord(ctx context.Context, hostU
return &result, nil
}
// GetCertificateTemplateStatusesByNameForHosts returns cert template statuses keyed by
// host UUID and template name for all given hosts in a single query. Only install records
// are considered; pending-remove rows are excluded so that the name-to-status mapping is
// deterministic when both exist for the same template name.
func (ds *Datastore) GetCertificateTemplateStatusesByNameForHosts(ctx context.Context, hostUUIDs []string) (map[string]map[string]fleet.CertificateTemplateStatus, error) {
if len(hostUUIDs) == 0 {
return nil, nil
}
result := make(map[string]map[string]fleet.CertificateTemplateStatus, len(hostUUIDs))
if err := platform_mysql.BatchProcessSimple(hostUUIDs, 5000, func(batch []string) error {
stmt, args, err := sqlx.In(`
SELECT hct.host_uuid, ct.name, hct.status
FROM host_certificate_templates hct
JOIN certificate_templates ct ON ct.id = hct.certificate_template_id
WHERE hct.host_uuid IN (?) AND hct.operation_type = ?
`, batch, fleet.MDMOperationTypeInstall)
if err != nil {
return ctxerr.Wrap(ctx, err, "build IN query for cert template statuses")
}
var rows []struct {
HostUUID string `db:"host_uuid"`
Name string `db:"name"`
Status string `db:"status"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "get certificate template statuses by name for hosts")
}
for _, r := range rows {
if result[r.HostUUID] == nil {
result[r.HostUUID] = make(map[string]fleet.CertificateTemplateStatus)
}
result[r.HostUUID][r.Name] = fleet.CertificateTemplateStatus(r.Status)
}
return nil
}); err != nil {
return nil, err
}
return result, nil
}
// RetryHostCertificateTemplate resets a failed certificate to pending for automatic retry,
// increments retry_count, preserves the error detail, and clears challenge/cert fields.
func (ds *Datastore) RetryHostCertificateTemplate(ctx context.Context, hostUUID string, certificateTemplateID uint, detail string) error {

View file

@ -867,6 +867,14 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.EqualValues(t, fleet.CertificateTemplateDelivering, *r.Status)
}
// GetCertificateTemplateStatusesByNameForHosts should return delivering for both (non-terminal -> withhold ONC profiles)
allCertStatuses, err := ds.GetCertificateTemplateStatusesByNameForHosts(ctx, []string{"android-host"})
require.NoError(t, err)
certStatuses := allCertStatuses["android-host"]
require.Len(t, certStatuses, 2)
require.Equal(t, fleet.CertificateTemplateDelivering, certStatuses[setup.template.Name])
require.Equal(t, fleet.CertificateTemplateDelivering, certStatuses["Test Cert 2"])
// Step 4: Transition to delivered (challenges are created on-demand)
err = ds.TransitionCertificateTemplatesToDelivered(ctx, "android-host", []uint{setup.template.ID, templateTwo.ID})
require.NoError(t, err)
@ -881,6 +889,14 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.Nil(t, r.FleetChallenge) // Challenge not created yet
}
// GetCertificateTemplateStatusesByNameForHosts should return delivered for both (still non-terminal)
allCertStatuses, err = ds.GetCertificateTemplateStatusesByNameForHosts(ctx, []string{"android-host"})
require.NoError(t, err)
certStatuses = allCertStatuses["android-host"]
require.Len(t, certStatuses, 2)
require.Equal(t, fleet.CertificateTemplateDelivered, certStatuses[setup.template.Name])
require.Equal(t, fleet.CertificateTemplateDelivered, certStatuses["Test Cert 2"])
// Step 5: Create challenges on-demand (simulating device fetch)
challenge1, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, "android-host", setup.template.ID)
require.NoError(t, err)
@ -905,6 +921,27 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
}
}
// Step 6: Transition first cert to verified, leave second as delivered.
// This tests that GetCertificateTemplateStatusesByNameForHosts returns mixed statuses
// and only includes install records.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_certificate_templates SET status = ? WHERE host_uuid = ? AND certificate_template_id = ?",
fleet.CertificateTemplateVerified, "android-host", setup.template.ID)
return err
})
allCertStatuses, err = ds.GetCertificateTemplateStatusesByNameForHosts(ctx, []string{"android-host"})
require.NoError(t, err)
certStatuses = allCertStatuses["android-host"]
require.Len(t, certStatuses, 2)
require.Equal(t, fleet.CertificateTemplateVerified, certStatuses[setup.template.Name])
require.Equal(t, fleet.CertificateTemplateDelivered, certStatuses["Test Cert 2"])
// Nonexistent host returns empty map
allCertStatuses, err = ds.GetCertificateTemplateStatusesByNameForHosts(ctx, []string{"no-such-host"})
require.NoError(t, err)
require.Empty(t, allCertStatuses)
// Test revert scenario: Create new pending records, transition to delivering, then revert
createEnrolledAndroidHost(t, ctx, ds, "revert-test-host", &setup.team.ID)
_, err = ds.CreatePendingCertificateTemplatesForExistingHosts(ctx, setup.template.ID, setup.team.ID)

View file

@ -2799,6 +2799,10 @@ type Datastore interface {
// RetryHostCertificateTemplate resets a failed certificate to pending for automatic retry, increments
// retry_count, preserves the error detail, and clears challenge/cert fields.
RetryHostCertificateTemplate(ctx context.Context, hostUUID string, certificateTemplateID uint, detail string) error
// GetCertificateTemplateStatusesByNameForHosts returns cert template statuses
// keyed by host UUID and template name for all given hosts in a single query.
// Only install records are considered; pending-remove rows are excluded.
GetCertificateTemplateStatusesByNameForHosts(ctx context.Context, hostUUIDs []string) (map[string]map[string]CertificateTemplateStatus, error)
// BulkInsertHostCertificateTemplates inserts multiple host_certificate_templates records.
BulkInsertHostCertificateTemplates(ctx context.Context, hostCertTemplates []HostCertificateTemplate) error
// DeleteHostCertificateTemplates deletes specific host_certificate_templates records

View file

@ -5,6 +5,10 @@ import "time"
// AndroidCertificateTemplateProfileID Used by the front-end for determining the displaying logic.
const AndroidCertificateTemplateProfileID = "fleet-host-certificate-template"
// ONCProfileWithheldDetailPrefix is the prefix used in the detail field of withheld Android
// profiles that are waiting for a certificate to be installed before they can be applied.
const ONCProfileWithheldDetailPrefix = "Waiting for certificate"
// MaxCertificateInstallRetries is the maximum number of automatic retries after the initial attempt
// when the Android agent reports a certificate install failure. Manual resend via the UI sets
// retry_count to this value so the resend gets exactly one attempt with no automatic retry.

81
server/mdm/android/onc.go Normal file
View file

@ -0,0 +1,81 @@
package android
import "encoding/json"
// ONC structs. Minimal types for extracting certificate alias references from
// Android's openNetworkConfiguration policy field (Chrome OS ONC spec).
type oncConfig struct {
NetworkConfigurations []oncNetworkConfiguration `json:"NetworkConfigurations"`
}
type oncNetworkConfiguration struct {
WiFi *oncEAPWrapper `json:"WiFi,omitempty"`
Ethernet *oncEAPWrapper `json:"Ethernet,omitempty"`
VPN *oncCertKeyHolder `json:"VPN,omitempty"`
}
// oncEAPWrapper is shared by WiFi and Ethernet, which nest the cert alias inside an EAP sub-object.
type oncEAPWrapper struct {
EAP *oncCertKeyHolder `json:"EAP,omitempty"`
}
// oncCertKeyHolder holds certificate client auth fields. ClientCertKeyPairAlias is only
// meaningful when ClientCertType is "KeyPairAlias" per the ONC spec; otherwise it is ignored.
type oncCertKeyHolder struct {
ClientCertType string `json:"ClientCertType,omitempty"`
ClientCertKeyPairAlias string `json:"ClientCertKeyPairAlias,omitempty"`
}
// extractAlias returns the ClientCertKeyPairAlias only when ClientCertType is "KeyPairAlias".
// Per the ONC spec, the alias field is ignored for all other ClientCertType values.
func extractAlias(h *oncCertKeyHolder) string {
if h.ClientCertType == "KeyPairAlias" && h.ClientCertKeyPairAlias != "" {
return h.ClientCertKeyPairAlias
}
return ""
}
// ExtractCertAliasesFromONC parses an openNetworkConfiguration JSON blob
// and returns all ClientCertKeyPairAlias values found.
func ExtractCertAliasesFromONC(oncJSON json.RawMessage) ([]string, error) {
var onc oncConfig
if err := json.Unmarshal(oncJSON, &onc); err != nil {
return nil, err
}
var aliases []string
for _, nc := range onc.NetworkConfigurations {
if nc.WiFi != nil && nc.WiFi.EAP != nil {
if a := extractAlias(nc.WiFi.EAP); a != "" {
aliases = append(aliases, a)
}
}
if nc.Ethernet != nil && nc.Ethernet.EAP != nil {
if a := extractAlias(nc.Ethernet.EAP); a != "" {
aliases = append(aliases, a)
}
}
if nc.VPN != nil {
if a := extractAlias(nc.VPN); a != "" {
aliases = append(aliases, a)
}
}
}
return aliases, nil
}
// ExtractCertAliasesFromProfileJSON parses an Android profile's raw JSON
// and returns any ClientCertKeyPairAlias values found in its
// openNetworkConfiguration field. Returns nil if no ONC field exists.
func ExtractCertAliasesFromProfileJSON(profileJSON json.RawMessage) ([]string, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(profileJSON, &fields); err != nil {
return nil, err
}
oncJSON, hasONC := fields["openNetworkConfiguration"]
if !hasONC {
return nil, nil
}
return ExtractCertAliasesFromONC(oncJSON)
}

View file

@ -0,0 +1,172 @@
package android
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractCertAliasesFromONC(t *testing.T) {
tests := []struct {
name string
oncJSON string
expected []string
wantErr bool
}{
{
name: "WiFi EAP with KeyPairAlias",
oncJSON: `{
"NetworkConfigurations": [{
"WiFi": {
"EAP": {
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": "wifi-cert"
}
}
}]
}`,
expected: []string{"wifi-cert"},
},
{
name: "VPN with KeyPairAlias",
oncJSON: `{
"NetworkConfigurations": [{
"VPN": {
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": "vpn-cert"
}
}]
}`,
expected: []string{"vpn-cert"},
},
{
name: "WiFi without EAP (WPA-PSK)",
oncJSON: `{
"NetworkConfigurations": [{
"WiFi": {
"SSID": "GuestNet",
"Security": "WPA-PSK",
"Passphrase": "guest123"
}
}]
}`,
expected: nil,
},
{
name: "alias present but ClientCertType is Ref (ignored per ONC spec)",
oncJSON: `{
"NetworkConfigurations": [{
"WiFi": {
"EAP": {
"ClientCertType": "Ref",
"ClientCertKeyPairAlias": "should-be-ignored"
}
}
}]
}`,
expected: nil,
},
{
name: "alias present but ClientCertType is missing (ignored per ONC spec)",
oncJSON: `{
"NetworkConfigurations": [{
"WiFi": {
"EAP": {
"ClientCertKeyPairAlias": "should-be-ignored"
}
}
}]
}`,
expected: nil,
},
{
name: "mix of KeyPairAlias and other ClientCertTypes",
oncJSON: `{
"NetworkConfigurations": [
{
"WiFi": {
"EAP": {
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": "real-cert"
}
}
},
{
"VPN": {
"ClientCertType": "Ref",
"ClientCertKeyPairAlias": "ignored-cert"
}
}
]
}`,
expected: []string{"real-cert"},
},
{
name: "invalid JSON",
oncJSON: `{invalid}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aliases, err := ExtractCertAliasesFromONC(json.RawMessage(tt.oncJSON))
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, aliases)
})
}
}
func TestExtractCertAliasesFromProfileJSON(t *testing.T) {
tests := []struct {
name string
profileJSON string
expected []string
wantErr bool
}{
{
name: "profile with ONC containing cert ref",
profileJSON: `{
"openNetworkConfiguration": {
"NetworkConfigurations": [{
"WiFi": {
"EAP": {
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": "my-cert"
}
}
}]
}
}`,
expected: []string{"my-cert"},
},
{
name: "profile without ONC field",
profileJSON: `{"cameraDisabled": true, "maximumTimeToLock": 300}`,
expected: nil,
},
{
name: "invalid JSON",
profileJSON: `not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aliases, err := ExtractCertAliasesFromProfileJSON(json.RawMessage(tt.profileJSON))
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, aliases)
})
}
}

View file

@ -117,10 +117,21 @@ func (r *profileReconciler) ReconcileProfiles(ctx context.Context) error {
toRemoveByHostUUID[prof.HostUUID] = append(toRemoveByHostUUID[prof.HostUUID], prof)
}
// Extract ONC cert aliases once for all hosts (profile contents are shared),
// then batch-fetch cert statuses for all hosts in a single DB query.
certAliases := extractProfileCertAliases(ctx, r.Logger, profilesContents)
var allCertStatuses map[string]map[string]fleet.CertificateTemplateStatus
if len(certAliases) > 0 {
allCertStatuses, err = r.DS.GetCertificateTemplateStatusesByNameForHosts(ctx, slices.Collect(maps.Keys(profilesByHostUUID)))
if err != nil {
return ctxerr.Wrap(ctx, err, "batch get certificate template statuses")
}
}
var bulkHostProfs []*fleet.MDMAndroidProfilePayload
for hostUUID, toInstallProfs := range profilesByHostUUID {
toRemove := toRemoveByHostUUID[hostUUID]
bulkProfs, err := r.sendHostProfiles(ctx, hostUUID, toInstallProfs, toRemove, profilesContents)
bulkProfs, err := r.sendHostProfiles(ctx, hostUUID, toInstallProfs, toRemove, profilesContents, certAliases, allCertStatuses[hostUUID])
if err != nil {
return ctxerr.Wrapf(ctx, err, "send profiles for host %s", hostUUID)
}
@ -130,7 +141,7 @@ func (r *profileReconciler) ReconcileProfiles(ctx context.Context) error {
// if there are hosts with only profiles to remove, process them too
for hostUUID, toRemove := range toRemoveByHostUUID {
bulkProfs, err := r.sendHostProfiles(ctx, hostUUID, nil, toRemove, nil)
bulkProfs, err := r.sendHostProfiles(ctx, hostUUID, nil, toRemove, nil, nil, nil)
if err != nil {
return ctxerr.Wrapf(ctx, err, "send profiles for host %s", hostUUID)
}
@ -149,6 +160,8 @@ func (r *profileReconciler) sendHostProfiles(
profilesToMerge []*fleet.MDMAndroidProfilePayload,
profilesToRemove []*fleet.MDMAndroidProfilePayload,
profilesContents map[string]json.RawMessage,
certAliases map[string][]string,
certStatuses map[string]fleet.CertificateTemplateStatus,
) ([]*fleet.MDMAndroidProfilePayload, error) {
const maxRequestFailures = 3
@ -162,8 +175,32 @@ func (r *profileReconciler) sendHostProfiles(
return cmp.Compare(a.ProfileName, b.ProfileName)
})
// Withhold profiles whose openNetworkConfiguration references certificates
// that are not yet verified or terminally failed on this host.
var withheldProfiles []*fleet.MDMAndroidProfilePayload
if len(certAliases) > 0 {
profilesToMerge, withheldProfiles = filterProfilesWithPendingCerts(profilesToMerge, certAliases, certStatuses)
}
// map of the bulk struct keyed by profile UUID
bulkProfilesByUUID := make(map[string]*fleet.MDMAndroidProfilePayload, len(profilesToMerge)+len(profilesToRemove))
bulkProfilesByUUID := make(map[string]*fleet.MDMAndroidProfilePayload, len(profilesToMerge)+len(profilesToRemove)+len(withheldProfiles))
// appendWithheld adds withheld profiles to the result. Called before every return
// so they're always persisted, but after the policy patch loop so they don't get
// policy request metadata set on them.
appendWithheld := func() {
for _, prof := range withheldProfiles {
status := fleet.MDMDeliveryPending
bulkProfilesByUUID[prof.ProfileUUID] = &fleet.MDMAndroidProfilePayload{
HostUUID: hostUUID,
Status: &status,
OperationType: fleet.MDMOperationTypeInstall,
ProfileUUID: prof.ProfileUUID,
ProfileName: prof.ProfileName,
Detail: prof.Detail,
}
}
}
// if every profile to install has > max failures, mark all as failed and done.
setFailCount := initRequestFailCountForSetOfProfiles(profilesToMerge, profilesToRemove)
@ -211,6 +248,7 @@ func (r *profileReconciler) sendHostProfiles(
Detail: detail,
}
}
appendWithheld()
return slices.Collect(maps.Values(bulkProfilesByUUID)), nil
}
@ -318,6 +356,7 @@ func (r *profileReconciler) sendHostProfiles(
}
}
if patchPolicyReqFailed {
appendWithheld()
return slices.Collect(maps.Values(bulkProfilesByUUID)), nil
}
@ -369,6 +408,7 @@ func (r *profileReconciler) sendHostProfiles(
}
}
appendWithheld()
return slices.Collect(maps.Values(bulkProfilesByUUID)), nil
}
@ -417,6 +457,72 @@ func buildPolicyFieldsOverriddenErrorMessage(overriddenFields []string) string {
return fmt.Sprintf(sb.String(), args...)
}
// profileCertAliases holds the extracted cert aliases (or parse error) for a profile.
// extractProfileCertAliases parses ONC cert aliases from all profile contents upfront.
// Returns a non-empty map only if at least one profile has aliases.
func extractProfileCertAliases(ctx context.Context, logger *slog.Logger, profilesContents map[string]json.RawMessage) map[string][]string {
result := make(map[string][]string)
for profileUUID, content := range profilesContents {
aliases, err := android.ExtractCertAliasesFromProfileJSON(content)
if err != nil {
// Should not happen since profiles are validated on upload.
logger.ErrorContext(ctx, "failed to extract ONC cert aliases from profile", "profile.uuid", profileUUID, "err", err)
ctxerr.Handle(ctx, err)
continue
}
if len(aliases) > 0 {
result[profileUUID] = aliases
}
}
return result
}
// filterProfilesWithPendingCerts separates profiles into those ready to merge
// and those that should be withheld because they contain openNetworkConfiguration
// with ClientCertKeyPairAlias references to certificates not yet in a terminal
// state (verified or failed) on the host.
func filterProfilesWithPendingCerts(
profiles []*fleet.MDMAndroidProfilePayload,
profileAliases map[string][]string,
certStatuses map[string]fleet.CertificateTemplateStatus,
) (ready, withheld []*fleet.MDMAndroidProfilePayload) {
for _, prof := range profiles {
aliases, ok := profileAliases[prof.ProfileUUID]
if !ok {
ready = append(ready, prof)
continue
}
shouldWithhold := false
var pendingCertName string
for _, alias := range aliases {
status, exists := certStatuses[alias]
if !exists {
// No cert template with this name assigned to host.
// Admin may be referencing a pre-installed cert.
continue
}
if status != fleet.CertificateTemplateVerified &&
status != fleet.CertificateTemplateFailed {
shouldWithhold = true
pendingCertName = alias
break
}
}
if shouldWithhold {
prof.Detail = fmt.Sprintf(
fleet.ONCProfileWithheldDetailPrefix+" %q to be installed on the host before applying this profile.",
pendingCertName,
)
withheld = append(withheld, prof)
} else {
ready = append(ready, prof)
}
}
return ready, withheld
}
func (r *profileReconciler) patchPolicy(ctx context.Context, policyID, policyName string,
policy *androidmanagement.Policy, metadata map[string]string,
) (req *android.MDMAndroidPolicyRequest, skip bool, err error) {

View file

@ -0,0 +1,158 @@
package service
import (
"encoding/json"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/logging/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterProfilesWithPendingCerts(t *testing.T) {
t.Parallel()
oncProfile := func(uuid, name, certAlias string) (*fleet.MDMAndroidProfilePayload, json.RawMessage) {
payload := &fleet.MDMAndroidProfilePayload{
ProfileUUID: uuid,
ProfileName: name,
}
content := json.RawMessage([]byte(`{
"openNetworkConfiguration": {
"NetworkConfigurations": [{
"WiFi": {
"EAP": {
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": "` + certAlias + `"
}
}
}]
}
}`))
return payload, content
}
nonONCProfile := func(uuid, name string) (*fleet.MDMAndroidProfilePayload, json.RawMessage) {
payload := &fleet.MDMAndroidProfilePayload{
ProfileUUID: uuid,
ProfileName: name,
}
content := json.RawMessage([]byte(`{"cameraDisabled": true}`))
return payload, content
}
filter := func(profiles []*fleet.MDMAndroidProfilePayload, contents map[string]json.RawMessage, certStatuses map[string]fleet.CertificateTemplateStatus) (ready, withheld []*fleet.MDMAndroidProfilePayload) {
return filterProfilesWithPendingCerts(profiles, extractProfileCertAliases(t.Context(), testutils.TestLogger(t), contents), certStatuses)
}
t.Run("pending cert withholds ONC profile", func(t *testing.T) {
prof, content := oncProfile("p1", "wifi-profile", "my-cert")
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{"p1": content}
certStatuses := map[string]fleet.CertificateTemplateStatus{
"my-cert": fleet.CertificateTemplatePending,
}
ready, withheld := filter(profiles, contents, certStatuses)
require.Empty(t, ready)
require.Len(t, withheld, 1)
assert.Contains(t, withheld[0].Detail, `Waiting for certificate "my-cert"`)
})
t.Run("verified cert releases ONC profile", func(t *testing.T) {
prof, content := oncProfile("p1", "wifi-profile", "my-cert")
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{"p1": content}
certStatuses := map[string]fleet.CertificateTemplateStatus{
"my-cert": fleet.CertificateTemplateVerified,
}
ready, withheld := filter(profiles, contents, certStatuses)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
t.Run("failed cert releases ONC profile", func(t *testing.T) {
prof, content := oncProfile("p1", "wifi-profile", "my-cert")
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{"p1": content}
certStatuses := map[string]fleet.CertificateTemplateStatus{
"my-cert": fleet.CertificateTemplateFailed,
}
ready, withheld := filter(profiles, contents, certStatuses)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
t.Run("unknown alias releases ONC profile", func(t *testing.T) {
prof, content := oncProfile("p1", "wifi-profile", "unknown-cert")
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{"p1": content}
certStatuses := map[string]fleet.CertificateTemplateStatus{}
ready, withheld := filter(profiles, contents, certStatuses)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
t.Run("non-ONC profile is never withheld", func(t *testing.T) {
prof, content := nonONCProfile("p1", "camera-policy")
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{"p1": content}
certStatuses := map[string]fleet.CertificateTemplateStatus{
"my-cert": fleet.CertificateTemplatePending,
}
ready, withheld := filter(profiles, contents, certStatuses)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
t.Run("multiple cert refs all must be terminal", func(t *testing.T) {
payload := &fleet.MDMAndroidProfilePayload{
ProfileUUID: "p1",
ProfileName: "multi-net",
}
content := json.RawMessage([]byte(`{
"openNetworkConfiguration": {
"NetworkConfigurations": [
{"WiFi": {"EAP": {"ClientCertType": "KeyPairAlias", "ClientCertKeyPairAlias": "cert-a"}}},
{"VPN": {"ClientCertType": "KeyPairAlias", "ClientCertKeyPairAlias": "cert-b"}}
]
}
}`))
profiles := []*fleet.MDMAndroidProfilePayload{payload}
contents := map[string]json.RawMessage{"p1": content}
// cert-a verified but cert-b pending: withhold
certStatuses := map[string]fleet.CertificateTemplateStatus{
"cert-a": fleet.CertificateTemplateVerified,
"cert-b": fleet.CertificateTemplatePending,
}
ready, withheld := filter(profiles, contents, certStatuses)
require.Empty(t, ready)
require.Len(t, withheld, 1)
assert.Contains(t, withheld[0].Detail, `"cert-b"`)
// both verified: release
certStatuses["cert-b"] = fleet.CertificateTemplateVerified
payload.Detail = ""
ready, withheld = filter(profiles, contents, certStatuses)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
t.Run("profile with missing content is not withheld", func(t *testing.T) {
prof := &fleet.MDMAndroidProfilePayload{
ProfileUUID: "p1",
ProfileName: "missing-content",
}
profiles := []*fleet.MDMAndroidProfilePayload{prof}
contents := map[string]json.RawMessage{} // no content for p1
ready, withheld := filter(profiles, contents, nil)
require.Len(t, ready, 1)
require.Empty(t, withheld)
})
}

View file

@ -81,6 +81,7 @@ func TestReconcileProfiles(t *testing.T) {
{"CertificateTemplates", testCertificateTemplates},
{"BuildAndSendFleetAgentConfigForEnrollment", testBuildAndSendFleetAgentConfigForEnrollment},
{"CertificateTemplatesIncludesExistingVerified", testCertificateTemplatesIncludesExistingVerified},
{"ONCWithheldUntilCertVerified", testONCWithheldUntilCertVerified},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -1219,3 +1220,185 @@ func testCertificateTemplatesIncludesExistingVerified(t *testing.T, ds fleet.Dat
// Pending certificate transitions to delivering before the API call
assertCertTemplate(pendingCert.ID, fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeInstall)
}
func testONCWithheldUntilCertVerified(t *testing.T, ds fleet.Datastore, client *mock.Client, reconciler *profileReconciler) {
ctx := t.Context()
client.EnterprisesPoliciesPatchFunc = func(ctx context.Context, enterpriseID string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) {
policy.Version = 1
return policy, nil
}
client.EnterprisesDevicesPatchFunc = func(ctx context.Context, name string, device *androidmanagement.Device) (*androidmanagement.Device, error) {
return device, nil
}
// Create a team with enroll secret
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "onc-test-team"})
require.NoError(t, err)
err = ds.ApplyEnrollSecrets(ctx, &team.ID, []*fleet.EnrollSecret{{Secret: "secret", TeamID: &team.ID}})
require.NoError(t, err)
// Create a certificate authority and certificate template
ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: new("Test CA"),
URL: new("http://localhost:8080/scep"),
Challenge: new("test-challenge"),
})
require.NoError(t, err)
certTemplate, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "wifi-cert",
TeamID: team.ID,
CertificateAuthorityID: ca.ID,
SubjectName: "CN=WiFi Cert",
})
require.NoError(t, err)
// Create an Android host in the team
host := createAndroidHostInTeam(t, ds, 100, &team.ID)
// Create a host_certificate_template record in "delivered" status (agent has received it
// but hasn't completed SCEP enrollment yet). We use "delivered" instead of "pending" to
// avoid triggering cert template reconciliation (which would try to call AMAPI).
err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{{
HostUUID: host.UUID,
CertificateTemplateID: certTemplate.ID,
Status: fleet.CertificateTemplateDelivered,
OperationType: fleet.MDMOperationTypeInstall,
Name: certTemplate.Name,
}})
require.NoError(t, err)
mds := ds.(*mysql.Datastore)
// Create an ONC profile referencing the certificate by alias (= template name)
oncProfile := androidProfileWithPayloadForTest("onc-wifi", fmt.Sprintf(`{
"openNetworkConfiguration": {
"NetworkConfigurations": [{
"GUID": "corp-wifi",
"Name": "Corporate WiFi",
"Type": "WiFi",
"WiFi": {
"SSID": "CorpNet",
"Security": "WPA-EAP",
"EAP": {
"Outer": "EAP-TLS",
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": %q
}
}
}]
}
}`, certTemplate.Name))
oncProfile.TeamID = &team.ID
oncProfile, err = ds.NewMDMAndroidConfigProfile(ctx, *oncProfile)
require.NoError(t, err)
// Create a non-ONC profile (should always be applied)
nonONCProfile := androidProfileForTest("camera-policy")
nonONCProfile.TeamID = &team.ID
nonONCProfile, err = ds.NewMDMAndroidConfigProfile(ctx, *nonONCProfile)
require.NoError(t, err)
// --- Phase 1: cert is pending, ONC should be withheld, non-ONC applied ---
err = reconciler.ReconcileProfiles(ctx)
require.NoError(t, err)
assertHostProfiles(t, ds, []*fleet.MDMAndroidProfilePayload{
// Non-ONC profile is applied normally
{
HostUUID: host.UUID, ProfileUUID: nonONCProfile.ProfileUUID, ProfileName: nonONCProfile.Name,
Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall,
IncludedInPolicyVersion: new(1), RequestFailCount: 0,
PolicyRequestUUID: new(""), DeviceRequestUUID: new(""),
},
// ONC profile is withheld (pending with detail, no policy/device request)
{
HostUUID: host.UUID, ProfileUUID: oncProfile.ProfileUUID, ProfileName: oncProfile.Name,
Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall,
Detail: fmt.Sprintf("Waiting for certificate %q to be installed on the host before applying this profile.", certTemplate.Name),
RequestFailCount: 0,
},
})
// --- Phase 2: transition cert to "verified", ONC should now be applied ---
mysql.ExecAdhocSQL(t, mds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_certificate_templates SET status = ? WHERE host_uuid = ? AND certificate_template_id = ?",
fleet.CertificateTemplateVerified, host.UUID, certTemplate.ID,
)
return err
})
client.EnterprisesPoliciesPatchFuncInvoked = false
client.EnterprisesDevicesPatchFuncInvoked = false
err = reconciler.ReconcileProfiles(ctx)
require.NoError(t, err)
// Both profiles should now be applied (included in policy, with request UUIDs)
assertHostProfiles(t, ds, []*fleet.MDMAndroidProfilePayload{
{
HostUUID: host.UUID, ProfileUUID: nonONCProfile.ProfileUUID, ProfileName: nonONCProfile.Name,
Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall,
IncludedInPolicyVersion: new(1), RequestFailCount: 0,
PolicyRequestUUID: new(""), DeviceRequestUUID: new(""),
},
{
HostUUID: host.UUID, ProfileUUID: oncProfile.ProfileUUID, ProfileName: oncProfile.Name,
Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall,
IncludedInPolicyVersion: new(1), RequestFailCount: 0,
PolicyRequestUUID: new(""), DeviceRequestUUID: new(""),
},
})
// --- Phase 3: test that ONC is also released on terminal failure ---
// Reset cert to pending and profile to need re-delivery
mysql.ExecAdhocSQL(t, mds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_certificate_templates SET status = ? WHERE host_uuid = ? AND certificate_template_id = ?",
fleet.CertificateTemplateDelivered, host.UUID, certTemplate.ID,
)
return err
})
mysql.ExecAdhocSQL(t, mds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_mdm_android_profiles SET status = NULL WHERE host_uuid = ?",
host.UUID,
)
return err
})
// cert is "delivered" (not terminal), ONC should be withheld
err = reconciler.ReconcileProfiles(ctx)
require.NoError(t, err)
phase3Profiles, err := ds.GetHostMDMAndroidProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range phase3Profiles {
if p.ProfileUUID == oncProfile.ProfileUUID {
require.Equal(t, &fleet.MDMDeliveryPending, p.Status)
require.Contains(t, p.Detail, "Waiting for certificate")
}
}
// Now set cert to "failed" (terminal) and re-reconcile
mysql.ExecAdhocSQL(t, mds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_certificate_templates SET status = ? WHERE host_uuid = ? AND certificate_template_id = ?",
fleet.CertificateTemplateFailed, host.UUID, certTemplate.ID,
)
return err
})
err = reconciler.ReconcileProfiles(ctx)
require.NoError(t, err)
// Both profiles applied (cert is terminally failed, ONC released)
finalProfiles, err := ds.GetHostMDMAndroidProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range finalProfiles {
if p.ProfileUUID == oncProfile.ProfileUUID {
require.NotContains(t, p.Detail, "Waiting for certificate")
}
}
}

View file

@ -1817,6 +1817,8 @@ type GetHostCertificateTemplateRecordFunc func(ctx context.Context, hostUUID str
type RetryHostCertificateTemplateFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint, detail string) error
type GetCertificateTemplateStatusesByNameForHostsFunc func(ctx context.Context, hostUUIDs []string) (map[string]map[string]fleet.CertificateTemplateStatus, error)
type BulkInsertHostCertificateTemplatesFunc func(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error
type DeleteHostCertificateTemplatesFunc func(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error
@ -4549,6 +4551,9 @@ type DataStore struct {
RetryHostCertificateTemplateFunc RetryHostCertificateTemplateFunc
RetryHostCertificateTemplateFuncInvoked bool
GetCertificateTemplateStatusesByNameForHostsFunc GetCertificateTemplateStatusesByNameForHostsFunc
GetCertificateTemplateStatusesByNameForHostsFuncInvoked bool
BulkInsertHostCertificateTemplatesFunc BulkInsertHostCertificateTemplatesFunc
BulkInsertHostCertificateTemplatesFuncInvoked bool
@ -10891,6 +10896,13 @@ func (s *DataStore) RetryHostCertificateTemplate(ctx context.Context, hostUUID s
return s.RetryHostCertificateTemplateFunc(ctx, hostUUID, certificateTemplateID, detail)
}
func (s *DataStore) GetCertificateTemplateStatusesByNameForHosts(ctx context.Context, hostUUIDs []string) (map[string]map[string]fleet.CertificateTemplateStatus, error) {
s.mu.Lock()
s.GetCertificateTemplateStatusesByNameForHostsFuncInvoked = true
s.mu.Unlock()
return s.GetCertificateTemplateStatusesByNameForHostsFunc(ctx, hostUUIDs)
}
func (s *DataStore) BulkInsertHostCertificateTemplates(ctx context.Context, hostCertTemplates []fleet.HostCertificateTemplate) error {
s.mu.Lock()
s.BulkInsertHostCertificateTemplatesFuncInvoked = true

View file

@ -0,0 +1,29 @@
package testutils
import (
"log/slog"
"testing"
)
// TestLogger returns a *slog.Logger that routes output through t.Log.
// Logs are only printed when a test fails (or with -v), keeping passing test output clean.
// In parallel tests, logs stay grouped with their test instead of interleaving on stdout.
//
// Example usage:
//
// func TestSomething(t *testing.T) {
// logger := testutils.TestLogger(t)
// svc := mypackage.NewService(logger)
// // ... test svc; log output only appears if the test fails
// }
func TestLogger(t testing.TB) *slog.Logger {
return slog.New(slog.NewTextHandler(tLogWriter{t}, nil))
}
// tLogWriter adapts testing.TB to io.Writer so slog output is captured by t.Log
type tLogWriter struct{ t testing.TB }
func (w tLogWriter) Write(p []byte) (int, error) {
w.t.Log(string(p))
return len(p), nil
}

View file

@ -2470,12 +2470,11 @@ func (c *Client) DoGitOps(
}
}
err = c.doGitOpsPolicies(incoming, teamSoftwareInstallers, teamVPPApps, teamScripts, logFn, dryRun)
if err != nil {
return nil, err
}
// Apply Android certificates if present
// Apply Android certificates before policies so that certificate templates
// exist on the server before the profile reconciler's next cron cycle. This
// prevents a race where the cron fires after profiles are uploaded (by
// ApplyGroup above) but before cert templates exist, which would cause ONC
// profiles to be sent without waiting for the cert.
err = c.doGitOpsAndroidCertificates(incoming, logFn, dryRun)
if err != nil {
var gitOpsErr *gitOpsValidationError
@ -2485,6 +2484,11 @@ func (c *Client) DoGitOps(
return nil, err
}
err = c.doGitOpsPolicies(incoming, teamSoftwareInstallers, teamVPPApps, teamScripts, logFn, dryRun)
if err != nil {
return nil, err
}
// apply icon changes from software installers and VPP apps
if len(teamSoftwareInstallers) > 0 || len(teamVPPApps) > 0 {
iconUpdates := fleet.IconChanges{}.WithUploadedHashes(iconSettings.UploadedHashes).WithSoftware(teamSoftwareInstallers, teamVPPApps)

View file

@ -1675,3 +1675,231 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateResend() {
require.Equal(t, fleet.CertificateTemplateVerified, record.Status, "should succeed on retry")
}) // end "automatic retry" subtest
}
// TestONCProfileWithheldUntilCertReady tests the end-to-end flow where an
// Android ONC profile referencing a certificate via ClientCertKeyPairAlias is
// withheld during profile reconciliation until the certificate reaches a
// terminal state (verified or failed). It uses the real PubSub enrollment path
// to verify that cert template records are created automatically during enrollment,
// ensuring the ONC withholding logic works for newly enrolled devices.
func (s *integrationMDMTestSuite) TestONCProfileWithheldUntilCertReady() {
t := s.T()
ctx := t.Context()
s.enableAndroidMDM(t)
s.setSkipWorkerJobs(t)
// Create team with enroll secret
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name(), Secrets: []*fleet.EnrollSecret{
{Secret: "secret-" + t.Name()},
}})
require.NoError(t, err)
// Create certificate authority and certificate template BEFORE enrolling the host.
// This ensures CreatePendingCertificateTemplatesForNewHost finds it during enrollment.
caID, _ := s.createTestCertificateAuthority(t, ctx)
var certTemplateResp applyCertificateTemplateSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{
Specs: []*fleet.CertificateRequestSpec{{
Name: "wifi-cert",
Team: team.Name,
CertificateAuthorityId: caID,
SubjectName: "CN=WiFi Cert",
}},
}, http.StatusOK, &certTemplateResp)
// Get the certificate template ID
var certTemplateID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &certTemplateID,
"SELECT id FROM certificate_templates WHERE name = ? AND team_id = ?", "wifi-cert", team.ID)
})
require.NotZero(t, certTemplateID)
// Enroll the Android host through the real PubSub enrollment path.
// This exercises CreatePendingCertificateTemplatesForNewHost automatically.
host, _, _ := s.createAndEnrollAndroidDevice(t, "onc-test", &team.ID, true)
// Set orbit_node_key so the host can authenticate to the cert status API.
// In production, Orbit enrollment sets this; PubSub enrollment only sets node_key.
orbitNodeKey := "android/" + host.UUID
host.OrbitNodeKey = &orbitNodeKey
require.NoError(t, s.ds.UpdateHost(ctx, host))
// Verify that enrollment created the cert template record for this host.
var certStatus string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &certStatus,
"SELECT status FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
host.UUID, certTemplateID)
})
require.Equal(t, string(fleet.CertificateTemplatePending), certStatus,
"enrollment should have created a pending cert template record")
// Create ONC profile referencing the certificate + a non-ONC profile
oncProfileJSON, cameraProfileJSON := s.oncWithholdingProfiles()
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "onc-wifi", Contents: oncProfileJSON},
{Name: "camera-policy", Contents: cameraProfileJSON},
}}, http.StatusNoContent, "team_id", fmt.Sprint(team.ID))
// Trigger profile reconciliation
s.awaitTriggerAndroidProfileSchedule(t)
// Verify: ONC profile withheld (pending with detail), camera profile applied
s.assertONCProfileWithheld(t, host.UUID)
// Report certificate as verified through the real API endpoint.
// After the first reconciliation, reconcileCertificateTemplates transitioned the cert
// to "delivered", so the status API will accept the update.
updateReq, err := json.Marshal(updateCertificateStatusRequest{
Status: string(fleet.CertificateTemplateVerified),
Detail: new("Certificate installed successfully"),
})
require.NoError(t, err)
resp := s.DoRawWithHeaders("PUT", fmt.Sprintf("/api/fleetd/certificates/%d/status", certTemplateID), updateReq, http.StatusOK, map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
})
resp.Body.Close()
s.awaitTriggerAndroidProfileSchedule(t)
// Both profiles should now be applied (included in policy)
s.assertONCProfilesReleased(t, host.UUID, "cert verified")
}
// TestONCProfileReleasedAfterCertTemplateDeleted tests that when a certificate
// template is deleted, any ONC profiles that were withheld waiting for that
// certificate are released by the reconciler on the next run. The reconciler's
// filterProfilesWithPendingCerts treats a missing cert status as "no cert
// template assigned" and considers the profile ready.
func (s *integrationMDMTestSuite) TestONCProfileReleasedAfterCertTemplateDeleted() {
t := s.T()
ctx := t.Context()
s.enableAndroidMDM(t)
s.setSkipWorkerJobs(t)
// Create team with enroll secret
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name(), Secrets: []*fleet.EnrollSecret{
{Secret: "secret-" + t.Name()},
}})
require.NoError(t, err)
caID, _ := s.createTestCertificateAuthority(t, ctx)
var certTemplateResp applyCertificateTemplateSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/certificates", applyCertificateTemplateSpecsRequest{
Specs: []*fleet.CertificateRequestSpec{{
Name: "wifi-cert",
Team: team.Name,
CertificateAuthorityId: caID,
SubjectName: "CN=WiFi Cert",
}},
}, http.StatusOK, &certTemplateResp)
var certTemplateID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &certTemplateID,
"SELECT id FROM certificate_templates WHERE name = ? AND team_id = ?", "wifi-cert", team.ID)
})
require.NotZero(t, certTemplateID)
host, _, _ := s.createAndEnrollAndroidDevice(t, "onc-delete-test", &team.ID, true)
oncProfileJSON, cameraProfileJSON := s.oncWithholdingProfiles()
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "onc-wifi", Contents: oncProfileJSON},
{Name: "camera-policy", Contents: cameraProfileJSON},
}}, http.StatusNoContent, "team_id", fmt.Sprint(team.ID))
s.awaitTriggerAndroidProfileSchedule(t)
s.assertONCProfileWithheld(t, host.UUID)
// Delete the certificate template via API
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", certTemplateID), nil, http.StatusOK)
// Trigger reconciliation -- ONC should now be released since the cert template
// no longer exists and filterProfilesWithPendingCerts treats missing statuses
// as "no cert template assigned to host".
s.awaitTriggerAndroidProfileSchedule(t)
s.assertONCProfilesReleased(t, host.UUID, "cert template deleted")
}
// oncWithholdingProfiles returns ONC and camera profile JSON payloads for ONC
// withholding tests. The ONC profile references a "wifi-cert" certificate alias.
func (s *integrationMDMTestSuite) oncWithholdingProfiles() (oncJSON, cameraJSON []byte) {
oncJSON = fmt.Appendf(nil, `{
"openNetworkConfiguration": {
"NetworkConfigurations": [{
"GUID": "corp-wifi",
"Name": "Corporate WiFi",
"Type": "WiFi",
"WiFi": {
"SSID": "CorpNet",
"Security": "WPA-EAP",
"EAP": {
"Outer": "EAP-TLS",
"ClientCertType": "KeyPairAlias",
"ClientCertKeyPairAlias": %q
}
}
}]
}
}`, "wifi-cert")
cameraJSON = []byte(`{"cameraDisabled": true}`)
return oncJSON, cameraJSON
}
// assertONCProfileWithheld verifies that the ONC profile is withheld and the
// camera profile is applied for the given host.
func (s *integrationMDMTestSuite) assertONCProfileWithheld(t *testing.T, hostUUID string) {
t.Helper()
ctx := t.Context()
var profileStatuses []struct {
ProfileName string `db:"profile_name"`
Status string `db:"status"`
Detail *string `db:"detail"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &profileStatuses,
"SELECT profile_name, status, detail FROM host_mdm_android_profiles WHERE host_uuid = ? ORDER BY profile_name",
hostUUID)
})
require.Len(t, profileStatuses, 2)
require.Equal(t, "camera-policy", profileStatuses[0].ProfileName)
require.Equal(t, "pending", profileStatuses[0].Status)
if profileStatuses[0].Detail != nil {
require.NotContains(t, *profileStatuses[0].Detail, "Waiting for certificate")
}
require.Equal(t, "onc-wifi", profileStatuses[1].ProfileName)
require.Equal(t, "pending", profileStatuses[1].Status)
require.NotNil(t, profileStatuses[1].Detail)
require.Contains(t, *profileStatuses[1].Detail, `Waiting for certificate "wifi-cert"`)
}
// assertONCProfilesReleased verifies that all profiles for the host are pending
// without any "Waiting for certificate" detail, meaning the ONC profile has
// been released from withholding.
func (s *integrationMDMTestSuite) assertONCProfilesReleased(t *testing.T, hostUUID, context string) {
t.Helper()
ctx := t.Context()
var profileStatuses []struct {
ProfileName string `db:"profile_name"`
Status string `db:"status"`
Detail *string `db:"detail"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &profileStatuses,
"SELECT profile_name, status, detail FROM host_mdm_android_profiles WHERE host_uuid = ? ORDER BY profile_name",
hostUUID)
})
require.Len(t, profileStatuses, 2)
for _, ps := range profileStatuses {
require.Equal(t, "pending", ps.Status, "profile %s should be pending (delivered to AMAPI)", ps.ProfileName)
if ps.Detail != nil {
require.NotContains(t, *ps.Detail, "Waiting for certificate",
"profile %s should not have waiting detail after %s", ps.ProfileName, context)
}
}
}