mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
Resolves #38484. This includes a CI job change to make sure we don't introduce any more env vars that don't get proxied (and thus turned off outside `--dev`). # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests Manual QA touched hot paths, but did _not_ manually test every FLEET_DEV_* environment variable change. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Centralized dev-mode environment management for consistent FLEET_DEV_* handling and test-friendly overrides. * Dev-mode allows targeted overrides for certain dev-only configuration when running with --dev. * **Chores** * Migrated environment access to the centralized dev-mode helper across the codebase. * Added CI checks to enforce proper usage of FLEET_DEV_* variables. * **Documentation** * Added guidance on dev-mode environment variable rules and overrides. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
401 lines
15 KiB
Go
401 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const conditionalAccessAppleProfileTemplate = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>PayloadContent</key>
|
|
<array>
|
|
<!-- Trusted CA certificate -->
|
|
<dict>
|
|
<key>PayloadCertificateFileName</key>
|
|
<string>conditional_access_ca.der</string>
|
|
<key>PayloadContent</key>
|
|
<data>{{.CACertBase64}}</data>
|
|
<key>PayloadDescription</key>
|
|
<string>Fleet conditional access CA certificate</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access CA</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-ca</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.root</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.CACertUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- SCEP configuration -->
|
|
<dict>
|
|
<key>PayloadContent</key>
|
|
<dict>
|
|
<key>URL</key>
|
|
<string>{{.SCEPURL}}</string>
|
|
<key>Challenge</key>
|
|
<string>{{.Challenge}}</string>
|
|
<key>Keysize</key>
|
|
<integer>2048</integer>
|
|
<key>Key Type</key>
|
|
<string>RSA</string>
|
|
<key>Key Usage</key>
|
|
<integer>5</integer>
|
|
<key>ExtendedKeyUsage</key>
|
|
<array>
|
|
<string>1.3.6.1.5.5.7.3.2</string>
|
|
</array>
|
|
<key>Subject</key>
|
|
<array>
|
|
<array>
|
|
<array>
|
|
<string>CN</string>
|
|
<string>{{.CertificateCN}}</string>
|
|
</array>
|
|
</array>
|
|
</array>
|
|
<key>SubjectAltName</key>
|
|
<dict>
|
|
<key>uniformResourceIdentifier</key>
|
|
<array>
|
|
<string>urn:device:apple:uuid:%HardwareUUID%</string>
|
|
</array>
|
|
</dict>
|
|
<key>Retries</key>
|
|
<integer>3</integer>
|
|
<key>RetryDelay</key>
|
|
<integer>10</integer>
|
|
<!-- ACL for browser access -->
|
|
<key>AllowAllAppsAccess</key>
|
|
<true/>
|
|
<key>KeyIsExtractable</key>
|
|
<false/>
|
|
</dict>
|
|
<key>PayloadDescription</key>
|
|
<string>Configures SCEP for Fleet conditional access for Okta certificate</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access SCEP</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-scep</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.scep</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.SCEPPayloadUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- Identity preference for mTLS endpoint -->
|
|
<dict>
|
|
<key>Name</key>
|
|
<string>{{.MTLSURL}}</string>
|
|
<key>PayloadCertificateUUID</key>
|
|
<string>{{.SCEPPayloadUUID}}</string>
|
|
<key>PayloadDescription</key>
|
|
<string>Identity preference for mTLS endpoints</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet mTLS identity preference</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-preference</string>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.security.identitypreference</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.IdentityPrefUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
<!-- Chrome web browser configuration -->
|
|
<dict>
|
|
<key>PayloadType</key>
|
|
<string>com.apple.ManagedClient.preferences</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.chrome.certs</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.ChromeConfigUUID}}</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Chrome mTLS auto-select</string>
|
|
<key>PayloadContent</key>
|
|
<dict>
|
|
<key>com.google.Chrome</key>
|
|
<dict>
|
|
<key>Forced</key>
|
|
<array>
|
|
<dict>
|
|
<key>mcx_preference_settings</key>
|
|
<dict>
|
|
<key>AllowPolicyInIncognito</key>
|
|
<true/>
|
|
<key>AutoSelectCertificateForUrls</key>
|
|
<array>
|
|
<!-- MUST be stringified JSON -->
|
|
<string>{"pattern":"{{.MTLSURL}}","filter":{"SUBJECT":{"CN":"{{.CertificateCN}}"}}}</string>
|
|
</array>
|
|
</dict>
|
|
</dict>
|
|
</array>
|
|
</dict>
|
|
</dict>
|
|
</dict>
|
|
</array>
|
|
<key>PayloadDescription</key>
|
|
<string>Configures SCEP enrollment for Okta conditional access</string>
|
|
<key>PayloadDisplayName</key>
|
|
<string>Fleet conditional access for Okta</string>
|
|
<key>PayloadIdentifier</key>
|
|
<string>com.fleetdm.conditional-access-okta</string>
|
|
<key>PayloadOrganization</key>
|
|
<string>Fleet Device Management</string>
|
|
<key>PayloadRemovalDisallowed</key>
|
|
<false/>
|
|
<key>PayloadScope</key>
|
|
<string>User</string>
|
|
<key>PayloadType</key>
|
|
<string>Configuration</string>
|
|
<key>PayloadUUID</key>
|
|
<string>{{.RootPayloadUUID}}</string>
|
|
<key>PayloadVersion</key>
|
|
<integer>1</integer>
|
|
</dict>
|
|
</plist>
|
|
`
|
|
|
|
var conditionalAccessAppleProfileTemplateParsed = template.Must(template.New("conditionalAccessAppleProfile").Parse(
|
|
conditionalAccessAppleProfileTemplate))
|
|
|
|
// fleetConditionalAccessNamespace is a custom UUID namespace for Fleet Okta conditional access profiles.
|
|
// Generated using: uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://fleetdm.com/learn-more-about/okta-conditional-access"))
|
|
// This ensures UUIDs are unique to Fleet's Okta conditional access feature and won't collide with other systems.
|
|
var fleetConditionalAccessNamespace = uuid.Must(uuid.Parse("fe5c0046-e83e-5a1d-9693-ace1348d34ec"))
|
|
|
|
// generateDeterministicUUID generates a UUID v5 based on the server URL and a component name.
|
|
// This ensures the same server always generates the same UUIDs for profile components.
|
|
func generateDeterministicUUID(serverURL, component string) string {
|
|
// Use Fleet's conditional access namespace to avoid collisions
|
|
// Create a deterministic UUID based on serverURL + component
|
|
name := fmt.Sprintf("%s:%s", serverURL, component)
|
|
return uuid.NewSHA1(fleetConditionalAccessNamespace, []byte(name)).String()
|
|
}
|
|
|
|
type appleProfileTemplateData struct {
|
|
CACertBase64 string
|
|
SCEPURL string
|
|
Challenge string
|
|
CertificateCN string
|
|
MTLSURL string
|
|
CACertUUID string
|
|
SCEPPayloadUUID string
|
|
IdentityPrefUUID string
|
|
ChromeConfigUUID string
|
|
RootPayloadUUID string
|
|
}
|
|
|
|
type conditionalAccessGetIdPSigningCertRequest struct{}
|
|
|
|
type conditionalAccessGetIdPSigningCertResponse struct {
|
|
CertPEM []byte
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r conditionalAccessGetIdPSigningCertResponse) Error() error { return r.Err }
|
|
|
|
func (r conditionalAccessGetIdPSigningCertResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.CertPEM)), 10))
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-idp-signing-cert.pem\"")
|
|
|
|
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the header provided
|
|
n, err := w.Write(r.CertPEM)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_written", n)
|
|
}
|
|
}
|
|
|
|
func conditionalAccessGetIdPSigningCertEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
certPEM, err := svc.ConditionalAccessGetIdPSigningCert(ctx)
|
|
if err != nil {
|
|
return conditionalAccessGetIdPSigningCertResponse{Err: err}, nil
|
|
}
|
|
return conditionalAccessGetIdPSigningCertResponse{
|
|
CertPEM: certPEM,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ConditionalAccessGetIdPSigningCert(ctx context.Context) (certPEM []byte, err error) {
|
|
// Check user is authorized to read conditional access Okta IdP certificate
|
|
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
|
|
}
|
|
|
|
// Check that server private key is configured
|
|
if len(svc.config.Server.PrivateKey) == 0 {
|
|
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
|
|
}
|
|
|
|
// Load IdP certificate from mdm_config_assets
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetConditionalAccessIDPCert,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load IdP certificate")
|
|
}
|
|
|
|
certAsset, ok := assets[fleet.MDMAssetConditionalAccessIDPCert]
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "IdP certificate not configured")
|
|
}
|
|
|
|
return certAsset.Value, nil
|
|
}
|
|
|
|
type conditionalAccessGetIdPAppleProfileResponse struct {
|
|
ProfileData []byte
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r conditionalAccessGetIdPAppleProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r conditionalAccessGetIdPAppleProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.ProfileData)), 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"fleet-conditional-access.mobileconfig\"")
|
|
|
|
// OK to just log the error here as writing anything on `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the header provided
|
|
n, err := w.Write(r.ProfileData)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_written", n)
|
|
}
|
|
}
|
|
|
|
func conditionalAccessGetIdPAppleProfileEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
if err != nil {
|
|
return conditionalAccessGetIdPAppleProfileResponse{Err: err}, nil
|
|
}
|
|
return conditionalAccessGetIdPAppleProfileResponse{
|
|
ProfileData: profileData,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ConditionalAccessGetIdPAppleProfile(ctx context.Context) (profileData []byte, err error) {
|
|
// Check user is authorized to read conditional access Apple profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.ConditionalAccessIDPAssets{}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to authorize")
|
|
}
|
|
|
|
// Check that server private key is configured
|
|
if len(svc.config.Server.PrivateKey) == 0 {
|
|
return nil, &fleet.BadRequestError{Message: "Fleet server private key is not configured. Learn more: https://fleetdm.com/learn-more-about/fleet-server-private-key"}
|
|
}
|
|
|
|
// Load CA certificate for SCEP from mdm_config_assets
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetConditionalAccessCACert,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load conditional access CA certificate")
|
|
}
|
|
|
|
caCertAsset, ok := assets[fleet.MDMAssetConditionalAccessCACert]
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "conditional access CA certificate not configured")
|
|
}
|
|
|
|
// Parse PEM certificate
|
|
block, _ := pem.Decode(caCertAsset.Value)
|
|
if block == nil {
|
|
return nil, ctxerr.New(ctx, "failed to decode CA certificate PEM")
|
|
}
|
|
|
|
// Parse DER certificate
|
|
_, err = x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse CA certificate")
|
|
}
|
|
|
|
// Base64 encode the DER certificate for the profile
|
|
caCertBase64 := base64.StdEncoding.EncodeToString(block.Bytes)
|
|
|
|
// Get app config for server URL
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to load app config")
|
|
}
|
|
if strings.TrimSpace(appConfig.ServerSettings.ServerURL) == "" {
|
|
return nil, &fleet.BadRequestError{Message: "server URL is not configured"}
|
|
}
|
|
|
|
// Construct SCEP URL using net/url package
|
|
parsedServerURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse server URL")
|
|
}
|
|
scepURL := parsedServerURL.JoinPath("/api/fleet/conditional_access/scep").String()
|
|
|
|
// Get global enroll secrets
|
|
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get enroll secrets")
|
|
}
|
|
if len(secrets) == 0 {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "global enroll secret is not configured"})
|
|
}
|
|
|
|
// Use the first global enroll secret as the challenge
|
|
challenge := secrets[0].Secret
|
|
|
|
// Get mTLS URL using ConditionalAccessIdPSSOURL
|
|
mtlsURL, err := appConfig.ConditionalAccessIdPSSOURL(dev_mode.Env)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get mTLS URL")
|
|
}
|
|
|
|
// Generate deterministic UUIDs based on server URL
|
|
serverURL := appConfig.ServerSettings.ServerURL
|
|
caCertUUID := generateDeterministicUUID(serverURL, "conditional-access-ca-cert")
|
|
scepPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-scep")
|
|
identityPrefUUID := generateDeterministicUUID(serverURL, "conditional-access-identity-pref")
|
|
chromeConfigUUID := generateDeterministicUUID(serverURL, "conditional-access-chrome-config")
|
|
rootPayloadUUID := generateDeterministicUUID(serverURL, "conditional-access-root-payload")
|
|
|
|
// Execute template
|
|
var buf bytes.Buffer
|
|
if err := conditionalAccessAppleProfileTemplateParsed.Execute(&buf, appleProfileTemplateData{
|
|
CACertBase64: caCertBase64,
|
|
SCEPURL: scepURL,
|
|
Challenge: challenge,
|
|
CertificateCN: "Fleet conditional access for Okta",
|
|
MTLSURL: mtlsURL,
|
|
CACertUUID: caCertUUID,
|
|
SCEPPayloadUUID: scepPayloadUUID,
|
|
IdentityPrefUUID: identityPrefUUID,
|
|
ChromeConfigUUID: chromeConfigUUID,
|
|
RootPayloadUUID: rootPayloadUUID,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to execute profile template")
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|