Update queries APIs that drive the OS settings UI (#36018)

**Related issue:** Resolves #35532

Update queries APIs that drive the OS settings UI
This commit is contained in:
Juan Fernandez
2025-11-26 13:25:49 -04:00
committed by GitHub
parent f0abddb8d5
commit 287710b1c5
12 changed files with 505 additions and 6 deletions
@@ -0,0 +1 @@
* updated queries / APIs that drive the OS Settings UI to include the status of host cert templates.
@@ -2,6 +2,7 @@ package mysql
import (
"context"
"errors"
"fmt"
"strings"
@@ -205,3 +206,82 @@ func (ds *Datastore) UpdateCertificateStatus(ctx context.Context, hostUUID strin
return nil
}
func (ds *Datastore) GetHostCertificateTemplates(ctx context.Context, hostUUID string) ([]fleet.HostCertificateTemplate, error) {
if hostUUID == "" {
return nil, errors.New("hostUUID cannot be empty")
}
stmt := `
SELECT
ct.name,
hct.status
FROM host_certificate_templates hct
INNER JOIN certificate_templates ct ON ct.id = hct.certificate_template_id
WHERE hct.host_uuid = ?`
var hTemplates []fleet.HostCertificateTemplate
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hTemplates, stmt, hostUUID); err != nil {
return nil, err
}
return hTemplates, nil
}
func (ds *Datastore) GetMDMProfileSummaryFromHostCertificateTemplates(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
var stmt string
var args []interface{}
if teamID != nil && *teamID > 0 {
stmt = `
SELECT
hct.status AS status,
COUNT(DISTINCT hct.host_uuid) AS n
FROM host_certificate_templates hct
INNER JOIN certificate_templates ct ON hct.certificate_template_id = ct.id
WHERE ct.team_id = ?
GROUP BY 1`
args = append(args, *teamID)
} else {
stmt = `
SELECT
hct.status AS status,
COUNT(DISTINCT hct.host_uuid) AS n
FROM host_certificate_templates hct
GROUP BY 1`
}
var dest []struct {
Count uint `db:"n"`
Status string `db:"status"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt, args...); err != nil {
return nil, err
}
byStatus := make(map[string]uint)
for _, s := range dest {
if _, ok := byStatus[s.Status]; ok {
return nil, fmt.Errorf("duplicate status %s found", s.Status)
}
byStatus[s.Status] = s.Count
}
var res fleet.MDMProfilesSummary
for s, c := range byStatus {
switch fleet.MDMDeliveryStatus(s) {
case fleet.MDMDeliveryFailed:
res.Failed = c
case fleet.MDMDeliveryPending:
res.Pending = c
case fleet.MDMDeliveryVerifying:
res.Verifying = c
case fleet.MDMDeliveryVerified:
res.Verified = c
default:
return nil, fmt.Errorf("unknown status %s", s)
}
}
return &res, nil
}
@@ -3,10 +3,13 @@ package mysql
import (
"context"
"fmt"
"sort"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
@@ -25,6 +28,8 @@ func TestCertificates(t *testing.T) {
{"DeleteCertificateTemplate", testDeleteCertificateTemplate},
{"BatchUpsertCertificates", testBatchUpsertCertificates},
{"BatchDeleteCertificateTemplates", testBatchDeleteCertificateTemplates},
{"GetHostCertificateTemplates", testGetHostCertificateTemplates},
{"GetMDMProfileSummaryFromHostCertificateTemplates", testGetMDMProfileSummaryFromHostCertificateTemplates},
}
for _, c := range cases {
@@ -780,3 +785,218 @@ WHERE host_uuid = ? AND certificate_template_id = ?;
})
}
}
func testGetHostCertificateTemplates(t *testing.T, ds *Datastore) {
defer TruncateTables(t, ds)
ctx := context.Background()
h1 := test.NewHost(t, ds, "host_1", "127.0.0.1", "1", "1", time.Now())
h2 := test.NewHost(t, ds, "host_2", "127.0.0.2", "2", "2", time.Now())
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "Test Team"})
require.NoError(t, err)
h2.TeamID = &team.ID
err = ds.UpdateHost(ctx, h2)
require.NoError(t, err)
// Create a test certificate authority
ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("Test SCEP CA"),
URL: ptr.String("http://localhost:8080/scep"),
Challenge: ptr.String("test-challenge"),
})
require.NoError(t, err)
// Create some certificate templates
ct1, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "AAA",
TeamID: team.ID,
CertificateAuthorityID: ca.ID,
SubjectName: "CN=Test Subject 1",
})
require.NoError(t, err)
ct2, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "BBB",
TeamID: team.ID,
CertificateAuthorityID: ca.ID,
SubjectName: "CN=Test Subject 2",
})
require.NoError(t, err)
// Set the installation status on the certificate templates
// TODO: Refactor this to use UpdateStatus DB method when available
_, err = ds.writer(ctx).ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, fleet_challenge, status) VALUES (?, ?, ?, ?)",
h2.UUID, ct1.ID, "test-challenge", fleet.OSSettingsVerified,
)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, fleet_challenge, status) VALUES (?, ?, ?, ?)",
h2.UUID, ct2.ID, "test-challenge", fleet.OSSettingsFailed,
)
require.NoError(t, err)
testCases := []struct {
name string
do func(*testing.T, *Datastore)
}{
{
"hostUUID is not provided",
func(t *testing.T, ds *Datastore) {
_, err := ds.GetHostCertificateTemplates(ctx, "")
require.Error(t, err)
},
},
{
"No certificate templates found",
func(t *testing.T, ds *Datastore) {
templates, err := ds.GetHostCertificateTemplates(ctx, h1.UUID)
require.NoError(t, err)
require.Empty(t, templates)
},
},
{
"Returns the certificates available for the host",
func(t *testing.T, datastore *Datastore) {
templates, err := ds.GetHostCertificateTemplates(ctx, h2.UUID)
require.NoError(t, err)
require.Len(t, templates, 2)
// Sort the templates by name to make results deterministic
sort.Slice(templates, func(i, j int) bool { return templates[i].Name < templates[j].Name })
require.Equal(t, ct1.Name, templates[0].Name)
require.Equal(t, fleet.MDMDeliveryVerified, templates[0].Status)
require.Equal(t, ct2.Name, templates[1].Name)
require.Equal(t, fleet.MDMDeliveryFailed, templates[1].Status)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.do(t, ds)
})
}
}
func testGetMDMProfileSummaryFromHostCertificateTemplates(t *testing.T, ds *Datastore) {
defer TruncateTables(t, ds)
ctx := context.Background()
// Doing this with no data in the table should be ok
result, err := ds.GetMDMProfileSummaryFromHostCertificateTemplates(ctx, nil)
require.NoError(t, err)
require.Equal(t, uint(0), result.Pending)
require.Equal(t, uint(0), result.Failed)
require.Equal(t, uint(0), result.Verified)
require.Equal(t, uint(0), result.Verifying)
h1 := test.NewHost(t, ds, "host_1", "127.0.0.1", "1", "1", time.Now())
h2 := test.NewHost(t, ds, "host_2", "127.0.0.2", "2", "2", time.Now())
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 2"})
require.NoError(t, err)
h1.TeamID = &team1.ID
err = ds.UpdateHost(ctx, h2)
require.NoError(t, err)
h2.TeamID = &team2.ID
err = ds.UpdateHost(ctx, h2)
require.NoError(t, err)
// Create a test certificate authority
ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("Test SCEP CA"),
URL: ptr.String("http://localhost:8080/scep"),
Challenge: ptr.String("test-challenge"),
})
require.NoError(t, err)
// Create some certificate templates
ct1, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "AAA",
TeamID: team1.ID,
CertificateAuthorityID: ca.ID,
SubjectName: "CN=Test Subject 1",
})
require.NoError(t, err)
ct2, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
Name: "BBB",
TeamID: team2.ID,
CertificateAuthorityID: ca.ID,
SubjectName: "CN=Test Subject 2",
})
require.NoError(t, err)
// Set the installation status on the certificate templates
// TODO: Refactor this to use UpdateStatus DB method when available
_, err = ds.writer(ctx).ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, fleet_challenge, status) VALUES (?, ?, ?, ?)",
h1.UUID, ct1.ID, "test-challenge", fleet.OSSettingsPending,
)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, fleet_challenge, status) VALUES (?, ?, ?, ?)",
h2.UUID, ct1.ID, "test-challenge", fleet.OSSettingsVerified,
)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx,
"INSERT INTO host_certificate_templates (host_uuid, certificate_template_id, fleet_challenge, status) VALUES (?, ?, ?, ?)",
h2.UUID, ct2.ID, "test-challenge", fleet.OSSettingsFailed,
)
require.NoError(t, err)
testCases := []struct {
name string
do func(*testing.T, *Datastore)
}{
{
"no teamID provided",
func(t *testing.T, ds *Datastore) {
result, err := ds.GetMDMProfileSummaryFromHostCertificateTemplates(ctx, nil)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint(1), result.Pending)
require.Equal(t, uint(1), result.Verified)
require.Equal(t, uint(1), result.Failed)
},
},
{
"teamID provided",
func(t *testing.T, ds *Datastore) {
result, err := ds.GetMDMProfileSummaryFromHostCertificateTemplates(ctx, &team1.ID)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint(0), result.Failed)
require.Equal(t, uint(1), result.Verified)
require.Equal(t, uint(1), result.Pending)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.do(t, ds)
})
}
}
+21
View File
@@ -31,3 +31,24 @@ type CertificateTemplateResponseFull struct {
SubjectName string `json:"subject_name" db:"subject_name"`
TeamID uint `json:"-" db:"team_id"`
}
// HostCertificateTemplate represents a certificate template associated with a particular host
type HostCertificateTemplate struct {
HostUUID string `db:"host_uuid" json:"-"`
Name string `db:"name" json:"-"`
Status MDMDeliveryStatus `db:"status" json:"-"`
}
// ToHostMDMProfile maps a HostCertificateTemplate to a HostMDMProfile, suitable for use in the MDM API
func (p *HostCertificateTemplate) ToHostMDMProfile() HostMDMProfile {
if p == nil {
return HostMDMProfile{}
}
return HostMDMProfile{
HostUUID: p.HostUUID,
Name: p.Name,
Platform: "android",
Status: &p.Status,
}
}
@@ -0,0 +1,49 @@
package fleet
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestHostCertificateTemplate(t *testing.T) {
t.Run("ToHostMDMProfile", func(t *testing.T) {
tests := []struct {
name string
template *HostCertificateTemplate
expectation func(*testing.T, HostMDMProfile)
}{
{
name: "nil template",
template: nil,
expectation: func(t *testing.T, profile HostMDMProfile) {
require.Equal(t, "", profile.HostUUID)
require.Equal(t, "", profile.Name)
require.Equal(t, "", profile.Platform)
require.Nil(t, profile.Status)
},
},
{
name: "maps fields correctly",
template: &HostCertificateTemplate{
HostUUID: "1234",
Name: "HostCertificate",
Status: MDMDeliveryVerified,
},
expectation: func(t *testing.T, profile HostMDMProfile) {
require.Equal(t, "1234", profile.HostUUID)
require.Equal(t, "HostCertificate", profile.Name)
require.Equal(t, "android", profile.Platform)
require.Equal(t, MDMDeliveryVerified, *profile.Status)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.expectation(t, tt.template.ToHostMDMProfile())
})
}
})
}
+7
View File
@@ -2338,6 +2338,13 @@ type Datastore interface {
// assigned to any team).
GetMDMAndroidProfilesSummary(ctx context.Context, teamID *uint) (*MDMProfilesSummary, error)
// GetCertificateStatusSummary GetCertificateTemplatesSummary returns a summary of the current state of certificate templates on each host in
// the specified team (or, if no team is specified, each host that is not assigned to any team).
GetMDMProfileSummaryFromHostCertificateTemplates(ctx context.Context, teamID *uint) (*MDMProfilesSummary, error)
// GetHostCertificateTemplates returns what certificate templates are currently associated with the specified host.
GetHostCertificateTemplates(ctx context.Context, hostUUID string) ([]HostCertificateTemplate, error)
// GetHostMDMAndroidProfiles retrieves the Android MDM profiles for a specific host.
GetHostMDMAndroidProfiles(ctx context.Context, hostUUID string) ([]HostMDMAndroidProfile, error)
+21 -4
View File
@@ -405,14 +405,14 @@ type MDMDiskEncryptionSummary struct {
}
// MDMProfilesSummary reports the number of hosts being managed with configuration
// profiles and/or disk encryption. Each host may be counted in only one of four mutually-exclusive categories:
// Failed, Pending, Verifying, or Verified.
// profiles, disk encryption or certificate templates.
// Each host may be counted in only one of four mutually exclusive categories: Failed, Pending, Verifying, or Verified.
type MDMProfilesSummary struct {
// Verified includes each host where Fleet has verified the installation of all of the
// Verified includes each host where Fleet has verified the installation of all the
// profiles currently applicable to the host. If any of the profiles are pending, failed, or
// subject to verification for the host, the host is not counted as verified.
Verified uint `json:"verified" db:"verified"`
// Verifying includes each host where the MDM service has successfully delivered all of the
// Verifying includes each host where the MDM service has successfully delivered all the
// profiles currently applicable to the host. If any of the profiles are pending or failed for
// the host, the host is not counted as verifying.
Verifying uint `json:"verifying" db:"verifying"`
@@ -424,6 +424,23 @@ type MDMProfilesSummary struct {
Failed uint `json:"failed" db:"failed"`
}
func (mdmPS *MDMProfilesSummary) Add(other *MDMProfilesSummary) *MDMProfilesSummary {
var s1, s2 MDMProfilesSummary
if mdmPS != nil {
s1 = *mdmPS
}
if other != nil {
s2 = *other
}
return &MDMProfilesSummary{
Verified: s1.Verified + s2.Verified,
Verifying: s1.Verifying + s2.Verifying,
Pending: s1.Pending + s2.Pending,
Failed: s1.Failed + s2.Failed,
}
}
// HostMDMProfile is the status of an MDM profile on a host. It can be used to represent either
// a Windows or macOS profile.
type HostMDMProfile struct {
+65
View File
@@ -717,3 +717,68 @@ func TestFilterOutUserScopedProfiles(t *testing.T) {
require.ElementsMatch(t, filteredProfiles, []*fleet.MDMAppleProfilePayload{&systemScopedProfile})
}
func TestMDMProfilesSummary(t *testing.T) {
t.Run("Add", func(t *testing.T) {
tests := []struct {
name string
a *fleet.MDMProfilesSummary
b *fleet.MDMProfilesSummary
expected *fleet.MDMProfilesSummary
}{
{
name: "Both nil",
a: nil,
b: nil,
expected: &fleet.MDMProfilesSummary{Verified: 0, Verifying: 0, Pending: 0, Failed: 0},
},
{
name: "First nil",
a: nil,
b: &fleet.MDMProfilesSummary{Verified: 1, Verifying: 2, Pending: 3, Failed: 4},
expected: &fleet.MDMProfilesSummary{Verified: 1, Verifying: 2, Pending: 3, Failed: 4},
},
{
name: "Second nil",
a: &fleet.MDMProfilesSummary{Verified: 1, Verifying: 2, Pending: 3, Failed: 4},
b: nil,
expected: &fleet.MDMProfilesSummary{Verified: 1, Verifying: 2, Pending: 3, Failed: 4},
},
{
name: "Combined values",
a: &fleet.MDMProfilesSummary{Verified: 2, Verifying: 4, Pending: 6, Failed: 8},
b: &fleet.MDMProfilesSummary{Verified: 1, Verifying: 3, Pending: 5, Failed: 7},
expected: &fleet.MDMProfilesSummary{
Verified: 3,
Verifying: 7,
Pending: 11,
Failed: 15,
},
},
{
name: "All zero values",
a: &fleet.MDMProfilesSummary{Verified: 0, Verifying: 0, Pending: 0, Failed: 0},
b: &fleet.MDMProfilesSummary{Verified: 0, Verifying: 0, Pending: 0, Failed: 0},
expected: &fleet.MDMProfilesSummary{Verified: 0, Verifying: 0, Pending: 0, Failed: 0},
},
{
name: "Large values",
a: &fleet.MDMProfilesSummary{Verified: 1_000_000, Verifying: 2_000_000, Pending: 3_000_000, Failed: 4_000_000},
b: &fleet.MDMProfilesSummary{Verified: 5_000_000, Verifying: 6_000_000, Pending: 7_000_000, Failed: 8_000_000},
expected: &fleet.MDMProfilesSummary{
Verified: 6_000_000,
Verifying: 8_000_000,
Pending: 10_000_000,
Failed: 12_000_000,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.a.Add(tt.b)
require.Equal(t, tt.expected, result)
})
}
})
}
+24
View File
@@ -1515,6 +1515,10 @@ type DeleteMDMAndroidConfigProfileFunc func(ctx context.Context, profileUUID str
type GetMDMAndroidProfilesSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error)
type GetMDMProfileSummaryFromHostCertificateTemplatesFunc func(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error)
type GetHostCertificateTemplatesFunc func(ctx context.Context, hostUUID string) ([]fleet.HostCertificateTemplate, error)
type GetHostMDMAndroidProfilesFunc func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAndroidProfile, error)
type NewAndroidPolicyRequestFunc func(ctx context.Context, req *fleet.MDMAndroidPolicyRequest) error
@@ -3874,6 +3878,12 @@ type DataStore struct {
GetMDMAndroidProfilesSummaryFunc GetMDMAndroidProfilesSummaryFunc
GetMDMAndroidProfilesSummaryFuncInvoked bool
GetMDMProfileSummaryFromHostCertificateTemplatesFunc GetMDMProfileSummaryFromHostCertificateTemplatesFunc
GetMDMProfileSummaryFromHostCertificateTemplatesFuncInvoked bool
GetHostCertificateTemplatesFunc GetHostCertificateTemplatesFunc
GetHostCertificateTemplatesFuncInvoked bool
GetHostMDMAndroidProfilesFunc GetHostMDMAndroidProfilesFunc
GetHostMDMAndroidProfilesFuncInvoked bool
@@ -9279,6 +9289,20 @@ func (s *DataStore) GetMDMAndroidProfilesSummary(ctx context.Context, teamID *ui
return s.GetMDMAndroidProfilesSummaryFunc(ctx, teamID)
}
func (s *DataStore) GetMDMProfileSummaryFromHostCertificateTemplates(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
s.mu.Lock()
s.GetMDMProfileSummaryFromHostCertificateTemplatesFuncInvoked = true
s.mu.Unlock()
return s.GetMDMProfileSummaryFromHostCertificateTemplatesFunc(ctx, teamID)
}
func (s *DataStore) GetHostCertificateTemplates(ctx context.Context, hostUUID string) ([]fleet.HostCertificateTemplate, error) {
s.mu.Lock()
s.GetHostCertificateTemplatesFuncInvoked = true
s.mu.Unlock()
return s.GetHostCertificateTemplatesFunc(ctx, hostUUID)
}
func (s *DataStore) GetHostMDMAndroidProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMAndroidProfile, error) {
s.mu.Lock()
s.GetHostMDMAndroidProfilesFuncInvoked = true
+8
View File
@@ -1363,6 +1363,14 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
profiles = append(profiles, p.ToHostMDMProfile())
}
hCertTemplates, err := svc.ds.GetHostCertificateTemplates(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host certificate templates")
}
for _, ct := range hCertTemplates {
profiles = append(profiles, ct.ToHostMDMProfile())
}
case "darwin", "ios", "ipados":
if ac.MDM.EnabledAndConfigured {
profs, err := svc.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
+6 -2
View File
@@ -1032,7 +1032,12 @@ func (svc *Service) GetMDMAndroidProfilesSummary(ctx context.Context, teamID *ui
return nil, ctxerr.Wrap(ctx, err)
}
return ps, nil
hcts, err := svc.ds.GetMDMProfileSummaryFromHostCertificateTemplates(ctx, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
return ps.Add(hcts), nil
}
// authorizeAllHostsTeams is a helper function that loads the hosts
@@ -2443,7 +2448,6 @@ func getAndroidProfiles(ctx context.Context,
appCfg *fleet.AppConfig,
profiles map[int]fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
// isPremium bool,
) (map[int]*fleet.MDMAndroidConfigProfile, error) {
profs := make(map[int]*fleet.MDMAndroidConfigProfile, len(profiles))
for i, profile := range profiles {
+3
View File
@@ -676,6 +676,9 @@ func TestMDMCommonAuthorization(t *testing.T) {
ds.GetConfigEnableDiskEncryptionFunc = func(ctx context.Context, teamID *uint) (fleet.DiskEncryptionConfig, error) {
return fleet.DiskEncryptionConfig{}, nil
}
ds.GetMDMProfileSummaryFromHostCertificateTemplatesFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
return &fleet.MDMProfilesSummary{}, nil
}
ds.AreHostsConnectedToFleetMDMFunc = func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
res := make(map[string]bool, len(hosts))