Support operation type in android cert status API (#37400)

This commit is contained in:
Tim Lee 2025-12-17 13:54:24 -07:00 committed by GitHub
parent 01746ed6ab
commit 4e472b188a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 175 additions and 51 deletions

View file

@ -51,9 +51,13 @@ func (ds *Datastore) ListCertificateTemplatesForHosts(ctx context.Context, hostU
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.status AS status,
host_certificate_templates.operation_type AS operation_type,
certificate_authorities.type AS ca_type,
certificate_authorities.name AS ca_name
FROM certificate_templates
INNER JOIN hosts ON hosts.team_id = certificate_templates.team_id
INNER JOIN certificate_authorities ON certificate_authorities.id = certificate_templates.certificate_authority_id
LEFT JOIN host_certificate_templates
ON host_certificate_templates.host_uuid = hosts.uuid
AND host_certificate_templates.certificate_template_id = certificate_templates.id
@ -81,6 +85,7 @@ func (ds *Datastore) GetCertificateTemplateForHost(ctx context.Context, hostUUID
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,
certificate_authorities.type AS ca_type,
certificate_authorities.name AS ca_name
FROM certificate_templates
@ -176,23 +181,20 @@ func (ds *Datastore) UpsertCertificateStatus(
certificateTemplateID uint,
status fleet.MDMDeliveryStatus,
detail *string,
operationType fleet.MDMOperationType,
) error {
updateStmt := `
UPDATE host_certificate_templates
SET status = ?, detail = ?
WHERE host_uuid = ? AND certificate_template_id = ?`
insertStmt := `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, detail, fleet_challenge, operation_type)
VALUES (?, ?, ?, ?, ?, ?)`
// Validate the status.
if !status.IsValid() {
return ctxerr.Wrap(ctx, fmt.Errorf("Invalid status '%s'", string(status)))
}
updateStmt := `
UPDATE host_certificate_templates
SET status = ?, detail = ?, operation_type = ?
WHERE host_uuid = ? AND certificate_template_id = ?`
// Attempt to update the certificate status for the given host and template.
result, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, detail, hostUUID, certificateTemplateID)
result, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, detail, operationType, hostUUID, certificateTemplateID)
if err != nil {
return err
}
@ -216,8 +218,10 @@ func (ds *Datastore) UpsertCertificateStatus(
return ctxerr.Wrap(ctx, err, "could not read certificate template for inserting new record")
}
// Default to install operation type for new records
params := []any{hostUUID, certificateTemplateID, status, detail, "", fleet.MDMOperationTypeInstall}
insertStmt := `
INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, detail, fleet_challenge, operation_type)
VALUES (?, ?, ?, ?, ?, ?)`
params := []any{hostUUID, certificateTemplateID, status, detail, "", operationType}
if _, err := ds.writer(ctx).ExecContext(ctx, insertStmt, params...); err != nil {
return ctxerr.Wrap(ctx, err, "could not insert new host certificate template")
}

View file

@ -366,6 +366,15 @@ func testUpsertHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// Create a third template for testing insert with operation type
templateThree, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "Cert3",
TeamID: setup.team.ID,
CertificateAuthorityID: setup.ca.ID,
SubjectName: "CN=Test Subject 3",
})
require.NoError(t, err)
hostUUID := uuid.New().String()
_, err = ds.NewHost(ctx, &fleet.Host{
UUID: hostUUID,
@ -377,55 +386,82 @@ func testUpsertHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
// Create an initial record for the first template
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, fleet_challenge) VALUES (?, ?, ?, ?)",
hostUUID, setup.template.ID, "pending", "some_challenge_value")
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, status, fleet_challenge, operation_type) VALUES (?, ?, ?, ?, ?)",
hostUUID, setup.template.ID, fleet.MDMDeliveryPending, "some_challenge_value", fleet.MDMOperationTypeInstall)
return err
})
cases := []struct {
name string
templateID uint
newStatus string
expectedErrorMsg string
detail *string
name string
templateID uint
newStatus string
expectedErrorMsg string
detail *string
operationType fleet.MDMOperationType
expectedOperationType string
}{
{
name: "valid update",
templateID: setup.template.ID,
newStatus: "verified",
name: "valid update with install operation type",
templateID: setup.template.ID,
newStatus: "verified",
operationType: fleet.MDMOperationTypeInstall,
expectedOperationType: "install",
},
{
name: "valid update with details",
templateID: setup.template.ID,
newStatus: "failed",
detail: ptr.String("some details"),
name: "valid update with details",
templateID: setup.template.ID,
newStatus: "failed",
detail: ptr.String("some details"),
operationType: fleet.MDMOperationTypeInstall,
expectedOperationType: "install",
},
{
name: "invalid status",
templateID: setup.template.ID,
newStatus: "invalid_status",
operationType: fleet.MDMOperationTypeInstall,
expectedErrorMsg: "Invalid status 'invalid_status'",
},
{
name: "creates new record if does not exist",
templateID: templateTwo.ID,
newStatus: "verified",
detail: ptr.String("some details"),
name: "creates new record with install operation type",
templateID: templateTwo.ID,
newStatus: "verified",
detail: ptr.String("some details"),
operationType: fleet.MDMOperationTypeInstall,
expectedOperationType: "install",
},
{
name: "update operation type to remove",
templateID: setup.template.ID,
newStatus: "pending",
operationType: fleet.MDMOperationTypeRemove,
expectedOperationType: "remove",
},
{
name: "creates new record with remove operation type",
templateID: templateThree.ID,
newStatus: "pending",
operationType: fleet.MDMOperationTypeRemove,
expectedOperationType: "remove",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ds.UpsertCertificateStatus(ctx, hostUUID, tc.templateID, fleet.MDMDeliveryStatus(tc.newStatus), tc.detail)
err := ds.UpsertCertificateStatus(ctx, hostUUID, tc.templateID, fleet.MDMDeliveryStatus(tc.newStatus), tc.detail, tc.operationType)
if tc.expectedErrorMsg == "" {
require.NoError(t, err)
var status string
var result struct {
Status string `db:"status"`
OperationType string `db:"operation_type"`
}
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &status,
"SELECT status FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
return sqlx.GetContext(ctx, q, &result,
"SELECT status, operation_type FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?",
hostUUID, tc.templateID)
})
require.Equal(t, tc.newStatus, status)
require.Equal(t, tc.newStatus, result.Status)
require.Equal(t, tc.expectedOperationType, result.OperationType)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.expectedErrorMsg)

