mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #34539 Added endpoint to get a sample Apple config profile that IT admin can use for Okta conditional access configuration. `/api/_version_/fleet/conditional_access/idp/apple/profile` And additional cleanup/improvements: - logging - error handling (sending errors to Sentry/OTEL) - redirect end user to error page if IT admin hasn't set up conditional access in Fleet Contributor API changes at: https://github.com/fleetdm/fleet/pull/35632/files # Checklist for submitter - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - Will be added to related PR: #35204 ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Apple profile generation for Okta conditional access IdP integration. * New endpoint for retrieving conditional access Apple configuration profiles. * **Bug Fixes** * Improved error handling and logging for conditional access operations. * Enhanced error responses for missing server URL configuration. * **Refactoring** * Centralized error handling for internal server errors with improved context logging. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
468 lines
16 KiB
Go
468 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mock"
|
|
"github.com/fleetdm/fleet/v4/server/test"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestConditionalAccessGetIdPSigningCertAuth(t *testing.T) {
|
|
t.Parallel()
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
|
|
// Mock the datastore to return a valid IdP certificate
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessIDPCert: {
|
|
Name: fleet.MDMAssetConditionalAccessIDPCert,
|
|
Value: []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
user *fleet.User
|
|
shouldFail bool
|
|
}{
|
|
{"global admin", test.UserAdmin, false},
|
|
{"global maintainer", test.UserMaintainer, false},
|
|
{"global observer", test.UserObserver, false},
|
|
{"global observer+", test.UserObserverPlus, false},
|
|
{"global gitops", test.UserGitOps, false},
|
|
{"team admin", test.UserTeamAdminTeam1, true},
|
|
{"team maintainer", test.UserTeamMaintainerTeam1, true},
|
|
{"team observer", test.UserTeamObserverTeam1, true},
|
|
{"team observer+", test.UserTeamObserverPlusTeam1, true},
|
|
{"team gitops", test.UserTeamGitOpsTeam1, true},
|
|
{"user no roles", test.UserNoRoles, true},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := test.UserContext(ctx, tt.user)
|
|
|
|
certPEM, err := svc.ConditionalAccessGetIdPSigningCert(ctx)
|
|
if tt.shouldFail {
|
|
require.Error(t, err)
|
|
var forbiddenError *authz.Forbidden
|
|
require.ErrorAs(t, err, &forbiddenError)
|
|
require.Nil(t, certPEM)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, certPEM)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConditionalAccessGetIdPSigningCert(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("missing server private key", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "" // Not configured
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
certPEM, err := svc.ConditionalAccessGetIdPSigningCert(ctx)
|
|
var badReqErr *fleet.BadRequestError
|
|
require.ErrorAs(t, err, &badReqErr)
|
|
require.Contains(t, err.Error(), "Fleet server private key is not configured")
|
|
require.Nil(t, certPEM)
|
|
})
|
|
}
|
|
|
|
func TestConditionalAccessGetIdPAppleProfileAuth(t *testing.T) {
|
|
t.Parallel()
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
|
|
// Mock valid certificate
|
|
certPEM := generateTestCertPEM(t)
|
|
|
|
// Mock the datastore methods
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "https://fleet.example.com",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
return []*fleet.EnrollSecret{
|
|
{Secret: "test-secret-123"},
|
|
}, nil
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
user *fleet.User
|
|
shouldFail bool
|
|
}{
|
|
{"global admin", test.UserAdmin, false},
|
|
{"global maintainer", test.UserMaintainer, false},
|
|
{"global observer", test.UserObserver, false},
|
|
{"global observer+", test.UserObserverPlus, false},
|
|
{"global gitops", test.UserGitOps, false},
|
|
{"team admin", test.UserTeamAdminTeam1, true},
|
|
{"team maintainer", test.UserTeamMaintainerTeam1, true},
|
|
{"team observer", test.UserTeamObserverTeam1, true},
|
|
{"team observer+", test.UserTeamObserverPlusTeam1, true},
|
|
{"team gitops", test.UserTeamGitOpsTeam1, true},
|
|
{"user no roles", test.UserNoRoles, true},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := test.UserContext(ctx, tt.user)
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
if tt.shouldFail {
|
|
require.Error(t, err)
|
|
var forbiddenError *authz.Forbidden
|
|
require.ErrorAs(t, err, &forbiddenError)
|
|
require.Nil(t, profileData)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, profileData)
|
|
|
|
// Verify the profile contains expected content
|
|
profileStr := string(profileData)
|
|
require.Contains(t, profileStr, "com.fleetdm.conditional-access")
|
|
require.Contains(t, profileStr, "https://okta.fleet.example.com")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// generateTestCertPEM generates a test certificate in PEM format for testing
|
|
func generateTestCertPEM(t *testing.T) []byte {
|
|
// Create a simple self-signed certificate
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: "Test CA",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
IsCA: true,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
|
require.NoError(t, err)
|
|
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
|
|
return certPEM
|
|
}
|
|
|
|
func TestConditionalAccessGetIdPAppleProfile(t *testing.T) {
|
|
certPEM := generateTestCertPEM(t)
|
|
|
|
t.Run("missing server private key", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "" // Not configured
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
var badReqErr *fleet.BadRequestError
|
|
require.ErrorAs(t, err, &badReqErr)
|
|
require.Contains(t, err.Error(), "Fleet server private key is not configured")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("success - generates valid profile", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "https://fleet.example.com:8080",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
return []*fleet.EnrollSecret{
|
|
{Secret: "test-secret-456"},
|
|
}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, profileData)
|
|
|
|
profileStr := string(profileData)
|
|
// Verify XML structure
|
|
require.Contains(t, profileStr, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
|
|
require.Contains(t, profileStr, "<!DOCTYPE plist")
|
|
|
|
// Verify URLs with port preserved
|
|
require.Contains(t, profileStr, "https://fleet.example.com:8080/api/fleet/conditional_access/scep")
|
|
require.Contains(t, profileStr, "https://okta.fleet.example.com:8080")
|
|
|
|
// Verify challenge secret
|
|
require.Contains(t, profileStr, "test-secret-456")
|
|
|
|
// Verify payload identifiers
|
|
require.Contains(t, profileStr, "com.fleetdm.conditional-access-ca")
|
|
require.Contains(t, profileStr, "com.fleetdm.conditional-access-scep")
|
|
require.Contains(t, profileStr, "com.fleetdm.conditional-access-preference")
|
|
require.Contains(t, profileStr, "com.fleetdm.chrome.certs")
|
|
|
|
// Verify certificate CN is present in the profile
|
|
require.Contains(t, profileStr, "Fleet conditional access for Okta")
|
|
})
|
|
|
|
t.Run("missing CA certificate", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "conditional access CA certificate not configured")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("invalid PEM certificate", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: []byte("not a valid PEM"),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "failed to decode CA certificate PEM")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("invalid DER certificate", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
// Valid PEM structure but invalid DER content
|
|
invalidCertPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: []byte("invalid DER data"),
|
|
})
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: invalidCertPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "failed to parse CA certificate")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("no enroll secrets", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "https://fleet.example.com",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
return []*fleet.EnrollSecret{}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
var badReqErr *fleet.BadRequestError
|
|
require.ErrorAs(t, err, &badReqErr)
|
|
require.Contains(t, err.Error(), "global enroll secret is not configured")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("server URL not configured", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
var badReqErr *fleet.BadRequestError
|
|
require.ErrorAs(t, err, &badReqErr)
|
|
require.Contains(t, err.Error(), "server URL is not configured")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("invalid server URL", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "://invalid-url",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
profileData, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "failed to parse server URL")
|
|
require.Nil(t, profileData)
|
|
})
|
|
|
|
t.Run("deterministic UUIDs based on server URL", func(t *testing.T) {
|
|
ds := new(mock.Store)
|
|
cfg := config.TestConfig()
|
|
cfg.Server.PrivateKey = "test-private-key"
|
|
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil)
|
|
ctx = test.UserContext(ctx, test.UserAdmin)
|
|
|
|
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
|
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
|
fleet.MDMAssetConditionalAccessCACert: {
|
|
Name: fleet.MDMAssetConditionalAccessCACert,
|
|
Value: certPEM,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
|
return &fleet.AppConfig{
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "https://fleet.example.com",
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) {
|
|
return []*fleet.EnrollSecret{
|
|
{Secret: "test-secret"},
|
|
}, nil
|
|
}
|
|
|
|
// Generate profile twice with same server URL
|
|
profileData1, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.NoError(t, err)
|
|
|
|
profileData2, err := svc.ConditionalAccessGetIdPAppleProfile(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// UUIDs should be identical
|
|
require.Equal(t, profileData1, profileData2)
|
|
})
|
|
}
|