fleet/server/service/conditional_access_idp_test.go
Victor Lyuboslavsky 7f67ac940f
Okta IdP Apple profile endpoint + fixes (#35526)
<!-- 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 -->
2025-11-14 13:49:08 -06:00

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)
})
}