Deleting/adding Android certs to host on team transfer (#37616)

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

Resolves unreleased 4.79 bug and needs to be cherry picked. Also
includes fixes from manually going through the test plan at:
[#30876](https://github.com/fleetdm/fleet/issues/30876)

# Checklist for submitter

## Testing

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

For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results

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

## Release Notes

* **New Features**
* Per-template versioning and explicit operation/status fields for host
certificate templates; delivery payloads now include per-template
details.

* **Bug Fixes**
* Removal preparation broadened to also clear failed entries and handle
per-host removals; delivery/transition ordering adjusted to avoid race
conditions.

* **Tests**
* Extensive tests added for team-transfer flows, per-host
removal/preparation, and end-to-end Android certificate template
scenarios.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky 2026-01-06 10:20:07 -06:00 committed by GitHub
parent ce6b30c421
commit af1e150a2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1076 additions and 108 deletions

View file

@ -76,6 +76,7 @@ func (ds *Datastore) GetCertificateTemplateByIdForHost(ctx context.Context, id u
certificate_authorities.type AS certificate_authority_type,
certificate_authorities.challenge_encrypted AS scep_challenge_encrypted,
host_certificate_templates.status AS status,
COALESCE(BIN_TO_UUID(host_certificate_templates.uuid, true), '') AS uuid,
host_certificate_templates.fleet_challenge AS fleet_challenge
FROM certificate_templates
INNER JOIN certificate_authorities ON certificate_templates.certificate_authority_id = certificate_authorities.id
@ -86,6 +87,9 @@ func (ds *Datastore) GetCertificateTemplateByIdForHost(ctx context.Context, id u
WHERE certificate_templates.id = ?
`, fleet.MDMOperationTypeInstall)
if err := sqlx.GetContext(ctx, ds.reader(ctx), &template, stmt, hostUUID, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ctxerr.Wrap(ctx, notFound("CertificateTemplateForHost"))
}
return nil, ctxerr.Wrap(ctx, err, "getting certificate_template by id for host")
}
@ -305,7 +309,8 @@ func (ds *Datastore) CreatePendingCertificateTemplatesForExistingHosts(
fleet_challenge,
status,
operation_type,
name
name,
uuid
)
SELECT
hosts.uuid,
@ -313,7 +318,8 @@ func (ds *Datastore) CreatePendingCertificateTemplatesForExistingHosts(
NULL,
'%s',
'%s',
ct.name
ct.name,
UUID_TO_BIN(UUID(), true)
FROM hosts
INNER JOIN host_mdm ON host_mdm.host_id = hosts.id
INNER JOIN certificate_templates ct ON ct.id = ?
@ -344,18 +350,27 @@ func (ds *Datastore) CreatePendingCertificateTemplatesForNewHost(
certificate_template_id,
status,
operation_type,
name
name,
uuid
)
SELECT
?,
id,
'%s',
'%s',
name
name,
UUID_TO_BIN(UUID(), true)
FROM certificate_templates
WHERE team_id = ?
ON DUPLICATE KEY UPDATE host_uuid = host_uuid
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall)
ON DUPLICATE KEY UPDATE
-- allow 'remove' to transition to 'pending install', generating new uuid
uuid = IF(operation_type = '%s', UUID_TO_BIN(UUID(), true), uuid),
status = IF(operation_type = '%s', '%s', status),
operation_type = IF(operation_type = '%s', '%s', operation_type)
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall,
fleet.MDMOperationTypeRemove,
fleet.MDMOperationTypeRemove, fleet.CertificateTemplatePending,
fleet.MDMOperationTypeRemove, fleet.MDMOperationTypeInstall)
result, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID, teamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "create pending certificate templates for new host")

View file

@ -274,6 +274,11 @@ func testGetCertificateTemplateByID(t *testing.T, ds *Datastore) {
require.Equal(t, fleet.CertificateTemplateDelivered, templateForHost.Status)
require.Equal(t, "fleet-challenge", *templateForHost.FleetChallenge)
require.Equal(t, "test-challenge", *templateForHost.SCEPChallenge)
// GetCertificateTemplateByIdForHost should return notFound for a host without a host_certificate_template record
_, err = ds.GetCertificateTemplateByIdForHost(ctx, certificateTemplateID, "non-existent-host-uuid")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err), "expected notFound error, got: %v", err)
},
},
{

View file

@ -40,19 +40,27 @@ func (ds *Datastore) ListAndroidHostUUIDsWithDeliverableCertificateTemplates(ctx
return hostUUIDs, nil
}
// ListCertificateTemplatesForHosts returns ALL certificate templates for the given host UUIDs
// ListCertificateTemplatesForHosts returns ALL certificate templates for the given host UUIDs.
// This includes:
// 1. Templates matching the host's current team (for install operations)
// 2. Templates marked for removal (which may be from a previous team after team transfer)
func (ds *Datastore) ListCertificateTemplatesForHosts(ctx context.Context, hostUUIDs []string) ([]fleet.CertificateTemplateForHost, error) {
if len(hostUUIDs) == 0 {
return nil, nil
}
query, args, err := sqlx.In(`
// Query 1: Templates matching host's current team
// Query 2: UNION with removal entries from host_certificate_templates
// (these may reference templates from a different team after team transfer)
// UNION removes duplicates if a template appears in both result sets
query, args, err := sqlx.In(fmt.Sprintf(`
SELECT
hosts.uuid AS host_uuid,
certificate_templates.id AS certificate_template_id,
host_certificate_templates.fleet_challenge AS fleet_challenge,
host_certificate_templates.status AS status,
host_certificate_templates.operation_type AS operation_type,
COALESCE(BIN_TO_UUID(host_certificate_templates.uuid, true), '') AS uuid,
certificate_authorities.type AS ca_type,
certificate_authorities.name AS ca_name
FROM certificate_templates
@ -63,8 +71,27 @@ func (ds *Datastore) ListCertificateTemplatesForHosts(ctx context.Context, hostU
AND host_certificate_templates.certificate_template_id = certificate_templates.id
WHERE
hosts.uuid IN (?)
ORDER BY hosts.uuid, certificate_templates.id
`, hostUUIDs)
UNION
SELECT
hct.host_uuid AS host_uuid,
hct.certificate_template_id AS certificate_template_id,
hct.fleet_challenge AS fleet_challenge,
hct.status AS status,
hct.operation_type AS operation_type,
COALESCE(BIN_TO_UUID(hct.uuid, true), '') AS uuid,
ca.type AS ca_type,
ca.name AS ca_name
FROM host_certificate_templates hct
INNER JOIN certificate_templates ct ON ct.id = hct.certificate_template_id
INNER JOIN certificate_authorities ca ON ca.id = ct.certificate_authority_id
WHERE
hct.host_uuid IN (?)
AND hct.operation_type = '%s'
ORDER BY host_uuid, certificate_template_id
`, fleet.MDMOperationTypeRemove), hostUUIDs, hostUUIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build query for certificate templates")
}
@ -86,6 +113,7 @@ func (ds *Datastore) GetCertificateTemplateForHost(ctx context.Context, hostUUID
host_certificate_templates.fleet_challenge AS fleet_challenge,
host_certificate_templates.status AS status,
host_certificate_templates.operation_type AS operation_type,
COALESCE(BIN_TO_UUID(host_certificate_templates.uuid, true), '') AS uuid,
certificate_authorities.type AS ca_type,
certificate_authorities.name AS ca_name
FROM certificate_templates
@ -122,6 +150,7 @@ func (ds *Datastore) GetHostCertificateTemplateRecord(ctx context.Context, hostU
status,
operation_type,
detail,
COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid,
created_at,
updated_at
FROM host_certificate_templates
@ -154,7 +183,8 @@ func (ds *Datastore) BulkInsertHostCertificateTemplates(ctx context.Context, hos
fleet_challenge,
status,
operation_type,
name
name,
uuid
) VALUES %s
`
@ -163,7 +193,7 @@ func (ds *Datastore) BulkInsertHostCertificateTemplates(ctx context.Context, hos
for _, hct := range hostCertTemplates {
args = append(args, hct.HostUUID, hct.CertificateTemplateID, hct.FleetChallenge, hct.Status, hct.OperationType, hct.Name)
placeholders.WriteString("(?,?,?,?,?,?),")
placeholders.WriteString("(?,?,?,?,?,?,UUID_TO_BIN(UUID(), true)),")
}
stmt := fmt.Sprintf(sqlInsert, strings.TrimSuffix(placeholders.String(), ","))
@ -266,8 +296,8 @@ func (ds *Datastore) UpsertCertificateStatus(
}
insertStmt := `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, detail, fleet_challenge, operation_type, name)
VALUES (?, ?, ?, ?, ?, ?, ?)`
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, detail, fleet_challenge, operation_type, name, uuid)
VALUES (?, ?, ?, ?, ?, ?, ?, UUID_TO_BIN(UUID(), true))`
params := []any{hostUUID, certificateTemplateID, status, detail, "", operationType, templateInfo.Name}
if _, err := ds.writer(ctx).ExecContext(ctx, insertStmt, params...); err != nil {
@ -279,7 +309,7 @@ func (ds *Datastore) UpsertCertificateStatus(
}
// ListAndroidHostUUIDsWithPendingCertificateTemplates returns hosts that have
// certificate templates in 'pending' status ready for delivery.
// certificate templates in 'pending' status ready for delivery (both install and remove operations).
func (ds *Datastore) ListAndroidHostUUIDsWithPendingCertificateTemplates(
ctx context.Context,
offset int,
@ -288,12 +318,10 @@ func (ds *Datastore) ListAndroidHostUUIDsWithPendingCertificateTemplates(
stmt := fmt.Sprintf(`
SELECT DISTINCT host_uuid
FROM host_certificate_templates
WHERE
status = '%s' AND
operation_type = '%s'
WHERE status = '%s'
ORDER BY host_uuid
LIMIT ? OFFSET ?
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall)
`, fleet.CertificateTemplatePending)
var hostUUIDs []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostUUIDs, stmt, limit, offset); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list host uuids with pending certificate templates")
@ -302,7 +330,7 @@ func (ds *Datastore) ListAndroidHostUUIDsWithPendingCertificateTemplates(
}
// GetAndTransitionCertificateTemplatesToDelivering retrieves all certificate templates
// with operation_type='install' for a host, transitions any pending ones to 'delivering' status,
// for a host (both install and remove operations), transitions any pending ones to 'delivering' status,
// and returns both the newly delivering template IDs and the existing (verified/delivered) ones.
// This prevents concurrent cron runs from processing the same templates.
func (ds *Datastore) GetAndTransitionCertificateTemplatesToDelivering(
@ -311,41 +339,59 @@ func (ds *Datastore) GetAndTransitionCertificateTemplatesToDelivering(
) (*fleet.HostCertificateTemplatesForDelivery, error) {
result := &fleet.HostCertificateTemplatesForDelivery{}
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Select ALL templates with operation_type='install' for this host
// Select ALL templates (both install and remove) for this host
var rows []struct {
ID uint `db:"id"`
CertificateTemplateID uint `db:"certificate_template_id"`
Status fleet.CertificateTemplateStatus `db:"status"`
OperationType fleet.MDMOperationType `db:"operation_type"`
UUID string `db:"uuid"`
}
selectStmt := fmt.Sprintf(`
SELECT id, certificate_template_id, status
const selectStmt = `
SELECT id, certificate_template_id, status, operation_type, COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid
FROM host_certificate_templates
WHERE
host_uuid = ? AND
operation_type = '%s'
WHERE host_uuid = ?
FOR UPDATE
`, fleet.MDMOperationTypeInstall)
`
if err := sqlx.SelectContext(ctx, tx, &rows, selectStmt, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "select install templates")
return ctxerr.Wrap(ctx, err, "select templates")
}
if len(rows) == 0 {
return nil
}
// Separate templates by status
// Separate templates by status and build the Templates list
var pendingIDs []uint // primary key IDs for UPDATE (only pending ones need transitioning)
for _, r := range rows {
switch r.Status {
case fleet.CertificateTemplatePending:
pendingIDs = append(pendingIDs, r.ID)
result.DeliveringTemplateIDs = append(result.DeliveringTemplateIDs, r.CertificateTemplateID)
// Status will be delivering after transition
result.Templates = append(result.Templates, fleet.HostCertificateTemplateForDelivery{
CertificateTemplateID: r.CertificateTemplateID,
Status: fleet.CertificateTemplateDelivering,
OperationType: r.OperationType,
UUID: r.UUID,
})
case fleet.CertificateTemplateDelivering:
// Already delivering (from a previous failed run), include in delivering list; should be very rare
result.DeliveringTemplateIDs = append(result.DeliveringTemplateIDs, r.CertificateTemplateID)
result.Templates = append(result.Templates, fleet.HostCertificateTemplateForDelivery{
CertificateTemplateID: r.CertificateTemplateID,
Status: r.Status,
OperationType: r.OperationType,
UUID: r.UUID,
})
default:
// delivered, verified, failed
result.OtherTemplateIDs = append(result.OtherTemplateIDs, r.CertificateTemplateID)
result.Templates = append(result.Templates, fleet.HostCertificateTemplateForDelivery{
CertificateTemplateID: r.CertificateTemplateID,
Status: r.Status,
OperationType: r.OperationType,
UUID: r.UUID,
})
}
}
@ -471,28 +517,32 @@ func (ds *Datastore) RevertStaleCertificateTemplates(
}
// SetHostCertificateTemplatesToPendingRemove prepares certificate templates for removal.
// For a given certificate template ID, it deletes any rows with status=pending and
// updates all other rows to status=pending, operation_type=remove.
// For a given certificate template ID, it deletes any rows with status in (pending, failed)
// and operation_type=install, then updates rows with operation_type=install to pending remove.
// Rows already in remove state are left unchanged (idempotent).
func (ds *Datastore) SetHostCertificateTemplatesToPendingRemove(
ctx context.Context,
certificateTemplateID uint,
) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Delete rows with status=pending
// Delete rows with status in (pending, failed) and operation_type=install
// These certificates were never successfully installed on the device
deleteStmt := fmt.Sprintf(`
DELETE FROM host_certificate_templates
WHERE certificate_template_id = ? AND status = '%s'
`, fleet.CertificateTemplatePending)
WHERE certificate_template_id = ? AND status IN ('%s', '%s') AND operation_type = '%s'
`, fleet.CertificateTemplatePending, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall)
if _, err := tx.ExecContext(ctx, deleteStmt, certificateTemplateID); err != nil {
return ctxerr.Wrap(ctx, err, "delete pending host certificate templates")
return ctxerr.Wrap(ctx, err, "delete pending/failed host certificate templates")
}
// Update all remaining rows to status=pending, operation_type=remove
// Update rows with operation_type=install to status=pending, operation_type=remove with new UUID
// Only generate new UUID when transitioning from install to remove
// Rows already in remove state are left unchanged (idempotent)
updateStmt := fmt.Sprintf(`
UPDATE host_certificate_templates
SET status = '%s', operation_type = '%s'
WHERE certificate_template_id = ?
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove)
SET status = '%s', operation_type = '%s', uuid = UUID_TO_BIN(UUID(), true)
WHERE certificate_template_id = ? AND operation_type = '%s'
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, fleet.MDMOperationTypeInstall)
if _, err := tx.ExecContext(ctx, updateStmt, certificateTemplateID); err != nil {
return ctxerr.Wrap(ctx, err, "update host certificate templates to pending remove")
}
@ -500,3 +550,37 @@ func (ds *Datastore) SetHostCertificateTemplatesToPendingRemove(
return nil
})
}
// SetHostCertificateTemplatesToPendingRemoveForHost prepares all certificate templates
// for a specific host for removal. Used during team transfer to mark old team's templates
// for removal before creating new pending templates for the new team.
// Records with operation_type=remove are left unchanged (removal already in progress).
func (ds *Datastore) SetHostCertificateTemplatesToPendingRemoveForHost(
ctx context.Context,
hostUUID string,
) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Delete rows with status in (pending, failed) and operation_type=install
// These certificates were never successfully installed on the device
deleteStmt := fmt.Sprintf(`
DELETE FROM host_certificate_templates
WHERE host_uuid = ? AND status IN ('%s', '%s') AND operation_type = '%s'
`, fleet.CertificateTemplatePending, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall)
if _, err := tx.ExecContext(ctx, deleteStmt, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "delete pending/failed install host certificate templates for host")
}
// Update remaining install rows to status=pending, operation_type=remove with new UUID
// New UUID ensures the agent can distinguish this removal from previous operations
updateStmt := fmt.Sprintf(`
UPDATE host_certificate_templates
SET status = '%s', operation_type = '%s', uuid = UUID_TO_BIN(UUID(), true)
WHERE host_uuid = ? AND operation_type = '%s'
`, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, fleet.MDMOperationTypeInstall)
if _, err := tx.ExecContext(ctx, updateStmt, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "update host certificate templates to pending remove for host")
}
return nil
})
}

View file

@ -32,6 +32,11 @@ func TestHostCertificateTemplates(t *testing.T) {
{"CertificateTemplateFullStateMachine", testCertificateTemplateFullStateMachine},
{"RevertStaleCertificateTemplates", testRevertStaleCertificateTemplates},
{"SetHostCertificateTemplatesToPendingRemove", testSetHostCertificateTemplatesToPendingRemove},
{"SetHostCertificateTemplatesToPendingRemoveForHost", testSetHostCertificateTemplatesToPendingRemoveForHost},
{"ListCertificateTemplatesForHostsIncludesRemovalAfterTeamTransfer", testListCertificateTemplatesForHostsIncludesRemovalAfterTeamTransfer},
{"ListAndroidHostUUIDsWithPendingCertificateTemplatesIncludesRemoval", testListAndroidHostUUIDsWithPendingCertificateTemplatesIncludesRemoval},
{"GetAndTransitionCertificateTemplatesToDeliveringIncludesRemoval", testGetAndTransitionCertificateTemplatesToDeliveringIncludesRemoval},
{"CertificateTemplateReinstalledAfterTransferBackToOriginalTeam", testCertificateTemplateReinstalledAfterTransferBackToOriginalTeam},
}
for _, c := range cases {
@ -828,7 +833,7 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Len(t, certTemplates.DeliveringTemplateIDs, 2)
require.ElementsMatch(t, []uint{setup.template.ID, templateTwo.ID}, certTemplates.DeliveringTemplateIDs)
require.Empty(t, certTemplates.OtherTemplateIDs) // No existing verified/delivered templates yet
require.Len(t, certTemplates.Templates, 2)
// Verify host is no longer in pending list
hostUUIDs, err = ds.ListAndroidHostUUIDsWithPendingCertificateTemplates(ctx, 0, 10)
@ -840,7 +845,7 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Len(t, certTemplates.DeliveringTemplateIDs, 2) // Already delivering from previous call
require.ElementsMatch(t, []uint{setup.template.ID, templateTwo.ID}, certTemplates.DeliveringTemplateIDs)
require.Empty(t, certTemplates.OtherTemplateIDs) // No delivered/verified/failed yet
require.Len(t, certTemplates.Templates, 2)
// Verify database shows delivering status
records, err := ds.ListCertificateTemplatesForHosts(ctx, []string{"android-host"})
@ -1028,17 +1033,17 @@ func testRevertStaleCertificateTemplates(t *testing.T, ds *Datastore) {
func testSetHostCertificateTemplatesToPendingRemove(t *testing.T, ds *Datastore) {
ctx := t.Context()
t.Run("deletes pending rows and updates others to pending remove", func(t *testing.T) {
t.Run("deletes pending and failed rows and updates others to pending remove", func(t *testing.T) {
defer TruncateTables(t, ds)
setup := createCertTemplateTestSetup(t, ctx, ds, "")
// Insert records with various statuses for the same template
_, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name) VALUES
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?)
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name, uuid) VALUES
(?, ?, ?, ?, ?, ?, UUID_TO_BIN(UUID(), true)),
(?, ?, ?, ?, ?, ?, UUID_TO_BIN(UUID(), true)),
(?, ?, ?, ?, ?, ?, UUID_TO_BIN(UUID(), true)),
(?, ?, ?, ?, ?, ?, UUID_TO_BIN(UUID(), true))
`,
"host-pending", setup.template.ID, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall, nil, setup.template.Name,
"host-delivered", setup.template.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge1", setup.template.Name,
@ -1057,20 +1062,47 @@ func testSetHostCertificateTemplatesToPendingRemove(t *testing.T, ds *Datastore)
require.NoError(t, err)
require.Equal(t, 0, count)
// Verify remaining rows have status=pending and operation_type=remove
// Verify the failed row was also deleted
err = ds.writer(ctx).GetContext(ctx, &count,
"SELECT COUNT(*) FROM host_certificate_templates WHERE host_uuid = ?", "host-failed")
require.NoError(t, err)
require.Equal(t, 0, count)
// Verify remaining rows (delivered, verified) have status=pending and operation_type=remove
var remaining []struct {
HostUUID string `db:"host_uuid"`
Status string `db:"status"`
OperationType fleet.MDMOperationType `db:"operation_type"`
UUID string `db:"uuid"`
}
err = ds.writer(ctx).SelectContext(ctx, &remaining,
"SELECT host_uuid, status, operation_type FROM host_certificate_templates ORDER BY host_uuid")
"SELECT host_uuid, status, operation_type, COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid FROM host_certificate_templates ORDER BY host_uuid")
require.NoError(t, err)
require.Len(t, remaining, 3)
require.Len(t, remaining, 2)
for _, r := range remaining {
require.Equal(t, string(fleet.CertificateTemplatePending), r.Status)
require.Equal(t, fleet.MDMOperationTypeRemove, r.OperationType)
require.NotEmpty(t, r.UUID, "UUID should be set after transition to remove")
}
// Capture UUIDs before second call
uuidsBefore := make(map[string]string)
for _, r := range remaining {
uuidsBefore[r.HostUUID] = r.UUID
}
// Second call should be idempotent - UUIDs should not change
err = ds.SetHostCertificateTemplatesToPendingRemove(ctx, setup.template.ID)
require.NoError(t, err)
err = ds.writer(ctx).SelectContext(ctx, &remaining,
"SELECT host_uuid, status, operation_type, COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid FROM host_certificate_templates ORDER BY host_uuid")
require.NoError(t, err)
require.Len(t, remaining, 2)
for _, r := range remaining {
require.Equal(t, uuidsBefore[r.HostUUID], r.UUID, "UUID should not change when already in remove state")
}
})
@ -1131,3 +1163,350 @@ func testSetHostCertificateTemplatesToPendingRemove(t *testing.T, ds *Datastore)
require.NoError(t, err)
})
}
func testSetHostCertificateTemplatesToPendingRemoveForHost(t *testing.T, ds *Datastore) {
ctx := t.Context()
t.Run("deletes pending and failed installs, updates other installs, leaves removes unchanged", func(t *testing.T) {
setup := createCertTemplateTestSetup(t, ctx, ds, "")
// Create additional templates
templateTwo, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "Cert2",
TeamID: setup.team.ID,
CertificateAuthorityID: setup.ca.ID,
SubjectName: "CN=Test2",
})
require.NoError(t, err)
templateThree, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "Cert3",
TeamID: setup.team.ID,
CertificateAuthorityID: setup.ca.ID,
SubjectName: "CN=Test3",
})
require.NoError(t, err)
templateFour, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "Cert4",
TeamID: setup.team.ID,
CertificateAuthorityID: setup.ca.ID,
SubjectName: "CN=Test4",
})
require.NoError(t, err)
// Insert records for host-1 (the target host) and host-2 (should not be affected)
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name) VALUES
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?)
`,
"host-1", setup.template.ID, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall, nil, setup.template.Name,
"host-1", templateTwo.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge1", templateTwo.Name,
"host-1", templateThree.ID, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall, nil, templateThree.Name,
"host-1", templateFour.ID, fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeRemove, nil, templateFour.Name,
"host-2", setup.template.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, "challenge2", setup.template.Name,
)
require.NoError(t, err)
// Call the method for host-1 only
err = ds.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, "host-1")
require.NoError(t, err)
// Verify host-1's pending install was deleted
var count int
err = ds.writer(ctx).GetContext(ctx, &count,
"SELECT COUNT(*) FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
"host-1", setup.template.ID)
require.NoError(t, err)
require.Equal(t, 0, count, "pending install should be deleted")
// Verify host-1's failed install was also deleted
err = ds.writer(ctx).GetContext(ctx, &count,
"SELECT COUNT(*) FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
"host-1", templateThree.ID)
require.NoError(t, err)
require.Equal(t, 0, count, "failed install should be deleted")
// Verify host-1's delivered install was updated to pending remove
var row struct {
Status string `db:"status"`
OperationType fleet.MDMOperationType `db:"operation_type"`
}
err = ds.writer(ctx).GetContext(ctx, &row,
"SELECT status, operation_type FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
"host-1", templateTwo.ID)
require.NoError(t, err)
require.Equal(t, string(fleet.CertificateTemplatePending), row.Status)
require.Equal(t, fleet.MDMOperationTypeRemove, row.OperationType)
// Verify host-1's delivering remove was NOT changed (removal in progress)
err = ds.writer(ctx).GetContext(ctx, &row,
"SELECT status, operation_type FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
"host-1", templateFour.ID)
require.NoError(t, err)
require.Equal(t, string(fleet.CertificateTemplateDelivering), row.Status, "remove operation should not change status")
require.Equal(t, fleet.MDMOperationTypeRemove, row.OperationType, "remove operation should stay as remove")
// Verify host-2 was NOT affected
err = ds.writer(ctx).GetContext(ctx, &row,
"SELECT status, operation_type FROM host_certificate_templates WHERE host_uuid = ?", "host-2")
require.NoError(t, err)
require.Equal(t, string(fleet.CertificateTemplateDelivered), row.Status)
require.Equal(t, fleet.MDMOperationTypeInstall, row.OperationType)
})
}
// testListCertificateTemplatesForHostsIncludesRemovalAfterTeamTransfer verifies that
// ListCertificateTemplatesForHosts returns removal entries for templates from a previous team
// after the host has transferred to a new team.
func testListCertificateTemplatesForHostsIncludesRemovalAfterTeamTransfer(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Setup: Create two teams with certificate templates using existing helpers
setupA := createCertTemplateTestSetup(t, ctx, ds, "Team A for removal test")
setupB := createCertTemplateTestSetup(t, ctx, ds, "Team B for removal test")
// Create host initially in Team A
host := createEnrolledAndroidHost(t, ctx, ds, uuid.New().String(), &setupA.team.ID)
// Insert verified certificate from Team A using BulkInsertHostCertificateTemplates
challenge := "challenge-a"
err := ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{{
HostUUID: host.UUID,
CertificateTemplateID: setupA.template.ID,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
FleetChallenge: &challenge,
Name: setupA.template.Name,
}})
require.NoError(t, err)
// Simulate team transfer: move host to Team B using UpdateHost
host.TeamID = &setupB.team.ID
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// Mark Team A template for removal using the datastore method
err = ds.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, host.UUID)
require.NoError(t, err)
// Insert pending install for Team B template
err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{{
HostUUID: host.UUID,
CertificateTemplateID: setupB.template.ID,
Status: fleet.CertificateTemplatePending,
OperationType: fleet.MDMOperationTypeInstall,
Name: setupB.template.Name,
}})
require.NoError(t, err)
// Act: List certificate templates for host
results, err := ds.ListCertificateTemplatesForHosts(ctx, []string{host.UUID})
require.NoError(t, err)
require.Len(t, results, 2, "should include both install and removal templates")
templatesByID := make(map[uint]fleet.CertificateTemplateForHost)
for _, r := range results {
templatesByID[r.CertificateTemplateID] = r
}
// Team A template should be marked for removal
require.Contains(t, templatesByID, setupA.template.ID, "should include Team A template marked for removal")
require.NotNil(t, templatesByID[setupA.template.ID].OperationType)
require.Equal(t, fleet.MDMOperationTypeRemove, *templatesByID[setupA.template.ID].OperationType)
require.NotNil(t, templatesByID[setupA.template.ID].Status)
require.Equal(t, fleet.CertificateTemplatePending, *templatesByID[setupA.template.ID].Status)
// Team B template should be pending install
require.Contains(t, templatesByID, setupB.template.ID, "should include Team B template")
require.NotNil(t, templatesByID[setupB.template.ID].OperationType)
require.Equal(t, fleet.MDMOperationTypeInstall, *templatesByID[setupB.template.ID].OperationType)
}
// testListAndroidHostUUIDsWithPendingCertificateTemplatesIncludesRemoval verifies that
// ListAndroidHostUUIDsWithPendingCertificateTemplates returns hosts with pending removal templates.
func testListAndroidHostUUIDsWithPendingCertificateTemplatesIncludesRemoval(t *testing.T, ds *Datastore) {
ctx := t.Context()
setup := createCertTemplateTestSetup(t, ctx, ds, "")
host := createEnrolledAndroidHost(t, ctx, ds, uuid.New().String(), &setup.team.ID)
// Insert a pending removal template using BulkInsertHostCertificateTemplates
err := ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{{
HostUUID: host.UUID,
CertificateTemplateID: setup.template.ID,
Status: fleet.CertificateTemplatePending,
OperationType: fleet.MDMOperationTypeRemove,
Name: setup.template.Name,
}})
require.NoError(t, err)
// Act: List hosts with pending templates
results, err := ds.ListAndroidHostUUIDsWithPendingCertificateTemplates(ctx, 0, 100)
require.NoError(t, err)
require.Len(t, results, 1, "should include host with pending removal")
require.Equal(t, host.UUID, results[0])
}
// testGetAndTransitionCertificateTemplatesToDeliveringIncludesRemoval verifies that
// GetAndTransitionCertificateTemplatesToDelivering handles both install and remove operations.
func testGetAndTransitionCertificateTemplatesToDeliveringIncludesRemoval(t *testing.T, ds *Datastore) {
ctx := t.Context()
setup := createCertTemplateTestSetup(t, ctx, ds, "")
host := createEnrolledAndroidHost(t, ctx, ds, uuid.New().String(), &setup.team.ID)
// Create second template for removal
templateForRemoval, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "Template for Removal",
TeamID: setup.team.ID,
CertificateAuthorityID: setup.ca.ID,
SubjectName: "CN=Template for Removal",
})
require.NoError(t, err)
// Insert both pending install and pending removal using BulkInsertHostCertificateTemplates
err = ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{
{
HostUUID: host.UUID,
CertificateTemplateID: setup.template.ID,
Status: fleet.CertificateTemplatePending,
OperationType: fleet.MDMOperationTypeInstall,
Name: setup.template.Name,
},
{
HostUUID: host.UUID,
CertificateTemplateID: templateForRemoval.ID,
Status: fleet.CertificateTemplatePending,
OperationType: fleet.MDMOperationTypeRemove,
Name: templateForRemoval.Name,
},
})
require.NoError(t, err)
// Act: Transition to delivering
result, err := ds.GetAndTransitionCertificateTemplatesToDelivering(ctx, host.UUID)
require.NoError(t, err)
// Assert: Should include BOTH install and removal templates
// Currently only install is included
require.Len(t, result.Templates, 2, "should include both install and removal")
require.Len(t, result.DeliveringTemplateIDs, 2, "should transition both to delivering")
// Verify both templates are in delivering state in the database
var statuses []struct {
CertificateTemplateID uint `db:"certificate_template_id"`
Status string `db:"status"`
OperationType string `db:"operation_type"`
}
err = ds.writer(ctx).SelectContext(ctx, &statuses,
`SELECT certificate_template_id, status, operation_type
FROM host_certificate_templates WHERE host_uuid = ?`, host.UUID)
require.NoError(t, err)
require.Len(t, statuses, 2)
for _, s := range statuses {
require.Equal(t, string(fleet.CertificateTemplateDelivering), s.Status,
"template %d should be in delivering status", s.CertificateTemplateID)
}
// Verify the result contains templates with correct operation types
hasInstall := false
hasRemove := false
for _, tmpl := range result.Templates {
if tmpl.OperationType == fleet.MDMOperationTypeInstall {
hasInstall = true
}
if tmpl.OperationType == fleet.MDMOperationTypeRemove {
hasRemove = true
}
}
require.True(t, hasInstall, "should include install operation")
require.True(t, hasRemove, "should include remove operation")
}
// testCertificateTemplateReinstalledAfterTransferBackToOriginalTeam verifies that when a host
// transfers back to its original team, the certificate template that was marked for removal
// is correctly transitioned back to pending install.
func testCertificateTemplateReinstalledAfterTransferBackToOriginalTeam(t *testing.T, ds *Datastore) {
ctx := t.Context()
setupA := createCertTemplateTestSetup(t, ctx, ds, "Team A")
setupB := createCertTemplateTestSetup(t, ctx, ds, "Team B")
host := createEnrolledAndroidHost(t, ctx, ds, uuid.New().String(), &setupA.team.ID)
// Host starts with verified cert in Team A
challenge := "challenge-a"
err := ds.BulkInsertHostCertificateTemplates(ctx, []fleet.HostCertificateTemplate{{
HostUUID: host.UUID,
CertificateTemplateID: setupA.template.ID,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
FleetChallenge: &challenge,
Name: setupA.template.Name,
}})
require.NoError(t, err)
// Capture initial UUID
initialResults, err := ds.ListCertificateTemplatesForHosts(ctx, []string{host.UUID})
require.NoError(t, err)
var initialUUID string
for _, r := range initialResults {
if r.CertificateTemplateID == setupA.template.ID {
require.NotNil(t, r.UUID, "initial UUID should be set")
initialUUID = *r.UUID
break
}
}
require.NotEmpty(t, initialUUID, "initial UUID should not be empty")
// Transfer to Team B: mark Team A cert for removal, create pending install for Team B
host.TeamID = &setupB.team.ID
require.NoError(t, ds.UpdateHost(ctx, host))
require.NoError(t, ds.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, host.UUID))
_, err = ds.CreatePendingCertificateTemplatesForNewHost(ctx, host.UUID, setupB.team.ID)
require.NoError(t, err)
// Capture UUID after marking for removal (should be different from initial)
removeResults, err := ds.ListCertificateTemplatesForHosts(ctx, []string{host.UUID})
require.NoError(t, err)
var uuidAfterRemove string
for _, r := range removeResults {
if r.CertificateTemplateID == setupA.template.ID {
require.NotNil(t, r.UUID, "UUID after remove should be set")
uuidAfterRemove = *r.UUID
break
}
}
require.NotEqual(t, initialUUID, uuidAfterRemove, "UUID should change when marked for removal")
// Transfer back to Team A: mark Team B cert for removal, re-create pending install for Team A
host.TeamID = &setupA.team.ID
require.NoError(t, ds.UpdateHost(ctx, host))
require.NoError(t, ds.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, host.UUID))
_, err = ds.CreatePendingCertificateTemplatesForNewHost(ctx, host.UUID, setupA.team.ID)
require.NoError(t, err)
// Team A's cert should now be pending install (not pending remove) with a new UUID
results, err := ds.ListCertificateTemplatesForHosts(ctx, []string{host.UUID})
require.NoError(t, err)
var certA *fleet.CertificateTemplateForHost
for _, r := range results {
if r.CertificateTemplateID == setupA.template.ID {
certA = &r
break
}
}
require.NotNil(t, certA, "Team A cert should exist")
require.Equal(t, fleet.MDMOperationTypeInstall, *certA.OperationType)
require.Equal(t, fleet.CertificateTemplatePending, *certA.Status)
require.NotNil(t, certA.UUID, "UUID should be set")
require.NotEqual(t, uuidAfterRemove, *certA.UUID, "UUID should change when reinstalled")
}

View file

@ -39,6 +39,7 @@ type CertificateTemplateResponse struct {
type CertificateTemplateResponseForHost struct {
CertificateTemplateResponse
Status CertificateTemplateStatus `json:"status" db:"status"`
UUID string `json:"uuid" db:"uuid"`
SCEPChallenge *string `json:"scep_challenge" db:"scep_challenge"`
FleetChallenge *string `json:"fleet_challenge" db:"fleet_challenge"`
SCEPChallengeEncrypted []byte `json:"-" db:"scep_challenge_encrypted"`
@ -68,13 +69,22 @@ func CertificateTemplateStatusToMDMDeliveryStatus(s CertificateTemplateStatus) M
}
}
// HostCertificateTemplateForDelivery represents a certificate template being prepared
// for delivery to a host, including its current status and operation type.
type HostCertificateTemplateForDelivery struct {
CertificateTemplateID uint
Status CertificateTemplateStatus
OperationType MDMOperationType
UUID string
}
// HostCertificateTemplatesForDelivery contains the result of preparing certificate templates
// for delivery to a host. It includes both the templates being transitioned to delivering
// status and the templates that are already installed (verified/delivered).
// for delivery to a host.
type HostCertificateTemplatesForDelivery struct {
// DeliveringTemplateIDs are the certificate template IDs that were transitioned
// from pending to delivering status in this operation.
// from pending to delivering status in this operation. Used for challenge generation.
DeliveringTemplateIDs []uint
// OtherTemplateIDs are other certificate template IDs.
OtherTemplateIDs []uint
// Templates contains all certificate templates with their current status and operation.
// Pending templates will show as delivering (their post-transition status).
Templates []HostCertificateTemplateForDelivery
}

View file

@ -2607,6 +2607,10 @@ type Datastore interface {
// updates all other rows to status=pending, operation_type=remove.
SetHostCertificateTemplatesToPendingRemove(ctx context.Context, certificateTemplateID uint) error
// SetHostCertificateTemplatesToPendingRemoveForHost prepares all certificate templates
// for a specific host for removal.
SetHostCertificateTemplatesToPendingRemoveForHost(ctx context.Context, hostUUID string) error
// GetCurrentTime gets the current time from the database
GetCurrentTime(ctx context.Context) (time.Time, error)

View file

@ -12,6 +12,7 @@ type HostCertificateTemplate struct {
Status CertificateTemplateStatus `db:"status"`
OperationType MDMOperationType `db:"operation_type"`
Detail *string `db:"detail" json:"-"`
UUID string `db:"uuid"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
@ -43,6 +44,7 @@ type CertificateTemplateForHost struct {
FleetChallenge *string `db:"fleet_challenge"`
Status *CertificateTemplateStatus `db:"status"`
OperationType *MDMOperationType `db:"operation_type"`
UUID *string `db:"uuid"`
CAType CAConfigAssetType `db:"ca_type"`
CAName string `db:"ca_name"`
}

