diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 464be66280aa..cc81cec3785f 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/licensing" "github.com/fleetdm/fleet/v4/ee/server/scim" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/condaccess" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" @@ -1355,6 +1356,15 @@ the way that the Fleet server works. if err = hostidentity.RegisterSCEP(rootMux, hostIdentitySCEPDepot, ds, logger, &config); err != nil { initFatal(err, "setup host identity SCEP") } + + // Conditional Access SCEP + condAccessSCEPDepot, err := mds.NewConditionalAccessSCEPDepot(kitlog.With(logger, "component", "conditional-access-scep-depot"), &config) + if err != nil { + initFatal(err, "setup conditional access SCEP depot") + } + if err = condaccess.RegisterSCEP(rootMux, condAccessSCEPDepot, ds, logger, &config); err != nil { + initFatal(err, "setup conditional access SCEP") + } } else { level.Warn(logger).Log("msg", "Host identity SCEP is not available because no server private key has been set up.") } diff --git a/ee/server/integrationtest/condaccess/condaccess_test.go b/ee/server/integrationtest/condaccess/condaccess_test.go new file mode 100644 index 000000000000..4ee20e9cf912 --- /dev/null +++ b/ee/server/integrationtest/condaccess/condaccess_test.go @@ -0,0 +1,350 @@ +package condaccess + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" + scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client" + "github.com/fleetdm/fleet/v4/server/mdm/scep/x509util" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/smallstep/scep" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testEnrollmentSecret = "test_secret" + +func TestConditionalAccessSCEP(t *testing.T) { + s := SetUpSuiteWithConfig(t, "integrationtest.ConditionalAccessSCEP", func(cfg *config.FleetConfig) { + cfg.Osquery.EnrollCooldown = 0 // Disable rate limiting for most tests + }) + + cases := []struct { + name string + fn func(t *testing.T, s *Suite) + }{ + {"GetCACaps", testGetCACaps}, + {"GetCACert", testGetCACert}, + {"SCEPEnrollment", testSCEPEnrollment}, + {"InvalidChallenge", testInvalidChallenge}, + {"MissingUUID", testMissingUUID}, + {"NonExistentHost", testNonExistentHost}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{ + "conditional_access_scep_serials", "conditional_access_scep_certificates", + }...) + c.fn(t, s) + }) + } +} + +func testGetCACaps(t *testing.T, s *Suite) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Request CA capabilities + resp, err := http.Get(s.Server.URL + "/api/fleet/conditional_access/scep?operation=GetCACaps") + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + caps := string(body) + // Verify expected capabilities + assert.Contains(t, caps, "SHA-256") + assert.Contains(t, caps, "AES") + assert.Contains(t, caps, "POSTPKIOperation") + // Verify Renewal is NOT present + assert.NotContains(t, caps, "Renewal") +} + +func testGetCACert(t *testing.T, s *Suite) { + // Request CA certificate + resp, err := http.Get(s.Server.URL + "/api/fleet/conditional_access/scep?operation=GetCACert") + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NotEmpty(t, body) + + // Parse the certificate + cert, err := x509.ParseCertificate(body) + require.NoError(t, err) + + // Verify CA certificate attributes + assert.Equal(t, "Fleet Conditional Access CA", cert.Subject.CommonName) + assert.Contains(t, cert.Subject.Organization, "Local Certificate Authority") + assert.True(t, cert.IsCA) + + // Verify RSA key + rsaPubKey, ok := cert.PublicKey.(*rsa.PublicKey) + require.True(t, ok, "CA cert should use RSA public key") + assert.Equal(t, 2048, rsaPubKey.N.BitLen(), "RSA key should be 2048 bits") +} + +func testSCEPEnrollment(t *testing.T, s *Suite) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Create a test host + host, err := s.DS.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-scep-1"), + NodeKey: ptr.String("test-node-key-scep-1"), + UUID: "test-uuid-scep-1", + Hostname: "test-hostname-scep-1", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + // Request certificate via SCEP + cert := requestSCEPCertificate(t, s, host.UUID, testEnrollmentSecret) + require.NotNil(t, cert) + + // Verify certificate attributes + assert.NotNil(t, cert.SerialNumber) + assert.True(t, time.Now().Before(cert.NotAfter)) + assert.True(t, time.Now().After(cert.NotBefore)) + + // Verify SAN URI contains the host UUID + require.Len(t, cert.URIs, 1) + assert.Equal(t, "urn:device:apple:uuid:"+host.UUID, cert.URIs[0].String()) + + // Verify certificate is stored in database and linked to host + hostID, err := s.DS.GetConditionalAccessCertHostIDBySerialNumber(ctx, uint64(cert.SerialNumber.Int64())) //nolint:gosec,G115 + require.NoError(t, err) + assert.Equal(t, host.ID, hostID) + + // Verify certificate validity period (398 days, Apple's maximum) + expectedMaxDuration := 398*24*time.Hour + 24*time.Hour // Allow 1 day tolerance + expectedMinDuration := 398*24*time.Hour - 24*time.Hour + actualDuration := cert.NotAfter.Sub(cert.NotBefore) + assert.True(t, actualDuration >= expectedMinDuration && actualDuration <= expectedMaxDuration, + "Certificate should be valid for approximately 398 days") +} + +func testInvalidChallenge(t *testing.T, s *Suite) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Create a test host + host, err := s.DS.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-invalid-1"), + NodeKey: ptr.String("test-node-key-invalid-1"), + UUID: "test-uuid-invalid-1", + Hostname: "test-hostname-invalid-1", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + // Try to enroll with invalid challenge + httpResp, pkiMsgResp, cert := requestSCEPCertificateWithChallenge(t, s, host.UUID, "invalid-secret") + require.Equal(t, http.StatusOK, httpResp.StatusCode, "SCEP returns HTTP 200 even for failures") + require.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail with invalid challenge") + require.Nil(t, cert, "Certificate should not be issued with invalid challenge") +} + +func testMissingUUID(t *testing.T, s *Suite) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Try to enroll without UUID in SAN URI + httpResp, pkiMsgResp, cert := requestSCEPCertificateWithoutUUID(t, s, testEnrollmentSecret) + require.Equal(t, http.StatusOK, httpResp.StatusCode, "SCEP returns HTTP 200 even for failures") + require.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail without UUID") + require.Nil(t, cert, "Certificate should not be issued without UUID in SAN URI") + + // Verify no certificate was stored + _, err = s.DS.GetConditionalAccessCertHostIDBySerialNumber(ctx, 1) + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) +} + +func testNonExistentHost(t *testing.T, s *Suite) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Try to enroll with UUID for a host that doesn't exist + httpResp, pkiMsgResp, cert := requestSCEPCertificateWithChallenge(t, s, "non-existent-uuid", testEnrollmentSecret) + require.Equal(t, http.StatusOK, httpResp.StatusCode, "SCEP returns HTTP 200 even for failures") + require.Equal(t, scep.FAILURE, pkiMsgResp.PKIStatus, "SCEP request should fail for non-existent host") + require.Nil(t, cert, "Certificate should not be issued for non-existent host") +} + +// Helper functions + +func createTempRSAKeyAndCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) { + // Create temporary RSA key for SCEP envelope (required by SCEP protocol) + tempRSAKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Create self-signed certificate for SCEP protocol using RSA key + deviceCertTemplate := x509.Certificate{ + Subject: pkix.Name{ + CommonName: commonName, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + deviceCertDerBytes, err := x509.CreateCertificate( + rand.Reader, + &deviceCertTemplate, + &deviceCertTemplate, + &tempRSAKey.PublicKey, + tempRSAKey, + ) + require.NoError(t, err) + + deviceCert, err := x509.ParseCertificate(deviceCertDerBytes) + require.NoError(t, err) + return tempRSAKey, deviceCert +} + +func requestSCEPCertificate(t *testing.T, s *Suite, hostUUID string, challenge string) *x509.Certificate { + httpResp, pkiMsgResp, cert := requestSCEPCertificateWithChallenge(t, s, hostUUID, challenge) + require.Equal(t, http.StatusOK, httpResp.StatusCode, "SCEP request should succeed") + require.Equal(t, scep.SUCCESS, pkiMsgResp.PKIStatus, "SCEP request should succeed") + return cert +} + +func requestSCEPCertificateWithChallenge(t *testing.T, s *Suite, hostUUID string, challenge string) (*http.Response, *scep.PKIMessage, *x509.Certificate) { + deviceURI, err := url.Parse("urn:device:apple:uuid:" + hostUUID) + require.NoError(t, err) + + return requestSCEPCertificateWithOptions(t, s, []*url.URL{deviceURI}, challenge) +} + +func requestSCEPCertificateWithoutUUID(t *testing.T, s *Suite, challenge string) (*http.Response, *scep.PKIMessage, *x509.Certificate) { + return requestSCEPCertificateWithOptions(t, s, nil, challenge) +} + +func requestSCEPCertificateWithOptions(t *testing.T, s *Suite, uris []*url.URL, challenge string) (*http.Response, *scep.PKIMessage, *x509.Certificate) { + ctx := context.Background() + + // Generate RSA key pair for the device (conditional access uses RSA, not ECC) + deviceKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Create SCEP client + scepURL := fmt.Sprintf("%s/api/fleet/conditional_access/scep", s.Server.URL) + scepClient, err := scepclient.New(scepURL, s.Logger) + require.NoError(t, err) + + // Get CA certificate + resp, _, err := scepClient.GetCACert(ctx, "") + require.NoError(t, err) + caCerts, err := x509.ParseCertificates(resp) + require.NoError(t, err) + require.NotEmpty(t, caCerts) + + // Create CSR template with SAN URI + hostIdentifier := "test-device" + csrTemplate := x509util.CertificateRequest{ + CertificateRequest: x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: hostIdentifier, + }, + URIs: uris, + SignatureAlgorithm: x509.SHA256WithRSA, + }, + ChallengePassword: challenge, + } + + csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, deviceKey) + require.NoError(t, err) + csr, err := x509.ParseCertificateRequest(csrDerBytes) + require.NoError(t, err) + + tempRSAKey, deviceCert := createTempRSAKeyAndCert(t, hostIdentifier) + + // Create SCEP PKI message + pkiMsgReq := &scep.PKIMessage{ + MessageType: scep.PKCSReq, + Recipients: caCerts, + SignerKey: tempRSAKey, + SignerCert: deviceCert, + } + + msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(s.Logger)) + require.NoError(t, err) + + // Send PKI operation request using HTTP client directly to capture response + httpReq, err := http.NewRequestWithContext(ctx, "POST", scepURL+"?operation=PKIOperation", strings.NewReader(string(msg.Raw))) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/x-pki-message") + + httpResp, err := http.DefaultClient.Do(httpReq) + require.NoError(t, err) + defer httpResp.Body.Close() + + // For rate limit errors, we expect HTTP 429 and should return immediately + if httpResp.StatusCode == http.StatusTooManyRequests { + return httpResp, nil, nil + } + + // For other errors, fail the test + require.Equal(t, http.StatusOK, httpResp.StatusCode, "Expected HTTP 200 but got %s", httpResp.Status) + + // Read response body + respBytes, err := io.ReadAll(httpResp.Body) + require.NoError(t, err) + + // Parse response + pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(s.Logger), scep.WithCACerts(msg.Recipients)) + require.NoError(t, err) + + // Check for SCEP-level failure + if pkiMsgResp.PKIStatus != scep.SUCCESS { + return httpResp, pkiMsgResp, nil + } + + // Decrypt PKI envelope using RSA key + err = pkiMsgResp.DecryptPKIEnvelope(deviceCert, tempRSAKey) + require.NoError(t, err) + + // Verify we got a certificate + require.NotNil(t, pkiMsgResp.CertRepMessage) + require.NotNil(t, pkiMsgResp.CertRepMessage.Certificate) + + cert := pkiMsgResp.CertRepMessage.Certificate + return httpResp, pkiMsgResp, cert +} diff --git a/ee/server/integrationtest/condaccess/scep_rate_limit_test.go b/ee/server/integrationtest/condaccess/scep_rate_limit_test.go new file mode 100644 index 000000000000..dffe105b8960 --- /dev/null +++ b/ee/server/integrationtest/condaccess/scep_rate_limit_test.go @@ -0,0 +1,71 @@ +package condaccess + +import ( + "net/http" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSCEPRateLimit(t *testing.T) { + // Set up suite with rate limiting configuration + cooldown := 5 * time.Minute + s := SetUpSuiteWithConfig(t, "integrationtest.ConditionalAccessSCEPRateLimit", func(cfg *config.FleetConfig) { + cfg.Osquery.EnrollCooldown = cooldown + }) + + defer mysql.TruncateTables(t, s.BaseSuite.DS, []string{ + "conditional_access_scep_serials", "conditional_access_scep_certificates", + }...) + + t.Run("RateLimitSameHost", func(t *testing.T) { + ctx := t.Context() + + // Create enrollment secret + err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: testEnrollmentSecret}}) + require.NoError(t, err) + + // Create a test host + host, err := s.DS.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-rate-limit"), + NodeKey: ptr.String("test-node-key-rate-limit"), + UUID: "test-uuid-rate-limit", + Hostname: "test-hostname-rate-limit", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + // First certificate request - should succeed + cert1 := requestSCEPCertificate(t, s, host.UUID, testEnrollmentSecret) + require.NotNil(t, cert1, "First certificate request should succeed") + assert.Equal(t, "urn:device:apple:uuid:"+host.UUID, cert1.URIs[0].String()) + + // Second certificate request immediately after - should fail due to rate limit + httpResp, pkiMsgResp, cert2 := requestSCEPCertificateWithChallenge(t, s, host.UUID, testEnrollmentSecret) + require.Equal(t, http.StatusTooManyRequests, httpResp.StatusCode, "Should return HTTP 429 for rate limit") + require.Nil(t, pkiMsgResp, "PKI message not parsed for rate limit errors") + require.Nil(t, cert2, "Second certificate request should fail due to rate limit") + + // Different host should be able to get certificate + differentHost, err := s.DS.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-different"), + NodeKey: ptr.String("test-node-key-different"), + UUID: "test-uuid-different", + Hostname: "test-hostname-different", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + certDifferent := requestSCEPCertificate(t, s, differentHost.UUID, testEnrollmentSecret) + require.NotNil(t, certDifferent, "Different host should be able to get certificate") + assert.Equal(t, "urn:device:apple:uuid:"+differentHost.UUID, certDifferent.URIs[0].String()) + }) +} diff --git a/ee/server/integrationtest/condaccess/suite.go b/ee/server/integrationtest/condaccess/suite.go new file mode 100644 index 000000000000..bd3ed7512129 --- /dev/null +++ b/ee/server/integrationtest/condaccess/suite.go @@ -0,0 +1,67 @@ +package condaccess + +import ( + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/fleetdm/fleet/v4/server/service/integrationtest" + "github.com/go-kit/kit/log" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/require" +) + +type Suite struct { + integrationtest.BaseSuite +} + +func SetUpSuite(t *testing.T, uniqueTestName string) *Suite { + return SetUpSuiteWithConfig(t, uniqueTestName, nil) +} + +func SetUpSuiteWithConfig(t *testing.T, uniqueTestName string, configModifier func(cfg *config.FleetConfig)) *Suite { + // Note: t.Parallel() is called when MySQL datastore options are processed + license := &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + ds, fleetCfg, fleetSvc, ctx := integrationtest.SetUpMySQLAndService(t, uniqueTestName, &service.TestServerOpts{ + License: license, + Pool: redistest.SetupRedis(t, t.Name(), false, false, false), + }) + + // Apply config modifications + if configModifier != nil { + configModifier(&fleetCfg) + } + + logger := log.NewLogfmtLogger(os.Stdout) + condAccessSCEPDepot, err := ds.NewConditionalAccessSCEPDepot(kitlog.With(logger, "component", "conditional-access-scep-depot"), &fleetCfg) + require.NoError(t, err) + + users, server := service.RunServerForTestsWithServiceWithDS(t, ctx, ds, fleetSvc, &service.TestServerOpts{ + License: license, + FleetConfig: &fleetCfg, + Logger: logger, + ConditionalAccess: &service.ConditionalAccess{ + SCEPStorage: condAccessSCEPDepot, + }, + }) + + s := &Suite{ + BaseSuite: integrationtest.BaseSuite{ + Logger: logger, + DS: ds, + FleetCfg: fleetCfg, + Users: users, + Server: server, + }, + } + + integrationtest.SetUpServerURL(t, ds, server) + + s.BaseSuite.Token = s.BaseSuite.GetTestAdminToken(t) + return s +} diff --git a/ee/server/service/condaccess/config.go b/ee/server/service/condaccess/config.go new file mode 100644 index 000000000000..1deb5d9e300e --- /dev/null +++ b/ee/server/service/condaccess/config.go @@ -0,0 +1,56 @@ +package condaccess + +import ( + "context" + "fmt" + + "github.com/fleetdm/fleet/v4/pkg/certificate" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" +) + +func initAssets(ds fleet.Datastore) error { + // Check if we have existing certs and keys + expectedAssets := []fleet.MDMAssetName{ + fleet.MDMAssetConditionalAccessCACert, + fleet.MDMAssetConditionalAccessCAKey, + } + savedAssets, err := ds.GetAllMDMConfigAssetsByName(context.Background(), expectedAssets, nil) + if err != nil { + // allow not found errors as it means we're generating the assets for the first time. + if !fleet.IsNotFound(err) { + return fmt.Errorf("loading existing conditional access assets from the database: %w", err) + } + } + + if len(savedAssets) != len(expectedAssets) { + // Then we should create them + caCert := depot.NewCACert( + depot.WithYears(10), + depot.WithCommonName("Fleet Conditional Access CA"), + // Signal that the CA is local to the deployment and not necessarily managed by Fleet or another external vendor + depot.WithOrganization("Local Certificate Authority"), + ) + scepCert, scepKey, err := depot.NewCACertKey(caCert) + if err != nil { + return fmt.Errorf("generating conditional access SCEP cert and key: %w", err) + } + + // Store our config assets encrypted + var assets []fleet.MDMConfigAsset + for k, v := range map[fleet.MDMAssetName][]byte{ + fleet.MDMAssetConditionalAccessCACert: certificate.EncodeCertPEM(scepCert), + fleet.MDMAssetConditionalAccessCAKey: certificate.EncodePrivateKeyPEM(scepKey), + } { + assets = append(assets, fleet.MDMConfigAsset{ + Name: k, + Value: v, + }) + } + + if err := ds.InsertMDMConfigAssets(context.Background(), assets, nil); err != nil { + return fmt.Errorf("inserting conditional access SCEP assets: %w", err) + } + } + return nil +} diff --git a/ee/server/service/condaccess/config_test.go b/ee/server/service/condaccess/config_test.go new file mode 100644 index 000000000000..d6aa1b9eb485 --- /dev/null +++ b/ee/server/service/condaccess/config_test.go @@ -0,0 +1,116 @@ +package condaccess + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitAssets(t *testing.T) { + ds := mysql.CreateMySQLDS(t) + defer mysql.TruncateTables(t, ds, []string{"mdm_config_assets"}...) + + ctx := t.Context() + + // Initialize assets + err := initAssets(ds) + require.NoError(t, err) + + // Verify assets were created + expectedAssets := []fleet.MDMAssetName{ + fleet.MDMAssetConditionalAccessCACert, + fleet.MDMAssetConditionalAccessCAKey, + } + savedAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, expectedAssets, nil) + require.NoError(t, err) + require.Len(t, savedAssets, 2, "Should have created both CA cert and key") + + // Verify we have both cert and key + var caCertAsset, caKeyAsset fleet.MDMConfigAsset + var foundCert, foundKey bool + for _, asset := range savedAssets { + if asset.Name == fleet.MDMAssetConditionalAccessCACert { + caCertAsset = asset + foundCert = true + } else if asset.Name == fleet.MDMAssetConditionalAccessCAKey { + caKeyAsset = asset + foundKey = true + } + } + require.True(t, foundCert, "Should have CA cert") + require.True(t, foundKey, "Should have CA key") + + // Verify cert is valid PEM + pemBlock, _ := pem.Decode(caCertAsset.Value) + require.NotNil(t, pemBlock, "CA cert should be valid PEM") + require.Equal(t, "CERTIFICATE", pemBlock.Type, "PEM block should be a certificate") + + // Parse the certificate + cert, err := x509.ParseCertificate(pemBlock.Bytes) + require.NoError(t, err, "Should be able to parse CA certificate") + + // Verify certificate attributes + assert.Equal(t, "Fleet Conditional Access CA", cert.Subject.CommonName, "CA cert should have correct common name") + assert.Contains(t, cert.Subject.Organization, "Local Certificate Authority", "CA cert should have correct organization") + + // Verify certificate is valid for 10 years (with tolerance for leap years) + // 10 years can be 3652 days (2 leap years) or 3653 days (3 leap years) + expectedMinDuration := 10*365*24*time.Hour - 2*24*time.Hour // Allow for non-leap years + expectedMaxDuration := 10*365*24*time.Hour + 3*24*time.Hour // Allow for leap years + actualDuration := cert.NotAfter.Sub(cert.NotBefore) + assert.True(t, actualDuration >= expectedMinDuration && actualDuration <= expectedMaxDuration, + "CA cert should be valid for approximately 10 years (actual: %v, expected: %v-%v)", + actualDuration, expectedMinDuration, expectedMaxDuration) + + // Verify cert is a CA + assert.True(t, cert.IsCA, "Certificate should be marked as CA") + assert.True(t, cert.BasicConstraintsValid, "Basic constraints should be valid") + + // Verify key usage + assert.Equal(t, x509.KeyUsageCertSign|x509.KeyUsageCRLSign|x509.KeyUsageDigitalSignature, cert.KeyUsage, + "CA cert should have correct key usage") + + // Verify the certificate uses RSA public key + rsaPubKey, ok := cert.PublicKey.(*rsa.PublicKey) + require.True(t, ok, "Certificate should use RSA public key") + assert.Equal(t, 2048, rsaPubKey.N.BitLen(), "RSA key should be 2048 bits") + + // Verify key is valid PEM and is RSA + keyPemBlock, _ := pem.Decode(caKeyAsset.Value) + require.NotNil(t, keyPemBlock, "CA key should be valid PEM") + require.Equal(t, "RSA PRIVATE KEY", keyPemBlock.Type, "PEM block should be an RSA private key") + + // Parse and verify the private key + rsaPrivKey, err := x509.ParsePKCS1PrivateKey(keyPemBlock.Bytes) + require.NoError(t, err, "Should be able to parse RSA private key") + assert.Equal(t, 2048, rsaPrivKey.N.BitLen(), "RSA private key should be 2048 bits") + + // Save original values for idempotency check + originalCertValue := caCertAsset.Value + originalKeyValue := caKeyAsset.Value + + // Second initialization - should not regenerate + err = initAssets(ds) + require.NoError(t, err) + + // Get the assets again + secondAssets, err := ds.GetAllMDMConfigAssetsByName(ctx, expectedAssets, nil) + require.NoError(t, err) + require.Len(t, secondAssets, 2) + + // Verify values are unchanged + for _, asset := range secondAssets { + if asset.Name == fleet.MDMAssetConditionalAccessCACert { + assert.Equal(t, originalCertValue, asset.Value, "CA cert should not be regenerated") + } else if asset.Name == fleet.MDMAssetConditionalAccessCAKey { + assert.Equal(t, originalKeyValue, asset.Value, "CA key should not be regenerated") + } + } +} diff --git a/ee/server/service/condaccess/depot/depot.go b/ee/server/service/condaccess/depot/depot.go new file mode 100644 index 000000000000..57c6f98f0298 --- /dev/null +++ b/ee/server/service/condaccess/depot/depot.go @@ -0,0 +1,179 @@ +package depot + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/fleetdm/fleet/v4/pkg/certificate" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/assets" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/jmoiron/sqlx" +) + +const maxCommonNameLength = 64 + +// ConditionalAccessSCEPDepot is a MySQL-backed SCEP certificate depot for conditional access. +type ConditionalAccessSCEPDepot struct { + db *sqlx.DB + ds fleet.Datastore + logger log.Logger + config *config.FleetConfig +} + +var _ depot.Depot = (*ConditionalAccessSCEPDepot)(nil) + +// NewConditionalAccessSCEPDepot creates and returns a *ConditionalAccessSCEPDepot. +func NewConditionalAccessSCEPDepot(db *sqlx.DB, ds fleet.Datastore, logger log.Logger, cfg *config.FleetConfig) (*ConditionalAccessSCEPDepot, error) { + if err := db.Ping(); err != nil { + return nil, err + } + return &ConditionalAccessSCEPDepot{ + db: db, + ds: ds, + logger: logger, + config: cfg, + }, nil +} + +// CA returns the CA's certificate and private key. +func (d *ConditionalAccessSCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) { + cert, err := assets.KeyPair(context.Background(), d.ds, + fleet.MDMAssetConditionalAccessCACert, + fleet.MDMAssetConditionalAccessCAKey) + if err != nil { + return nil, nil, fmt.Errorf("getting assets: %w", err) + } + + pk, ok := cert.PrivateKey.(*rsa.PrivateKey) + if !ok { + return nil, nil, errors.New("private key not in RSA format") + } + + return []*x509.Certificate{cert.Leaf}, pk, nil +} + +// Serial allocates and returns a new (increasing) serial number. +func (d *ConditionalAccessSCEPDepot) Serial() (*big.Int, error) { + // Insert an empty row to generate a new auto-incremented serial number + result, err := d.db.Exec(`INSERT INTO conditional_access_scep_serials () VALUES ();`) + if err != nil { + return nil, err + } + lid, err := result.LastInsertId() + if err != nil { + return nil, err + } + return big.NewInt(lid), nil +} + +// HasCN returns whether the given certificate exists in the depot. +// For conditional access SCEP, renewal is not supported, so this always returns false. +func (d *ConditionalAccessSCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) { + // Not used - no renewal support for conditional access + return false, nil +} + +// Put stores a certificate under the given name. +// The certificate must contain a SAN URI with the device UUID in the format: +// urn:device:apple:uuid: +// The UUID is used to look up the host in Fleet, and the certificate is only issued +// if the host exists. Old certificates for the same host are automatically revoked. +func (d *ConditionalAccessSCEPDepot) Put(name string, crt *x509.Certificate) error { + if crt.Subject.CommonName == "" || len(crt.Subject.CommonName) > maxCommonNameLength { + return errors.New("common name empty or too long") + } + if !crt.SerialNumber.IsInt64() { + return errors.New("cannot represent serial number as int64") + } + + // Extract UUID from SAN URI + // Expected format: urn:device:apple:uuid: + uuid := extractUUIDFromCert(crt) + if uuid == "" { + return errors.New("no device UUID found in certificate SAN URI") + } + + // Look up host BEFORE storing certificate + host, err := d.ds.HostByIdentifier(context.Background(), uuid) + if err != nil { + return fmt.Errorf("host not found for UUID %s: %w", uuid, err) + } + + // Apply rate limiting if configured + cooldown := d.config.Osquery.EnrollCooldown + if cooldown > 0 { + existingCertCreatedAt, err := d.ds.GetConditionalAccessCertCreatedAtByHostID(context.Background(), host.ID) + switch { + case err != nil && !fleet.IsNotFound(err): + return fmt.Errorf("checking existing certificate: %w", err) + case err == nil: + // Certificate exists, check if rate limit applies + if time.Since(*existingCertCreatedAt) < cooldown { + return backoff.Permanent(ctxerr.Errorf(context.Background(), "host %s requesting certificates too often", uuid)) + } + } + // If certificate doesn't exist or rate limit doesn't apply, continue + } + + certPEM := certificate.EncodeCertPEM(crt) + + // Insert new certificate - following industry best practice of issuing new cert + // before revoking old ones. For zero-downtime rotation, old certificates are NOT + // immediately revoked, allowing a grace period where both are valid. + // + // This prevents authentication failures when: + // - Network delays in delivering the new certificate to the client + // - Client is offline during certificate rotation (client will request new cert when it comes back online) + _, err = d.db.ExecContext(context.Background(), ` + INSERT INTO conditional_access_scep_certificates + (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem) + VALUES + (?, ?, ?, ?, ?, ?)`, + crt.SerialNumber.Int64(), + host.ID, + name, + crt.NotBefore, + crt.NotAfter, + certPEM, + ) + if err != nil { + return err + } + + level.Info(d.logger).Log( + "msg", "stored conditional access certificate", + "cn", name, + "serial", crt.SerialNumber.Int64(), + "host_id", host.ID, + "uuid", uuid, + ) + + return nil +} + +// extractUUIDFromCert extracts the device UUID from the SAN URI field. +// Expected format: urn:device:apple:uuid: +// Returns the UUID portion or empty string if not found. +func extractUUIDFromCert(crt *x509.Certificate) string { + const prefix = "urn:device:apple:uuid:" + for _, uri := range crt.URIs { + // Check if this is a device URI (urn:device:apple:uuid:...) + uriStr := uri.String() + if strings.HasPrefix(uriStr, prefix) { + return strings.TrimPrefix(uriStr, prefix) + } + } + return "" +} diff --git a/ee/server/service/condaccess/scep.go b/ee/server/service/condaccess/scep.go new file mode 100644 index 000000000000..13b8acaf1995 --- /dev/null +++ b/ee/server/service/condaccess/scep.go @@ -0,0 +1,220 @@ +package condaccess + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + + "github.com/cenkalti/backoff/v4" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/assets" + scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" + "github.com/fleetdm/fleet/v4/server/service/middleware/otel" + "github.com/go-kit/kit/log" + kitlog "github.com/go-kit/log" + "github.com/smallstep/scep" +) + +const ( + scepPath = "/api/fleet/conditional_access/scep" + scepValidityDays = 398 // Apple's maximum allowed certificate validity +) + +// RateLimitError is an error type that generates a 429 status code. +type RateLimitError struct { + Message string +} + +// Error returns the error message. +func (e *RateLimitError) Error() string { + return e.Message +} + +// StatusCode implements the kithttp StatusCoder interface +func (e *RateLimitError) StatusCode() int { return http.StatusTooManyRequests } + +// RegisterSCEP registers the HTTP handler for conditional access SCEP service. +func RegisterSCEP( + mux *http.ServeMux, + scepStorage scepdepot.Depot, + ds fleet.Datastore, + logger kitlog.Logger, + fleetConfig *config.FleetConfig, +) error { + if fleetConfig == nil { + return errors.New("fleet config is nil") + } + err := initAssets(ds) + if err != nil { + return fmt.Errorf("initializing conditional access assets: %w", err) + } + + // Create signer without renewal middleware (key difference from host identity SCEP) + var signer scepserver.CSRSignerContext = scepserver.SignCSRAdapter(scepdepot.NewSigner( + scepStorage, + scepdepot.WithValidityDays(scepValidityDays), + )) + + // Add challenge middleware for enrollment secret verification + signer = challengeMiddleware(ds, signer) + + scepService := NewSCEPService( + ds, + signer, + kitlog.With(logger, "component", "conditional-access-scep"), + ) + + scepLogger := kitlog.With(logger, "component", "http-conditional-access-scep") + e := scepserver.MakeServerEndpoints(scepService) + e.GetEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.GetEndpoint) + e.PostEndpoint = scepserver.EndpointLoggingMiddleware(scepLogger)(e.PostEndpoint) + + // The scepserver error handler does not send errors to APM/Sentry/Redis. + // It should be enhanced to do so if/when we start monitoring error traces. + // This note also applies to the other SCEP servers we use. + // That is why we're not using ctxerr wrappers here. + scepHandler := scepserver.MakeHTTPHandler(e, scepService, scepLogger) + scepHandler = otel.WrapHandler(scepHandler, scepPath, *fleetConfig) + mux.Handle(scepPath, scepHandler) + return nil +} + +// challengeMiddleware checks that ChallengePassword matches an enrollment secret. +// Unlike host identity SCEP, this does NOT support renewal (no renewal extension check). +func challengeMiddleware(ds fleet.Datastore, next scepserver.CSRSignerContext) scepserver.CSRSignerContextFunc { + return func(ctx context.Context, m *scep.CSRReqMessage) (*x509.Certificate, error) { + // No renewal support for conditional access SCEP + // Always require a valid challenge password + + if m.ChallengePassword == "" { + return nil, errors.New("missing challenge") + } + _, err := ds.VerifyEnrollSecret(ctx, m.ChallengePassword) + switch { + case fleet.IsNotFound(err): + return nil, errors.New("invalid challenge") + case err != nil: + return nil, fmt.Errorf("verifying enrollment secret: %w", err) + } + return next.SignCSRContext(ctx, m) + } +} + +var _ scepserver.Service = (*service)(nil) + +type service struct { + // The (chainable) CSR signing function + signer scepserver.CSRSignerContext + logger log.Logger + ds fleet.Datastore +} + +// GetCACaps returns the CA capabilities. +// Conditional access SCEP does NOT support renewal, so "Renewal" is excluded. +func (svc *service) GetCACaps(_ context.Context) ([]byte, error) { + // Supported SCEP CA Capabilities: + // + // Cryptographic and Algorithm Support: + // [x] POSTPKIOperation // Supports HTTP POST for PKIOperation (preferred over GET) + // [ ] SHA-1 // Not supported - deprecated + // [x] SHA-256 // Supports SHA-256 for signing + // [ ] SHA-512 // Not needed for our use case + // [x] AES // Supports AES encryption for PKCS#7 enveloped data + // [ ] DES3 // Not supported - deprecated + // + // Operational Capabilities: + // [ ] GetNextCACert // Not supported - no CA rollover + // [ ] Renewal // NOT SUPPORTED - key difference from host identity SCEP + // [ ] Update // Not supported + // + // Note: Renewal is explicitly excluded for conditional access SCEP. + // Clients must re-enroll with a valid challenge to get a new certificate. + defaultCaps := []byte("SHA-256\nAES\nPOSTPKIOperation") + return defaultCaps, nil +} + +// GetCACert returns the CA certificate. +func (svc *service) GetCACert(ctx context.Context, _ string) ([]byte, int, error) { + cert, err := caKeyPair(ctx, svc.ds) + if err != nil { + return nil, 0, fmt.Errorf("retrieving conditional access SCEP CA certificate (GetCACert): %w", err) + } + return cert.Leaf.Raw, 1, nil +} + +// caKeyPair retrieves the CA certificate and key for conditional access. +func caKeyPair(ctx context.Context, ds fleet.MDMAssetRetriever) (*tls.Certificate, error) { + return assets.KeyPair(ctx, ds, fleet.MDMAssetConditionalAccessCACert, fleet.MDMAssetConditionalAccessCAKey) +} + +// PKIOperation handles SCEP PKI operations (certificate signing). +func (svc *service) PKIOperation(ctx context.Context, data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, &fleet.BadRequestError{Message: "missing data for PKIOperation"} + } + msg, err := scep.ParsePKIMessage(data, scep.WithLogger(svc.logger)) + if err != nil { + return nil, err + } + + cert, err := caKeyPair(ctx, svc.ds) + if err != nil { + return nil, fmt.Errorf("retrieving conditional access SCEP CA certificate: %w", err) + } + + pk, ok := cert.PrivateKey.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key not in RSA format") + } + + if err := msg.DecryptPKIEnvelope(cert.Leaf, pk); err != nil { + return nil, err + } + + crt, err := svc.signer.SignCSRContext(ctx, msg.CSRReqMessage) + if err == nil && crt == nil { + err = errors.New("signer returned nil certificate without error") + } + if err != nil { + svc.logger.Log("msg", "failed to sign CSR", "err", err) + + // Check if this is a rate limit error (permanent error from backoff) + var permanentErr *backoff.PermanentError + if errors.As(err, &permanentErr) { + // Return HTTP 429 for rate limit errors + return nil, &RateLimitError{Message: err.Error()} + } + + certRep, err := msg.Fail(cert.Leaf, pk, scep.BadRequest) + if certRep == nil { + return nil, err + } + return certRep.Raw, err + } + + certRep, err := msg.Success(cert.Leaf, pk, crt) + if certRep == nil { + return nil, err + } + return certRep.Raw, err +} + +// GetNextCACert is not implemented for conditional access SCEP. +func (svc *service) GetNextCACert(_ context.Context) ([]byte, error) { + return nil, errors.New("not implemented") +} + +// NewSCEPService creates a new conditional access SCEP service. +func NewSCEPService(ds fleet.Datastore, signer scepserver.CSRSignerContext, logger log.Logger) scepserver.Service { + return &service{ + ds: ds, + signer: signer, + logger: logger, + } +} diff --git a/server/datastore/mysql/conditional_access_scep.go b/server/datastore/mysql/conditional_access_scep.go new file mode 100644 index 000000000000..818a9441cc42 --- /dev/null +++ b/server/datastore/mysql/conditional_access_scep.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/jmoiron/sqlx" +) + +// GetConditionalAccessCertHostIDBySerialNumber retrieves the host_id for a valid certificate by serial number. +// This is a lightweight method optimized for authentication flows. +func (ds *Datastore) GetConditionalAccessCertHostIDBySerialNumber(ctx context.Context, serial uint64) (uint, error) { + stmt := ` + SELECT host_id + FROM conditional_access_scep_certificates + WHERE serial = ? AND revoked = 0 AND not_valid_after > NOW() + ` + var hostID uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &hostID, stmt, serial) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, ctxerr.Wrap(ctx, notFound("ConditionalAccessCertificate")) + } + return 0, ctxerr.Wrap(ctx, err, "get conditional access cert host_id by serial") + } + return hostID, nil +} + +// GetConditionalAccessCertCreatedAtByHostID retrieves the created_at timestamp of the most recent certificate for a host. +// This is a lightweight method for rate limiting checks. +func (ds *Datastore) GetConditionalAccessCertCreatedAtByHostID(ctx context.Context, hostID uint) (*time.Time, error) { + stmt := ` + SELECT created_at + FROM conditional_access_scep_certificates + WHERE host_id = ? + ORDER BY created_at DESC + LIMIT 1 + ` + var createdAt time.Time + err := sqlx.GetContext(ctx, ds.reader(ctx), &createdAt, stmt, hostID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("ConditionalAccessCertificate")) + } + return nil, ctxerr.Wrap(ctx, err, "get conditional access cert created_at by host ID") + } + return &createdAt, nil +} diff --git a/server/datastore/mysql/conditional_access_scep_test.go b/server/datastore/mysql/conditional_access_scep_test.go new file mode 100644 index 000000000000..3e859b1588d1 --- /dev/null +++ b/server/datastore/mysql/conditional_access_scep_test.go @@ -0,0 +1,159 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConditionalAccessSCEP(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"GetCertBySerialAndCreatedAt", testGetConditionalAccessCertBySerialAndCreatedAt}, + {"RevokedCertsNotReturned", testRevokedConditionalAccessCertsNotReturned}, + {"ExpiredCertsNotReturned", testExpiredConditionalAccessCertsNotReturned}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + c.fn(t, ds) + }) + } +} + +func testGetConditionalAccessCertBySerialAndCreatedAt(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Create a test host + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-1"), + NodeKey: ptr.String("test-node-key-1"), + UUID: "test-uuid-1", + Hostname: "test-hostname-1", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + require.NotNil(t, host) + + // Insert a valid test certificate + now := time.Now() + serialNumber := insertConditionalAccessCert(t, ds, ctx, host.ID, "test-cn", now.Add(-24*time.Hour), now.Add(365*24*time.Hour), false) + + // Test retrieval by serial number + hostID, err := ds.GetConditionalAccessCertHostIDBySerialNumber(ctx, serialNumber) + require.NoError(t, err) + assert.Equal(t, host.ID, hostID) + + // Test retrieval of created_at by host ID + createdAt, err := ds.GetConditionalAccessCertCreatedAtByHostID(ctx, host.ID) + require.NoError(t, err) + require.NotNil(t, createdAt) + // Verify timestamp is reasonable (created in the past, within last 24 hours) + assert.True(t, createdAt.Before(time.Now())) + assert.True(t, createdAt.After(time.Now().Add(-24*time.Hour))) + + // Test non-existent serial + _, err = ds.GetConditionalAccessCertHostIDBySerialNumber(ctx, 999) + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + // Test non-existent host + _, err = ds.GetConditionalAccessCertCreatedAtByHostID(ctx, 999999) + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) +} + +func testRevokedConditionalAccessCertsNotReturned(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a test host + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-6"), + NodeKey: ptr.String("test-node-key-6"), + UUID: "test-uuid-6", + Hostname: "test-hostname-6", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + // Insert a revoked certificate + now := time.Now() + serialNumber := insertConditionalAccessCert(t, ds, ctx, host.ID, "revoked-cert", now.Add(-24*time.Hour), now.Add(365*24*time.Hour), true) + + // Revoked certs should not be returned by serial number lookup + _, err = ds.GetConditionalAccessCertHostIDBySerialNumber(ctx, serialNumber) + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + // Note: GetConditionalAccessCertCreatedAtByHostID doesn't filter by revoked status + // since it's used for rate limiting checks, not authentication +} + +func testExpiredConditionalAccessCertsNotReturned(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a test host + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test-host-7"), + NodeKey: ptr.String("test-node-key-7"), + UUID: "test-uuid-7", + Hostname: "test-hostname-7", + Platform: "darwin", + DetailUpdatedAt: time.Now(), + }) + require.NoError(t, err) + + // Insert an expired certificate + now := time.Now() + serialNumber := insertConditionalAccessCert(t, ds, ctx, host.ID, "expired-cert", now.Add(-400*24*time.Hour), now.Add(-24*time.Hour), false) + + // Expired certs should not be returned by serial number lookup + _, err = ds.GetConditionalAccessCertHostIDBySerialNumber(ctx, serialNumber) + require.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + // Note: GetConditionalAccessCertCreatedAtByHostID doesn't filter by expiration status + // since it's used for rate limiting checks, not authentication +} + +// insertConditionalAccessCert inserts a conditional access SCEP certificate for testing. +// Returns the serial number of the inserted certificate. +func insertConditionalAccessCert(t *testing.T, ds *Datastore, ctx context.Context, hostID uint, name string, notValidBefore, notValidAfter time.Time, revoked bool) uint64 { + t.Helper() + + certPEM := `-----BEGIN CERTIFICATE----- +MIICEjCCAXsCAg36MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG +-----END CERTIFICATE-----` + + var serialNumber uint64 + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + result, err := q.ExecContext(ctx, `INSERT INTO conditional_access_scep_serials () VALUES ()`) + require.NoError(t, err) + + lastID, err := result.LastInsertId() + require.NoError(t, err) + serialNumber = uint64(lastID) // nolint:gosec,G115 + + _, err = q.ExecContext(ctx, ` + INSERT INTO conditional_access_scep_certificates + (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, serialNumber, hostID, name, notValidBefore, notValidAfter, certPEM, revoked) + return err + }) + + return serialNumber +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 2d430be9cfc9..8d2539cac430 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -566,6 +566,7 @@ var hostRefs = []string{ "host_mdm_commands", "microsoft_compliance_partner_host_statuses", "host_identity_scep_certificates", + "conditional_access_scep_certificates", // unlike for host_software_installs, where we use soft-delete so that // existing activities can still access the installation details, this is not // needed for in-house apps as the activity contains the MDM command UUID and diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 666888ce76e5..7d46d7bf72ba 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -8476,6 +8476,17 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { host.ID, inHouseID, uuid.NewString(), fleet.MacOSPlatform) require.NoError(t, err) + // Insert into conditional_access_scep_certificates table + result, err = ds.writer(context.Background()).Exec(`INSERT INTO conditional_access_scep_serials () VALUES ()`) + require.NoError(t, err) + caCertSerial, err := result.LastInsertId() + require.NoError(t, err) + _, err = ds.writer(context.Background()).Exec(` + INSERT INTO conditional_access_scep_certificates (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, caCertSerial, host.ID, "test-ca-host", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour), "-----BEGIN CERTIFICATE-----", false) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables.go b/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables.go new file mode 100644 index 000000000000..0a0a584d7ad5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables.go @@ -0,0 +1,53 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20251106000000, Down_20251106000000) +} + +func Up_20251106000000(tx *sql.Tx) error { + // Create conditional_access_scep_serials table first (referenced by foreign key) + // Reserve serial number 1 for system use, similar to host identity SCEP + _, err := tx.Exec(` + CREATE TABLE conditional_access_scep_serials ( + serial bigint unsigned NOT NULL AUTO_INCREMENT, + created_at DATETIME(6) NULL DEFAULT NOW(6), + PRIMARY KEY (serial) + ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + if err != nil { + return fmt.Errorf("failed to create conditional_access_scep_serials table: %w", err) + } + + // Create conditional_access_scep_certificates table + _, err = tx.Exec(` + CREATE TABLE conditional_access_scep_certificates ( + serial bigint unsigned NOT NULL, + host_id int unsigned NOT NULL, + name varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + not_valid_before datetime NOT NULL, + not_valid_after datetime NOT NULL, + certificate_pem text COLLATE utf8mb4_unicode_ci NOT NULL, + revoked tinyint(1) NOT NULL DEFAULT '0', + created_at DATETIME(6) NULL DEFAULT NOW(6), + updated_at DATETIME(6) NULL DEFAULT NOW(6) ON UPDATE NOW(6), + PRIMARY KEY (serial), + KEY idx_conditional_access_host_id (host_id), + CONSTRAINT conditional_access_scep_certificates_ibfk_1 FOREIGN KEY (serial) REFERENCES conditional_access_scep_serials (serial), + CONSTRAINT conditional_access_scep_certificates_chk_1 CHECK ((substr(certificate_pem,1,27) = _utf8mb4'-----BEGIN CERTIFICATE-----')) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `) + if err != nil { + return fmt.Errorf("failed to create conditional_access_scep_certificates table: %w", err) + } + + return nil +} + +func Down_20251106000000(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables_test.go b/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables_test.go new file mode 100644 index 000000000000..cd855afdfb28 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251106000000_AddConditionalAccessSCEPTables_test.go @@ -0,0 +1,91 @@ +package tables + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestUp_20251106000000(t *testing.T) { + db := applyUpToPrev(t) + + // Create a host to reference (conditional_access_scep_certificates requires a host_id) + hostID := insertHost(t, db, nil) + + // Apply current migration + applyNext(t, db) + + // Valid certificate PEM that starts with "-----BEGIN CERTIFICATE-----" + validCertPEM := `-----BEGIN CERTIFICATE----- +MIIDjzCCAnegAwIBAgIBATANBgkqhkiG9w0BAQsFADBpMQkwBwYDVQQGEwAxJDAi +BgNVBAoTG0xvY2FsIGNlcnRpZmljYXRlIGF1dGhvcml0eTEQMA4GA1UECxMHU0NF +UCBDQTEkMCIGA1UEAxMbRmxlZXQgY29uZGl0aW9uYWwgYWNjZXNzIENBMB4XDTI1 +MTEwNjE2MjEyNloXDTM1MTEwNjE2MjEyNlowaTEJMAcGA1UEBhMAMSQwIgYDVQQK +ExtMb2NhbCBjZXJ0aWZpY2F0ZSBhdXRob3JpdHkxEDAOBgNVBAsTB1NDRVAgQ0Ex +JDAiBgNVBAMTG0ZsZWV0IGNvbmRpdGlvbmFsIGFjY2VzcyBDQTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBALZIk1qcmD1r9Plj2SC+FZgfXNUIIGJmnLXD +oGflLLkBpTjfm48NH0gOQwbRLfudi/Kdo2kx2d7cvV2Seu1Dgx4+Suh87Zj277Xp +280qSFTxbo+2W+rpTRoACf774+cw/fribH/j+k58hBPFHCIvx/iUBWXqjLxvx+b+ +borRH6jWKevVCeh2x6KsRO1UM5ll3pJa3StAMPSdtldgI8iTt18vfc8+53AslTw+ +7ri9SbE26zxh0XhUUuR2uzfSiptbKmwNc7CsrS3juCmi8CAayQHQ8NjIyXv5d3zT +uoR0Agk4Wes29Z0WRCJ9gskxaB6pM6idyccp39bB0qqjhIEo6MECAwEAAaNCMEAw +DgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFAErVNWV +jX16em5Jw9IFi02Q9y5GMA0GCSqGSIb3DQEBCwUAA4IBAQBNRbVDL+0p8V29hvRx ++Ea0E87DRONM0ym4DEH2fQV23FyQzXyxlYbLdN32ssHsQNU+eHrjjWfjxcy6b3H/ +64fNLFvS4ThfJymJB8gvj+b180MmX+YUOhUsLLPTOA4gCdZagDS80ngmcjoh2E4J +sO1WlnLrMmXCwtU+VZXxfVU2oXkSoy+wpzuixNbxi6WsH6PObRZ2FKcZSqQyRp01 +fU7N5JakKVGW43vKWYK4oB9EFc2pO/yuZYz/BXaMtW3AUpCJd+YZjWEkfqzKj11+ +kLWyc3155w2EmkO2J21v/53o5gZWgjeyPY4edtOaoWWz2eHkn3k2QQZ76V1nzfWb +CT1g +-----END CERTIFICATE-----` + + // Insert a serial number first (required by foreign key) + serialID := execNoErrLastID(t, db, `INSERT INTO conditional_access_scep_serials (created_at) VALUES (?)`, time.Now()) + + // Test 1: Valid certificate PEM should be accepted + execNoErr(t, db, ` + INSERT INTO conditional_access_scep_certificates + (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, serialID, hostID, "Test Device", time.Now(), time.Now().Add(365*24*time.Hour), validCertPEM, false) + + // Verify the certificate was inserted + var count int + err := db.Get(&count, `SELECT COUNT(*) FROM conditional_access_scep_certificates WHERE serial = ?`, serialID) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Test 2: Invalid certificate PEM (not starting with "-----BEGIN CERTIFICATE-----") should fail + invalidCertPEM := `INVALID CERTIFICATE DATA +This does not start with the required prefix` + + // Insert another serial number + invalidSerialID := execNoErrLastID(t, db, `INSERT INTO conditional_access_scep_serials (created_at) VALUES (?)`, time.Now()) + + // This should fail due to CHECK constraint + _, err = db.Exec(` + INSERT INTO conditional_access_scep_certificates + (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, invalidSerialID, hostID, "Invalid Device", time.Now(), time.Now().Add(365*24*time.Hour), invalidCertPEM, false) + require.Error(t, err) + require.ErrorContains(t, err, "Check constraint 'conditional_access_scep_certificates_chk_1' is violated") + + // Test 3: Certificate PEM starting with wrong prefix should also fail + // nolint:gosec,G101 + wrongPrefixCertPEM := `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAtN... +-----END RSA PRIVATE KEY-----` + + // Insert another serial number + wrongPrefixSerialID := execNoErrLastID(t, db, `INSERT INTO conditional_access_scep_serials (created_at) VALUES (?)`, time.Now()) + + _, err = db.Exec(` + INSERT INTO conditional_access_scep_certificates + (serial, host_id, name, not_valid_before, not_valid_after, certificate_pem, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, wrongPrefixSerialID, hostID, "Wrong Prefix Device", time.Now(), time.Now().Add(365*24*time.Hour), wrongPrefixCertPEM, false) + require.Error(t, err) + require.ErrorContains(t, err, "Check constraint 'conditional_access_scep_certificates_chk_1' is violated") +} diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 539b898b283e..7d5677c0eeb7 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -17,6 +17,7 @@ import ( "github.com/XSAM/otelsql" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" + condaccessdepot "github.com/fleetdm/fleet/v4/ee/server/service/condaccess/depot" hostidscepdepot "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/depot" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" @@ -183,6 +184,12 @@ func (ds *Datastore) NewHostIdentitySCEPDepot(logger log.Logger, cfg *config.Fle return hostidscepdepot.NewHostIdentitySCEPDepot(ds.primary, ds, logger, cfg) } +// NewConditionalAccessSCEPDepot returns a new conditional access SCEP depot that uses the +// underlying MySQL writer *sql.DB. +func (ds *Datastore) NewConditionalAccessSCEPDepot(logger log.Logger, cfg *config.FleetConfig) (scep_depot.Depot, error) { + return condaccessdepot.NewConditionalAccessSCEPDepot(ds.primary, ds, logger, cfg) +} + type entity struct { name string } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 2d4ebfccc2ef..62ae45542b1e 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -259,6 +259,32 @@ CREATE TABLE `challenges` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `conditional_access_scep_certificates` ( + `serial` bigint unsigned NOT NULL, + `host_id` int unsigned NOT NULL, + `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `not_valid_before` datetime NOT NULL, + `not_valid_after` datetime NOT NULL, + `certificate_pem` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `revoked` tinyint(1) NOT NULL DEFAULT '0', + `created_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`serial`), + KEY `idx_conditional_access_host_id` (`host_id`), + CONSTRAINT `conditional_access_scep_certificates_ibfk_1` FOREIGN KEY (`serial`) REFERENCES `conditional_access_scep_serials` (`serial`), + CONSTRAINT `conditional_access_scep_certificates_chk_1` CHECK ((substr(`certificate_pem`,1,27) = _utf8mb4'-----BEGIN CERTIFICATE-----')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `conditional_access_scep_serials` ( + `serial` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`serial`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `cron_stats` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -1638,9 +1664,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=439 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=440 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'); +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'); /*!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/datastore.go b/server/fleet/datastore.go index 4b9148cea5f1..f8a6f510c1b5 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2440,6 +2440,14 @@ type Datastore interface { // UpdateHostIdentityCertHostIDBySerial updates the host ID associated with a certificate using its serial number. UpdateHostIdentityCertHostIDBySerial(ctx context.Context, serialNumber uint64, hostID uint) error + // ///////////////////////////////////////////////////////////////////////////// + // Conditional access certificates + + // GetConditionalAccessCertHostIDBySerialNumber retrieves the host_id for a valid certificate by serial number. + GetConditionalAccessCertHostIDBySerialNumber(ctx context.Context, serial uint64) (uint, error) + // GetConditionalAccessCertCreatedAtByHostID retrieves the created_at timestamp of the most recent certificate for a host. + GetConditionalAccessCertCreatedAtByHostID(ctx context.Context, hostID uint) (*time.Time, error) + // ///////////////////////////////////////////////////////////////////////////// // Certificate Authorities // NewCertificateAuthority creates a new certificate authority. diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 8afd309d2ef7..c03c6329b652 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -854,6 +854,10 @@ const ( MDMAssetHostIdentityCACert MDMAssetName = "host_identity_ca_cert" // MDMAssetHostIdentityCAKey is the name of the root CA private key used for host identity MDMAssetHostIdentityCAKey MDMAssetName = "host_identity_ca_key" + // MDMAssetConditionalAccessCACert is the name of the root CA certificate used for conditional access SCEP + MDMAssetConditionalAccessCACert MDMAssetName = "conditional_access_ca_cert" + // MDMAssetConditionalAccessCAKey is the name of the root CA private key used for conditional access SCEP + MDMAssetConditionalAccessCAKey MDMAssetName = "conditional_access_ca_key" ) type MDMConfigAsset struct { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 0993da7164b3..9c85c17b9213 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1573,6 +1573,10 @@ type GetHostIdentityCertByNameFunc func(ctx context.Context, name string) (*type type UpdateHostIdentityCertHostIDBySerialFunc func(ctx context.Context, serialNumber uint64, hostID uint) error +type GetConditionalAccessCertHostIDBySerialNumberFunc func(ctx context.Context, serial uint64) (uint, error) + +type GetConditionalAccessCertCreatedAtByHostIDFunc func(ctx context.Context, hostID uint) (*time.Time, error) + type NewCertificateAuthorityFunc func(ctx context.Context, ca *fleet.CertificateAuthority) (*fleet.CertificateAuthority, error) type GetCertificateAuthorityByIDFunc func(ctx context.Context, id uint, includeSecrets bool) (*fleet.CertificateAuthority, error) @@ -3919,6 +3923,12 @@ type DataStore struct { UpdateHostIdentityCertHostIDBySerialFunc UpdateHostIdentityCertHostIDBySerialFunc UpdateHostIdentityCertHostIDBySerialFuncInvoked bool + GetConditionalAccessCertHostIDBySerialNumberFunc GetConditionalAccessCertHostIDBySerialNumberFunc + GetConditionalAccessCertHostIDBySerialNumberFuncInvoked bool + + GetConditionalAccessCertCreatedAtByHostIDFunc GetConditionalAccessCertCreatedAtByHostIDFunc + GetConditionalAccessCertCreatedAtByHostIDFuncInvoked bool + NewCertificateAuthorityFunc NewCertificateAuthorityFunc NewCertificateAuthorityFuncInvoked bool @@ -9377,6 +9387,20 @@ func (s *DataStore) UpdateHostIdentityCertHostIDBySerial(ctx context.Context, se return s.UpdateHostIdentityCertHostIDBySerialFunc(ctx, serialNumber, hostID) } +func (s *DataStore) GetConditionalAccessCertHostIDBySerialNumber(ctx context.Context, serial uint64) (uint, error) { + s.mu.Lock() + s.GetConditionalAccessCertHostIDBySerialNumberFuncInvoked = true + s.mu.Unlock() + return s.GetConditionalAccessCertHostIDBySerialNumberFunc(ctx, serial) +} + +func (s *DataStore) GetConditionalAccessCertCreatedAtByHostID(ctx context.Context, hostID uint) (*time.Time, error) { + s.mu.Lock() + s.GetConditionalAccessCertCreatedAtByHostIDFuncInvoked = true + s.mu.Unlock() + return s.GetConditionalAccessCertCreatedAtByHostIDFunc(ctx, hostID) +} + func (s *DataStore) NewCertificateAuthority(ctx context.Context, ca *fleet.CertificateAuthority) (*fleet.CertificateAuthority, error) { s.mu.Lock() s.NewCertificateAuthorityFuncInvoked = true diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index e7aad3c07aca..16a3d62ccce4 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -17,6 +17,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/ee/server/scim" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/condaccess" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" @@ -350,6 +351,11 @@ type HostIdentity struct { RequireHTTPMessageSignature bool } +// ConditionalAccess combines conditional access-related test options +type ConditionalAccess struct { + SCEPStorage scep_depot.Depot +} + type TestServerOpts struct { Logger kitlog.Logger License *fleet.LicenseInfo @@ -386,6 +392,7 @@ type TestServerOpts struct { EnableSCIM bool ConditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy HostIdentity *HostIdentity + ConditionalAccess *ConditionalAccess } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) { @@ -502,6 +509,10 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl require.NoError(t, err) extra = append(extra, WithHTTPSigVerifier(httpSigVerifier)) } + + if len(opts) > 0 && opts[0].ConditionalAccess != nil { + require.NoError(t, condaccess.RegisterSCEP(rootMux, opts[0].ConditionalAccess.SCEPStorage, ds, logger, &cfg)) + } apiHandler := MakeHandler(svc, cfg, logger, limitStore, redisPool, featureRoutes, extra...) rootMux.Handle("/api/", apiHandler) var errHandler *errorstore.Handler