diff --git a/changes/36774-cert-template-all-idp-vars b/changes/36774-cert-template-all-idp-vars
new file mode 100644
index 0000000000..1f0ffc37e1
--- /dev/null
+++ b/changes/36774-cert-template-all-idp-vars
@@ -0,0 +1 @@
+* Added support for all IdP variables and host platform in certificate template subject names and SANs.
diff --git a/server/service/certificate_templates.go b/server/service/certificate_templates.go
index be41087bbd..40e321e891 100644
--- a/server/service/certificate_templates.go
+++ b/server/service/certificate_templates.go
@@ -11,11 +11,39 @@ import (
"github.com/fleetdm/fleet/v4/server/variables"
)
+// escapeDNValue escapes special characters in a string value being substituted
+// into an X.500 Distinguished Name or SAN, per RFC 4514 §2.4.
+func escapeDNValue(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ for i, r := range s {
+ switch {
+ case r == ',' || r == '+' || r == '"' || r == '\\' || r == '<' || r == '>' || r == ';':
+ b.WriteByte('\\')
+ b.WriteRune(r)
+ case r == '#' && i == 0:
+ b.WriteByte('\\')
+ b.WriteRune(r)
+ case r == ' ' && (i == 0 || i == len(s)-1):
+ b.WriteByte('\\')
+ b.WriteRune(r)
+ default:
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
// Fleet variables supported in certificate template subject names and SANs.
var fleetVarsSupportedInCertificateTemplates = []fleet.FleetVarName{
fleet.FleetVarHostUUID,
fleet.FleetVarHostHardwareSerial,
+ fleet.FleetVarHostPlatform,
fleet.FleetVarHostEndUserIDPUsername,
+ fleet.FleetVarHostEndUserIDPUsernameLocalPart,
+ fleet.FleetVarHostEndUserIDPGroups,
+ fleet.FleetVarHostEndUserIDPDepartment,
+ fleet.FleetVarHostEndUserIDPFullname,
}
// maxCertificateTemplateSubjectAlternativeNameLength caps the SAN string length to prevent
@@ -106,7 +134,7 @@ func validateCertificateTemplateSubjectAlternativeName(san, certName string) err
// replaceCertificateVariables replaces FLEET_VAR_* variables in the input string with actual
// host values. endUsersMemo is an optional cross-call cache for the host's end-user list — pass
// the same `*[]fleet.HostEndUser` (with `*memo == nil` initially) into successive calls for the
-// same host to avoid re-fetching from the datastore. The IDP-username variable is the only one
+// same host to avoid re-fetching from the datastore. The IDP related variable is the only one
// that triggers a DB round-trip; UUID and hardware serial come from the in-memory host struct.
func (svc *Service) replaceCertificateVariables(ctx context.Context, input string, host *fleet.Host, endUsersMemo *[]fleet.HostEndUser) (string, error) {
fleetVars := variables.Find(input)
@@ -114,6 +142,36 @@ func (svc *Service) replaceCertificateVariables(ctx context.Context, input strin
return input, nil
}
+ // fetchEndUsers lazily fetches and caches the host's end-user list.
+ fetchEndUsers := func(fleetVar string) ([]fleet.HostEndUser, error) {
+ if endUsersMemo != nil && *endUsersMemo != nil {
+ return *endUsersMemo, nil
+ }
+ fetched, err := fleet.GetEndUsers(ctx, svc.ds, host.ID)
+ if err != nil {
+ return nil, ctxerr.Wrapf(ctx, err, "getting host end users for variable %s", fleetVar)
+ }
+ if endUsersMemo != nil {
+ if fetched == nil {
+ fetched = []fleet.HostEndUser{}
+ }
+ *endUsersMemo = fetched
+ }
+ return fetched, nil
+ }
+
+ // requireIDPUser fetches end users and returns the first IDP user, or an error if none.
+ requireIDPUser := func(fleetVar string) (*fleet.HostEndUser, error) {
+ users, err := fetchEndUsers(fleetVar)
+ if err != nil {
+ return nil, err
+ }
+ if len(users) == 0 || users[0].IdpUserName == "" {
+ return nil, ctxerr.Errorf(ctx, "host %s does not have an IDP user for variable %s", host.UUID, fleetVar)
+ }
+ return &users[0], nil
+ }
+
result := input
for _, fleetVar := range fleetVars {
switch fleetVar {
@@ -121,30 +179,58 @@ func (svc *Service) replaceCertificateVariables(ctx context.Context, input strin
if host.UUID == "" {
return "", ctxerr.Errorf(ctx, "host does not have a UUID for variable %s", fleetVar)
}
- result = fleet.FleetVarHostUUIDRegexp.ReplaceAllString(result, host.UUID)
+ result = fleet.FleetVarHostUUIDRegexp.ReplaceAllString(result, escapeDNValue(host.UUID))
case string(fleet.FleetVarHostHardwareSerial):
if host.HardwareSerial == "" {
return "", ctxerr.Errorf(ctx, "host %s does not have a hardware serial for variable %s", host.UUID, fleetVar)
}
- result = fleet.FleetVarHostHardwareSerialRegexp.ReplaceAllString(result, host.HardwareSerial)
+ result = fleet.FleetVarHostHardwareSerialRegexp.ReplaceAllString(result, escapeDNValue(host.HardwareSerial))
+ case string(fleet.FleetVarHostPlatform):
+ if host.Platform == "" {
+ return "", ctxerr.Errorf(ctx, "host %s does not have a platform for variable %s", host.UUID, fleetVar)
+ }
+ result = fleet.FleetVarHostPlatformRegexp.ReplaceAllString(result, escapeDNValue(host.Platform))
case string(fleet.FleetVarHostEndUserIDPUsername):
- var users []fleet.HostEndUser
- if endUsersMemo != nil && *endUsersMemo != nil {
- users = *endUsersMemo
- } else {
- fetched, err := fleet.GetEndUsers(ctx, svc.ds, host.ID)
- if err != nil {
- return "", ctxerr.Wrapf(ctx, err, "getting host end users for variable %s", fleetVar)
- }
- users = fetched
- if endUsersMemo != nil {
- *endUsersMemo = users
- }
+ user, err := requireIDPUser(fleetVar)
+ if err != nil {
+ return "", err
}
- if len(users) == 0 || users[0].IdpUserName == "" {
- return "", ctxerr.Errorf(ctx, "host %s does not have an IDP username for variable %s", host.UUID, fleetVar)
+ result = fleet.FleetVarHostEndUserIDPUsernameRegexp.ReplaceAllString(result, escapeDNValue(user.IdpUserName))
+ case string(fleet.FleetVarHostEndUserIDPUsernameLocalPart):
+ user, err := requireIDPUser(fleetVar)
+ if err != nil {
+ return "", err
}
- result = fleet.FleetVarHostEndUserIDPUsernameRegexp.ReplaceAllString(result, users[0].IdpUserName)
+ local, _, _ := strings.Cut(user.IdpUserName, "@")
+ result = fleet.FleetVarHostEndUserIDPUsernameLocalPartRegexp.ReplaceAllString(result, escapeDNValue(local))
+ case string(fleet.FleetVarHostEndUserIDPGroups):
+ user, err := requireIDPUser(fleetVar)
+ if err != nil {
+ return "", err
+ }
+ if len(user.IdpGroups) == 0 {
+ return "", ctxerr.Errorf(ctx, "host %s does not have IDP groups for variable %s", host.UUID, fleetVar)
+ }
+ result = fleet.FleetVarHostEndUserIDPGroupsRegexp.ReplaceAllString(result, escapeDNValue(strings.Join(user.IdpGroups, ",")))
+ case string(fleet.FleetVarHostEndUserIDPDepartment):
+ user, err := requireIDPUser(fleetVar)
+ if err != nil {
+ return "", err
+ }
+ if user.Department == "" {
+ return "", ctxerr.Errorf(ctx, "host %s does not have an IDP department for variable %s", host.UUID, fleetVar)
+ }
+ result = fleet.FleetVarHostEndUserIDPDepartmentRegexp.ReplaceAllString(result, escapeDNValue(user.Department))
+ case string(fleet.FleetVarHostEndUserIDPFullname):
+ user, err := requireIDPUser(fleetVar)
+ if err != nil {
+ return "", err
+ }
+ fullName := strings.TrimSpace(user.IdpFullName)
+ if fullName == "" {
+ return "", ctxerr.Errorf(ctx, "host %s does not have an IDP full name for variable %s", host.UUID, fleetVar)
+ }
+ result = fleet.FleetVarHostEndUserIDPFullnameRegexp.ReplaceAllString(result, escapeDNValue(fullName))
default:
return "", ctxerr.Errorf(ctx, "unsupported Fleet variable %s in certificate template", fleetVar)
}
diff --git a/server/service/certificate_templates_test.go b/server/service/certificate_templates_test.go
index 2c7bdff223..338b6c26ad 100644
--- a/server/service/certificate_templates_test.go
+++ b/server/service/certificate_templates_test.go
@@ -266,9 +266,24 @@ func TestCreateCertificateTemplateSubjectAlternativeName(t *testing.T) {
t.Run("Unsupported variable in SAN is rejected", func(t *testing.T) {
svc, ctx, _ := makePremiumService(t)
- _, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "EMAIL=$FLEET_VAR_HOST_PLATFORM")
+ _, err := svc.CreateCertificateTemplate(ctx, "wifi", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", "EMAIL=$FLEET_VAR_NDES_SCEP_CHALLENGE")
require.Error(t, err)
- require.Contains(t, err.Error(), "FLEET_VAR_HOST_PLATFORM")
+ require.Contains(t, err.Error(), "FLEET_VAR_NDES_SCEP_CHALLENGE")
+ })
+
+ t.Run("All supported HOST variables accepted in SAN", func(t *testing.T) {
+ svc, ctx, _ := makePremiumService(t)
+
+ san := "DNS=$FLEET_VAR_HOST_UUID, EMAIL=$FLEET_VAR_HOST_END_USER_IDP_USERNAME, " +
+ "UPN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART, " +
+ "URI=$FLEET_VAR_HOST_END_USER_IDP_GROUPS, " +
+ "DNS=$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT, " +
+ "EMAIL=$FLEET_VAR_HOST_END_USER_IDP_FULL_NAME, " +
+ "DNS=$FLEET_VAR_HOST_PLATFORM, " +
+ "DNS=$FLEET_VAR_HOST_HARDWARE_SERIAL"
+ resp, err := svc.CreateCertificateTemplate(ctx, "all-vars", TeamID, ValidCATypeID, "CN=$FLEET_VAR_HOST_UUID", san)
+ require.NoError(t, err)
+ require.Equal(t, san, resp.SubjectAlternativeName)
})
}
@@ -555,6 +570,179 @@ func TestApplyCertificateTemplateSpecs(t *testing.T) {
})
}
+func TestReplaceCertificateVariables(t *testing.T) {
+ ds := new(mock.Store)
+
+ givenName := "Jane"
+ familyName := "Doe"
+ dept := "Engineering"
+
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return &fleet.ScimUser{
+ UserName: "jane@example.com",
+ GivenName: &givenName,
+ FamilyName: &familyName,
+ Department: &dept,
+ Groups: []fleet.ScimUserGroup{
+ {DisplayName: "admins"},
+ {DisplayName: "devs"},
+ },
+ }, nil
+ }
+ ds.ListHostDeviceMappingFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostDeviceMapping, error) {
+ return nil, nil
+ }
+
+ svc := &Service{ds: ds}
+ host := &fleet.Host{
+ ID: 1,
+ UUID: "host-uuid-123",
+ HardwareSerial: "SERIAL-456",
+ Platform: "android",
+ }
+
+ t.Run("HOST_UUID", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_UUID", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=host-uuid-123", result)
+ })
+
+ t.Run("HOST_HARDWARE_SERIAL", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_HARDWARE_SERIAL", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=SERIAL-456", result)
+ })
+
+ t.Run("HOST_PLATFORM", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "O=$FLEET_VAR_HOST_PLATFORM", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "O=android", result)
+ })
+
+ t.Run("HOST_END_USER_IDP_USERNAME", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=jane@example.com", result)
+ })
+
+ t.Run("HOST_END_USER_IDP_USERNAME_LOCAL_PART", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=jane", result)
+ })
+
+ t.Run("HOST_END_USER_IDP_GROUPS", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_GROUPS", host, nil)
+ require.NoError(t, err)
+ // Comma between groups is escaped so it's not mistaken for a DN separator.
+ require.Equal(t, `OU=admins\,devs`, result)
+ })
+
+ t.Run("HOST_END_USER_IDP_DEPARTMENT", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "OU=Engineering", result)
+ })
+
+ t.Run("HOST_END_USER_IDP_FULL_NAME", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_FULL_NAME", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=Jane Doe", result)
+ })
+
+ t.Run("multiple variables in one string", func(t *testing.T) {
+ input := "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME,O=$FLEET_VAR_HOST_PLATFORM,OU=$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT"
+ result, err := svc.replaceCertificateVariables(t.Context(), input, host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=jane@example.com,O=android,OU=Engineering", result)
+ })
+
+ t.Run("endUsersMemo is populated on first call and reused", func(t *testing.T) {
+ var memo []fleet.HostEndUser
+ _, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME", host, &memo)
+ require.NoError(t, err)
+ require.NotNil(t, memo)
+ require.Len(t, memo, 1)
+
+ // Second call reuses the memo without hitting the datastore again.
+ ds.ScimUserByHostIDFuncInvoked = false
+ _, err = svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_FULL_NAME", host, &memo)
+ require.NoError(t, err)
+ require.False(t, ds.ScimUserByHostIDFuncInvoked)
+ })
+
+ t.Run("missing IDP user returns error", func(t *testing.T) {
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return nil, ¬FoundError{}
+ }
+ _, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME", host, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not have an IDP user")
+ })
+
+ t.Run("missing groups returns error", func(t *testing.T) {
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return &fleet.ScimUser{UserName: "jane@example.com"}, nil
+ }
+ _, err := svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_GROUPS", host, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not have IDP groups")
+ })
+
+ t.Run("missing department returns error", func(t *testing.T) {
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return &fleet.ScimUser{UserName: "jane@example.com"}, nil
+ }
+ _, err := svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT", host, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not have an IDP department")
+ })
+
+ t.Run("missing full name returns error", func(t *testing.T) {
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return &fleet.ScimUser{UserName: "jane@example.com"}, nil
+ }
+ _, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_HOST_END_USER_IDP_FULL_NAME", host, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "does not have an IDP full name")
+ })
+
+ t.Run("no variables returns input unchanged", func(t *testing.T) {
+ result, err := svc.replaceCertificateVariables(t.Context(), "CN=static-value", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, "CN=static-value", result)
+ })
+
+ t.Run("unsupported variable returns error", func(t *testing.T) {
+ _, err := svc.replaceCertificateVariables(t.Context(), "CN=$FLEET_VAR_NDES_SCEP_CHALLENGE", host, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "unsupported Fleet variable")
+ })
+
+ t.Run("special characters are RFC 4514 escaped", func(t *testing.T) {
+ dept := "Sales, Marketing + Ops"
+ ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) {
+ return &fleet.ScimUser{
+ UserName: "jane@example.com",
+ GivenName: &givenName,
+ FamilyName: &familyName,
+ Department: &dept,
+ Groups: []fleet.ScimUserGroup{
+ {DisplayName: "group"},
+ {DisplayName: `group"B"`},
+ },
+ }, nil
+ }
+ result, err := svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, `OU=Sales\, Marketing \+ Ops`, result)
+
+ result, err = svc.replaceCertificateVariables(t.Context(), "OU=$FLEET_VAR_HOST_END_USER_IDP_GROUPS", host, nil)
+ require.NoError(t, err)
+ require.Equal(t, `OU=group\\,group\"B\"`, result)
+ })
+}
+
func TestResendHostCertificateTemplate(t *testing.T) {
ds := new(mock.Store)
opts := &TestServerOpts{}