View file

@ -60,7 +60,10 @@ type AgentManagedConfiguration struct {
}
type AgentCertificateTemplate struct {
ID uint `json:"id"`
ID uint `json:"id"`
Status string `json:"status"`
Operation string `json:"operation"`
UUID string `json:"uuid"`
}
// MDMAndroidPolicyRequest represents a request made to the Android Management

View file

@ -916,13 +916,16 @@ func testCertificateTemplates(t *testing.T, ds fleet.Datastore, client *mock.Cli
require.Equal(t, fmt.Sprintf("%s/policies/%s", reconciler.Enterprise.Name(), host1.Host.UUID), capturedPolicyName)
require.Len(t, capturedPolicies, 1)
// Verify the managed configuration contains certificate template IDs
// Verify the managed configuration contains certificate template IDs with status and operation
var managedConfig android.AgentManagedConfiguration
err = json.Unmarshal(capturedPolicies[0].ManagedConfiguration, &managedConfig)
require.NoError(t, err)
require.Len(t, managedConfig.CertificateTemplateIDs, 2)
for _, certTemplate := range managedConfig.CertificateTemplateIDs {
require.Contains(t, certificateTemplateIDs, certTemplate.ID)
// When sent to API, templates are in "delivering" status (transition to "delivered" happens after API success)
require.EqualValues(t, fleet.CertificateTemplateDelivering, certTemplate.Status)
require.EqualValues(t, fleet.MDMOperationTypeInstall, certTemplate.Operation)
}
// Verify that host_certificate_template records were created with pending status
@ -1214,14 +1217,22 @@ func testCertificateTemplatesIncludesExistingVerified(t *testing.T, ds fleet.Dat
require.Len(t, managedConfig.CertificateTemplateIDs, 5,
"Agent config should include all certificate templates (verified, delivered, delivering, failed, and pending)")
// Verify all certificate template IDs are present
templateIDs := make(map[uint]bool)
// Verify all certificate template IDs are present with correct status and operation
templatesByID := make(map[uint]android.AgentCertificateTemplate)
for _, tmpl := range managedConfig.CertificateTemplateIDs {
templateIDs[tmpl.ID] = true
templatesByID[tmpl.ID] = tmpl
}
require.True(t, templateIDs[verifiedCert.ID], "Verified certificate should be in the config")
require.True(t, templateIDs[deliveredCert.ID], "Delivered certificate should be in the config")
require.True(t, templateIDs[deliveringCert.ID], "Delivering certificate should be in the config")
require.True(t, templateIDs[failedCert.ID], "Failed certificate should be in the config")
require.True(t, templateIDs[pendingCert.ID], "Pending certificate should be in the config")
assertCertTemplate := func(certID uint, expectedStatus fleet.CertificateTemplateStatus, expectedOp fleet.MDMOperationType) {
require.Contains(t, templatesByID, certID)
require.EqualValues(t, expectedStatus, templatesByID[certID].Status)
require.EqualValues(t, expectedOp, templatesByID[certID].Operation)
}
assertCertTemplate(verifiedCert.ID, fleet.CertificateTemplateVerified, fleet.MDMOperationTypeInstall)
assertCertTemplate(deliveredCert.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall)
assertCertTemplate(deliveringCert.ID, fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeInstall)
assertCertTemplate(failedCert.ID, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall)
// Pending certificate transitions to delivering before the API call
assertCertTemplate(pendingCert.ID, fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeInstall)
}

View file

@ -920,6 +920,7 @@ func buildFleetAgentAppPolicy(packageName, sha256Fingerprint string, managedConf
RoleType: "COMPANION_APP",
},
},
AutoUpdateMode: "AUTO_UPDATE_HIGH_PRIORITY",
}, nil
}
@ -1004,9 +1005,19 @@ func (svc *Service) buildAgentManagedConfig(ctx context.Context, hostUUID string
var certificateTemplateIDs []android.AgentCertificateTemplate
for _, ct := range certTemplates {
certificateTemplateIDs = append(certificateTemplateIDs, android.AgentCertificateTemplate{
template := android.AgentCertificateTemplate{
ID: ct.CertificateTemplateID,
})
}
if ct.Status != nil {
template.Status = string(*ct.Status)
}
if ct.OperationType != nil {
template.Operation = string(*ct.OperationType)
}
if ct.UUID != nil {
template.UUID = *ct.UUID
}
certificateTemplateIDs = append(certificateTemplateIDs, template)
}
return &android.AgentManagedConfiguration{
@ -1233,8 +1244,8 @@ func (svc *Service) BuildAndSendFleetAgentConfig(ctx context.Context, enterprise
return secrets, nil
}
// Helper to build config for a single host
buildHostConfig := func(hostUUID string, templateIDs []uint) (*android.AgentManagedConfiguration, error) {
// Helper to build config for a single host using pre-fetched certificate templates
buildHostConfig := func(hostUUID string, templates []fleet.HostCertificateTemplateForDelivery) (*android.AgentManagedConfiguration, error) {
androidHost, err := svc.ds.AndroidHostLiteByHostUUID(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "get android host %s", hostUUID)
@ -1248,11 +1259,13 @@ func (svc *Service) BuildAndSendFleetAgentConfig(ctx context.Context, enterprise
return nil, ctxerr.Errorf(ctx, "no enroll secrets found for team %v", androidHost.Host.TeamID)
}
// Build certificate template IDs list
var certificateTemplateIDs []android.AgentCertificateTemplate
for _, templateID := range templateIDs {
for _, ct := range templates {
certificateTemplateIDs = append(certificateTemplateIDs, android.AgentCertificateTemplate{
ID: templateID,
ID: ct.CertificateTemplateID,
Status: string(ct.Status),
Operation: string(ct.OperationType),
UUID: ct.UUID,
})
}
@ -1280,7 +1293,7 @@ func (svc *Service) BuildAndSendFleetAgentConfig(ctx context.Context, enterprise
}
// Send config without new certificates (needed for new host enrollment)
// There should be no other certificates either, but including them just in case.
config, err := buildHostConfig(hostUUID, certTemplates.OtherTemplateIDs)
config, err := buildHostConfig(hostUUID, certTemplates.Templates)
if err != nil {
level.Error(svc.logger).Log("msg", "failed to build host config without certs", "host_uuid", hostUUID, "err", err)
return ctxerr.Wrapf(ctx, err, "build host config without certs for host %s", hostUUID)
@ -1294,7 +1307,7 @@ func (svc *Service) BuildAndSendFleetAgentConfig(ctx context.Context, enterprise
}
// Step 2: Build and send config to AMAPI with ALL certificate templates
config, err := buildHostConfig(hostUUID, append(certTemplates.DeliveringTemplateIDs, certTemplates.OtherTemplateIDs...))
config, err := buildHostConfig(hostUUID, certTemplates.Templates)
if err != nil {
level.Error(svc.logger).Log("msg", "failed to build host config", "host_uuid", hostUUID, "err", err)
return ctxerr.Wrapf(ctx, err, "build host config for %s", hostUUID)

View file

@ -1705,6 +1705,8 @@ type RevertHostCertificateTemplatesToPendingFunc func(ctx context.Context, hostU
type SetHostCertificateTemplatesToPendingRemoveFunc func(ctx context.Context, certificateTemplateID uint) error
type SetHostCertificateTemplatesToPendingRemoveForHostFunc func(ctx context.Context, hostUUID string) error
type GetCurrentTimeFunc func(ctx context.Context) (time.Time, error)
type UpdateOrDeleteHostMDMWindowsProfileFunc func(ctx context.Context, profile *fleet.HostMDMWindowsProfile) error
@ -4237,6 +4239,9 @@ type DataStore struct {
SetHostCertificateTemplatesToPendingRemoveFunc SetHostCertificateTemplatesToPendingRemoveFunc
SetHostCertificateTemplatesToPendingRemoveFuncInvoked bool
SetHostCertificateTemplatesToPendingRemoveForHostFunc SetHostCertificateTemplatesToPendingRemoveForHostFunc
SetHostCertificateTemplatesToPendingRemoveForHostFuncInvoked bool
GetCurrentTimeFunc GetCurrentTimeFunc
GetCurrentTimeFuncInvoked bool
@ -10139,6 +10144,13 @@ func (s *DataStore) SetHostCertificateTemplatesToPendingRemove(ctx context.Conte
return s.SetHostCertificateTemplatesToPendingRemoveFunc(ctx, certificateTemplateID)
}
func (s *DataStore) SetHostCertificateTemplatesToPendingRemoveForHost(ctx context.Context, hostUUID string) error {
s.mu.Lock()
s.SetHostCertificateTemplatesToPendingRemoveForHostFuncInvoked = true
s.mu.Unlock()
return s.SetHostCertificateTemplatesToPendingRemoveForHostFunc(ctx, hostUUID)
}
func (s *DataStore) GetCurrentTime(ctx context.Context) (time.Time, error) {
s.mu.Lock()
s.GetCurrentTimeFuncInvoked = true

View file

@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"time"
@ -96,10 +97,7 @@ func TestCreateCertificateTemplate(t *testing.T) {
})
t.Run("Name too long", func(t *testing.T) {
longName := string(make([]byte, 256))
for i := range longName {
longName = longName[:i] + "a" + longName[i+1:]
}
longName := strings.Repeat("a", 256)
_, err := svc.CreateCertificateTemplate(ctx, longName, TeamID, uint(ValidCATypeID), "CN=$FLEET_VAR_HOST_UUID")
require.Error(t, err)
require.Contains(t, err.Error(), "Certificate template name is too long")
@ -356,10 +354,7 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) {
})
t.Run("Name too long", func(t *testing.T) {
longName := string(make([]byte, 256))
for i := range longName {
longName = longName[:i] + "a" + longName[i+1:]
}
longName := strings.Repeat("a", 256)
err := svc.ApplyCertificateTemplateSpecs(ctx, []*fleet.CertificateRequestSpec{
{
Name: longName,

View file

@ -627,20 +627,22 @@ func (svc *Service) UpdateCertificateStatus(
return err
}
if record.Status != fleet.CertificateTemplateDelivered {
level.Info(svc.logger).Log("msg", "ignoring certificate status update for non-delivered certificate", "host_uuid", host.UUID, "certificate_template_id", certificateTemplateID, "current_status", record.Status, "new_status", status)
return nil
}
if record.OperationType != opType {
level.Info(svc.logger).Log("msg", "ignoring certificate status update for different operation type", "host_uuid", host.UUID, "certificate_template_id", certificateTemplateID, "current_operation_type", record.OperationType, "new_operation_type", opType)
return nil
}
// If operation_type is "remove" and status is "verified", delete the host_certificate_template row
// If operation_type is "remove" and status is "verified", delete the host_certificate_template row.
// This allows deletions even when there are race conditions or status sync issues
// (e.g., device reports removal before server transitions status).
if opType == fleet.MDMOperationTypeRemove && status == fleet.MDMDeliveryVerified {
return svc.ds.DeleteHostCertificateTemplate(ctx, host.UUID, certificateTemplateID)
}
if record.Status != fleet.CertificateTemplateDelivered {
level.Info(svc.logger).Log("msg", "ignoring certificate status update for non-delivered certificate", "host_uuid", host.UUID, "certificate_template_id", certificateTemplateID, "current_status", record.Status, "new_status", status)
return nil
}
return svc.ds.UpsertCertificateStatus(ctx, host.UUID, certificateTemplateID, status, detail, opType)
}

View file

@ -116,6 +116,61 @@ func (s *integrationMDMTestSuite) verifyCertificateStatusWithSubject(
}
}
// createTestCertificateAuthority creates a test certificate authority for use in tests.
func (s *integrationMDMTestSuite) createTestCertificateAuthority(t *testing.T, ctx context.Context) (uint, *fleet.CertificateAuthority) {
ca, err := s.ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String(t.Name() + "-CA"),
URL: ptr.String("http://localhost:8080/scep"),
Challenge: ptr.String("test-challenge"),
})
require.NoError(t, err)
return ca.ID, ca
}
// createEnrolledAndroidHost creates an enrolled Android host in a team and returns the host and orbit node key.
func (s *integrationMDMTestSuite) createEnrolledAndroidHost(t *testing.T, ctx context.Context, enterpriseID string, teamID *uint, suffix string) (*fleet.Host, string) {
hostUUID := uuid.NewString()
androidHostInput := &fleet.AndroidHost{
Host: &fleet.Host{
Hostname: t.Name() + "-host-" + suffix,
ComputerName: t.Name() + "-device-" + suffix,
Platform: "android",
OSVersion: "Android 14",
Build: "build1",
Memory: 1024,
TeamID: teamID,
HardwareSerial: uuid.NewString(),
UUID: hostUUID,
},
Device: &android.Device{
DeviceID: strings.ReplaceAll(uuid.NewString(), "-", ""),
EnterpriseSpecificID: ptr.String(enterpriseID),
AppliedPolicyID: ptr.String("1"),
},
}
androidHostInput.SetNodeKey(enterpriseID)
createdAndroidHost, err := s.ds.NewAndroidHost(ctx, androidHostInput)
require.NoError(t, err)
host := createdAndroidHost.Host
orbitNodeKey := *host.NodeKey
host.OrbitNodeKey = &orbitNodeKey
require.NoError(t, s.ds.UpdateHost(ctx, host))
// Mark host as enrolled in host_mdm
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_mdm (host_id, enrolled, server_url, installed_from_dep, is_server)
VALUES (?, 1, 'https://example.com', 0, 0)
ON DUPLICATE KEY UPDATE enrolled = 1
`, host.ID)
return err
})
return host, orbitNodeKey
}
// TestCertificateTemplateLifecycle tests the full Android certificate template lifecycle.
func (s *integrationMDMTestSuite) TestCertificateTemplateLifecycle() {
t := s.T()
@ -653,3 +708,347 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateUnenrollReenroll() {
s.verifyCertificateStatusWithSubject(t, host, orbitNodeKey, certTemplateID2, certTemplateName2, caID,
fleet.CertificateTemplatePending, "", "CN="+host.UUID)
}
// TestCertificateTemplateTeamTransfer tests certificate template behavior when Android hosts transfer between teams:
// 1. Host with certs in various statuses (pending, delivering, delivered, verified, failed, remove) transfers teams -> all certs marked for removal
// 2. Host without any certs transfers to a team with certs -> gets new pending certs
// 3. Host with certs transfers to another team with different certs -> old certs removed, new certs added
func (s *integrationMDMTestSuite) TestCertificateTemplateTeamTransfer() {
t := s.T()
ctx := t.Context()
setupAMAPIEnvVars(t)
enterpriseID := s.enableAndroidMDM(t)
// Create two teams with different certificate templates
teamAName := t.Name() + "-teamA"
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(teamAName),
},
}, http.StatusOK, &createTeamResp)
teamAID := createTeamResp.Team.ID
teamBName := t.Name() + "-teamB"
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(teamBName),
},
}, http.StatusOK, &createTeamResp)
teamBID := createTeamResp.Team.ID
// Create enroll secrets for both teams
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", teamAID), modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "teamA-secret"}},
}, http.StatusOK, &teamEnrollSecretsResponse{})
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", teamBID), modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "teamB-secret"}},
}, http.StatusOK, &teamEnrollSecretsResponse{})
// Create a test certificate authority
caID, _ := s.createTestCertificateAuthority(t, ctx)
// Create certificate templates for Team A
certTemplateA1Name := strings.ReplaceAll(t.Name(), "/", "-") + "-TeamA-Cert1"
var createCertResp createCertificateTemplateResponse
s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{
Name: certTemplateA1Name,
TeamID: teamAID,
CertificateAuthorityId: caID,
SubjectName: "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL",
}, http.StatusOK, &createCertResp)
certTemplateA1ID := createCertResp.ID
certTemplateA2Name := strings.ReplaceAll(t.Name(), "/", "-") + "-TeamA-Cert2"
s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{
Name: certTemplateA2Name,
TeamID: teamAID,
CertificateAuthorityId: caID,
SubjectName: "CN=$FLEET_VAR_HOST_UUID",
}, http.StatusOK, &createCertResp)
certTemplateA2ID := createCertResp.ID
// Create certificate templates for Team B
certTemplateB1Name := strings.ReplaceAll(t.Name(), "/", "-") + "-TeamB-Cert1"
s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{
Name: certTemplateB1Name,
TeamID: teamBID,
CertificateAuthorityId: caID,
SubjectName: "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL",
}, http.StatusOK, &createCertResp)
certTemplateB1ID := createCertResp.ID
// Helper to get certificate template statuses for a host from the database
getCertTemplateStatuses := func(hostUUID string) map[uint]struct {
Status fleet.CertificateTemplateStatus
OperationType fleet.MDMOperationType
} {
result := make(map[uint]struct {
Status fleet.CertificateTemplateStatus
OperationType fleet.MDMOperationType
})
var rows []struct {
CertificateTemplateID uint `db:"certificate_template_id"`
Status fleet.CertificateTemplateStatus `db:"status"`
OperationType fleet.MDMOperationType `db:"operation_type"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &rows, `
SELECT certificate_template_id, status, operation_type
FROM host_certificate_templates
WHERE host_uuid = ?
`, hostUUID)
})
for _, r := range rows {
result[r.CertificateTemplateID] = struct {
Status fleet.CertificateTemplateStatus
OperationType fleet.MDMOperationType
}{r.Status, r.OperationType}
}
return result
}
// Set up AMAPI mock to succeed
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(_ context.Context, _ string, _ []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
return &androidmanagement.Policy{}, nil
}
t.Run("host with certs in all status/operation combinations transfers to team without certs", func(t *testing.T) {
// Create a team with 9 certificate templates to test all status/operation combinations
teamEName := t.Name() + "-teamE"
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(teamEName),
},
}, http.StatusOK, &createTeamResp)
teamEID := createTeamResp.Team.ID
// Create enroll secret for team E
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", teamEID), modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "teamE-secret"}},
}, http.StatusOK, &teamEnrollSecretsResponse{})
// Create a team without certificate templates for transfer target
teamFName := t.Name() + "-teamF"
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(teamFName),
},
}, http.StatusOK, &createTeamResp)
teamFID := createTeamResp.Team.ID
// Create enroll secret for team F
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", teamFID), modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "teamF-secret"}},
}, http.StatusOK, &teamEnrollSecretsResponse{})
// Define all status/operation combinations to test
type certTestCase struct {
status fleet.CertificateTemplateStatus
operation fleet.MDMOperationType
shouldDelete bool // true if record should be deleted during transfer
expectedStatus fleet.CertificateTemplateStatus // expected status after transfer (if not deleted)
expectedOp fleet.MDMOperationType // expected operation after transfer (if not deleted)
templateID uint
templateName string
}
testCases := []certTestCase{
// Install operations: pending/failed are deleted, others are marked for removal
{fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall, true, "", "", 0, ""},
{fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeInstall, false, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, false, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateVerified, fleet.MDMOperationTypeInstall, false, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateFailed, fleet.MDMOperationTypeInstall, true, "", "", 0, ""},
// Remove operations: all stay unchanged (removal already in progress)
{fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, false, fleet.CertificateTemplatePending, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeRemove, false, fleet.CertificateTemplateDelivering, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeRemove, false, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeRemove, 0, ""},
{fleet.CertificateTemplateFailed, fleet.MDMOperationTypeRemove, false, fleet.CertificateTemplateFailed, fleet.MDMOperationTypeRemove, 0, ""},
}
// Create certificate templates for team E
for i := range testCases {
name := fmt.Sprintf("%s-Cert-%s-%s", strings.ReplaceAll(t.Name(), "/", "-"), testCases[i].status, testCases[i].operation)
s.DoJSON("POST", "/api/latest/fleet/certificates", createCertificateTemplateRequest{
Name: name,
TeamID: teamEID,
CertificateAuthorityId: caID,
SubjectName: fmt.Sprintf("CN=Test-%d", i),
}, http.StatusOK, &createCertResp)
testCases[i].templateID = createCertResp.ID
testCases[i].templateName = name
}
// Create host in Team E
host, _ := s.createEnrolledAndroidHost(t, ctx, enterpriseID, &teamEID, "all-statuses")
// Insert certificate template records with all status/operation combinations
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
for _, tc := range testCases {
challenge := "challenge"
if tc.status == fleet.CertificateTemplatePending || tc.status == fleet.CertificateTemplateFailed {
challenge = "" // No challenge for pending/failed
}
_, err := q.ExecContext(ctx, `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, operation_type, fleet_challenge, name)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?)
`, host.UUID, tc.templateID, tc.status, tc.operation, challenge, tc.templateName)
if err != nil {
return err
}
}
return nil
})
// Verify initial state - should have 9 certificate template records
statuses := getCertTemplateStatuses(host.UUID)
require.Len(t, statuses, 9, "Should have 9 certificate template records")
// Transfer host to Team F (no certs)
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &teamFID,
HostIDs: []uint{host.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
// Run the worker to process the transfer
s.runWorker()
// Verify results
statuses = getCertTemplateStatuses(host.UUID)
// Count expected remaining records (those not deleted)
expectedRemaining := 0
for _, tc := range testCases {
if !tc.shouldDelete {
expectedRemaining++
}
}
require.Len(t, statuses, expectedRemaining, "Should have %d certificate template records after transfer", expectedRemaining)
// Verify each test case
for _, tc := range testCases {
status, exists := statuses[tc.templateID]
if tc.shouldDelete {
require.False(t, exists, "Record for %s/%s should be deleted", tc.status, tc.operation)
} else {
require.True(t, exists, "Record for %s/%s should exist", tc.status, tc.operation)
require.Equal(t, tc.expectedStatus, status.Status,
"Record for %s/%s should have status=%s", tc.status, tc.operation, tc.expectedStatus)
require.Equal(t, tc.expectedOp, status.OperationType,
"Record for %s/%s should have operation_type=%s", tc.status, tc.operation, tc.expectedOp)
}
}
})
t.Run("host without certs transfers to team with certs", func(t *testing.T) {
// Create a team without certificate templates
teamDName := t.Name() + "-teamD"
s.DoJSON("POST", "/api/latest/fleet/teams", createTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(teamDName),
},
}, http.StatusOK, &createTeamResp)
teamDID := createTeamResp.Team.ID
// Create enroll secret for team D
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", teamDID), modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "teamD-secret"}},
}, http.StatusOK, &teamEnrollSecretsResponse{})
// Create host in Team D (no certs)
host, _ := s.createEnrolledAndroidHost(t, ctx, enterpriseID, &teamDID, "no-certs")
// Verify no certificate templates for this host
statuses := getCertTemplateStatuses(host.UUID)
require.Empty(t, statuses, "Host should have no certificate templates initially")
// Transfer host to Team B (has certs)
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &teamBID,
HostIDs: []uint{host.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
// Run the worker to process the transfer
s.runWorker()
// Verify host now has Team B's certificate template as pending install
statuses = getCertTemplateStatuses(host.UUID)
require.Len(t, statuses, 1, "Host should have Team B's certificate template")
require.Equal(t, fleet.CertificateTemplatePending, statuses[certTemplateB1ID].Status)
require.Equal(t, fleet.MDMOperationTypeInstall, statuses[certTemplateB1ID].OperationType)
})
t.Run("host with certs transfers to team with different certs", func(t *testing.T) {
// Create host in Team A
host, orbitNodeKey := s.createEnrolledAndroidHost(t, ctx, enterpriseID, &teamAID, "transfer-certs")
// Create pending certificate templates for this host (Team A certs)
_, err := s.ds.CreatePendingCertificateTemplatesForNewHost(ctx, host.UUID, teamAID)
require.NoError(t, err)
// Set both certs to verified status (simulating they were both installed on device)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE host_certificate_templates
SET status = ?, fleet_challenge = 'challenge'
WHERE host_uuid = ?
`, fleet.CertificateTemplateVerified, host.UUID)
return err
})
// Verify initial state - host has Team A's certs (both verified)
statuses := getCertTemplateStatuses(host.UUID)
require.Len(t, statuses, 2)
require.Contains(t, statuses, certTemplateA1ID)
require.Contains(t, statuses, certTemplateA2ID)
require.Equal(t, fleet.CertificateTemplateVerified, statuses[certTemplateA1ID].Status)
require.Equal(t, fleet.CertificateTemplateVerified, statuses[certTemplateA2ID].Status)
// Transfer host to Team B
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &teamBID,
HostIDs: []uint{host.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
// Run the worker to process the transfer
s.runWorker()
// Verify:
// - Team A's certs (both verified/installed) are marked as pending remove
// - Team B's cert is added as pending install
statuses = getCertTemplateStatuses(host.UUID)
require.Len(t, statuses, 3, "Should have 2 old certs (pending remove) + 1 new cert (pending install)")
// Team A certs should be pending remove
require.Equal(t, fleet.CertificateTemplatePending, statuses[certTemplateA1ID].Status)
require.Equal(t, fleet.MDMOperationTypeRemove, statuses[certTemplateA1ID].OperationType)
require.Equal(t, fleet.CertificateTemplatePending, statuses[certTemplateA2ID].Status)
require.Equal(t, fleet.MDMOperationTypeRemove, statuses[certTemplateA2ID].OperationType)
// Team B cert should be pending install
require.Equal(t, fleet.CertificateTemplatePending, statuses[certTemplateB1ID].Status)
require.Equal(t, fleet.MDMOperationTypeInstall, statuses[certTemplateB1ID].OperationType)
// Test that device can report "verified" for a pending removal and the record gets deleted.
// This handles race conditions where the device processes the removal before the server
// transitions the status through the full state machine.
updateReq, err := json.Marshal(updateCertificateStatusRequest{
Status: string(fleet.CertificateTemplateVerified),
OperationType: ptr.String(string(fleet.MDMOperationTypeRemove)),
})
require.NoError(t, err)
resp := s.DoRawWithHeaders("PUT", fmt.Sprintf("/api/fleetd/certificates/%d/status", certTemplateA1ID), updateReq, http.StatusOK, map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
})
_ = resp.Body.Close()
// Verify the record was deleted
statuses = getCertTemplateStatuses(host.UUID)
require.Len(t, statuses, 2, "Should have 1 pending remove + 1 pending install after removal confirmed")
_, exists := statuses[certTemplateA1ID]
require.False(t, exists, "certTemplateA1 should be deleted after verified removal")
})
}

View file

@ -15163,7 +15163,7 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplate() {
// Verified status - should be updated to pending/remove
_, err = q.ExecContext(ctx, insertSQL, hostVerified.UUID, certificateTemplateID, "verified", "install", "challenge2", certTemplateName)
require.NoError(t, err)
// Failed status - should be updated to pending/remove
// Failed status - should be deleted (never successfully installed)
_, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certificateTemplateID, "failed", "install", "challenge3", certTemplateName)
require.NoError(t, err)
return nil
@ -15224,23 +15224,28 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplate() {
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", certificateTemplateID), nil, http.StatusOK, &deleteResp)
// After deletion:
// - hostPending (pending/install) should have NO profile (record was deleted)
// - hostDelivered, hostVerified, hostFailed should have pending/remove profiles
// - hostPending (pending/install) should have NO profile (record was deleted - never installed)
// - hostFailed (failed/install) should have NO profile (record was deleted - never successfully installed)
// - hostDelivered, hostVerified should have pending/remove profiles
// (kept for cron job to process removal from devices)
// Verify hostPending has no profile after deletion
// Verify hostPending has no profile after deletion (was pending/install, never installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostPending.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName)
require.Nil(t, profile, "hostPending should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified/failed status now have pending/remove profiles
// Verify hostFailed has no profile after deletion (was failed/install, never successfully installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostFailed.ID), nil, http.StatusOK, &getHostResp)
profile = findProfile(getHostResp.Host.MDM.Profiles, certTemplateName)
require.Nil(t, profile, "hostFailed should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified status now have pending/remove profiles
for _, tc := range []struct {
host *fleet.Host
hostName string
}{
{hostDelivered, "hostDelivered"},
{hostVerified, "hostVerified"},
{hostFailed, "hostFailed"},
} {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName)
@ -15416,16 +15421,22 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplateSpec() {
require.True(t, fleet.IsNotFound(err), "certificate template 2 should be deleted")
// After deletion:
// - hostPending (pending/install) should have NO profile (record was deleted)
// - hostDelivered, hostVerified, hostFailed should have pending/remove profiles
// - hostPending (pending/install) should have NO profile (record was deleted - never installed)
// - hostFailed (failed/install) should have NO profile (record was deleted - never successfully installed)
// - hostDelivered, hostVerified should have pending/remove profiles
// (kept for cron job to process removal from devices)
// Verify hostPending has no profile after deletion
// Verify hostPending has no profile after deletion (was pending/install, never installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostPending.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName1)
require.Nil(t, profile, "hostPending should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified/failed status now have pending/remove profiles
// Verify hostFailed has no profile after deletion (was failed/install, never successfully installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostFailed.ID), nil, http.StatusOK, &getHostResp)
profile = findProfile(getHostResp.Host.MDM.Profiles, certTemplateName2)
require.Nil(t, profile, "hostFailed should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified status now have pending/remove profiles
for _, tc := range []struct {
host *fleet.Host
hostName string
@ -15433,7 +15444,6 @@ func (s *integrationTestSuite) TestDeleteCertificateTemplateSpec() {
}{
{hostDelivered, "hostDelivered", certTemplateName1},
{hostVerified, "hostVerified", certTemplateName2},
{hostFailed, "hostFailed", certTemplateName2},
} {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, tc.templateName)

View file

@ -23391,8 +23391,8 @@ func (s *integrationEnterpriseTestSuite) TestDeleteTeamCertificateTemplates() {
// Verified status - should be updated to pending/remove
_, err = q.ExecContext(ctx, insertSQL, hostVerified.UUID, certificateTemplateID2, "verified", "install", "challenge2", certTemplateName2)
require.NoError(t, err)
// Failed status - should be updated to pending/remove
_, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certificateTemplateID2, "failed", "install", "challenge3", certTemplateName2)
// Failed status - should be deleted (never successfully installed)
_, err = q.ExecContext(ctx, insertSQL, hostFailed.UUID, certificateTemplateID2, "failed", "install", nil, certTemplateName2)
require.NoError(t, err)
return nil
})
@ -23464,16 +23464,22 @@ func (s *integrationEnterpriseTestSuite) TestDeleteTeamCertificateTemplates() {
require.True(t, fleet.IsNotFound(err), "certificate template 2 should be deleted")
// After team deletion:
// - hostPending (pending/install) should have NO profile (record was deleted)
// - hostDelivered, hostVerified, hostFailed should have pending/remove profiles
// - hostPending (pending/install) should have NO profile (record was deleted - never installed)
// - hostFailed (failed/install) should have NO profile (record was deleted - never successfully installed)
// - hostDelivered, hostVerified should have pending/remove profiles
// (kept for cron job to process removal from devices)
// Verify hostPending has no profile after deletion
// Verify hostPending has no profile after deletion (was pending/install, never installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostPending.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, certTemplateName1)
require.Nil(t, profile, "hostPending should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified/failed status now have pending/remove profiles
// Verify hostFailed has no profile after deletion (was failed/install, never successfully installed)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostFailed.ID), nil, http.StatusOK, &getHostResp)
profile = findProfile(getHostResp.Host.MDM.Profiles, certTemplateName2)
require.Nil(t, profile, "hostFailed should not have certificate template profile after deletion")
// Verify hosts that had delivered/verified status now have pending/remove profiles
for _, tc := range []struct {
host *fleet.Host
hostName string
@ -23481,7 +23487,6 @@ func (s *integrationEnterpriseTestSuite) TestDeleteTeamCertificateTemplates() {
}{
{hostDelivered, "hostDelivered", certTemplateName1},
{hostVerified, "hostVerified", certTemplateName2},
{hostFailed, "hostFailed", certTemplateName2},
} {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", tc.host.ID), nil, http.StatusOK, &getHostResp)
profile := findProfile(getHostResp.Host.MDM.Profiles, tc.templateName)

View file

@ -476,12 +476,25 @@ func (v *SoftwareWorker) bulkSetAndroidAppsAvailableForHosts(ctx context.Context
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", uuid)
}
teamID := ptr.ValOrZero(androidHost.TeamID)
// Update certificate templates for team transfer:
// 1. Mark old templates as pending removal
// 2. Create new pending templates for the new team
// This must happen before building the managed config, which includes certificate template IDs.
if err := v.Datastore.SetHostCertificateTemplatesToPendingRemoveForHost(ctx, uuid); err != nil {
return ctxerr.Wrap(ctx, err, "set host certificate templates to pending remove for host")
}
if _, err := v.Datastore.CreatePendingCertificateTemplatesForNewHost(ctx, uuid, teamID); err != nil {
return ctxerr.Wrap(ctx, err, "create pending certificate templates for new host")
}
appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID)
if err != nil {
return ctxerr.WrapWithData(ctx, err, "get android apps in scope for host", map[string]any{"host_id": hostID})
}
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, ptr.ValOrZero(androidHost.TeamID))
configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk get android app configurations")
}

View file

@ -65,6 +65,12 @@ func TestBulkSetAndroidAppsAvailableForHostsPreservesFleetAgent(t *testing.T) {
},
}, nil
}
ds.SetHostCertificateTemplatesToPendingRemoveForHostFunc = func(ctx context.Context, hostUUID string) error {
return nil
}
ds.CreatePendingCertificateTemplatesForNewHostFunc = func(ctx context.Context, hostUUID string, teamID uint) (int64, error) {
return 0, nil
}
ds.GetAndroidAppsInScopeForHostFunc = func(ctx context.Context, hostID uint) ([]string, error) {
return []string{"com.example.teamapp"}, nil
}