From bbfbea8de2139e81b42600aa5f096a07012e0208 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Mon, 18 May 2026 11:41:02 -0600 Subject: [PATCH] Cert renewal for non-proxied SCEP and ACME (Phase 1 + Phase 2) (#45696) --- articles/view-certificates-in-host-vitals.md | 2 + changes/42827-macos-mdm-certificate-ingestion | 1 + docs/REST API/rest-api.md | 2 + server/datastore/mysql/host_certificates.go | 130 ++++++- .../datastore/mysql/host_certificates_test.go | 345 +++++++++++++++++- server/datastore/mysql/hosts_test.go | 2 +- server/datastore/mysql/mdm.go | 52 ++- server/datastore/mysql/mdm_test.go | 221 +++++++++++ ...lowNullTypeOnHostMDMManagedCertificates.go | 35 ++ ...llTypeOnHostMDMManagedCertificates_test.go | 40 ++ ...60518150028_AddOriginToHostCertificates.go | 34 ++ ...150028_AddOriginToHostCertificates_test.go | 57 +++ server/datastore/mysql/schema.sql | 7 +- server/fleet/certificate_authorities.go | 19 + server/fleet/datastore.go | 15 +- server/fleet/errors.go | 4 +- server/fleet/host_certificates.go | 17 + server/fleet/mdm.go | 32 +- server/fleet/mdm_test.go | 22 ++ server/mdm/apple/mobileconfig/mobileconfig.go | 31 +- .../apple/mobileconfig/mobileconfig_test.go | 54 +++ server/mdm/apple/profile_processor.go | 13 +- server/mdm/microsoft/profile_variables.go | 6 +- server/mock/datastore_mock.go | 18 +- server/service/apple_mdm.go | 85 ++++- server/service/apple_mdm_test.go | 272 +++++++++++++- server/service/conditional_access_idp.go | 6 + server/service/conditional_access_idp_test.go | 11 + server/service/integration_core_test.go | 2 +- .../service/integration_mdm_profiles_test.go | 2 +- server/service/mdm_profiles.go | 35 +- server/service/mdm_profiles_test.go | 22 +- server/service/osquery_utils/queries.go | 4 +- server/service/osquery_utils/queries_test.go | 9 +- server/service/windows_mdm_profiles.go | 24 +- server/service/windows_mdm_profiles_test.go | 14 +- 36 files changed, 1540 insertions(+), 105 deletions(-) create mode 100644 changes/42827-macos-mdm-certificate-ingestion create mode 100644 server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates.go create mode 100644 server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates_test.go create mode 100644 server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates.go create mode 100644 server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates_test.go diff --git a/articles/view-certificates-in-host-vitals.md b/articles/view-certificates-in-host-vitals.md index 7fca0fa7c0..2ef251dd77 100644 --- a/articles/view-certificates-in-host-vitals.md +++ b/articles/view-certificates-in-host-vitals.md @@ -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. diff --git a/changes/42827-macos-mdm-certificate-ingestion b/changes/42827-macos-mdm-certificate-ingestion new file mode 100644 index 0000000000..685b4263b4 --- /dev/null +++ b/changes/42827-macos-mdm-certificate-ingestion @@ -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. diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index d80b847985..9ebd7b8304 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -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 diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index caadabfef0..fbe9529dc4 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -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-` 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 +} diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index f10ba73e8c..e7c358ff1b 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -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-` 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}) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index cccce08ffa..cbbf52f5e6 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -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(), "-", "") diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index ede91be537..6321d6fb39 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -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") } diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index b014b09259..66d0c31986 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -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(`PayloadContentPayloadTypecom.apple.security.acme`) + scepXML := []byte(`PayloadContentPayloadTypecom.apple.security.scep`) + + 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) +} diff --git a/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates.go b/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates.go new file mode 100644 index 0000000000..3a7072c728 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates.go @@ -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 +} diff --git a/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates_test.go b/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates_test.go new file mode 100644 index 0000000000..ce80035ea5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260518124441_AllowNullTypeOnHostMDMManagedCertificates_test.go @@ -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") +} diff --git a/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates.go b/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates.go new file mode 100644 index 0000000000..4ec2babf05 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates.go @@ -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 +} diff --git a/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates_test.go b/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates_test.go new file mode 100644 index 0000000000..5063f0e1f2 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260518150028_AddOriginToHostCertificates_test.go @@ -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) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5eb4e0e2ca..b5710d7966 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -632,6 +632,7 @@ CREATE TABLE `host_certificates` ( `sha1_sum` binary(20) NOT NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `deleted_at` datetime(6) DEFAULT NULL, + `origin` enum('osquery','mdm') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'osquery', PRIMARY KEY (`id`), KEY `idx_host_certs_hid_cn` (`host_id`,`common_name`), KEY `idx_host_certs_not_valid_after` (`host_id`,`not_valid_after`) @@ -1007,7 +1008,7 @@ CREATE TABLE `host_mdm_idp_accounts` ( CREATE TABLE `host_mdm_managed_certificates` ( `host_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `profile_uuid` varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `type` enum('digicert','custom_scep_proxy','ndes','smallstep') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ndes', + `type` enum('digicert','custom_scep_proxy','ndes','smallstep') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `ca_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NDES', `challenge_retrieved_at` timestamp(6) NULL DEFAULT NULL, `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), @@ -1973,9 +1974,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=527 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=529 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260427134220,1,'2020-01-01 01:01:01'),(519,20260428125634,1,'2020-01-01 01:01:01'),(520,20260429180725,1,'2020-01-01 01:01:01'),(521,20260430103635,1,'2020-01-01 01:01:01'),(522,20260506132626,1,'2020-01-01 01:01:01'),(523,20260506171058,1,'2020-01-01 01:01:01'),(524,20260512143542,1,'2020-01-01 01:01:01'),(525,20260512173249,1,'2020-01-01 01:01:01'),(526,20260512173250,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260427134220,1,'2020-01-01 01:01:01'),(519,20260428125634,1,'2020-01-01 01:01:01'),(520,20260429180725,1,'2020-01-01 01:01:01'),(521,20260430103635,1,'2020-01-01 01:01:01'),(522,20260506132626,1,'2020-01-01 01:01:01'),(523,20260506171058,1,'2020-01-01 01:01:01'),(524,20260512143542,1,'2020-01-01 01:01:01'),(525,20260512173249,1,'2020-01-01 01:01:01'),(526,20260512173250,1,'2020-01-01 01:01:01'),(527,20260518124441,1,'2020-01-01 01:01:01'),(528,20260518150028,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/certificate_authorities.go b/server/fleet/certificate_authorities.go index 7b0bda1687..b29bd07be0 100644 --- a/server/fleet/certificate_authorities.go +++ b/server/fleet/certificate_authorities.go @@ -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"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 10ea4cd27e..be3baad15e 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -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 diff --git a/server/fleet/errors.go b/server/fleet/errors.go index e2aae7f39c..8b53da057f 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -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 ( diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index d4c3b391ee..aa7b7431ef 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -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( diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 31063dbfac..1ed80b008b 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -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 { diff --git a/server/fleet/mdm_test.go b/server/fleet/mdm_test.go index 4b45e6534f..2b93774a78 100644 --- a/server/fleet/mdm_test.go +++ b/server/fleet/mdm_test.go @@ -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 diff --git a/server/mdm/apple/mobileconfig/mobileconfig.go b/server/mdm/apple/mobileconfig/mobileconfig.go index 73619c1826..35c6247fab 100644 --- a/server/mdm/apple/mobileconfig/mobileconfig.go +++ b/server/mdm/apple/mobileconfig/mobileconfig.go @@ -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 { diff --git a/server/mdm/apple/mobileconfig/mobileconfig_test.go b/server/mdm/apple/mobileconfig/mobileconfig_test.go index 793503a438..de95659c7f 100644 --- a/server/mdm/apple/mobileconfig/mobileconfig_test.go +++ b/server/mdm/apple/mobileconfig/mobileconfig_test.go @@ -34,3 +34,57 @@ func TestXMLEscapeString(t *testing.T) { }) } } + +func TestHasPayloadType(t *testing.T) { + build := func(payloadType string) Mobileconfig { + return Mobileconfig(` + + + + PayloadContent + + + PayloadType + ` + payloadType + ` + PayloadIdentifier + com.example.profile.cert + PayloadDisplayName + Test Cert + PayloadUUID + 00000000-0000-0000-0000-000000000001 + PayloadVersion + 1 + + + PayloadDisplayName + Test Profile + PayloadIdentifier + com.example.profile + PayloadType + Configuration + PayloadUUID + 00000000-0000-0000-0000-000000000002 + PayloadVersion + 1 + +`) + } + + 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) + }) +} diff --git a/server/mdm/apple/profile_processor.go b/server/mdm/apple/profile_processor.go index e23d0ae545..d3fb4bf487 100644 --- a/server/mdm/apple/profile_processor.go +++ b/server/mdm/apple/profile_processor.go @@ -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) diff --git a/server/mdm/microsoft/profile_variables.go b/server/mdm/microsoft/profile_variables.go index eb2ca97836..ec4e4797ac 100644 --- a/server/mdm/microsoft/profile_variables.go +++ b/server/mdm/microsoft/profile_variables.go @@ -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 { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 445125bf28..1ea2023b35 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -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) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 7c90356496..73b9a5b9ae 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -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. diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 426151688f..b3b00c56c7 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -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 = ` + + + + PayloadContent + + + PayloadTypecom.apple.security.acme + PayloadIdentifiercom.test.acme + PayloadUUID11111111-2222-3333-4444-555555555555 + DirectoryURLhttps://acme.example.com/directory + Subject + + CNdevice-cn + OU%s + + + + PayloadIdentifiercom.test.profile.acme + PayloadTypeConfiguration + PayloadUUIDaaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + PayloadVersion1 + +` + + const rawSCEPProfile = ` + + + + PayloadContent + + + PayloadTypecom.apple.security.scep + PayloadIdentifiercom.test.scep + PayloadUUID22222222-3333-4444-5555-666666666666 + PayloadContent + + Challengestatic-challenge-value + URLhttps://scep.example.com/scep + Subject + + CNdevice-cn + OU%s + + + + + PayloadIdentifiercom.test.profile.rawscep + PayloadTypeConfiguration + PayloadUUIDbbbbbbbb-cccc-dddd-eeee-ffffffffffff + PayloadVersion1 + +` + + // Marker in CN, literal OU — confirms upload acceptance is independent + // of marker placement. + const acmeProfileCNMarker = ` + + + + PayloadContent + + + PayloadTypecom.apple.security.acme + PayloadIdentifiercom.test.acme.cn + PayloadUUID33333333-4444-5555-6666-777777777777 + DirectoryURLhttps://acme.example.com/directory + Subject + + CN%s + OUstatic-ou-value + + + + PayloadIdentifiercom.test.profile.acme.cn + PayloadTypeConfiguration + PayloadUUIDcccccccc-dddd-eeee-ffff-aaaaaaaaaaaa + PayloadVersion1 + +` + + const rawSCEPProfileCNMarker = ` + + + + PayloadContent + + + PayloadTypecom.apple.security.scep + PayloadIdentifiercom.test.scep.cn + PayloadUUID44444444-5555-6666-7777-888888888888 + PayloadContent + + Challengestatic-challenge-value + URLhttps://scep.example.com/scep + Subject + + CN%s + OUstatic-ou-value + + + + + PayloadIdentifiercom.test.profile.rawscep.cn + PayloadTypeConfiguration + PayloadUUIDdddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb + PayloadVersion1 + +` + + 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() diff --git a/server/service/conditional_access_idp.go b/server/service/conditional_access_idp.go index f5c8f1b8a2..a6ca3e9a58 100644 --- a/server/service/conditional_access_idp.go +++ b/server/service/conditional_access_idp.go @@ -71,6 +71,12 @@ const conditionalAccessAppleProfileTemplate = `{{.CertificateCN}} + + + OU + $FLEET_VAR_CERTIFICATE_RENEWAL_ID + + SubjectAltName diff --git a/server/service/conditional_access_idp_test.go b/server/service/conditional_access_idp_test.go index 7792884d35..720d1b7e87 100644 --- a/server/service/conditional_access_idp_test.go +++ b/server/service/conditional_access_idp_test.go @@ -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- 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)Subject.*OU\s*\$FLEET_VAR_CERTIFICATE_RENEWAL_ID`, + profileStr, + "renewal-ID marker must be in Subject OU, not CN", + ) }) t.Run("missing CA certificate", func(t *testing.T) { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ee7809890a..1f40f0c181 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -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{} diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index ee05f648e5..57d5e70861 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -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_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables") + require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables") s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ diff --git a/server/service/mdm_profiles.go b/server/service/mdm_profiles.go index 8fd7747166..ee7d38b167 100644 --- a/server/service/mdm_profiles.go +++ b/server/service/mdm_profiles.go @@ -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, $FLEET_VAR_%s, 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, $FLEET_VAR_%s, 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, $FLEET_VAR_%s, and $FLEET_VAR_%s variables.", fleet.FleetVarSmallstepSCEPChallengePrefix, fleet.FleetVarSmallstepSCEPProxyURLPrefix, fleet.FleetVarSCEPRenewalID) + return fmt.Sprintf("SCEP profile for Smallstep certificate authority requires: $FLEET_VAR_%s, $FLEET_VAR_%s, 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} } } diff --git a/server/service/mdm_profiles_test.go b/server/service/mdm_profiles_test.go index d61add7259..785aa1d695 100644 --- a/server/service/mdm_profiles_test.go +++ b/server/service/mdm_profiles_test.go @@ -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_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables.", + errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, 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_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables.", + errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, 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_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables.", + errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, 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_, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables.", + errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_, 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_, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables.", + errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.", }, { name: "Smallstep challenge and url CA names don't match", diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 3595df4438..efceac92d3 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -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) { diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 5bbd3fbb82..ebb60bf992 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -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 diff --git a/server/service/windows_mdm_profiles.go b/server/service/windows_mdm_profiles.go index 0a3268c24b..a92206f33b 100644 --- a/server/service/windows_mdm_profiles.go +++ b/server/service/windows_mdm_profiles.go @@ -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()) } } } diff --git a/server/service/windows_mdm_profiles_test.go b/server/service/windows_mdm_profiles_test.go index 8e9155d27d..0e3b54f9e3 100644 --- a/server/service/windows_mdm_profiles_test.go +++ b/server/service/windows_mdm_profiles_test.go @@ -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",