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