mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
6e3648a7d1
commit
36ad83f611
16 changed files with 1081 additions and 11 deletions
3
changes/42405-android-onc-after-cert
Normal file
3
changes/42405-android-onc-after-cert
Normal 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
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const generateTableConfig = (
|
|||
},
|
||||
},
|
||||
{
|
||||
Header: "Error",
|
||||
Header: "Details",
|
||||
disableSortBy: true,
|
||||
accessor: "detail",
|
||||
Cell: (cellProps: ITableStringCellProps) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
81
server/mdm/android/onc.go
Normal 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)
|
||||
}
|
||||
172
server/mdm/android/onc_test.go
Normal file
172
server/mdm/android/onc_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
158
server/mdm/android/service/profiles_filter_test.go
Normal file
158
server/mdm/android/service/profiles_filter_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
server/platform/logging/testutils/test_logger.go
Normal file
29
server/platform/logging/testutils/test_logger.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue