Android certs support all idp vars (#48100)

**Related issue:** Resolves #36774

# 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.

## 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**
* Certificate templates now support additional variables for subject
names and SANs, including host platform and identity-provider-derived
fields such as username (local part), groups, department, and full name.
* **Bug Fixes**
* Improved validation and error handling for missing host or
identity-provider data during template variable substitution.
* **Tests**
* Expanded coverage for supported/unsupported variables, correct
placeholder replacement, caching behavior, and RFC 4514 escaping in
DN-related values.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Konstantin Sykulev
2026-06-23 16:58:40 -05:00
committed by GitHub
parent 3900de5125
commit a93c61722d
3 changed files with 295 additions and 20 deletions
+1
View File
@@ -0,0 +1 @@
* Added support for all IdP variables and host platform in certificate template subject names and SANs.
+104 -18
View File
@@ -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)
}
+190 -2
View File
@@ -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, &notFoundError{}
}
_, 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<A>"},
{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\<A\>\,group\"B\"`, result)
})
}
func TestResendHostCertificateTemplate(t *testing.T) {
ds := new(mock.Store)
opts := &TestServerOpts{}