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:
@@ -0,0 +1 @@
|
||||
* Added support for all IdP variables and host platform in certificate template subject names and SANs.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<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{}
|
||||
|
||||
Reference in New Issue
Block a user