Cert renewal for non-proxied SCEP and ACME (Phase 1 + Phase 2) (#45696)
This commit is contained in:
@@ -19,6 +19,8 @@ Fleet API users can access host certificate information via the "Get host's cert
|
||||
|
||||
For macOS hosts, Fleet retrieves certificate information using osquery's `certificates` [table](https://fleetdm.com/learn-more-about/certificates-query). For iOS and iPadOS hosts, Fleet retrieves certificates via MDM using the `CertificateList` [command](https://developer.apple.com/documentation/devicemanagement/certificate-list-command).
|
||||
|
||||
When a macOS host installs a configuration profile containing an ACME payload, Fleet also retrieves the resulting certificate via the MDM `CertificateList` command. This surfaces hardware-bound ACME certificates that don't appear in osquery's `certificates` table. Ingestion runs per-host on each ACME profile install and re-install — there is no recurring cadence — so certificates from a given profile become visible the first time the profile is installed or re-deployed on a host.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The certificates section in host vitals provides you with a quick overview of the certificates installed on your macOS, iOS, and iPadOS devices. This feature helps you identify and troubleshoot certificate-related issues that may prevent your end users from connecting to the corporate network.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Surface hardware-bound ACME certificates on macOS host vitals by retrieving them via the MDM `CertificateList` command when an ACME-bearing configuration profile is installed or re-installed.
|
||||
@@ -5244,6 +5244,8 @@ Available for macOS, iOS, iPadOS, and Windows hosts only. Requires Fleet's MDM t
|
||||
|
||||
Retrieves the certificates installed on a host.
|
||||
|
||||
For macOS hosts, certificates from MDM-delivered profiles containing an ACME payload are retrieved via the MDM `CertificateList` command on each profile install and re-install (not on a recurring cadence). Hardware-bound ACME certificates that aren't visible to osquery first appear in the response after the host installs or re-installs the delivering profile.
|
||||
|
||||
`GET /api/v1/fleet/hosts/:id/certificates`
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -41,7 +41,7 @@ func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts
|
||||
return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts)
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error {
|
||||
func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error {
|
||||
type certSourceToSet struct {
|
||||
Source fleet.HostCertificateSource
|
||||
Username string
|
||||
@@ -50,6 +50,9 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
|
||||
incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs))
|
||||
incomingSourcesBySHA1 := make(map[string][]certSourceToSet, len(certs))
|
||||
for _, cert := range certs {
|
||||
// Tag every incoming cert with the calling ingestion source. We trust the
|
||||
// caller for this — origin scopes deletion semantics, not data integrity.
|
||||
cert.Origin = origin
|
||||
if cert.HostID != hostID {
|
||||
// caller should ensure this does not happen
|
||||
ds.logger.DebugContext(ctx, fmt.Sprintf("host certificates: host ID does not match provided certificate: %d %d", hostID, cert.HostID))
|
||||
@@ -163,8 +166,11 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
|
||||
now := time.Now()
|
||||
for _, row := range hostMDMManagedCerts {
|
||||
hostMDMManagedCert := &row.MDMManagedCertificate
|
||||
// DigiCert is populated server-side at issuance, not via osquery/MDM.
|
||||
if !hostMDMManagedCert.Type.SupportsRenewalID() {
|
||||
// Skip CA types that don't carry a renewal-ID marker — today only
|
||||
// DigiCert, which is server-issued and managed without matching
|
||||
// against ingested certs. Empty/NULL `Type` (rows created by the
|
||||
// non-proxied insert path below) IS eligible.
|
||||
if hostMDMManagedCert.Type != "" && !hostMDMManagedCert.Type.SupportsRenewalID() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -220,9 +226,84 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
|
||||
}
|
||||
}
|
||||
|
||||
// Non-proxied insert path: for each profile installed on this host
|
||||
// without an existing host_mdm_managed_certificates row, see if any
|
||||
// incoming cert's Subject carries the `fleet-<profile_uuid>` marker.
|
||||
// If so, create the row from the cert's metadata. This activates
|
||||
// renewal for ACME / non-proxied SCEP flows where Fleet isn't in the
|
||||
// issuance path so no row gets created at issuance time.
|
||||
hostMDMManagedCertsToInsert := make([]*fleet.MDMManagedCertificate, 0, len(incomingBySHA1))
|
||||
if len(incomingBySHA1) > 0 {
|
||||
existingProfileUUIDs := make(map[string]struct{}, len(hostMDMManagedCerts))
|
||||
for _, row := range hostMDMManagedCerts {
|
||||
existingProfileUUIDs[row.ProfileUUID] = struct{}{}
|
||||
}
|
||||
var candidateProfileUUIDs []string
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &candidateProfileUUIDs, `
|
||||
SELECT profile_uuid FROM host_mdm_apple_profiles
|
||||
WHERE host_uuid = ? AND operation_type = ?
|
||||
UNION
|
||||
SELECT profile_uuid FROM host_mdm_windows_profiles
|
||||
WHERE host_uuid = ? AND operation_type = ?`,
|
||||
hostUUID, fleet.MDMOperationTypeInstall,
|
||||
hostUUID, fleet.MDMOperationTypeInstall,
|
||||
); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "list candidate profile UUIDs for managed cert insert")
|
||||
}
|
||||
for _, profileUUID := range candidateProfileUUIDs {
|
||||
if _, exists := existingProfileUUIDs[profileUUID]; exists {
|
||||
continue
|
||||
}
|
||||
renewalIDString := "fleet-" + profileUUID
|
||||
var bestMatch *fleet.HostCertificateRecord
|
||||
for _, cert := range incomingBySHA1 {
|
||||
if !strings.Contains(cert.SubjectCommonName, renewalIDString) &&
|
||||
!strings.Contains(cert.SubjectOrganizationalUnit, renewalIDString) {
|
||||
continue
|
||||
}
|
||||
// Skip certs outside their validity window: a device may
|
||||
// still be reporting a just-expired cert alongside its
|
||||
// renewal, and latching onto it would seed the row with
|
||||
// backward-pointing dates.
|
||||
if cert.NotValidBefore.After(now) || cert.NotValidAfter.Before(now) {
|
||||
continue
|
||||
}
|
||||
if bestMatch == nil || cert.NotValidBefore.After(bestMatch.NotValidBefore) {
|
||||
bestMatch = cert
|
||||
}
|
||||
}
|
||||
if bestMatch == nil {
|
||||
continue
|
||||
}
|
||||
// Use a fixed sentinel for ca_name on non-proxied rows.
|
||||
// Proxied flows set ca_name from Fleet-controlled CA
|
||||
// registration (stable across renewals); deriving it from
|
||||
// the cert's Issuer CN would drift if the upstream CA ever
|
||||
// renames. The cert's actual issuer is available in
|
||||
// host_certificates for support visibility.
|
||||
// Type is written as NULL by insertHostMDMManagedCertDB —
|
||||
// Fleet wasn't in the issuance path so it doesn't know the
|
||||
// CA type. The struct's Type field is left unset.
|
||||
hostMDMManagedCertsToInsert = append(hostMDMManagedCertsToInsert, &fleet.MDMManagedCertificate{
|
||||
HostUUID: hostUUID,
|
||||
ProfileUUID: profileUUID,
|
||||
NotValidBefore: &bestMatch.NotValidBefore,
|
||||
NotValidAfter: &bestMatch.NotValidAfter,
|
||||
CAName: "non_proxied",
|
||||
Serial: ptr.String(fmt.Sprintf("%040s", bestMatch.Serial)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toDelete := make([]uint, 0, len(existingBySHA1))
|
||||
for sha1, existing := range existingBySHA1 {
|
||||
if _, ok := incomingBySHA1[sha1]; !ok {
|
||||
// Source-scoped delete: only remove rows whose origin matches the
|
||||
// calling ingestion source. An osquery sync omitting an MDM-only cert
|
||||
// must not delete that cert, and vice versa.
|
||||
if existing.Origin != origin {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, existing.ID)
|
||||
}
|
||||
}
|
||||
@@ -261,6 +342,10 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
|
||||
if err := updateHostMDMManagedCertDetailsDB(ctx, tx, hostMDMManagedCertsToUpdate); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update host mdm managed cert details")
|
||||
}
|
||||
|
||||
if err := insertHostMDMManagedCertDB(ctx, tx, hostMDMManagedCertsToInsert); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host mdm managed cert rows")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -380,6 +465,7 @@ SELECT
|
||||
hc.issuer_org,
|
||||
hc.issuer_org_unit,
|
||||
hc.issuer_common_name,
|
||||
hc.origin,
|
||||
hcs.source,
|
||||
hcs.username
|
||||
%s`, fromWhereClause)
|
||||
@@ -527,19 +613,25 @@ INSERT INTO host_certificates (
|
||||
issuer_country,
|
||||
issuer_org,
|
||||
issuer_org_unit,
|
||||
issuer_common_name
|
||||
issuer_common_name,
|
||||
origin
|
||||
) VALUES %s`
|
||||
|
||||
placeholders := make([]string, 0, len(certs))
|
||||
const singleRowPlaceholderCount = 19
|
||||
const singleRowPlaceholderCount = 20
|
||||
args := make([]interface{}, 0, len(certs)*singleRowPlaceholderCount)
|
||||
for _, cert := range certs {
|
||||
placeholders = append(placeholders, "("+strings.Repeat("?,", singleRowPlaceholderCount-1)+"?)")
|
||||
origin := cert.Origin
|
||||
if origin == "" {
|
||||
origin = fleet.HostCertificateOriginOsquery
|
||||
}
|
||||
args = append(args,
|
||||
cert.HostID, cert.SHA1Sum, cert.NotValidBefore, cert.NotValidAfter, cert.CertificateAuthority, cert.CommonName,
|
||||
cert.KeyAlgorithm, cert.KeyStrength, cert.KeyUsage, cert.Serial, cert.SigningAlgorithm,
|
||||
cert.SubjectCountry, cert.SubjectOrganization, cert.SubjectOrganizationalUnit, cert.SubjectCommonName,
|
||||
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName)
|
||||
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName,
|
||||
origin)
|
||||
}
|
||||
|
||||
stmt = fmt.Sprintf(stmt, strings.Join(placeholders, ","))
|
||||
@@ -592,3 +684,29 @@ func updateHostMDMManagedCertDetailsDB(ctx context.Context, tx sqlx.ExtContext,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertHostMDMManagedCertDB creates host_mdm_managed_certificates rows for
|
||||
// non-proxied SCEP/ACME flows discovered via cert ingestion. type is always
|
||||
// written as NULL because Fleet wasn't in the issuance path and doesn't know
|
||||
// the CA type. Uses INSERT IGNORE so a row created concurrently by another
|
||||
// transaction (e.g., a SCEP proxy issuance) doesn't cause a duplicate-key
|
||||
// error here — the matcher's UPDATE pass picks up that row on the next
|
||||
// ingestion call.
|
||||
func insertHostMDMManagedCertDB(ctx context.Context, tx sqlx.ExtContext, certs []*fleet.MDMManagedCertificate) error {
|
||||
if len(certs) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, c := range certs {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO host_mdm_managed_certificates
|
||||
(host_uuid, profile_uuid, ca_name, type,
|
||||
not_valid_before, not_valid_after, serial)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
|
||||
c.HostUUID, c.ProfileUUID, c.CAName,
|
||||
c.NotValidBefore, c.NotValidAfter, c.Serial)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host mdm managed certificate")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,8 +30,10 @@ func TestHostCertificates(t *testing.T) {
|
||||
}{
|
||||
{"UpdateAndList", testUpdateAndListHostCertificates},
|
||||
{"Update with host_mdm_managed_certificates to update", testUpdatingHostMDMManagedCertificates},
|
||||
{"Insert host_mdm_managed_certificates from non-proxied ingestion", testInsertingHostMDMManagedCertificatesFromIngestion},
|
||||
{"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows},
|
||||
{"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation},
|
||||
{"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete},
|
||||
{"Create certificates with long country code", testHostCertificateWithInvalidCountryCode},
|
||||
{"Truncate long certificate fields", testTruncateLongCertificateFields},
|
||||
{"Count matches main query", testListHostCertificatesCountMatches},
|
||||
@@ -79,7 +81,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
|
||||
generateTestHostCertificateRecord(t, 1, &expected2),
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// verify that we saved the records correctly
|
||||
certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true})
|
||||
@@ -103,7 +105,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, expected2.Subject.CommonName, certs[1].SubjectCommonName)
|
||||
|
||||
// simulate removal of a certificate
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}, fleet.HostCertificateOriginOsquery))
|
||||
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 1)
|
||||
@@ -113,7 +115,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
|
||||
// re-add first certificate but as a "user" source
|
||||
payload[0].Source = fleet.UserHostCertificate
|
||||
payload[0].Username = "A"
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}, fleet.HostCertificateOriginOsquery))
|
||||
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 2)
|
||||
@@ -157,7 +159,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
|
||||
for _, c := range cases {
|
||||
t.Log(c.desc)
|
||||
|
||||
err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest)
|
||||
err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest, fleet.HostCertificateOriginOsquery)
|
||||
require.NoError(t, err)
|
||||
certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", TestSecondaryOrderKey: "username"})
|
||||
require.NoError(t, err)
|
||||
@@ -303,7 +305,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) {
|
||||
generateTestHostCertificateRecord(t, host.ID, &expected3),
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload))
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// verify that we saved the records correctly
|
||||
certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "common_name"})
|
||||
@@ -350,7 +352,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, "step-ca", profile2.CAName)
|
||||
|
||||
// simulate removal of a certificate
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}, fleet.HostCertificateOriginOsquery))
|
||||
certs3, _, err := ds.ListHostCertificates(context.Background(), host.ID, fleet.ListOptions{OrderKey: "common_name"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs3, 2)
|
||||
@@ -493,7 +495,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
|
||||
for _, c := range certs {
|
||||
payload = append(payload, generateTestHostCertificateRecord(t, host.ID, c))
|
||||
}
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -504,7 +506,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
|
||||
payload = append(payload, existingRecs...)
|
||||
unrelated := unrelatedCertTemplate(fmt.Sprintf("unrelated-%d", unrelatedSerial), 24*time.Hour, unrelatedSerial)
|
||||
payload = append(payload, generateTestHostCertificateRecord(t, host.ID, unrelated))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
|
||||
}
|
||||
|
||||
t.Run("MissedIngestRecovered", func(t *testing.T) {
|
||||
@@ -617,7 +619,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
|
||||
backdateHMMC(t, profileUUID, 5*time.Hour)
|
||||
|
||||
// Re-pass the same records — toInsert will be empty, but recovery still runs.
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
got := getApple(t, profileUUID, "ca-stable")
|
||||
require.NotNil(t, got.NotValidAfter)
|
||||
@@ -640,7 +642,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
|
||||
olderCert := renewalCertTemplate(profileUUID, "-old", time.Now().Add(-48*time.Hour).Truncate(time.Second).UTC(), time.Now().Add(48*time.Hour).Truncate(time.Second).UTC(), 4501)
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{
|
||||
generateTestHostCertificateRecord(t, host.ID, olderCert),
|
||||
}))
|
||||
}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
got := getApple(t, profileUUID, "ca-mono")
|
||||
require.NotNil(t, got.NotValidAfter)
|
||||
@@ -648,6 +650,224 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
|
||||
})
|
||||
}
|
||||
|
||||
// testInsertingHostMDMManagedCertificatesFromIngestion exercises the
|
||||
// non-proxied insert path: when a profile is installed on a host without an
|
||||
// existing host_mdm_managed_certificates row, an ingested cert whose Subject
|
||||
// carries the `fleet-<profile_uuid>` marker creates the row. Also validates
|
||||
// that the matcher's SupportsRenewalID() guard does NOT skip empty/NULL
|
||||
// Type rows on subsequent ingestion (Decision 2.2 knock-on).
|
||||
func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Three profiles installed on the host:
|
||||
// nonProxied — no existing hmmc row; ingestion will create one (NULL Type).
|
||||
// proxied — existing hmmc row (custom_scep_proxy); matcher updates it.
|
||||
// noMatch — no existing hmmc row; no incoming cert carries its marker.
|
||||
cps := storeDummyConfigProfilesForTest(t, ds, 3)
|
||||
nonProxiedProfileUUID := cps[0].ProfileUUID
|
||||
proxiedProfileUUID := cps[1].ProfileUUID
|
||||
noMatchProfileUUID := cps[2].ProfileUUID
|
||||
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("ingest-host-osq"),
|
||||
NodeKey: ptr.String("ingest-host-nk"),
|
||||
UUID: "ingest-host-uuid",
|
||||
Hostname: "ingest-host",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
|
||||
{
|
||||
ProfileUUID: nonProxiedProfileUUID,
|
||||
ProfileIdentifier: cps[0].Identifier,
|
||||
ProfileName: cps[0].Name,
|
||||
HostUUID: host.UUID,
|
||||
Status: &fleet.MDMDeliveryPending,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
CommandUUID: "cmd-non-proxied",
|
||||
Checksum: []byte("0123456789abcdef"),
|
||||
Scope: fleet.PayloadScopeSystem,
|
||||
},
|
||||
{
|
||||
ProfileUUID: proxiedProfileUUID,
|
||||
ProfileIdentifier: cps[1].Identifier,
|
||||
ProfileName: cps[1].Name,
|
||||
HostUUID: host.UUID,
|
||||
Status: &fleet.MDMDeliveryPending,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
CommandUUID: "cmd-proxied",
|
||||
Checksum: []byte("0123456789abcdef"),
|
||||
Scope: fleet.PayloadScopeSystem,
|
||||
},
|
||||
{
|
||||
ProfileUUID: noMatchProfileUUID,
|
||||
ProfileIdentifier: cps[2].Identifier,
|
||||
ProfileName: cps[2].Name,
|
||||
HostUUID: host.UUID,
|
||||
Status: &fleet.MDMDeliveryPending,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
CommandUUID: "cmd-no-match",
|
||||
Checksum: []byte("0123456789abcdef"),
|
||||
Scope: fleet.PayloadScopeSystem,
|
||||
},
|
||||
}))
|
||||
|
||||
// Pre-existing proxied hmmc row for proxiedProfileUUID.
|
||||
require.NoError(t, ds.BulkUpsertMDMManagedCertificates(ctx, []*fleet.MDMManagedCertificate{
|
||||
{
|
||||
HostUUID: host.UUID,
|
||||
ProfileUUID: proxiedProfileUUID,
|
||||
Type: fleet.CAConfigCustomSCEPProxy,
|
||||
CAName: "custom-ca",
|
||||
},
|
||||
}))
|
||||
|
||||
// Build incoming certs:
|
||||
// certNonProxied — Subject CN carries marker for nonProxiedProfileUUID,
|
||||
// issued by a parent so IssuerCommonName is preserved
|
||||
// certProxied — Subject OU carries marker for proxiedProfileUUID
|
||||
// certUnrelated — no Fleet marker
|
||||
notBefore := time.Now().Add(-time.Hour).Truncate(time.Second).UTC()
|
||||
notAfter := time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC()
|
||||
customerCAParent := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Customer Hydrant ACME",
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Customer"},
|
||||
},
|
||||
SerialNumber: big.NewInt(9000),
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
NotBefore: notBefore.Add(-time.Hour),
|
||||
NotAfter: notAfter.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
}
|
||||
certNonProxied := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "MAC-SERIAL fleet-" + nonProxiedProfileUUID,
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Org Non-Proxied"},
|
||||
},
|
||||
SerialNumber: big.NewInt(7001),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certProxied := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "MAC-SERIAL Proxied",
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Org Proxied"},
|
||||
OrganizationalUnit: []string{"fleet-" + proxiedProfileUUID},
|
||||
},
|
||||
Issuer: pkix.Name{
|
||||
CommonName: "Custom SCEP Issuer",
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Custom"},
|
||||
},
|
||||
SerialNumber: big.NewInt(7002),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certUnrelated := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Some Other Cert",
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Unrelated"},
|
||||
},
|
||||
Issuer: pkix.Name{
|
||||
CommonName: "Other Issuer",
|
||||
Country: []string{"US"},
|
||||
Organization: []string{"Other"},
|
||||
},
|
||||
SerialNumber: big.NewInt(7003),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
payload := []*fleet.HostCertificateRecord{
|
||||
generateTestHostCertificateRecordWithParent(t, host.ID, &certNonProxied, &customerCAParent),
|
||||
generateTestHostCertificateRecord(t, host.ID, &certProxied),
|
||||
generateTestHostCertificateRecord(t, host.ID, &certUnrelated),
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// nonProxiedProfileUUID — row was inserted with NULL Type, matching cert's metadata.
|
||||
all, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
var nonProxiedRow, proxiedRow *fleet.MDMManagedCertificate
|
||||
for _, r := range all {
|
||||
switch r.ProfileUUID {
|
||||
case nonProxiedProfileUUID:
|
||||
nonProxiedRow = r
|
||||
case proxiedProfileUUID:
|
||||
proxiedRow = r
|
||||
case noMatchProfileUUID:
|
||||
t.Fatalf("noMatchProfileUUID should not have an hmmc row but does: %+v", r)
|
||||
}
|
||||
}
|
||||
require.NotNil(t, nonProxiedRow, "non-proxied profile should have a created hmmc row")
|
||||
assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow.Type, "Type should be NULL/empty for non-proxied row")
|
||||
assert.Equal(t, "non_proxied", nonProxiedRow.CAName, "CAName should be the fixed non-proxied sentinel, not derived from the cert")
|
||||
require.NotNil(t, nonProxiedRow.Serial)
|
||||
assert.Equal(t, fmt.Sprintf("%040s", certNonProxied.SerialNumber.Text(16)), *nonProxiedRow.Serial)
|
||||
require.NotNil(t, nonProxiedRow.NotValidAfter)
|
||||
assert.Equal(t, notAfter, *nonProxiedRow.NotValidAfter)
|
||||
|
||||
// proxiedProfileUUID — existing row updated with cert's serial / dates by the matcher.
|
||||
require.NotNil(t, proxiedRow)
|
||||
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, proxiedRow.Type, "Existing proxied Type preserved")
|
||||
require.NotNil(t, proxiedRow.Serial)
|
||||
assert.Equal(t, fmt.Sprintf("%040s", certProxied.SerialNumber.Text(16)), *proxiedRow.Serial)
|
||||
|
||||
// Subsequent ingestion of a renewed cert for the non-proxied profile must
|
||||
// advance not_valid_after — validates the matcher guard fix (Decision 2.2):
|
||||
// without it, the SupportsRenewalID() skip silently excludes NULL-Type rows.
|
||||
notAfter2 := notAfter.Add(48 * time.Hour)
|
||||
certRenewed := certNonProxied
|
||||
certRenewed.SerialNumber = big.NewInt(7011)
|
||||
// NotBefore must remain in the past (matcher filters out future-valid certs)
|
||||
// but later than the original so best-match-wins picks the renewed cert.
|
||||
certRenewed.NotBefore = notBefore.Add(30 * time.Minute)
|
||||
certRenewed.NotAfter = notAfter2
|
||||
renewedPayload := []*fleet.HostCertificateRecord{
|
||||
generateTestHostCertificateRecordWithParent(t, host.ID, &certRenewed, &customerCAParent),
|
||||
generateTestHostCertificateRecord(t, host.ID, &certProxied),
|
||||
generateTestHostCertificateRecord(t, host.ID, &certUnrelated),
|
||||
}
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
all2, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
var nonProxiedRow2 *fleet.MDMManagedCertificate
|
||||
for _, r := range all2 {
|
||||
if r.ProfileUUID == nonProxiedProfileUUID {
|
||||
nonProxiedRow2 = r
|
||||
}
|
||||
}
|
||||
require.NotNil(t, nonProxiedRow2)
|
||||
require.NotNil(t, nonProxiedRow2.NotValidAfter)
|
||||
assert.Equal(t, notAfter2, *nonProxiedRow2.NotValidAfter, "matcher must advance not_valid_after on NULL-Type rows")
|
||||
require.NotNil(t, nonProxiedRow2.Serial)
|
||||
assert.Equal(t, fmt.Sprintf("%040s", certRenewed.SerialNumber.Text(16)), *nonProxiedRow2.Serial)
|
||||
assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow2.Type, "Type should still be NULL/empty after update")
|
||||
}
|
||||
|
||||
func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord {
|
||||
b, _, err := GenerateTestCertBytes(template)
|
||||
require.NoError(t, err)
|
||||
@@ -763,8 +983,8 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
|
||||
host2Cert.Username = "jsmith"
|
||||
|
||||
// Add the same certificate to both hosts
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}, fleet.HostCertificateOriginOsquery))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// Verify both hosts have the correct certs, with the correct sources
|
||||
host1Certs, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{})
|
||||
@@ -785,7 +1005,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
|
||||
host2CertUpdated.Source = fleet.UserHostCertificate
|
||||
host2CertUpdated.Username = "janesmith"
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// Verify host1's certificate source was *not* updated
|
||||
host1CertsAfter, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{})
|
||||
@@ -800,7 +1020,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, "janesmith", host2CertsAfter[0].Username)
|
||||
|
||||
// Verify no-op case
|
||||
err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated})
|
||||
err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify host2's certificate source was updated
|
||||
@@ -814,7 +1034,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
|
||||
systemCertOnHost2 := fleet.NewHostCertificateRecord(host2.ID, parsed)
|
||||
systemCertOnHost2.Source = fleet.SystemHostCertificate
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// Verify host2 now has the certificate with both sources
|
||||
host2CertsMultiSource, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{})
|
||||
@@ -842,6 +1062,95 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, "jdoe", host1CertsMultiSource[0].Username)
|
||||
}
|
||||
|
||||
// testUpdateHostCertificatesOriginScopedDelete verifies that each ingestion
|
||||
// source only soft-deletes rows it owns: an osquery sync that omits an
|
||||
// MDM-only cert must not remove that cert, and vice versa.
|
||||
func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("origin-host-osquery-id"),
|
||||
NodeKey: ptr.String("origin-host-node-key"),
|
||||
UUID: "origin-host-uuid",
|
||||
Hostname: "origin-host",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mkCert := func(commonName string) *fleet.HostCertificateRecord {
|
||||
template := x509.Certificate{
|
||||
Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}},
|
||||
Issuer: pkix.Name{CommonName: "issuer", Organization: []string{"Issuer"}},
|
||||
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
certBytes, _, err := GenerateTestCertBytes(&template)
|
||||
require.NoError(t, err)
|
||||
block, _ := pem.Decode(certBytes)
|
||||
parsed, err := x509.ParseCertificate(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
rec := fleet.NewHostCertificateRecord(host.ID, parsed)
|
||||
rec.Source = fleet.SystemHostCertificate
|
||||
return rec
|
||||
}
|
||||
|
||||
osqueryOnly := mkCert("osquery-only")
|
||||
mdmOnly := mkCert("mdm-only")
|
||||
|
||||
// Initial state: osquery reports osqueryOnly; MDM reports mdmOnly.
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
|
||||
[]*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
|
||||
[]*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM))
|
||||
|
||||
certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 2)
|
||||
|
||||
originByCN := func(certs []*fleet.HostCertificateRecord) map[string]fleet.HostCertificateOrigin {
|
||||
m := make(map[string]fleet.HostCertificateOrigin, len(certs))
|
||||
for _, c := range certs {
|
||||
m[c.CommonName] = c.Origin
|
||||
}
|
||||
return m
|
||||
}
|
||||
require.Equal(t, map[string]fleet.HostCertificateOrigin{
|
||||
"osquery-only": fleet.HostCertificateOriginOsquery,
|
||||
"mdm-only": fleet.HostCertificateOriginMDM,
|
||||
}, originByCN(certs))
|
||||
|
||||
// Osquery sync runs again with an EMPTY cert list. The osquery-only cert
|
||||
// should be soft-deleted, but the mdm-only cert must survive.
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
|
||||
[]*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 1, "mdm-only cert should survive an osquery sync that omits it")
|
||||
require.Equal(t, "mdm-only", certs[0].CommonName)
|
||||
require.Equal(t, fleet.HostCertificateOriginMDM, certs[0].Origin)
|
||||
|
||||
// Now the symmetric case: osquery re-reports its cert, MDM sync runs with an
|
||||
// empty list. The mdm-only cert should be soft-deleted, osquery-only survives.
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
|
||||
[]*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
|
||||
[]*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM))
|
||||
|
||||
certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 1, "osquery-only cert should survive an MDM sync that omits it")
|
||||
require.Equal(t, "osquery-only", certs[0].CommonName)
|
||||
require.Equal(t, fleet.HostCertificateOriginOsquery, certs[0].Origin)
|
||||
}
|
||||
|
||||
// testHostCertificateWithInvalidCountryCode tests that a certificate with a country code longer than the standard 2 letters works
|
||||
func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
@@ -923,7 +1232,7 @@ func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) {
|
||||
payload[1].SubjectCountry = certWithNormalCountryTemplate.Subject.Country[0]
|
||||
payload[1].IssuerCountry = parentWithLongIssuerCountryTemplate.Subject.Country[0]
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// verify that we saved the records correctly
|
||||
certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
|
||||
@@ -1032,7 +1341,7 @@ func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update certificates - this should trigger truncation
|
||||
err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert})
|
||||
err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert}, fleet.HostCertificateOriginOsquery)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the certificate and verify all fields were truncated
|
||||
@@ -1115,7 +1424,7 @@ func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) {
|
||||
certUser.Source = fleet.UserHostCertificate
|
||||
certUser.Username = "alice"
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}))
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// Now list with metadata
|
||||
certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true})
|
||||
|
||||
@@ -9300,7 +9300,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
||||
NotValidAfter: now.Add(365 * 24 * time.Hour),
|
||||
Source: fleet.SystemHostCertificate,
|
||||
Username: "test-user",
|
||||
}}))
|
||||
}}, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// create an android device from this host
|
||||
deviceID := strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
|
||||
@@ -1682,6 +1682,32 @@ WHERE
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID, commandUUID string) (fleet.ProfileACMECommandResult, error) {
|
||||
const stmt = `
|
||||
SELECT
|
||||
h.id AS host_id,
|
||||
h.platform AS platform,
|
||||
hmap.profile_uuid AS profile_uuid,
|
||||
LOCATE('com.apple.security.acme', mac.mobileconfig) > 0 AS has_acme_payload
|
||||
FROM host_mdm_apple_profiles hmap
|
||||
JOIN hosts h
|
||||
ON h.uuid = hmap.host_uuid
|
||||
JOIN mdm_apple_configuration_profiles mac
|
||||
ON mac.profile_uuid = hmap.profile_uuid
|
||||
WHERE hmap.command_uuid = ?
|
||||
AND hmap.host_uuid = ?`
|
||||
|
||||
var dest fleet.ProfileACMECommandResult
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, commandUUID, hostUUID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return dest, notFound("HostMDMAppleProfile").WithMessage(fmt.Sprintf("command uuid %s not found for host uuid %s", commandUUID, hostUUID))
|
||||
}
|
||||
return dest, ctxerr.Wrap(ctx, err, "probe profile for ACME payload")
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func batchSetProfileLabelAssociationsDB(
|
||||
ctx context.Context,
|
||||
tx sqlx.ExtContext,
|
||||
@@ -3046,7 +3072,15 @@ func (ds *Datastore) ListHostMDMManagedCertificates(ctx context.Context, hostUUI
|
||||
// RenewMDMManagedCertificates marks managed certificate profiles for resend when renewal is required
|
||||
func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
|
||||
totalHostCertsToRenew := 0
|
||||
// Iteration set: every renewable CA type plus a NULL "non-proxied" bucket.
|
||||
// Non-proxied (NULL-type) rows come from cert ingestion (no Fleet-side
|
||||
// proxy step → no known CA type) and need their own renewal pass.
|
||||
hostCertTypesToRenew := fleet.ListCATypesWithRenewalSupport()
|
||||
typeMatchers := make([]sql.NullString, 0, len(hostCertTypesToRenew)+1)
|
||||
for _, t := range hostCertTypesToRenew {
|
||||
typeMatchers = append(typeMatchers, sql.NullString{String: string(t), Valid: true})
|
||||
}
|
||||
typeMatchers = append(typeMatchers, sql.NullString{Valid: false})
|
||||
// Map is used to take advantage of Go map iteration order randomization so that
|
||||
// if a customer is issuing certs across multiple platforms we will not bias renewals
|
||||
// toward a specific platform
|
||||
@@ -3054,7 +3088,11 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
|
||||
"apple": "host_mdm_apple_profiles",
|
||||
"windows": "host_mdm_windows_profiles",
|
||||
}
|
||||
for _, hostCertType := range hostCertTypesToRenew {
|
||||
for _, typeMatcher := range typeMatchers {
|
||||
hostCertType := typeMatcher.String
|
||||
if !typeMatcher.Valid {
|
||||
hostCertType = "non_proxied"
|
||||
}
|
||||
// Limit to 1000 renewals per CA type per run across all platforms
|
||||
limit := 1000
|
||||
for hostPlatform, table := range hostProfileTables {
|
||||
@@ -3075,12 +3113,14 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
|
||||
NotValidAfter time.Time `db:"not_valid_after"`
|
||||
ValidityPeriod int `db:"validity_period"`
|
||||
}{}
|
||||
// Fetch all MDM Managed certificates of the given type that aren't already queued for
|
||||
// resend(hmap.status=null) and which
|
||||
// Fetch all MDM Managed certificates of the given type (or NULL for
|
||||
// non-proxied) that aren't already queued for resend (hmap.status=null) and which
|
||||
// * Have a validity period > 30 days and are expiring in the next 30 days
|
||||
// * Have a validity period <= 30 days and are within half the validity period of expiration
|
||||
// nb: we SELECT not_valid_after and validity_period here so we can use them in the HAVING clause, but
|
||||
// we don't actually need them for the update logic.
|
||||
// we don't actually need them for the update logic. The `<=>` operator
|
||||
// is null-safe equal: matches non-NULL values like `=` and matches NULL
|
||||
// when both sides are NULL.
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostCertsToRenew, `
|
||||
SELECT
|
||||
hmmc.host_uuid,
|
||||
@@ -3093,12 +3133,12 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
|
||||
`+table+` hp
|
||||
ON hmmc.host_uuid = hp.host_uuid AND hmmc.profile_uuid = hp.profile_uuid
|
||||
WHERE
|
||||
hmmc.type = ? AND hp.status IS NOT NULL AND hp.operation_type = ?
|
||||
hmmc.type <=> ? AND hp.status IS NOT NULL AND hp.operation_type = ?
|
||||
HAVING
|
||||
validity_period IS NOT NULL AND
|
||||
((validity_period > 30 AND not_valid_after < DATE_ADD(NOW(), INTERVAL 30 DAY)) OR
|
||||
(validity_period <= 30 AND not_valid_after < DATE_ADD(NOW(), INTERVAL validity_period/2 DAY)))
|
||||
LIMIT ?`, hostCertType, fleet.MDMOperationTypeInstall, limit)
|
||||
LIMIT ?`, typeMatcher, fleet.MDMOperationTypeInstall, limit)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "retrieving mdm managed certificates to renew")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ func TestMDMShared(t *testing.T) {
|
||||
{"TestListNextPendingMDMWindowsHostUUIDsCursor", testListNextPendingMDMWindowsHostUUIDsCursor},
|
||||
{"TestCleanUpMDMManagedCertificates", testCleanUpMDMManagedCertificates},
|
||||
{"TestEnqueueCommandWithName", testEnqueueCommandWithName},
|
||||
{"TestProfileHasACMEPayloadForCommand", testProfileHasACMEPayloadForCommand},
|
||||
{"TestRenewMDMManagedCertificatesNullType", testRenewMDMManagedCertificatesNullType},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -10601,3 +10603,222 @@ func testCleanUpMDMManagedCertificates(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, appleProfileUUID, uid)
|
||||
})
|
||||
}
|
||||
|
||||
func testProfileHasACMEPayloadForCommand(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("acme-probe-osq"),
|
||||
NodeKey: ptr.String("acme-probe-nk"),
|
||||
UUID: "acme-probe-host-uuid",
|
||||
Hostname: "acme-probe-host",
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mkProfile := func(t *testing.T, name string, mobileconfig []byte) string {
|
||||
t.Helper()
|
||||
teamID := uint(0)
|
||||
profileUUID := uuid.NewString()
|
||||
stmt := `
|
||||
INSERT INTO mdm_apple_configuration_profiles
|
||||
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW())`
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, stmt,
|
||||
profileUUID, teamID, name, name, mobileconfig, []byte("0123456789abcdef"))
|
||||
return err
|
||||
})
|
||||
return profileUUID
|
||||
}
|
||||
|
||||
mkHostProfileLink := func(t *testing.T, hostUUID, profileUUID, commandUUID string) {
|
||||
t.Helper()
|
||||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||||
ProfileUUID: profileUUID,
|
||||
HostUUID: hostUUID,
|
||||
Checksum: []byte("0123456789abcdef"),
|
||||
Scope: fleet.PayloadScopeSystem,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
CommandUUID: commandUUID,
|
||||
}}))
|
||||
}
|
||||
|
||||
acmeXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.acme</string></dict></array></dict></plist>`)
|
||||
scepXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.scep</string></dict></array></dict></plist>`)
|
||||
|
||||
t.Run("darwin host with ACME profile, no pending refetch", func(t *testing.T) {
|
||||
profUUID := mkProfile(t, "acme-darwin", acmeXML)
|
||||
cmdUUID := uuid.NewString()
|
||||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||||
|
||||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, host.ID, got.HostID)
|
||||
require.Equal(t, "darwin", got.Platform)
|
||||
require.Equal(t, profUUID, got.ProfileUUID)
|
||||
require.True(t, got.HasACMEPayload)
|
||||
})
|
||||
|
||||
t.Run("darwin host with non-ACME profile reports has_acme_payload=false", func(t *testing.T) {
|
||||
profUUID := mkProfile(t, "scep-darwin", scepXML)
|
||||
cmdUUID := uuid.NewString()
|
||||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||||
|
||||
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "darwin", got.Platform)
|
||||
require.False(t, got.HasACMEPayload)
|
||||
})
|
||||
|
||||
t.Run("unknown command returns not found", func(t *testing.T) {
|
||||
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, "no-such-command")
|
||||
require.Error(t, err)
|
||||
require.True(t, fleet.IsNotFound(err))
|
||||
})
|
||||
|
||||
t.Run("unknown host returns not found", func(t *testing.T) {
|
||||
profUUID := mkProfile(t, "acme-unknown-host", acmeXML)
|
||||
cmdUUID := uuid.NewString()
|
||||
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
|
||||
|
||||
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, "no-such-host", cmdUUID)
|
||||
require.Error(t, err)
|
||||
require.True(t, fleet.IsNotFound(err))
|
||||
})
|
||||
}
|
||||
|
||||
func testRenewMDMManagedCertificatesNullType(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("renew-null-osq"),
|
||||
NodeKey: ptr.String("renew-null-nk"),
|
||||
UUID: "renew-null-host-uuid",
|
||||
Hostname: "renew-null-host",
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Helper: create an Apple config profile + a host_mdm_apple_profiles row
|
||||
// in 'verified' state (eligible for renewal cron resend) and an associated
|
||||
// host_mdm_managed_certificates row with the given type and an expiring
|
||||
// not_valid_after. Returns the profile UUID.
|
||||
mkExpiringRow := func(t *testing.T, name string, certType *string, caName string) string {
|
||||
t.Helper()
|
||||
profileUUID := uuid.NewString()
|
||||
// Insert mdm_apple_configuration_profiles row.
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO mdm_apple_configuration_profiles
|
||||
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
|
||||
VALUES (?, 0, ?, ?, ?, ?, NOW())`,
|
||||
profileUUID, name, name, []byte("dummy"), []byte("0123456789abcdef"))
|
||||
return err
|
||||
})
|
||||
// Insert host_mdm_apple_profiles row in verified state, eligible for renewal.
|
||||
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
|
||||
ProfileUUID: profileUUID,
|
||||
ProfileIdentifier: name,
|
||||
ProfileName: name,
|
||||
HostUUID: host.UUID,
|
||||
Status: &fleet.MDMDeliveryVerified,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
CommandUUID: "cmd-" + profileUUID,
|
||||
Checksum: []byte("0123456789abcdef"),
|
||||
Scope: fleet.PayloadScopeSystem,
|
||||
}}))
|
||||
// Insert host_mdm_managed_certificates row with cert that expires soon
|
||||
// (within the renewal cron's 30-day threshold, validity_period > 30).
|
||||
notValidBefore := time.Now().AddDate(-1, 0, 0) // 1 year ago
|
||||
notValidAfter := time.Now().AddDate(0, 0, 5) // 5 days from now
|
||||
serial := "0000000000000000000000000000000000000001"
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO host_mdm_managed_certificates
|
||||
(host_uuid, profile_uuid, ca_name, type,
|
||||
not_valid_before, not_valid_after, serial)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
host.UUID, profileUUID, caName, certType, notValidBefore, notValidAfter, serial)
|
||||
return err
|
||||
})
|
||||
return profileUUID
|
||||
}
|
||||
|
||||
ndesStr := "ndes"
|
||||
|
||||
// Two rows expiring on the same schedule: one with NULL type
|
||||
// (non-proxied flow) and one with type='ndes' (proxied flow).
|
||||
// Both buckets must be picked up by the renewal cron.
|
||||
nullProfile := mkExpiringRow(t, "null-prof", nil, "non-proxied-ca")
|
||||
ndesProfile := mkExpiringRow(t, "ndes-prof", &ndesStr, "ndes-ca")
|
||||
|
||||
// Sanity: both start as 'verified'.
|
||||
for _, profUUID := range []string{nullProfile, ndesProfile} {
|
||||
var status *string
|
||||
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
|
||||
SELECT status FROM host_mdm_apple_profiles
|
||||
WHERE host_uuid = ? AND profile_uuid = ?`,
|
||||
host.UUID, profUUID))
|
||||
require.NotNil(t, status)
|
||||
require.Equal(t, fleet.MDMDeliveryVerified, fleet.MDMDeliveryStatus(*status))
|
||||
}
|
||||
|
||||
require.NoError(t, ds.RenewMDMManagedCertificates(ctx))
|
||||
|
||||
// Both should now have status=NULL (queued for resend).
|
||||
for _, profUUID := range []string{nullProfile, ndesProfile} {
|
||||
var status *string
|
||||
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
|
||||
SELECT status FROM host_mdm_apple_profiles
|
||||
WHERE host_uuid = ? AND profile_uuid = ?`,
|
||||
host.UUID, profUUID))
|
||||
require.Nil(t, status, "profile %s should be queued for resend", profUUID)
|
||||
}
|
||||
|
||||
// Verify the read paths handle NULL `type` cleanly. The struct fields
|
||||
// `MDMManagedCertificate.Type` and `HostMDMCertificateProfile.Type` are
|
||||
// `CAConfigAssetType` (a string alias), not pointers. sqlx scans a NULL
|
||||
// column into a string-aliased field as the empty string — no error, no
|
||||
// special-case handling needed. This is the convention used throughout the
|
||||
// non-proxied flow: NULL in the column == zero value in Go.
|
||||
listed, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
|
||||
require.NoError(t, err, "ListHostMDMManagedCertificates must round-trip rows with NULL type")
|
||||
var sawNullRow, sawNDESRow bool
|
||||
for _, row := range listed {
|
||||
switch row.ProfileUUID {
|
||||
case nullProfile:
|
||||
sawNullRow = true
|
||||
require.Equal(t, fleet.CAConfigAssetType(""), row.Type,
|
||||
"NULL type column should scan to empty CAConfigAssetType")
|
||||
require.Equal(t, "non-proxied-ca", row.CAName)
|
||||
case ndesProfile:
|
||||
sawNDESRow = true
|
||||
require.Equal(t, fleet.CAConfigNDES, row.Type,
|
||||
"non-NULL type column should round-trip unchanged")
|
||||
}
|
||||
}
|
||||
require.True(t, sawNullRow, "ListHostMDMManagedCertificates must return the NULL-type row")
|
||||
require.True(t, sawNDESRow, "ListHostMDMManagedCertificates must return the existing ndes row")
|
||||
|
||||
// Same expectation via GetAppleHostMDMCertificateProfile, which returns
|
||||
// HostMDMCertificateProfile (different struct, same nullable column).
|
||||
nullProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, nullProfile, "non-proxied-ca")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, nullProfileDetail)
|
||||
require.Equal(t, fleet.CAConfigAssetType(""), nullProfileDetail.Type,
|
||||
"HostMDMCertificateProfile.Type must scan a NULL column as empty string")
|
||||
|
||||
ndesProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, ndesProfile, "ndes-ca")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ndesProfileDetail)
|
||||
require.Equal(t, fleet.CAConfigNDES, ndesProfileDetail.Type)
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260518124441, Down_20260518124441)
|
||||
}
|
||||
|
||||
func Up_20260518124441(tx *sql.Tx) error {
|
||||
// Allow NULL and remove the 'ndes' default on host_mdm_managed_certificates.type
|
||||
// so rows created from cert ingestion (PR 2.2) — for non-proxied flows where
|
||||
// Fleet isn't in the issuance path and doesn't know the CA type — can be
|
||||
// inserted without forcing a misleading type value. Existing rows are
|
||||
// unaffected; new INSERTs that don't specify type will get NULL instead of
|
||||
// 'ndes'. All existing INSERT call sites specify type explicitly, so removing
|
||||
// the default is safe.
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE host_mdm_managed_certificates
|
||||
MODIFY COLUMN type ENUM('digicert', 'custom_scep_proxy', 'ndes', 'smallstep')
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
NULL DEFAULT NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "alter host_mdm_managed_certificates.type to allow NULL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260518124441(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260518124441(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Pre-existing row using a current enum value: confirms the migration
|
||||
// preserves rows authored under the old NOT NULL DEFAULT 'ndes' shape.
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO host_mdm_managed_certificates
|
||||
(host_uuid, profile_uuid, ca_name, type)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
"host-1", "profile-1", "ca-existing", "ndes")
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
// Existing row still readable with the same value.
|
||||
var existingType string
|
||||
require.NoError(t, db.Get(&existingType, `
|
||||
SELECT type FROM host_mdm_managed_certificates
|
||||
WHERE host_uuid = 'host-1' AND profile_uuid = 'profile-1' AND ca_name = 'ca-existing'`))
|
||||
require.Equal(t, "ndes", existingType)
|
||||
|
||||
// NULL accepted for ingestion-created rows where Fleet doesn't know the CA type.
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO host_mdm_managed_certificates
|
||||
(host_uuid, profile_uuid, ca_name)
|
||||
VALUES (?, ?, ?)`,
|
||||
"host-1", "profile-3", "non-proxied-ca")
|
||||
var nullType *string
|
||||
require.NoError(t, db.Get(&nullType, `
|
||||
SELECT type FROM host_mdm_managed_certificates
|
||||
WHERE host_uuid = 'host-1' AND profile_uuid = 'profile-3' AND ca_name = 'non-proxied-ca'`))
|
||||
require.Nil(t, nullType, "type should be NULL when not specified after the migration")
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260518150028, Down_20260518150028)
|
||||
}
|
||||
|
||||
func Up_20260518150028(tx *sql.Tx) error {
|
||||
// Add an `origin` column tracking which ingestion source created the
|
||||
// host_certificates row. This scopes deletion semantics so each ingestion
|
||||
// source only soft-deletes rows it owns: an osquery sync that omits a row
|
||||
// inserted via MDM `CertificateList` will not delete that row, and vice
|
||||
// versa. The column is internal — not exposed in the public API.
|
||||
//
|
||||
// Existing rows default to 'osquery' since osquery has been the only
|
||||
// ingestion source until this change.
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE host_certificates
|
||||
ADD COLUMN origin ENUM('osquery', 'mdm') NOT NULL DEFAULT 'osquery'
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "add origin column to host_certificates")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260518150028(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260518150028(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Seed a host so we can reference it from host_certificates.
|
||||
execNoErr(t, db, `INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid, platform) VALUES (?, ?, ?, ?, ?);`,
|
||||
"oh1", "nk1", "h1", "uuid-1", "darwin")
|
||||
var hostID uint
|
||||
require.NoError(t, db.Get(&hostID, `SELECT id FROM hosts WHERE uuid = 'uuid-1'`))
|
||||
|
||||
// Insert an existing host_certificates row before migration. This stands in for
|
||||
// rows already present in production (origin should default to 'osquery').
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO host_certificates (
|
||||
host_id, not_valid_after, not_valid_before, certificate_authority,
|
||||
common_name, key_algorithm, key_strength, key_usage,
|
||||
serial, signing_algorithm,
|
||||
subject_country, subject_org, subject_org_unit, subject_common_name,
|
||||
issuer_country, issuer_org, issuer_org_unit, issuer_common_name,
|
||||
sha1_sum
|
||||
) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature',
|
||||
'1', 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '',
|
||||
?)`,
|
||||
hostID, []byte("0123456789abcdef0123"))
|
||||
|
||||
applyNext(t, db)
|
||||
|
||||
// New origin column must exist with default 'osquery' for the pre-existing row.
|
||||
var origin string
|
||||
require.NoError(t, db.Get(&origin, `SELECT origin FROM host_certificates WHERE host_id = ?`, hostID))
|
||||
require.Equal(t, "osquery", origin)
|
||||
|
||||
// Insert a new row explicitly tagged origin='mdm' to confirm the enum accepts both values.
|
||||
execNoErr(t, db, `
|
||||
INSERT INTO host_certificates (
|
||||
host_id, not_valid_after, not_valid_before, certificate_authority,
|
||||
common_name, key_algorithm, key_strength, key_usage,
|
||||
serial, signing_algorithm,
|
||||
subject_country, subject_org, subject_org_unit, subject_common_name,
|
||||
issuer_country, issuer_org, issuer_org_unit, issuer_common_name,
|
||||
sha1_sum, origin
|
||||
) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature',
|
||||
'2', 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '',
|
||||
?, 'mdm')`,
|
||||
hostID, []byte("fedcba9876543210fedc"))
|
||||
|
||||
var origins []string
|
||||
require.NoError(t, db.Select(&origins, `SELECT origin FROM host_certificates WHERE host_id = ? ORDER BY serial`, hostID))
|
||||
require.Equal(t, []string{"osquery", "mdm"}, origins)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -35,6 +35,25 @@ func (t CAConfigAssetType) SupportsRenewalID() bool {
|
||||
return slices.Contains(ListCATypesWithRenewalIDSupport(), t)
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner so that a NULL value in the
|
||||
// host_mdm_managed_certificates.type column (allowed for ingestion-created
|
||||
// rows from non-proxied flows) scans into the zero value rather than
|
||||
// producing "converting NULL to string is unsupported". Empty string is the
|
||||
// canonical "type unknown" sentinel — the matcher's SupportsRenewalID guard
|
||||
// and the renewal cron's null-safe equal both honor it.
|
||||
func (t *CAConfigAssetType) Scan(value any) error {
|
||||
if value == nil {
|
||||
*t = ""
|
||||
return nil
|
||||
}
|
||||
raw, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type for CAConfigAssetType: %T", value)
|
||||
}
|
||||
*t = CAConfigAssetType(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
type CAConfigAsset struct {
|
||||
Name string `db:"name"`
|
||||
Value []byte `db:"value"`
|
||||
|
||||
@@ -453,7 +453,20 @@ type Datastore interface {
|
||||
IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error)
|
||||
|
||||
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error)
|
||||
UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord) error
|
||||
// UpdateHostCertificates ingests certs reported by `origin`. Each call only
|
||||
// soft-deletes existing rows whose origin matches, so osquery and MDM
|
||||
// ingestion don't clobber each other's view.
|
||||
UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin) error
|
||||
|
||||
// ProfileHasACMEPayloadForCommand returns the host/profile gating data
|
||||
// needed to decide whether an InstallProfile ack should trigger a
|
||||
// CertificateList refetch: host platform, profile UUID, whether the
|
||||
// delivered profile contains a com.apple.security.acme payload, and
|
||||
// whether a refetch is already pending. All gates are computed
|
||||
// server-side in a single indexed lookup so the per-ack hot path stays
|
||||
// cheap. Substring-matched on the mobileconfig blob; bounded false-
|
||||
// positive risk (one redundant CertificateList per false match).
|
||||
ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID, commandUUID string) (ProfileACMECommandResult, error)
|
||||
|
||||
// AreHostsConnectedToFleetMDM checks each host MDM enrollment with
|
||||
// this server and returns a map indexed by the host uuid and a boolean
|
||||
|
||||
@@ -531,8 +531,8 @@ const (
|
||||
|
||||
// Error message variables
|
||||
var (
|
||||
NDESSCEPVariablesMissingErrMsg = fmt.Sprintf("SCEP profile for NDES certificate authority requires: $FLEET_VAR_%s, $FLEET_VAR_%s, and $FLEET_VAR_%s variables.", FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarSCEPRenewalID)
|
||||
SCEPRenewalIDWithoutURLChallengeErrMsg = "Variable \"$FLEET_VAR_" + string(FleetVarSCEPRenewalID) + "\" can't be used if variables for SCEP URL and Challenge are not specified."
|
||||
NDESSCEPVariablesMissingErrMsg = fmt.Sprintf("SCEP profile for NDES certificate authority requires: $FLEET_VAR_%s, $FLEET_VAR_%s, and $FLEET_VAR_%s variables.", FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarCertificateRenewalID)
|
||||
SCEPRenewalIDWithoutURLChallengeErrMsg = "Variable \"$FLEET_VAR_" + string(FleetVarCertificateRenewalID) + "\" can't be used if variables for SCEP URL and Challenge are not specified."
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -28,6 +28,19 @@ func (s HostCertificateSource) IsValid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// HostCertificateOrigin identifies the ingestion path that recorded a
|
||||
// host_certificates row. It scopes deletion semantics: each ingestion source
|
||||
// only soft-deletes rows it owns, so an osquery sync omitting an MDM-only cert
|
||||
// does not remove that cert, and vice versa.
|
||||
//
|
||||
// Internal-only: not exposed in the public API.
|
||||
type HostCertificateOrigin string
|
||||
|
||||
const (
|
||||
HostCertificateOriginOsquery HostCertificateOrigin = "osquery"
|
||||
HostCertificateOriginMDM HostCertificateOrigin = "mdm"
|
||||
)
|
||||
|
||||
// HostCertificateRecord is the database model for a host certificate.
|
||||
type HostCertificateRecord struct {
|
||||
ID uint `json:"-" db:"id"`
|
||||
@@ -65,6 +78,10 @@ type HostCertificateRecord struct {
|
||||
|
||||
Source HostCertificateSource `json:"-" db:"source"`
|
||||
Username string `json:"-" db:"username"` // username that owns the certificate, only if source == 'user'
|
||||
|
||||
// Origin identifies the ingestion source (osquery vs mdm). Used internally to
|
||||
// scope deletion semantics; not exposed in the public API.
|
||||
Origin HostCertificateOrigin `json:"-" db:"origin"`
|
||||
}
|
||||
|
||||
func NewHostCertificateRecord(
|
||||
|
||||
+26
-6
@@ -75,7 +75,8 @@ const (
|
||||
// Certificate authority variables
|
||||
FleetVarNDESSCEPChallenge FleetVarName = "NDES_SCEP_CHALLENGE"
|
||||
FleetVarNDESSCEPProxyURL FleetVarName = "NDES_SCEP_PROXY_URL"
|
||||
FleetVarSCEPRenewalID FleetVarName = "SCEP_RENEWAL_ID"
|
||||
FleetVarSCEPRenewalID FleetVarName = "SCEP_RENEWAL_ID" // deprecated in favor of FleetVarCertificateRenewalID, but remains for back-compat
|
||||
FleetVarCertificateRenewalID FleetVarName = "CERTIFICATE_RENEWAL_ID"
|
||||
FleetVarDigiCertDataPrefix FleetVarName = "DIGICERT_DATA_"
|
||||
FleetVarDigiCertPasswordPrefix FleetVarName = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials
|
||||
FleetVarCustomSCEPChallengePrefix FleetVarName = "CUSTOM_SCEP_CHALLENGE_"
|
||||
@@ -94,7 +95,7 @@ const (
|
||||
func HasCAVariables(fleetVars []string) bool {
|
||||
for _, v := range fleetVars {
|
||||
if v == string(FleetVarNDESSCEPChallenge) || v == string(FleetVarNDESSCEPProxyURL) ||
|
||||
v == string(FleetVarSCEPRenewalID) || v == string(FleetVarSCEPWindowsCertificateID) ||
|
||||
v == string(FleetVarSCEPRenewalID) || v == string(FleetVarCertificateRenewalID) || v == string(FleetVarSCEPWindowsCertificateID) ||
|
||||
strings.HasPrefix(v, string(FleetVarDigiCertDataPrefix)) || strings.HasPrefix(v, string(FleetVarDigiCertPasswordPrefix)) ||
|
||||
strings.HasPrefix(v, string(FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(v, string(FleetVarCustomSCEPProxyURLPrefix)) ||
|
||||
strings.HasPrefix(v, string(FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(v, string(FleetVarSmallstepSCEPProxyURLPrefix)) {
|
||||
@@ -114,10 +115,17 @@ var (
|
||||
FleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPChallenge))
|
||||
FleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPProxyURL))
|
||||
FleetVarHostEndUserIDPFullnameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPFullname))
|
||||
FleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPRenewalID))
|
||||
FleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostUUID))
|
||||
FleetVarHostPlatformRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostPlatform))
|
||||
FleetVarSCEPWindowsCertificateIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPWindowsCertificateID))
|
||||
FleetVarCertificateRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarCertificateRenewalID))
|
||||
// FleetVarRenewalIDRegexp matches either the preferred CERTIFICATE_RENEWAL_ID
|
||||
// or the legacy SCEP_RENEWAL_ID name. Use this for validation checks where
|
||||
// either form satisfies the requirement.
|
||||
FleetVarRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(
|
||||
`(\$FLEET_VAR_%[1]s)|(\${FLEET_VAR_%[1]s})|(\$FLEET_VAR_%[2]s)|(\${FLEET_VAR_%[2]s})`,
|
||||
FleetVarCertificateRenewalID, FleetVarSCEPRenewalID,
|
||||
))
|
||||
FleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostUUID))
|
||||
FleetVarHostPlatformRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostPlatform))
|
||||
FleetVarSCEPWindowsCertificateIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPWindowsCertificateID))
|
||||
|
||||
// Fleet variable replacement failed errors
|
||||
HostEndUserEmailIDPVariableReplacementFailedError = fmt.Sprintf("There is no IdP email for this host. "+
|
||||
@@ -311,6 +319,18 @@ type HostMDMProfileRetryCount struct {
|
||||
Retries uint `db:"retries"`
|
||||
}
|
||||
|
||||
// ProfileACMECommandResult bundles the gates needed to decide whether an
|
||||
// InstallProfile ack should trigger a CertificateList refetch on macOS:
|
||||
// host platform, profile UUID, and whether the delivered profile contains a
|
||||
// com.apple.security.acme payload. Computed in a single query keyed on
|
||||
// (host_uuid, command_uuid).
|
||||
type ProfileACMECommandResult struct {
|
||||
HostID uint `db:"host_id"`
|
||||
Platform string `db:"platform"`
|
||||
ProfileUUID string `db:"profile_uuid"`
|
||||
HasACMEPayload bool `db:"has_acme_payload"`
|
||||
}
|
||||
|
||||
// TeamIDSetter defines the method to set a TeamID value on a struct,
|
||||
// which helps define authorization helpers based on teams.
|
||||
type TeamIDSetter interface {
|
||||
|
||||
@@ -592,6 +592,7 @@ func TestHasCAVariables(t *testing.T) {
|
||||
{"NDES challenge", []string{string(fleet.FleetVarHostUUID), string(fleet.FleetVarNDESSCEPChallenge)}, true},
|
||||
{"NDES proxy URL", []string{string(fleet.FleetVarNDESSCEPProxyURL)}, true},
|
||||
{"SCEP renewal", []string{string(fleet.FleetVarSCEPRenewalID)}, true},
|
||||
{"Certificate renewal (preferred)", []string{string(fleet.FleetVarCertificateRenewalID)}, true},
|
||||
{"DigiCert data", []string{string(fleet.FleetVarDigiCertDataPrefix) + "my_ca"}, true},
|
||||
{"DigiCert password", []string{string(fleet.FleetVarDigiCertPasswordPrefix) + "my_ca"}, true},
|
||||
{"Custom SCEP challenge", []string{string(fleet.FleetVarCustomSCEPChallengePrefix) + "my_ca"}, true},
|
||||
@@ -610,6 +611,27 @@ func TestHasCAVariables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetVarRenewalIDRegexp(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"$FLEET_VAR_CERTIFICATE_RENEWAL_ID", true},
|
||||
{"${FLEET_VAR_CERTIFICATE_RENEWAL_ID}", true},
|
||||
{"$FLEET_VAR_SCEP_RENEWAL_ID", true},
|
||||
{"${FLEET_VAR_SCEP_RENEWAL_ID}", true},
|
||||
{"prefix $FLEET_VAR_CERTIFICATE_RENEWAL_ID suffix", true},
|
||||
{"$FLEET_VAR_OTHER_VAR", false},
|
||||
{"static-value", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, fleet.FleetVarRenewalIDRegexp.MatchString(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMacOSOnlyProfilesFromIOSIPadOS(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
profiles []*fleet.MDMAppleProfilePayload
|
||||
|
||||
@@ -16,10 +16,17 @@ import (
|
||||
const (
|
||||
// FleetFileVaultPayloadIdentifier is the value for the PayloadIdentifier
|
||||
// used by Fleet to configure FileVault and FileVault Escrow.
|
||||
FleetFileVaultPayloadIdentifier = "com.fleetdm.fleet.mdm.filevault"
|
||||
FleetFileVaultPayloadType = "com.apple.MCX.FileVault2"
|
||||
FleetCustomSettingsPayloadType = "com.apple.MCX"
|
||||
FleetRecoveryKeyEscrowPayloadType = "com.apple.security.FDERecoveryKeyEscrow"
|
||||
FleetFileVaultPayloadIdentifier = "com.fleetdm.fleet.mdm.filevault"
|
||||
FleetFileVaultPayloadType = "com.apple.MCX.FileVault2"
|
||||
FleetCustomSettingsPayloadType = "com.apple.MCX"
|
||||
FleetRecoveryKeyEscrowPayloadType = "com.apple.security.FDERecoveryKeyEscrow"
|
||||
|
||||
// ACMEPayloadType is the Apple-defined PayloadType for ACME certificate
|
||||
// payloads (com.apple.security.acme).
|
||||
ACMEPayloadType = "com.apple.security.acme"
|
||||
// SCEPPayloadType is the Apple-defined PayloadType for SCEP certificate
|
||||
// payloads (com.apple.security.scep).
|
||||
SCEPPayloadType = "com.apple.security.scep"
|
||||
DiskEncryptionProfileRestrictionErrMsg = "Couldn't add. The configuration profile can't include FileVault settings."
|
||||
|
||||
// FleetdConfigPayloadIdentifier is the value for the PayloadIdentifier used
|
||||
@@ -208,6 +215,22 @@ func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HasPayloadType reports whether the profile contains at least one payload
|
||||
// content item with the given PayloadType. Returns an error if the profile
|
||||
// cannot be parsed.
|
||||
func (mc Mobileconfig) HasPayloadType(payloadType string) (bool, error) {
|
||||
summaries, err := mc.payloadSummary()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, s := range summaries {
|
||||
if s.Type == payloadType {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (mc *Mobileconfig) ScreenPayloads(allowCustomOSUpdatesAndFileVault bool) error {
|
||||
pct, err := mc.payloadSummary()
|
||||
if err != nil {
|
||||
|
||||
@@ -34,3 +34,57 @@ func TestXMLEscapeString(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPayloadType(t *testing.T) {
|
||||
build := func(payloadType string) Mobileconfig {
|
||||
return Mobileconfig(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key>
|
||||
<string>` + payloadType + `</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.example.profile.cert</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Test Cert</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>00000000-0000-0000-0000-000000000001</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Test Profile</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.example.profile</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>00000000-0000-0000-0000-000000000002</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>`)
|
||||
}
|
||||
|
||||
t.Run("ACME profile reports ACME payload", func(t *testing.T) {
|
||||
got, err := build(ACMEPayloadType).HasPayloadType(ACMEPayloadType)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("SCEP profile does not report ACME payload", func(t *testing.T) {
|
||||
got, err := build(SCEPPayloadType).HasPayloadType(ACMEPayloadType)
|
||||
require.NoError(t, err)
|
||||
require.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("SCEP profile reports SCEP payload", func(t *testing.T) {
|
||||
got, err := build(SCEPPayloadType).HasPayloadType(SCEPPayloadType)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ func preprocessProfileContents(
|
||||
// In the future we should expand variablesUpdatedAt logic to include non-CA variables as
|
||||
// well
|
||||
for _, fleetVar := range fleetVars {
|
||||
if fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
|
||||
if fleetVar == string(fleet.FleetVarSCEPRenewalID) || fleetVar == string(fleet.FleetVarCertificateRenewalID) ||
|
||||
fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) ||
|
||||
strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) ||
|
||||
strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) ||
|
||||
@@ -230,7 +230,8 @@ func preprocessProfileContents(
|
||||
|
||||
case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) ||
|
||||
fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) ||
|
||||
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
|
||||
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) ||
|
||||
fleetVar == string(fleet.FleetVarSCEPRenewalID) || fleetVar == string(fleet.FleetVarCertificateRenewalID) ||
|
||||
fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID):
|
||||
// No extra validation needed for these variables
|
||||
|
||||
@@ -398,10 +399,12 @@ func preprocessProfileContents(
|
||||
// Insert the SCEP URL into the profile contents
|
||||
hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents)
|
||||
|
||||
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
|
||||
// Insert the SCEP renewal ID into the SCEP Payload CN or OU
|
||||
case fleetVar == string(fleet.FleetVarSCEPRenewalID), fleetVar == string(fleet.FleetVarCertificateRenewalID):
|
||||
// Insert the renewal ID into the SCEP/ACME Payload CN or OU.
|
||||
// Both legacy SCEP_RENEWAL_ID and the preferred
|
||||
// CERTIFICATE_RENEWAL_ID substitute to the same value.
|
||||
fleetRenewalID := "fleet-" + profUUID
|
||||
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID)
|
||||
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarRenewalIDRegexp, hostContents, fleetRenewalID)
|
||||
|
||||
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
|
||||
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents)
|
||||
|
||||
@@ -121,8 +121,10 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params
|
||||
switch {
|
||||
case fleetVar == string(fleet.FleetVarSCEPWindowsCertificateID):
|
||||
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, params.ProfileUUID)
|
||||
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
|
||||
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, result, "fleet-"+params.ProfileUUID)
|
||||
case fleetVar == string(fleet.FleetVarSCEPRenewalID), fleetVar == string(fleet.FleetVarCertificateRenewalID):
|
||||
// Both legacy SCEP_RENEWAL_ID and the preferred CERTIFICATE_RENEWAL_ID
|
||||
// substitute to the same value.
|
||||
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarRenewalIDRegexp, result, "fleet-"+params.ProfileUUID)
|
||||
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
|
||||
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
|
||||
err := profiles.IsCustomSCEPConfigured(deps.Context, deps.CustomSCEPCAs, caName, fleetVar, func(errMsg string) error {
|
||||
|
||||
@@ -345,7 +345,9 @@ type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) (
|
||||
|
||||
type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error)
|
||||
|
||||
type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error
|
||||
type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error
|
||||
|
||||
type ProfileHasACMEPayloadForCommandFunc func(ctx context.Context, hostUUID string, commandUUID string) (fleet.ProfileACMECommandResult, error)
|
||||
|
||||
type AreHostsConnectedToFleetMDMFunc func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error)
|
||||
|
||||
@@ -2470,6 +2472,9 @@ type DataStore struct {
|
||||
UpdateHostCertificatesFunc UpdateHostCertificatesFunc
|
||||
UpdateHostCertificatesFuncInvoked bool
|
||||
|
||||
ProfileHasACMEPayloadForCommandFunc ProfileHasACMEPayloadForCommandFunc
|
||||
ProfileHasACMEPayloadForCommandFuncInvoked bool
|
||||
|
||||
AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFunc
|
||||
AreHostsConnectedToFleetMDMFuncInvoked bool
|
||||
|
||||
@@ -6054,11 +6059,18 @@ func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts
|
||||
return s.ListHostCertificatesFunc(ctx, hostID, opts)
|
||||
}
|
||||
|
||||
func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error {
|
||||
func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error {
|
||||
s.mu.Lock()
|
||||
s.UpdateHostCertificatesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs)
|
||||
return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin)
|
||||
}
|
||||
|
||||
func (s *DataStore) ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID string, commandUUID string) (fleet.ProfileACMECommandResult, error) {
|
||||
s.mu.Lock()
|
||||
s.ProfileHasACMEPayloadForCommandFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ProfileHasACMEPayloadForCommandFunc(ctx, hostUUID, commandUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
|
||||
|
||||
+74
-11
@@ -71,7 +71,8 @@ const (
|
||||
var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{
|
||||
fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP,
|
||||
fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart,
|
||||
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID,
|
||||
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname,
|
||||
fleet.FleetVarSCEPRenewalID, fleet.FleetVarCertificateRenewalID,
|
||||
fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform,
|
||||
}
|
||||
|
||||
@@ -649,8 +650,8 @@ func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPV
|
||||
}
|
||||
foundCAs = append(foundCAs, ca)
|
||||
}
|
||||
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
}
|
||||
if len(foundCAs) < len(customSCEPVars.CAs()) {
|
||||
for _, ca := range customSCEPVars.CAs() {
|
||||
@@ -702,8 +703,8 @@ func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVars
|
||||
}
|
||||
foundCAs = append(foundCAs, ca)
|
||||
}
|
||||
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
}
|
||||
if len(foundCAs) < len(smallstepVars.CAs()) {
|
||||
for _, ca := range smallstepVars.CAs() {
|
||||
@@ -824,8 +825,8 @@ func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
}
|
||||
|
||||
// Check for the exact match on challenge and URL
|
||||
@@ -4118,15 +4119,29 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
|
||||
|
||||
switch requestType {
|
||||
case "InstallProfile":
|
||||
return nil, apple_mdm.HandleHostMDMProfileInstallResult(
|
||||
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
||||
if err := apple_mdm.HandleHostMDMProfileInstallResult(
|
||||
r.Context,
|
||||
svc.ds,
|
||||
cmdResult.Identifier(),
|
||||
cmdResult.CommandUUID,
|
||||
mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status),
|
||||
status,
|
||||
apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
|
||||
svc.newActivityFn,
|
||||
)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Best-effort: when an ACME profile is acknowledged on macOS, queue
|
||||
// CertificateList so hardware-bound certs (invisible to osquery) get
|
||||
// ingested into host_certificates. Failures here are logged but don't
|
||||
// affect the ack.
|
||||
if status != nil && *status == fleet.MDMDeliveryVerifying {
|
||||
if err := svc.maybeQueueCertificateListForACMEProfile(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID); err != nil {
|
||||
svc.logger.WarnContext(r.Context, "queue CertificateList after ACME profile install",
|
||||
"err", err, "host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
case "RemoveProfile":
|
||||
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
||||
detail := apple_mdm.FmtErrorChain(cmdResult.ErrorChain)
|
||||
@@ -5094,7 +5109,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte
|
||||
payload = append(payload, parsed)
|
||||
}
|
||||
|
||||
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload); err != nil {
|
||||
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates")
|
||||
}
|
||||
|
||||
@@ -5106,6 +5121,54 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// maybeQueueCertificateListForACMEProfile fires a CertificateList MDM command
|
||||
// after a successful InstallProfile ack on a macOS host whose profile contains
|
||||
// a com.apple.security.acme payload. This populates host_certificates with
|
||||
// hardware-bound ACME certs that osquery cannot see. iOS/iPadOS do not need
|
||||
// this hook because IOSiPadOSRefetch already runs CertificateList on a cron.
|
||||
//
|
||||
// Gating happens server-side in a single indexed query
|
||||
// (ProfileHasACMEPayloadForCommand): host platform and ACME payload presence.
|
||||
// The hot path early-returns for the common non-ACME / non-darwin cases
|
||||
// without parsing the profile or making additional roundtrips.
|
||||
//
|
||||
// We deliberately do NOT dedupe against an in-flight CertificateList: if a
|
||||
// previous refetch is still pending when this trigger fires, that earlier
|
||||
// refetch can capture state that predates the new ACME exchange completing
|
||||
// on-device. Letting the new install queue its own refetch ensures the new
|
||||
// cert is captured even if the earlier refetch was already in flight.
|
||||
// host_mdm_commands has a (host_id, command_type) PK so duplicate INSERTs
|
||||
// collapse via ON DUPLICATE KEY UPDATE, and handleRefetchCertsResults is
|
||||
// safe to call on an already-removed row.
|
||||
func (svc *MDMAppleCheckinAndCommandService) maybeQueueCertificateListForACMEProfile(ctx context.Context, hostUUID, commandUUID string) error {
|
||||
res, err := svc.ds.ProfileHasACMEPayloadForCommand(ctx, hostUUID, commandUUID)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "probe profile for ACME payload")
|
||||
}
|
||||
if res.Platform != "darwin" || !res.HasACMEPayload {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmdUUID := uuid.NewString()
|
||||
if err := svc.commander.CertificateList(ctx, []string{hostUUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueue CertificateList")
|
||||
}
|
||||
|
||||
// Track after the commander call so a CertificateList enqueue failure
|
||||
// doesn't leave a stale tracking row that would suppress future
|
||||
// triggers. Matches the iOS/iPadOS pattern in IOSiPadOSRefetch.
|
||||
if err := svc.ds.AddHostMDMCommands(ctx, []fleet.HostMDMCommand{{
|
||||
HostID: res.HostID,
|
||||
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
||||
}}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "track refetch certs command")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
||||
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
|
||||
// Caller should have checked this, but just in case we'll return an error.
|
||||
|
||||
@@ -2645,6 +2645,9 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
|
||||
require.ElementsMatch(t, toRetry, []string{profileIdentifier})
|
||||
return nil
|
||||
}
|
||||
ds.ProfileHasACMEPayloadForCommandFunc = func(ctx context.Context, hUUID, cmdUUID string) (fleet.ProfileACMECommandResult, error) {
|
||||
return fleet.ProfileACMECommandResult{Platform: "ios"}, nil
|
||||
}
|
||||
|
||||
_, err := svc.CommandAndReportResults(
|
||||
&mdm.Request{Context: ctx},
|
||||
@@ -2674,6 +2677,120 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeQueueCertificateListForACMEProfile verifies the on-demand
|
||||
// CertificateList trigger fires only on macOS hosts whose acked profile
|
||||
// contains an ACME payload, and that it dedups against pending refetches.
|
||||
func TestMaybeQueueCertificateListForACMEProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
hostUUID = "host-uuid"
|
||||
commandUUID = "cmd-uuid"
|
||||
profileUUID = "profile-uuid"
|
||||
hostID = uint(42)
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
probeResult fleet.ProfileACMECommandResult
|
||||
probeErr error
|
||||
expectAddCommand bool
|
||||
expectEnqueue bool
|
||||
}{
|
||||
{
|
||||
name: "macOS + ACME profile: enqueues CertificateList",
|
||||
probeResult: fleet.ProfileACMECommandResult{
|
||||
HostID: hostID, Platform: "darwin", ProfileUUID: profileUUID,
|
||||
HasACMEPayload: true,
|
||||
},
|
||||
expectAddCommand: true,
|
||||
expectEnqueue: true,
|
||||
},
|
||||
{
|
||||
name: "iOS host: skipped (existing refetch cron handles it)",
|
||||
probeResult: fleet.ProfileACMECommandResult{
|
||||
HostID: hostID, Platform: "ios", ProfileUUID: profileUUID,
|
||||
HasACMEPayload: true,
|
||||
},
|
||||
expectAddCommand: false,
|
||||
expectEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "macOS + non-ACME profile: no trigger",
|
||||
probeResult: fleet.ProfileACMECommandResult{
|
||||
HostID: hostID, Platform: "darwin", ProfileUUID: profileUUID,
|
||||
HasACMEPayload: false,
|
||||
},
|
||||
expectAddCommand: false,
|
||||
expectEnqueue: false,
|
||||
},
|
||||
{
|
||||
name: "command not found: no error, no trigger",
|
||||
probeErr: ¬FoundError{},
|
||||
expectAddCommand: false,
|
||||
expectEnqueue: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
ds.ProfileHasACMEPayloadForCommandFunc = func(ctx context.Context, hUUID, cmdUUID string) (fleet.ProfileACMECommandResult, error) {
|
||||
require.Equal(t, hostUUID, hUUID)
|
||||
require.Equal(t, commandUUID, cmdUUID)
|
||||
return c.probeResult, c.probeErr
|
||||
}
|
||||
var addedCommands []fleet.HostMDMCommand
|
||||
ds.AddHostMDMCommandsFunc = func(ctx context.Context, cmds []fleet.HostMDMCommand) error {
|
||||
addedCommands = append(addedCommands, cmds...)
|
||||
return nil
|
||||
}
|
||||
|
||||
mdmStorage := &mdmmock.MDMAppleStore{}
|
||||
pushFactory, _ := newMockAPNSPushProviderFactory()
|
||||
pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, NewNanoMDMLogger(slog.New(slog.DiscardHandler)))
|
||||
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
|
||||
var enqueued bool
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
enqueued = true
|
||||
require.Equal(t, []string{hostUUID}, id)
|
||||
require.Equal(t, "CertificateList", cmd.Command.Command.RequestType)
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
|
||||
res := make(map[string]*mdm.Push, len(ids))
|
||||
for _, id := range ids {
|
||||
res[id] = &mdm.Push{Token: []byte(id), Topic: "topic", PushMagic: "magic"}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
|
||||
cert, err := tls.LoadX509KeyPair("testdata/server.pem", "testdata/server.key")
|
||||
return &cert, "", err
|
||||
}
|
||||
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
svc := &MDMAppleCheckinAndCommandService{
|
||||
ds: ds,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
commander: cmdr,
|
||||
}
|
||||
err := svc.maybeQueueCertificateListForACMEProfile(ctx, hostUUID, commandUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
if c.expectAddCommand {
|
||||
require.Len(t, addedCommands, 1)
|
||||
require.Equal(t, fleet.RefetchCertsCommandUUIDPrefix, addedCommands[0].CommandType)
|
||||
require.Equal(t, hostID, addedCommands[0].HostID)
|
||||
} else {
|
||||
require.Empty(t, addedCommands)
|
||||
}
|
||||
require.Equal(t, c.expectEnqueue, enqueued)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMCommandAndReportResultsInstallApplicationAlreadyInstalled(t *testing.T) {
|
||||
const (
|
||||
hostUUID = "HOST-UUID-XYZ"
|
||||
@@ -7364,7 +7481,7 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
|
||||
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
|
||||
"$FLEET_VAR_SCEP_RENEWAL_ID",
|
||||
"com.apple.security.scep"),
|
||||
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
},
|
||||
{
|
||||
name: "Custom SCEP profile is not scep",
|
||||
@@ -7443,7 +7560,7 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
|
||||
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_NDES_SCEP_CHALLENGE", "$FLEET_VAR_NDES_SCEP_PROXY_URL",
|
||||
"$FLEET_VAR_SCEP_RENEWAL_ID",
|
||||
"com.apple.security.scep"),
|
||||
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
},
|
||||
{
|
||||
name: "NDES profile is not scep",
|
||||
@@ -7464,10 +7581,13 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
|
||||
errMsg: "Variable \"$FLEET_VAR_NDES_SCEP_PROXY_URL\" must be in the SCEP certificate's \"URL\" field.",
|
||||
},
|
||||
{
|
||||
name: "SCEP renewal ID without other variables",
|
||||
// Non-proxied SCEP: marker in CN won't trigger auto-renewal
|
||||
// but upload is not blocked.
|
||||
name: "raw SCEP with renewal-ID variable in CN uploads cleanly",
|
||||
profile: customSCEPForValidation("challenge", "url",
|
||||
"Name", "com.apple.security.scep"),
|
||||
errMsg: fleet.SCEPRenewalIDWithoutURLChallengeErrMsg,
|
||||
errMsg: "",
|
||||
vars: []string{"SCEP_RENEWAL_ID"},
|
||||
},
|
||||
{
|
||||
name: "NDES happy path",
|
||||
@@ -7535,13 +7655,13 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
|
||||
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName", "$FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName",
|
||||
"$FLEET_VAR_SCEP_RENEWAL_ID",
|
||||
"com.apple.security.scep"),
|
||||
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
},
|
||||
{
|
||||
name: "Smallstep renewal ID in both CN and OU",
|
||||
profile: customSCEPWithOURenewalIDForValidation("${FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName}", "${FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName}",
|
||||
"Name $FLEET_VAR_SCEP_RENEWAL_ID", "com.apple.security.scep"),
|
||||
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
|
||||
},
|
||||
{
|
||||
name: "Smallstep challenge is not a fleet variable",
|
||||
@@ -7583,6 +7703,146 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ACME and non-proxied SCEP profiles upload regardless of marker presence
|
||||
// or placement; the renewal-ID variable is opt-in only.
|
||||
func TestApplePayloadValidatorsAreOptional(t *testing.T) {
|
||||
t.Parallel()
|
||||
premiumLic := &fleet.LicenseInfo{Tier: fleet.TierPremium}
|
||||
groupedCAs := &fleet.GroupedCertificateAuthorities{}
|
||||
|
||||
const acmeProfile = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.apple.security.acme</string>
|
||||
<key>PayloadIdentifier</key><string>com.test.acme</string>
|
||||
<key>PayloadUUID</key><string>11111111-2222-3333-4444-555555555555</string>
|
||||
<key>DirectoryURL</key><string>https://acme.example.com/directory</string>
|
||||
<key>Subject</key>
|
||||
<array>
|
||||
<array><array><string>CN</string><string>device-cn</string></array></array>
|
||||
<array><array><string>OU</string><string>%s</string></array></array>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadIdentifier</key><string>com.test.profile.acme</string>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
const rawSCEPProfile = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.apple.security.scep</string>
|
||||
<key>PayloadIdentifier</key><string>com.test.scep</string>
|
||||
<key>PayloadUUID</key><string>22222222-3333-4444-5555-666666666666</string>
|
||||
<key>PayloadContent</key>
|
||||
<dict>
|
||||
<key>Challenge</key><string>static-challenge-value</string>
|
||||
<key>URL</key><string>https://scep.example.com/scep</string>
|
||||
<key>Subject</key>
|
||||
<array>
|
||||
<array><array><string>CN</string><string>device-cn</string></array></array>
|
||||
<array><array><string>OU</string><string>%s</string></array></array>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadIdentifier</key><string>com.test.profile.rawscep</string>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>bbbbbbbb-cccc-dddd-eeee-ffffffffffff</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
// Marker in CN, literal OU — confirms upload acceptance is independent
|
||||
// of marker placement.
|
||||
const acmeProfileCNMarker = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.apple.security.acme</string>
|
||||
<key>PayloadIdentifier</key><string>com.test.acme.cn</string>
|
||||
<key>PayloadUUID</key><string>33333333-4444-5555-6666-777777777777</string>
|
||||
<key>DirectoryURL</key><string>https://acme.example.com/directory</string>
|
||||
<key>Subject</key>
|
||||
<array>
|
||||
<array><array><string>CN</string><string>%s</string></array></array>
|
||||
<array><array><string>OU</string><string>static-ou-value</string></array></array>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadIdentifier</key><string>com.test.profile.acme.cn</string>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
const rawSCEPProfileCNMarker = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>PayloadType</key><string>com.apple.security.scep</string>
|
||||
<key>PayloadIdentifier</key><string>com.test.scep.cn</string>
|
||||
<key>PayloadUUID</key><string>44444444-5555-6666-7777-888888888888</string>
|
||||
<key>PayloadContent</key>
|
||||
<dict>
|
||||
<key>Challenge</key><string>static-challenge-value</string>
|
||||
<key>URL</key><string>https://scep.example.com/scep</string>
|
||||
<key>Subject</key>
|
||||
<array>
|
||||
<array><array><string>CN</string><string>%s</string></array></array>
|
||||
<array><array><string>OU</string><string>static-ou-value</string></array></array>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadIdentifier</key><string>com.test.profile.rawscep.cn</string>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadUUID</key><string>dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
</dict>
|
||||
</plist>`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
profile string
|
||||
}{
|
||||
{"ACME with preferred marker in OU", fmt.Sprintf(acmeProfile, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
|
||||
{"ACME with legacy marker in OU", fmt.Sprintf(acmeProfile, "$FLEET_VAR_SCEP_RENEWAL_ID")},
|
||||
{"ACME with no marker", fmt.Sprintf(acmeProfile, "static-ou-value")},
|
||||
{"ACME with preferred marker in CN", fmt.Sprintf(acmeProfileCNMarker, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
|
||||
{"ACME with legacy marker in CN", fmt.Sprintf(acmeProfileCNMarker, "$FLEET_VAR_SCEP_RENEWAL_ID")},
|
||||
{"raw SCEP with preferred marker in OU", fmt.Sprintf(rawSCEPProfile, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
|
||||
{"raw SCEP with legacy marker in OU", fmt.Sprintf(rawSCEPProfile, "$FLEET_VAR_SCEP_RENEWAL_ID")},
|
||||
{"raw SCEP with no marker", fmt.Sprintf(rawSCEPProfile, "static-ou-value")},
|
||||
{"raw SCEP with preferred marker in CN", fmt.Sprintf(rawSCEPProfileCNMarker, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
|
||||
{"raw SCEP with legacy marker in CN", fmt.Sprintf(rawSCEPProfileCNMarker, "$FLEET_VAR_SCEP_RENEWAL_ID")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := validateConfigProfileFleetVariables(tc.profile, premiumLic, groupedCAs)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeclarationFleetVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ const conditionalAccessAppleProfileTemplate = `<?xml version="1.0" encoding="UTF
|
||||
<string>{{.CertificateCN}}</string>
|
||||
</array>
|
||||
</array>
|
||||
<array>
|
||||
<array>
|
||||
<string>OU</string>
|
||||
<string>$FLEET_VAR_CERTIFICATE_RENEWAL_ID</string>
|
||||
</array>
|
||||
</array>
|
||||
</array>
|
||||
<key>SubjectAltName</key>
|
||||
<dict>
|
||||
|
||||
@@ -260,6 +260,17 @@ func TestConditionalAccessGetIdPAppleProfile(t *testing.T) {
|
||||
|
||||
// Verify certificate CN is present in the profile
|
||||
require.Contains(t, profileStr, "Fleet conditional access for Okta")
|
||||
|
||||
// Verify the renewal-ID marker is in the SCEP payload's Subject OU
|
||||
// so auto-renewal activates by default. Substituted to
|
||||
// fleet-<profile_uuid> at delivery time; Fleet's own SCEP CA
|
||||
// preserves OU in the issued cert.
|
||||
require.Contains(t, profileStr, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")
|
||||
require.Regexp(t,
|
||||
`(?s)<key>Subject</key>.*<string>OU</string>\s*<string>\$FLEET_VAR_CERTIFICATE_RENEWAL_ID</string>`,
|
||||
profileStr,
|
||||
"renewal-ID marker must be in Subject OU, not CN",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("missing CA certificate", func(t *testing.T) {
|
||||
|
||||
@@ -15669,7 +15669,7 @@ func (s *integrationTestSuite) TestHostCertificates() {
|
||||
Source: fleet.SystemHostCertificate,
|
||||
})
|
||||
}
|
||||
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs))
|
||||
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery))
|
||||
|
||||
// list all certs
|
||||
certResp = listHostCertificatesResponse{}
|
||||
|
||||
@@ -8384,7 +8384,7 @@ func testWindowsSCEPProfile(s *integrationMDMTestSuite, windowsScepProfile []byt
|
||||
}},
|
||||
http.StatusBadRequest)
|
||||
errMsg = extractServerErrorText(resp.Body)
|
||||
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables")
|
||||
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables")
|
||||
|
||||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||||
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/fleetdm/fleet/v4/server/variables"
|
||||
)
|
||||
|
||||
@@ -193,7 +194,7 @@ func (cs *CustomSCEPVarsFound) ErrorMessage() string {
|
||||
}
|
||||
|
||||
if !cs.renewalIdFound || len(cs.challengeCA) == 0 || len(cs.urlCA) == 0 {
|
||||
return fmt.Sprintf("SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarCustomSCEPChallengePrefix, fleet.FleetVarCustomSCEPProxyURLPrefix, fleet.FleetVarSCEPRenewalID)
|
||||
return fmt.Sprintf("SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarCustomSCEPChallengePrefix, fleet.FleetVarCustomSCEPProxyURLPrefix, fleet.FleetVarCertificateRenewalID)
|
||||
}
|
||||
|
||||
for ca := range cs.challengeCA {
|
||||
@@ -296,7 +297,7 @@ func (cs *SmallstepVarsFound) ErrorMessage() string {
|
||||
return fleet.SCEPRenewalIDWithoutURLChallengeErrMsg
|
||||
}
|
||||
if !cs.renewalIdFound || len(cs.challengeCA) == 0 || len(cs.urlCA) == 0 {
|
||||
return fmt.Sprintf("SCEP profile for Smallstep certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarSmallstepSCEPChallengePrefix, fleet.FleetVarSmallstepSCEPProxyURLPrefix, fleet.FleetVarSCEPRenewalID)
|
||||
return fmt.Sprintf("SCEP profile for Smallstep certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarSmallstepSCEPChallengePrefix, fleet.FleetVarSmallstepSCEPProxyURLPrefix, fleet.FleetVarCertificateRenewalID)
|
||||
}
|
||||
for ca := range cs.challengeCA {
|
||||
if _, ok := cs.urlCA[ca]; !ok {
|
||||
@@ -454,10 +455,11 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
|
||||
case k == string(fleet.FleetVarNDESSCEPChallenge):
|
||||
caFound = true
|
||||
ndesVars, ok = ndesVars.SetChallenge()
|
||||
case k == string(fleet.FleetVarSCEPRenewalID):
|
||||
case k == string(fleet.FleetVarSCEPRenewalID), k == string(fleet.FleetVarCertificateRenewalID):
|
||||
caFound = true
|
||||
// This is kind of a goofy way of doing things but essentially, since custom SCEP, NDES, and Smallstep
|
||||
// share the renewal ID Fleet variable, we need to set the
|
||||
// Custom SCEP, NDES, and Smallstep all share the renewal-ID
|
||||
// Fleet variable. The legacy SCEP_RENEWAL_ID and the preferred
|
||||
// CERTIFICATE_RENEWAL_ID names are interchangeable here.
|
||||
|
||||
customSCEPVars, ok = customSCEPVars.SetRenewalID()
|
||||
if ok {
|
||||
@@ -473,9 +475,10 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
|
||||
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s does not exist.", k)}
|
||||
}
|
||||
|
||||
if k == string(fleet.FleetVarSCEPRenewalID) {
|
||||
// Special message for renewal ID
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU)."}
|
||||
if k == string(fleet.FleetVarSCEPRenewalID) || k == string(fleet.FleetVarCertificateRenewalID) {
|
||||
// Special message for renewal ID — surface the preferred name
|
||||
// in the error regardless of which form the user authored.
|
||||
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
||||
}
|
||||
|
||||
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is already present in configuration profile.", k)}
|
||||
@@ -505,9 +508,19 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
|
||||
if smallstepVars.RenewalOnly() {
|
||||
smallstepVars = nil
|
||||
}
|
||||
// If only the renewal ID variable appeared without any of its associated variables, return an error. It is shared
|
||||
// by the 3 CA types but is only allowed when CA vars are in use
|
||||
if ndesVars == nil && smallstepVars == nil && customSCEPVars == nil {
|
||||
// ACME and non-proxied SCEP profiles legitimately have only the
|
||||
// renewal-ID variable; bypass the "needs URL/Challenge" error when
|
||||
// such a payload is present. Windows profiles parse as no-payload
|
||||
// and remain subject to the check.
|
||||
hasRenewableCertPayload := false
|
||||
mc := mobileconfig.Mobileconfig(profileContents)
|
||||
for _, pt := range []string{mobileconfig.ACMEPayloadType, mobileconfig.SCEPPayloadType} {
|
||||
if found, err := mc.HasPayloadType(pt); err == nil && found {
|
||||
hasRenewableCertPayload = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ndesVars == nil && smallstepVars == nil && customSCEPVars == nil && !hasRenewableCertPayload {
|
||||
return &fleet.BadRequestError{Message: fleet.SCEPRenewalIDWithoutURLChallengeErrMsg}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,20 +91,32 @@ func TestValidateProfileCertificateAuthorityVariables(t *testing.T) {
|
||||
{
|
||||
name: "Custom SCEP challenge missing",
|
||||
profile: customSCEPForValidation("challenge", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName", "Name", "com.apple.security.scep"),
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
|
||||
},
|
||||
{
|
||||
name: "Custom SCEP url missing",
|
||||
profile: customSCEPForValidation("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "https://bozo.com", "Name",
|
||||
"com.apple.security.scep"),
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
|
||||
},
|
||||
{
|
||||
name: "Custom SCEP renewal ID missing",
|
||||
profile: strings.Replace(customSCEPForValidation("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
|
||||
"Name",
|
||||
"com.apple.security.scep"), "$FLEET_VAR_SCEP_RENEWAL_ID", "", 1),
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
|
||||
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
|
||||
},
|
||||
{
|
||||
// This variable was renamed but needs to still validate
|
||||
// for back-compat.
|
||||
name: "Custom SCEP accepts legacy $FLEET_VAR_SCEP_RENEWAL_ID",
|
||||
profile: customSCEPForValidation(
|
||||
"$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName",
|
||||
"$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
|
||||
"Name",
|
||||
"com.apple.security.scep",
|
||||
),
|
||||
errMsg: "",
|
||||
},
|
||||
{
|
||||
name: "Custom SCEP challenge and url CA names don't match",
|
||||
@@ -160,13 +172,13 @@ func TestValidateProfileCertificateAuthorityVariables(t *testing.T) {
|
||||
{
|
||||
name: "Smallstep challenge missing",
|
||||
profile: customSCEPForValidation("challenge", "$FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName", "Name", "com.apple.security.scep"),
|
||||
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
|
||||
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
|
||||
},
|
||||
{
|
||||
name: "Smallstep url missing",
|
||||
profile: customSCEPForValidation("$FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName", "https://bozo.com", "Name",
|
||||
"com.apple.security.scep"),
|
||||
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
|
||||
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
|
||||
},
|
||||
{
|
||||
name: "Smallstep challenge and url CA names don't match",
|
||||
|
||||
@@ -3531,7 +3531,7 @@ func directIngestHostCertificatesDarwin(
|
||||
return nil
|
||||
}
|
||||
|
||||
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs)
|
||||
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)
|
||||
}
|
||||
|
||||
func directIngestHostCertificatesWindows(
|
||||
@@ -3629,7 +3629,7 @@ func directIngestHostCertificatesWindows(
|
||||
return nil
|
||||
}
|
||||
|
||||
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs)
|
||||
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)
|
||||
}
|
||||
|
||||
func maybeUpdateLastRestartedAt(now time.Time, host *fleet.Host) {
|
||||
|
||||
@@ -2776,9 +2776,10 @@ func TestDirectIngestHostCertificates(t *testing.T) {
|
||||
"path": "/Library/Keychains/System.keychain",
|
||||
}
|
||||
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error {
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error {
|
||||
require.Equal(t, host.ID, hostID)
|
||||
require.Equal(t, host.UUID, hostUUID)
|
||||
require.Equal(t, fleet.HostCertificateOriginOsquery, origin)
|
||||
require.Len(t, certs, 2)
|
||||
require.Equal(t, "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", hex.EncodeToString(certs[0].SHA1Sum))
|
||||
require.Equal(t, "Cert 1 Common Name", certs[0].CommonName)
|
||||
@@ -2855,7 +2856,8 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) {
|
||||
"path": "/Library/Keychains/System.keychain",
|
||||
}
|
||||
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error {
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error {
|
||||
require.Equal(t, fleet.HostCertificateOriginOsquery, origin)
|
||||
require.Len(t, certs, 1)
|
||||
cert := certs[0]
|
||||
|
||||
@@ -2938,9 +2940,10 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) {
|
||||
|
||||
rows := []map[string]string{c1, c2, c3, c4, c5, c6, c7}
|
||||
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) error {
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error {
|
||||
require.Equal(t, host.ID, hostID)
|
||||
require.Equal(t, host.UUID, hostUUID)
|
||||
require.Equal(t, fleet.HostCertificateOriginOsquery, origin)
|
||||
require.Len(t, certs, 3)
|
||||
|
||||
// We expect that the ingest function will deduplicate certs based on SHA1+username
|
||||
|
||||
@@ -148,6 +148,7 @@ var fleetVarsSupportedInWindowsProfiles = []fleet.FleetVarName{
|
||||
fleet.FleetVarHostHardwareSerial,
|
||||
fleet.FleetVarSCEPWindowsCertificateID,
|
||||
fleet.FleetVarSCEPRenewalID,
|
||||
fleet.FleetVarCertificateRenewalID,
|
||||
fleet.FleetVarHostEndUserIDPUsername,
|
||||
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
|
||||
fleet.FleetVarHostEndUserIDPFullname,
|
||||
@@ -158,6 +159,18 @@ var fleetVarsSupportedInWindowsProfiles = []fleet.FleetVarName{
|
||||
fleet.FleetVarNDESSCEPProxyURL,
|
||||
}
|
||||
|
||||
// subjectNameHasRenewalIDMarker reports whether a SubjectName data string
|
||||
// contains the renewal-ID variable in OU=. The legacy SCEP_RENEWAL_ID name
|
||||
// is accepted alongside CERTIFICATE_RENEWAL_ID for back-compat.
|
||||
func subjectNameHasRenewalIDMarker(data string) bool {
|
||||
for _, v := range []fleet.FleetVarName{fleet.FleetVarCertificateRenewalID, fleet.FleetVarSCEPRenewalID} {
|
||||
if strings.Contains(data, "OU="+v.WithPrefix()) || strings.Contains(data, "OU="+v.WithBraces()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateWindowsProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) ([]string, error) {
|
||||
foundVars := variables.Find(contents)
|
||||
if len(foundVars) == 0 {
|
||||
@@ -283,11 +296,9 @@ func additionalNDESValidationForWindowsProfiles(contents string, ndesVars *NDESV
|
||||
"Variable %q must be in the SCEP certificate's \"ServerURL\" field.", fleet.FleetVarNDESSCEPProxyURL.WithPrefix()),
|
||||
}
|
||||
}
|
||||
if isSubjectName &&
|
||||
!strings.Contains(dataContent, "OU="+fleet.FleetVarSCEPRenewalID.WithPrefix()) &&
|
||||
!strings.Contains(dataContent, "OU="+fleet.FleetVarSCEPRenewalID.WithBraces()) {
|
||||
if isSubjectName && !subjectNameHasRenewalIDMarker(dataContent) {
|
||||
return &fleet.BadRequestError{
|
||||
Message: fmt.Sprintf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarSCEPRenewalID.WithPrefix()),
|
||||
Message: fmt.Sprintf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarCertificateRenewalID.WithPrefix()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,9 +336,8 @@ func additionalCustomSCEPValidationForWindowsProfiles(contents string, customSCE
|
||||
return errors.New("SubjectName item is missing data")
|
||||
}
|
||||
|
||||
if !strings.Contains(cmd.Data.Content, "OU="+fleet.FleetVarSCEPRenewalID.WithPrefix()) && !strings.Contains(cmd.Data.Content, "OU="+fleet.FleetVarSCEPRenewalID.WithBraces()) {
|
||||
// Does not contain the renewal ID in any of it's two fleet var forms as the OU field
|
||||
return fmt.Errorf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarSCEPRenewalID.WithPrefix())
|
||||
if !subjectNameHasRenewalIDMarker(cmd.Data.Content) {
|
||||
return fmt.Errorf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarCertificateRenewalID.WithPrefix())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,19 @@ func TestAdditionalNDESValidationForWindowsProfiles(t *testing.T) {
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "$FLEET_VAR_NDES_SCEP_PROXY_URL") +
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test"),
|
||||
wantErr: true,
|
||||
errContains: "SubjectName item must contain the $FLEET_VAR_SCEP_RENEWAL_ID variable in the OU field",
|
||||
errContains: "SubjectName item must contain the $FLEET_VAR_CERTIFICATE_RENEWAL_ID variable in the OU field",
|
||||
},
|
||||
{
|
||||
name: "valid NDES profile with preferred CERTIFICATE_RENEWAL_ID",
|
||||
contents: addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/Challenge", "$FLEET_VAR_NDES_SCEP_CHALLENGE") +
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "$FLEET_VAR_NDES_SCEP_PROXY_URL") +
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test,OU=$FLEET_VAR_CERTIFICATE_RENEWAL_ID"),
|
||||
},
|
||||
{
|
||||
name: "valid NDES profile with preferred CERTIFICATE_RENEWAL_ID (braces syntax)",
|
||||
contents: addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/Challenge", "${FLEET_VAR_NDES_SCEP_CHALLENGE}") +
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "${FLEET_VAR_NDES_SCEP_PROXY_URL}") +
|
||||
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test,OU=${FLEET_VAR_CERTIFICATE_RENEWAL_ID}"),
|
||||
},
|
||||
{
|
||||
name: "nil ndes vars returns nil",
|
||||
|
||||
Reference in New Issue
Block a user