View file

@ -2539,7 +2539,7 @@ type Datastore interface {
// processed together as upserts using INSERT...ON DUPLICATE KEY UPDATE.
BatchApplyCertificateAuthorities(ctx context.Context, ops CertificateAuthoritiesBatchOperations) error
// UpdateCertificateStatus allows a host to update the installation status of a certificate given its template.
UpsertCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status MDMDeliveryStatus, detail *string) error
UpsertCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status MDMDeliveryStatus, detail *string, operationType MDMOperationType) error
// BatchUpsertCertificateTemplates upserts a batch of certificates.
// Returns a map of team IDs that had certificates inserted or updated.

View file

@ -42,6 +42,7 @@ type CertificateTemplateForHost struct {
CertificateTemplateID uint `db:"certificate_template_id"`
FleetChallenge *string `db:"fleet_challenge"`
Status *CertificateTemplateStatus `db:"status"`
OperationType *MDMOperationType `db:"operation_type"`
CAType CAConfigAssetType `db:"ca_type"`
CAName string `db:"ca_name"`
}

View file

@ -529,6 +529,15 @@ const (
MDMOperationTypeRemove MDMOperationType = "remove"
)
func (o MDMOperationType) IsValid() bool {
switch o {
case MDMOperationTypeInstall, MDMOperationTypeRemove:
return true
default:
return false
}
}
// MDMConfigProfileAuthz is used to check user authorization to read/write an
// MDM configuration profile.
type MDMConfigProfileAuthz struct {

View file

@ -648,7 +648,7 @@ type Service interface {
DeleteCertificateTemplate(ctx context.Context, id uint) error
ApplyCertificateTemplateSpecs(ctx context.Context, specs []*CertificateRequestSpec) error
DeleteCertificateTemplateSpecs(ctx context.Context, certificateTemplateIDs []uint, teamID uint) error
UpdateCertificateStatus(ctx context.Context, certificateTemplateID uint, status MDMDeliveryStatus, detail *string) error
UpdateCertificateStatus(ctx context.Context, certificateTemplateID uint, status MDMDeliveryStatus, detail *string, operationType *string) error
// /////////////////////////////////////////////////////////////////////////////
// GlobalScheduleService

View file

@ -1655,7 +1655,7 @@ type UpdateCertificateAuthorityByIDFunc func(ctx context.Context, id uint, certi
type BatchApplyCertificateAuthoritiesFunc func(ctx context.Context, ops fleet.CertificateAuthoritiesBatchOperations) error
type UpsertCertificateStatusFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string) error
type UpsertCertificateStatusFunc func(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string, operationType fleet.MDMOperationType) error
type BatchUpsertCertificateTemplatesFunc func(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error)
@ -9929,11 +9929,11 @@ func (s *DataStore) BatchApplyCertificateAuthorities(ctx context.Context, ops fl
return s.BatchApplyCertificateAuthoritiesFunc(ctx, ops)
}
func (s *DataStore) UpsertCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string) error {
func (s *DataStore) UpsertCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string, operationType fleet.MDMOperationType) error {
s.mu.Lock()
s.UpsertCertificateStatusFuncInvoked = true
s.mu.Unlock()
return s.UpsertCertificateStatusFunc(ctx, hostUUID, certificateTemplateID, status, detail)
return s.UpsertCertificateStatusFunc(ctx, hostUUID, certificateTemplateID, status, detail, operationType)
}
func (s *DataStore) BatchUpsertCertificateTemplates(ctx context.Context, certificates []*fleet.CertificateTemplate) ([]uint, error) {

View file

@ -407,7 +407,7 @@ type ApplyCertificateTemplateSpecsFunc func(ctx context.Context, specs []*fleet.
type DeleteCertificateTemplateSpecsFunc func(ctx context.Context, certificateTemplateIDs []uint, teamID uint) error
type UpdateCertificateStatusFunc func(ctx context.Context, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string) error
type UpdateCertificateStatusFunc func(ctx context.Context, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string, operationType *string) error
type GlobalScheduleQueryFunc func(ctx context.Context, sq *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error)
@ -3511,11 +3511,11 @@ func (s *Service) DeleteCertificateTemplateSpecs(ctx context.Context, certificat
return s.DeleteCertificateTemplateSpecsFunc(ctx, certificateTemplateIDs, teamID)
}
func (s *Service) UpdateCertificateStatus(ctx context.Context, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string) error {
func (s *Service) UpdateCertificateStatus(ctx context.Context, certificateTemplateID uint, status fleet.MDMDeliveryStatus, detail *string, operationType *string) error {
s.mu.Lock()
s.UpdateCertificateStatusFuncInvoked = true
s.mu.Unlock()
return s.UpdateCertificateStatusFunc(ctx, certificateTemplateID, status, detail)
return s.UpdateCertificateStatusFunc(ctx, certificateTemplateID, status, detail, operationType)
}
func (s *Service) GlobalScheduleQuery(ctx context.Context, sq *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error) {

View file

@ -9,6 +9,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log/level"
)
type createCertificateTemplateRequest struct {
@ -204,6 +205,7 @@ func (svc *Service) GetDeviceCertificateTemplate(ctx context.Context, id uint) (
certificate.ID,
fleet.MDMDeliveryFailed,
&errorMsg,
fleet.MDMOperationTypeInstall,
); err != nil {
return nil, err
}
@ -511,6 +513,8 @@ func (svc *Service) DeleteCertificateTemplateSpecs(ctx context.Context, certific
type updateCertificateStatusRequest struct {
CertificateTemplateID uint `url:"id"`
Status string `json:"status"`
// OperationType is optional and defaults to "install" if not provided.
OperationType *string `json:"operation_type,omitempty"`
// Detail provides additional information about the status change.
// For example, it can be used to provide a reason for a failed status change.
Detail *string `json:"detail,omitempty"`
@ -528,7 +532,7 @@ func updateCertificateStatusEndpoint(ctx context.Context, request interface{}, s
return nil, errors.New("invalid request")
}
err := svc.UpdateCertificateStatus(ctx, req.CertificateTemplateID, fleet.MDMDeliveryStatus(req.Status), req.Detail)
err := svc.UpdateCertificateStatus(ctx, req.CertificateTemplateID, fleet.MDMDeliveryStatus(req.Status), req.Detail, req.OperationType)
if err != nil {
return updateCertificateStatusResponse{Err: err}, nil
}
@ -541,6 +545,7 @@ func (svc *Service) UpdateCertificateStatus(
certificateTemplateID uint,
status fleet.MDMDeliveryStatus,
detail *string,
operationType *string,
) error {
// this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx)
@ -556,5 +561,30 @@ func (svc *Service) UpdateCertificateStatus(
return fleet.NewInvalidArgumentError("status", string(status))
}
return svc.ds.UpsertCertificateStatus(ctx, host.UUID, certificateTemplateID, status, detail)
// Default operation_type to "install" if not provided.
opType := fleet.MDMOperationTypeInstall
if operationType != nil && *operationType != "" {
opType = fleet.MDMOperationType(*operationType)
}
if !opType.IsValid() {
return fleet.NewInvalidArgumentError("operation_type", string(opType))
}
certificate, err := svc.ds.GetCertificateTemplateForHost(ctx, host.UUID, certificateTemplateID)
if err != nil {
return err
}
if certificate.Status != nil && *certificate.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", certificate.Status, "new_status", status)
return nil
}
if certificate.OperationType != nil && *certificate.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", certificate.OperationType, "new_operation_type", opType)
return nil
}
return svc.ds.UpsertCertificateStatus(ctx, host.UUID, certificateTemplateID, status, detail, opType)
}

View file

@ -244,7 +244,8 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateLifecycle() {
// Step: Verify the status is 'verified'
s.verifyCertificateStatus(t, host, orbitNodeKey, certificateTemplateID, certTemplateName, caID, fleet.CertificateTemplateVerified, successDetail)
// Step: Host updates the certificate status to 'failed' via fleetd API
// Step: Host attempts to update the certificate status to 'failed' via fleetd API
// This should be ignored since the current status is not 'delivered'
failedDetail := "Certificate installation failed: invalid challenge"
updateReq, err = json.Marshal(updateCertificateStatusRequest{
Status: string(fleet.CertificateTemplateFailed),
@ -257,8 +258,8 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateLifecycle() {
})
_ = resp.Body.Close()
// Step: Verify the status is 'failed' with details
s.verifyCertificateStatus(t, host, orbitNodeKey, certificateTemplateID, certTemplateName, caID, fleet.CertificateTemplateFailed, failedDetail)
// Step: Verify the status is still 'verified' with details
s.verifyCertificateStatus(t, host, orbitNodeKey, certificateTemplateID, certTemplateName, caID, fleet.CertificateTemplateVerified, successDetail)
// Delete the cert
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/certificates/%d", certificateTemplateID), nil, http.StatusOK)

View file

@ -14953,6 +14953,7 @@ INSERT INTO host_certificate_templates (
name string
templateID uint
newStatus string
newOperationType *string
detail *string
expectedResponseStatus int
expectedResponseMessage string
@ -15005,13 +15006,55 @@ INSERT INTO host_certificate_templates (
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
},
},
{
name: "with operation_type install",
templateID: certificateTemplateID,
newStatus: "verified",
expectedResponseStatus: http.StatusOK,
newOperationType: ptr.String("install"),
headers: map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
},
},
{
name: "with operation_type remove",
templateID: certificateTemplateID,
newStatus: "verified",
expectedResponseStatus: http.StatusOK,
newOperationType: ptr.String("remove"),
headers: map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
},
},
{
name: "with operation_type empty string",
templateID: certificateTemplateID,
newStatus: "verified",
expectedResponseStatus: http.StatusOK,
newOperationType: ptr.String(""),
headers: map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
},
},
{
name: "with invalid operation_type",
templateID: certificateTemplateID,
newStatus: "verified",
expectedResponseStatus: http.StatusUnprocessableEntity,
expectedResponseMessage: "must be 'install' or 'remove'",
newOperationType: ptr.String("invalid_operation"),
headers: map[string]string{
"Authorization": fmt.Sprintf("Node key %s", orbitNodeKey),
},
},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("TestUpdateHostCertificateTemplate:%s", tc.name), func(t *testing.T) {
req, err := json.Marshal(updateCertificateStatusRequest{
Status: tc.newStatus,
Detail: tc.detail,
Status: tc.newStatus,
Detail: tc.detail,
OperationType: tc.newOperationType,
})
require.NoError(t, err)