mirror of
https://github.com/fleetdm/fleet
synced 2026-05-18 14:38:53 +00:00
457 lines
17 KiB
Go
457 lines
17 KiB
Go
package fleet
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/service/externalsvc"
|
|
)
|
|
|
|
// TeamIntegrations contains the configuration for external services'
|
|
// integrations for a specific team.
|
|
type TeamIntegrations struct {
|
|
Jira []*TeamJiraIntegration `json:"jira"`
|
|
Zendesk []*TeamZendeskIntegration `json:"zendesk"`
|
|
}
|
|
|
|
// MatchWithIntegrations matches the team integrations to their corresponding
|
|
// global integrations found in globalIntgs, returning the resulting
|
|
// integrations struct. It returns an error if any team integration does not
|
|
// map to a global integration, but it will still return the complete list
|
|
// of integrations that do match.
|
|
func (ti TeamIntegrations) MatchWithIntegrations(globalIntgs Integrations) (Integrations, error) {
|
|
var result Integrations
|
|
|
|
jiraIntgs, err := IndexJiraIntegrations(globalIntgs.Jira)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
zendeskIntgs, err := IndexZendeskIntegrations(globalIntgs.Zendesk)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
var errs []string
|
|
for _, tmJira := range ti.Jira {
|
|
key := tmJira.UniqueKey()
|
|
intg, ok := jiraIntgs[key]
|
|
if !ok {
|
|
errs = append(errs, fmt.Sprintf("unknown Jira integration for url %s and project key %s", tmJira.URL, tmJira.ProjectKey))
|
|
continue
|
|
}
|
|
intg.EnableFailingPolicies = tmJira.EnableFailingPolicies
|
|
result.Jira = append(result.Jira, &intg)
|
|
}
|
|
for _, tmZendesk := range ti.Zendesk {
|
|
key := tmZendesk.UniqueKey()
|
|
intg, ok := zendeskIntgs[key]
|
|
if !ok {
|
|
errs = append(errs, fmt.Sprintf("unknown Zendesk integration for url %s and group ID %v", tmZendesk.URL, tmZendesk.GroupID))
|
|
continue
|
|
}
|
|
intg.EnableFailingPolicies = tmZendesk.EnableFailingPolicies
|
|
result.Zendesk = append(result.Zendesk, &intg)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
err = errors.New(strings.Join(errs, "\n"))
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
// Validate validates the team integrations for uniqueness.
|
|
func (ti TeamIntegrations) Validate() error {
|
|
jira := make(map[string]*TeamJiraIntegration, len(ti.Jira))
|
|
for _, j := range ti.Jira {
|
|
key := j.UniqueKey()
|
|
if _, ok := jira[key]; ok {
|
|
return fmt.Errorf("duplicate Jira integration for url %s and project key %s", j.URL, j.ProjectKey)
|
|
}
|
|
jira[key] = j
|
|
}
|
|
|
|
zendesk := make(map[string]*TeamZendeskIntegration, len(ti.Zendesk))
|
|
for _, z := range ti.Zendesk {
|
|
key := z.UniqueKey()
|
|
if _, ok := zendesk[key]; ok {
|
|
return fmt.Errorf("duplicate Zendesk integration for url %s and group ID %v", z.URL, z.GroupID)
|
|
}
|
|
zendesk[key] = z
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TeamJiraIntegration configures an instance of an integration with the Jira
|
|
// system for a team.
|
|
type TeamJiraIntegration struct {
|
|
URL string `json:"url"`
|
|
ProjectKey string `json:"project_key"`
|
|
EnableFailingPolicies bool `json:"enable_failing_policies"`
|
|
}
|
|
|
|
// UniqueKey returns the unique key of this integration.
|
|
func (j TeamJiraIntegration) UniqueKey() string {
|
|
return j.URL + "\n" + j.ProjectKey
|
|
}
|
|
|
|
// TeamZendeskIntegration configures an instance of an integration with the
|
|
// external Zendesk service for a team.
|
|
type TeamZendeskIntegration struct {
|
|
URL string `json:"url"`
|
|
GroupID int64 `json:"group_id"`
|
|
EnableFailingPolicies bool `json:"enable_failing_policies"`
|
|
}
|
|
|
|
// UniqueKey returns the unique key of this integration.
|
|
func (z TeamZendeskIntegration) UniqueKey() string {
|
|
return z.URL + "\n" + strconv.FormatInt(z.GroupID, 10)
|
|
}
|
|
|
|
// JiraIntegration configures an instance of an integration with the Jira
|
|
// system.
|
|
type JiraIntegration struct {
|
|
URL string `json:"url"`
|
|
Username string `json:"username"`
|
|
APIToken string `json:"api_token"`
|
|
ProjectKey string `json:"project_key"`
|
|
EnableFailingPolicies bool `json:"enable_failing_policies"`
|
|
EnableSoftwareVulnerabilities bool `json:"enable_software_vulnerabilities"`
|
|
}
|
|
|
|
func (j JiraIntegration) uniqueKey() string {
|
|
return j.URL + "\n" + j.ProjectKey
|
|
}
|
|
|
|
// IndexJiraIntegrations indexes the provided Jira integrations in a map keyed
|
|
// by 'URL\nProjectKey'. It returns an error if a duplicate configuration is
|
|
// found for the same combination. This is typically used to index the original
|
|
// integrations before applying the changes requested to modify the AppConfig.
|
|
//
|
|
// Note that the returned map uses non-pointer JiraIntegration struct values,
|
|
// so that any changes to the original value does not modify the value in the
|
|
// map. This is important because of how changes are merged with the original
|
|
// AppConfig when modifying it.
|
|
func IndexJiraIntegrations(jiraIntgs []*JiraIntegration) (map[string]JiraIntegration, error) {
|
|
indexed := make(map[string]JiraIntegration, len(jiraIntgs))
|
|
for _, intg := range jiraIntgs {
|
|
key := intg.uniqueKey()
|
|
if _, ok := indexed[key]; ok {
|
|
return nil, fmt.Errorf("duplicate Jira integration for url %s and project key %s", intg.URL, intg.ProjectKey)
|
|
}
|
|
indexed[key] = *intg
|
|
}
|
|
return indexed, nil
|
|
}
|
|
|
|
// ValidateJiraIntegrations validates that the merge of the original and new
|
|
// Jira integrations does not result in any duplicate configuration, and that
|
|
// each modified or added integration can successfully connect to the external
|
|
// Jira service. It returns the list of integrations that were deleted, if any.
|
|
//
|
|
// On successful return, the newJiraIntgs slice is ready to be saved - it may
|
|
// have been updated using the original integrations if the API token was
|
|
// missing.
|
|
func ValidateJiraIntegrations(ctx context.Context, oriJiraIntgsIndexed map[string]JiraIntegration, newJiraIntgs []*JiraIntegration) (deleted []*JiraIntegration, err error) {
|
|
newIndexed := make(map[string]*JiraIntegration, len(newJiraIntgs))
|
|
for i, new := range newJiraIntgs {
|
|
// first check for uniqueness
|
|
key := new.uniqueKey()
|
|
if _, ok := newIndexed[key]; ok {
|
|
return nil, fmt.Errorf("duplicate Jira integration for url %s and project key %s", new.URL, new.ProjectKey)
|
|
}
|
|
newIndexed[key] = new
|
|
|
|
// check if existing integration is being edited
|
|
if old, ok := oriJiraIntgsIndexed[key]; ok {
|
|
if old == *new {
|
|
// no further validation for unchanged integration
|
|
continue
|
|
}
|
|
// use stored API token if request does not contain new token
|
|
// intended only as a short-term accommodation for the frontend
|
|
// will be redesigned in dedicated endpoint for integration config
|
|
if new.APIToken == "" || new.APIToken == MaskedPassword {
|
|
new.APIToken = old.APIToken
|
|
}
|
|
}
|
|
|
|
// new or updated, test it
|
|
if err := makeTestJiraRequest(ctx, new); err != nil {
|
|
return nil, fmt.Errorf("Jira integration at index %d: %w", i, err)
|
|
}
|
|
}
|
|
|
|
// collect any deleted integration
|
|
for key, intg := range oriJiraIntgsIndexed {
|
|
intg := intg // do not take address of iteration variable
|
|
if _, ok := newIndexed[key]; !ok {
|
|
deleted = append(deleted, &intg)
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// IntegrationTestError is the type of error returned when a validation of an
|
|
// external service integration (e.g. Jira, Zendesk, etc.) failed due to the
|
|
// connection test to that external service.
|
|
//
|
|
// This is typically used to return a different status code in that case from
|
|
// an HTTP endpoint.
|
|
type IntegrationTestError struct {
|
|
Err error
|
|
}
|
|
|
|
// Error implements the error interface for IntegrationTestError.
|
|
func (e IntegrationTestError) Error() string {
|
|
return e.Err.Error()
|
|
}
|
|
|
|
func makeTestJiraRequest(ctx context.Context, intg *JiraIntegration) error {
|
|
if intg.APIToken == "" || intg.APIToken == MaskedPassword {
|
|
return IntegrationTestError{Err: errors.New("Jira integration request failed: missing or invalid API token")}
|
|
}
|
|
client, err := externalsvc.NewJiraClient(&externalsvc.JiraOptions{
|
|
BaseURL: intg.URL,
|
|
BasicAuthUsername: intg.Username,
|
|
BasicAuthPassword: intg.APIToken,
|
|
ProjectKey: intg.ProjectKey,
|
|
})
|
|
if err != nil {
|
|
return IntegrationTestError{Err: fmt.Errorf("Jira integration request failed: %w", err)}
|
|
}
|
|
if _, err := client.GetProject(ctx); err != nil {
|
|
return IntegrationTestError{Err: fmt.Errorf("Jira integration request failed: %w", err)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ZendeskIntegration configures an instance of an integration with the external Zendesk service.
|
|
type ZendeskIntegration struct {
|
|
URL string `json:"url"`
|
|
Email string `json:"email"`
|
|
APIToken string `json:"api_token"`
|
|
GroupID int64 `json:"group_id"`
|
|
EnableFailingPolicies bool `json:"enable_failing_policies"`
|
|
EnableSoftwareVulnerabilities bool `json:"enable_software_vulnerabilities"`
|
|
}
|
|
|
|
func (z ZendeskIntegration) uniqueKey() string {
|
|
return z.URL + "\n" + strconv.FormatInt(z.GroupID, 10)
|
|
}
|
|
|
|
// IndexZendeskIntegrations indexes the provided Zendesk integrations in a map
|
|
// keyed by 'URL\nGroupID'. It returns an error if a duplicate configuration is
|
|
// found for the same combination. This is typically used to index the original
|
|
// integrations before applying the changes requested to modify the AppConfig.
|
|
//
|
|
// Note that the returned map uses non-pointer ZendeskIntegration struct
|
|
// values, so that any changes to the original value does not modify the value
|
|
// in the map. This is important because of how changes are merged with the
|
|
// original AppConfig when modifying it.
|
|
func IndexZendeskIntegrations(zendeskIntgs []*ZendeskIntegration) (map[string]ZendeskIntegration, error) {
|
|
indexed := make(map[string]ZendeskIntegration, len(zendeskIntgs))
|
|
for _, intg := range zendeskIntgs {
|
|
key := intg.uniqueKey()
|
|
if _, ok := indexed[key]; ok {
|
|
return nil, fmt.Errorf("duplicate Zendesk integration for url %s and group id %v", intg.URL, intg.GroupID)
|
|
}
|
|
indexed[key] = *intg
|
|
}
|
|
return indexed, nil
|
|
}
|
|
|
|
// ValidateZendeskIntegrations validates that the merge of the original and
|
|
// new Zendesk integrations does not result in any duplicate configuration,
|
|
// and that each modified or added integration can successfully connect to the
|
|
// external Zendesk service. It returns the list of integrations that were
|
|
// deleted, if any.
|
|
//
|
|
// On successful return, the newZendeskIntgs slice is ready to be saved - it
|
|
// may have been updated using the original integrations if the API token was
|
|
// missing.
|
|
func ValidateZendeskIntegrations(ctx context.Context, oriZendeskIntgsIndexed map[string]ZendeskIntegration, newZendeskIntgs []*ZendeskIntegration) (deleted []*ZendeskIntegration, err error) {
|
|
newIndexed := make(map[string]*ZendeskIntegration, len(newZendeskIntgs))
|
|
for i, new := range newZendeskIntgs {
|
|
key := new.uniqueKey()
|
|
// first check for uniqueness
|
|
if _, ok := newIndexed[key]; ok {
|
|
return nil, fmt.Errorf("duplicate Zendesk integration for url %s and group id %v", new.URL, new.GroupID)
|
|
}
|
|
newIndexed[key] = new
|
|
|
|
// check if existing integration is being edited
|
|
if old, ok := oriZendeskIntgsIndexed[key]; ok {
|
|
if old == *new {
|
|
// no further validation for unchanged integration
|
|
continue
|
|
}
|
|
// use stored API token if request does not contain new token
|
|
// intended only as a short-term accommodation for the frontend
|
|
// will be redesigned in dedicated endpoint for integration config
|
|
if new.APIToken == "" || new.APIToken == MaskedPassword {
|
|
new.APIToken = old.APIToken
|
|
}
|
|
}
|
|
|
|
// new or updated, test it
|
|
if err := makeTestZendeskRequest(ctx, new); err != nil {
|
|
return nil, fmt.Errorf("Zendesk integration at index %d: %w", i, err)
|
|
}
|
|
}
|
|
|
|
// collect any deleted integration
|
|
for key, intg := range oriZendeskIntgsIndexed {
|
|
intg := intg // do not take address of iteration variable
|
|
if _, ok := newIndexed[key]; !ok {
|
|
deleted = append(deleted, &intg)
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
func makeTestZendeskRequest(ctx context.Context, intg *ZendeskIntegration) error {
|
|
if intg.APIToken == "" || intg.APIToken == MaskedPassword {
|
|
return IntegrationTestError{Err: errors.New("Zendesk integration request failed: missing or invalid API token")}
|
|
}
|
|
client, err := externalsvc.NewZendeskClient(&externalsvc.ZendeskOptions{
|
|
URL: intg.URL,
|
|
Email: intg.Email,
|
|
APIToken: intg.APIToken,
|
|
GroupID: intg.GroupID,
|
|
})
|
|
if err != nil {
|
|
return IntegrationTestError{Err: fmt.Errorf("Zendesk integration request failed: %w", err)}
|
|
}
|
|
grp, err := client.GetGroup(ctx)
|
|
if err != nil {
|
|
return IntegrationTestError{Err: fmt.Errorf("Zendesk integration request failed: %w", err)}
|
|
}
|
|
if grp.ID != intg.GroupID {
|
|
return IntegrationTestError{Err: fmt.Errorf("Zendesk integration request failed: no matching group id: received %d, expected %d", grp.ID, intg.GroupID)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Integrations configures the integrations with external systems.
|
|
type Integrations struct {
|
|
Jira []*JiraIntegration `json:"jira"`
|
|
Zendesk []*ZendeskIntegration `json:"zendesk"`
|
|
}
|
|
|
|
// ValidateEnabledHostStatusIntegrations checks that the host status integrations
|
|
// is properly configured if enabled. It adds any error it finds to the invalid
|
|
// argument error, that can then be checked after the call for errors using
|
|
// invalid.HasErrors.
|
|
func ValidateEnabledHostStatusIntegrations(webhook HostStatusWebhookSettings, invalid *InvalidArgumentError) {
|
|
if webhook.Enable {
|
|
if webhook.DestinationURL == "" {
|
|
invalid.Append("destination_url", "destination_url is required to enable the host status webhook")
|
|
}
|
|
if webhook.DaysCount <= 0 {
|
|
invalid.Append("days_count", "days_count must be > 0 to enable the host status webhook")
|
|
}
|
|
if webhook.HostPercentage <= 0 {
|
|
invalid.Append("host_percentage", "host_percentage must be > 0 to enable the host status webhook")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ValidateEnabledVulnerabilitiesIntegrations checks that a single integration
|
|
// is enabled for vulnerabilities. It adds any error it finds to the invalid
|
|
// argument error, that can then be checked after the call for errors using
|
|
// invalid.HasErrors.
|
|
func ValidateEnabledVulnerabilitiesIntegrations(webhook VulnerabilitiesWebhookSettings, intgs Integrations, invalid *InvalidArgumentError) {
|
|
webhookEnabled := webhook.Enable
|
|
var jiraEnabledCount int
|
|
for _, jira := range intgs.Jira {
|
|
if jira.EnableSoftwareVulnerabilities {
|
|
jiraEnabledCount++
|
|
}
|
|
}
|
|
var zendeskEnabledCount int
|
|
for _, zendesk := range intgs.Zendesk {
|
|
if zendesk.EnableSoftwareVulnerabilities {
|
|
zendeskEnabledCount++
|
|
}
|
|
}
|
|
|
|
if webhookEnabled && (jiraEnabledCount > 0 || zendeskEnabledCount > 0) {
|
|
invalid.Append("vulnerabilities", "cannot enable both webhook vulnerabilities and integration automations")
|
|
}
|
|
if jiraEnabledCount > 0 && zendeskEnabledCount > 0 {
|
|
invalid.Append("vulnerabilities", "cannot enable both jira integration and zendesk automations")
|
|
}
|
|
if jiraEnabledCount > 1 {
|
|
invalid.Append("vulnerabilities", "cannot enable more than one jira integration")
|
|
}
|
|
if zendeskEnabledCount > 1 {
|
|
invalid.Append("vulnerabilities", "cannot enable more than one zendesk integration")
|
|
}
|
|
if webhookEnabled && webhook.DestinationURL == "" {
|
|
invalid.Append("destination_url", "destination_url is required to enable the vulnerabilities webhook")
|
|
}
|
|
}
|
|
|
|
// ValidateEnabledFailingPoliciesIntegrations checks that a single integration
|
|
// is enabled for failing policies. It adds any error it finds to the invalid
|
|
// argument error, that can then be checked after the call for errors using
|
|
// invalid.HasErrors.
|
|
func ValidateEnabledFailingPoliciesIntegrations(webhook FailingPoliciesWebhookSettings, intgs Integrations, invalid *InvalidArgumentError) {
|
|
webhookEnabled := webhook.Enable
|
|
var jiraEnabledCount int
|
|
for _, jira := range intgs.Jira {
|
|
if jira.EnableFailingPolicies {
|
|
jiraEnabledCount++
|
|
}
|
|
}
|
|
var zendeskEnabledCount int
|
|
for _, zendesk := range intgs.Zendesk {
|
|
if zendesk.EnableFailingPolicies {
|
|
zendeskEnabledCount++
|
|
}
|
|
}
|
|
|
|
if webhookEnabled && (jiraEnabledCount > 0 || zendeskEnabledCount > 0) {
|
|
invalid.Append("failing policies", "cannot enable both webhook failing policies and integration automations")
|
|
}
|
|
if jiraEnabledCount > 0 && zendeskEnabledCount > 0 {
|
|
invalid.Append("failing policies", "cannot enable both jira and zendesk automations")
|
|
}
|
|
if jiraEnabledCount > 1 {
|
|
invalid.Append("failing policies", "cannot enable more than one jira integration")
|
|
}
|
|
if zendeskEnabledCount > 1 {
|
|
invalid.Append("failing policies", "cannot enable more than one zendesk integration")
|
|
}
|
|
if webhookEnabled && webhook.DestinationURL == "" {
|
|
invalid.Append("destination_url", "destination_url is required to enable the failing policies webhook")
|
|
}
|
|
}
|
|
|
|
// ValidateEnabledFailingPoliciesTeamIntegrations is like
|
|
// ValidateEnabledFailingPoliciesIntegrations, but for team-specific
|
|
// integration structs.
|
|
func ValidateEnabledFailingPoliciesTeamIntegrations(webhook FailingPoliciesWebhookSettings, teamIntgs TeamIntegrations, invalid *InvalidArgumentError) {
|
|
intgs := Integrations{
|
|
Jira: make([]*JiraIntegration, len(teamIntgs.Jira)),
|
|
Zendesk: make([]*ZendeskIntegration, len(teamIntgs.Zendesk)),
|
|
}
|
|
for i, j := range teamIntgs.Jira {
|
|
intgs.Jira[i] = &JiraIntegration{
|
|
URL: j.URL,
|
|
ProjectKey: j.ProjectKey,
|
|
EnableFailingPolicies: j.EnableFailingPolicies,
|
|
}
|
|
}
|
|
for i, z := range teamIntgs.Zendesk {
|
|
intgs.Zendesk[i] = &ZendeskIntegration{
|
|
URL: z.URL,
|
|
GroupID: z.GroupID,
|
|
EnableFailingPolicies: z.EnableFailingPolicies,
|
|
}
|
|
}
|
|
ValidateEnabledFailingPoliciesIntegrations(webhook, intgs, invalid)
|
|
}
|