diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index 801e04240..e81b70170 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -98,6 +98,9 @@ type Config struct { // DocuSignPrivateKey is the private key for the DocuSign API DocuSignPrivateKey string `json:"docuSignPrivateKey"` + + // SSS holds the Sanctions Screening Service client configuration + SSS SSS `json:"sss"` } // Auth0 model @@ -116,6 +119,27 @@ type Auth0Platform struct { URL string `json:"url"` } +// SSS holds the Sanctions Screening Service client configuration. The SSS +// integration reuses the shared LFX platform M2M (Auth0Platform) credentials, +// so only the SSS-specific base URL and audience are configured here. +// +// Sanctions screening is required by policy in deployed environments - these +// values are only loaded leniently (see config/ssm.go) so the SSM parameters +// can be provisioned before the feature is switched on. Whether a missing +// configuration is tolerated (no-op) or fatal must be enforced by the caller +// per stage; it must NOT be silently skipped in dev/staging/prod. +type SSS struct { + // BaseURL is the SSS host root WITHOUT the /api/v1 suffix - the client + // appends /api/v1/organizations/status itself (e.g. + // https://sanctions-screening.dev.v2.cluster.linuxfound.info) + BaseURL string `json:"base_url"` + // Audience is the Auth0 resource-server identifier for SSS, including any + // trailing slash - it must match the auth0-terraform resource server + // identifier exactly (e.g. + // https://sanctions-screening.dev.v2.cluster.linuxfound.info/) + Audience string `json:"audience"` +} + // Docraptor model type Docraptor struct { APIKey string `json:"apiKey"` diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index 21f97cf51..a29107fb0 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -13,6 +13,7 @@ import ( log "github.com/linuxfoundation/easycla/cla-backend-go/logging" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ssm" ) @@ -268,5 +269,43 @@ func loadSSMConfig(awsSession *session.Session, stage string) Config { //nolint } } + // Sanctions Screening Service (SSS) configuration is loaded leniently - the + // keys may not be provisioned in every environment yet, so look them up + // best-effort (non-fatal) rather than aborting startup like the keys above. + // This is only a rollout convenience: SSS is required by policy in deployed + // environments, and the caller must enforce that (see config.SSS). + loadOptionalSSSConfig(ssmClient, stage, &config, f) + return config } + +// loadOptionalSSSConfig fetches the SSS keys without aborting startup when they +// are missing, so the SSM parameters can be provisioned before the feature is +// switched on. A stage that requires SSS must reject an empty configuration at +// the call site rather than silently skipping the screening check. +func loadOptionalSSSConfig(ssmClient *ssm.SSM, stage string, config *Config, f logrus.Fields) { + config.SSS.BaseURL = getOptionalSSMString(ssmClient, fmt.Sprintf("cla-sss-base-url-%s", stage), f) + config.SSS.Audience = getOptionalSSMString(ssmClient, fmt.Sprintf("cla-sss-auth0-audience-%s", stage), f) +} + +// getOptionalSSMString fetches a parameter that may legitimately be absent while +// the feature is being rolled out. It logs exactly once: a missing parameter is +// reported at debug (an expected, benign state), while any other failure - IAM, +// throttling, etc. - is reported as a warning with the underlying error so it is +// not silently misattributed to "not set". Returns "" when the value is unreadable. +func getOptionalSSMString(ssmClient *ssm.SSM, key string, f logrus.Fields) string { + out, err := ssmClient.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(key), + WithDecryption: aws.Bool(false), + }) + if err != nil { + if aerr, ok := err.(awserr.Error); ok && aerr.Code() == ssm.ErrCodeParameterNotFound { + log.WithFields(f).Debugf("optional SSM key %s not provisioned - sanctions screening disabled until it is set", key) + } else { + log.WithFields(f).WithError(err).Warnf("unable to read optional SSM key %s - sanctions screening disabled", key) + } + return "" + } + + return strings.TrimSpace(*out.Parameter.Value) +} diff --git a/cla-backend-go/sss/from_config.go b/cla-backend-go/sss/from_config.go new file mode 100644 index 000000000..ec43f10ed --- /dev/null +++ b/cla-backend-go/sss/from_config.go @@ -0,0 +1,57 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import ( + "net/url" + "strings" +) + +// NewClientFromPlatformCredentials builds an SSS client that reuses the shared +// LFX platform M2M (Auth0) credentials already configured for EasyCLA. +// +// oauthTokenURL is the full Auth0 token endpoint used for platform auth +// (e.g. https:///oauth/token); its scheme+host is reused as the SSS +// client's Auth0 domain, since SSS authenticates with the same client against +// the same Auth0 tenant - only the requested audience differs. +// +// It returns (nil, nil) when baseURL or audience is empty so callers can treat +// an unconfigured SSS as a disabled, no-op feature rather than an error. +func NewClientFromPlatformCredentials(baseURL, audience, oauthTokenURL, clientID, clientSecret string) (*Client, error) { + baseURL = strings.TrimSpace(baseURL) + audience = strings.TrimSpace(audience) + if baseURL == "" || audience == "" { + return nil, nil + } + + return NewClient(SSSConfig{ + BaseURL: baseURL, + Auth0Domain: auth0DomainFromTokenURL(oauthTokenURL), + Auth0ClientID: strings.TrimSpace(clientID), + Auth0ClientSecret: strings.TrimSpace(clientSecret), + Auth0Audience: audience, + }) +} + +// auth0DomainFromTokenURL reduces a full Auth0 token endpoint to its scheme+host +// (e.g. https://tenant.auth0.com), which is what the SSS client expects as its +// Auth0 domain. It tolerates a missing scheme: url.Parse on a scheme-less value +// puts the whole string in Path and leaves Host empty, so a value like +// "tenant.auth0.com/oauth/token" would otherwise be passed through verbatim and +// produce a doubled "/oauth/token" when the client builds the token URL. +func auth0DomainFromTokenURL(oauthTokenURL string) string { + oauthTokenURL = strings.TrimSpace(oauthTokenURL) + if oauthTokenURL == "" { + return "" + } + + parseTarget := oauthTokenURL + if !strings.Contains(parseTarget, "://") { + parseTarget = "https://" + parseTarget + } + if u, err := url.Parse(parseTarget); err == nil && u.Host != "" { + return u.Scheme + "://" + u.Host + } + return oauthTokenURL +} diff --git a/cla-backend-go/sss/from_config_test.go b/cla-backend-go/sss/from_config_test.go new file mode 100644 index 000000000..0b437e979 --- /dev/null +++ b/cla-backend-go/sss/from_config_test.go @@ -0,0 +1,128 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sss + +import "testing" + +const ( + testAuth0Domain = "https://linuxfoundation.auth0.com" + testAuth0TokenURL = testAuth0Domain + "/oauth/token" +) + +func TestNewClientFromPlatformCredentials_DisabledWhenBaseURLMissing(t *testing.T) { + client, err := NewClientFromPlatformCredentials("", "https://sss.example/", testAuth0TokenURL, "id", "secret") + if err != nil { + t.Fatalf("expected no error when disabled, got: %v", err) + } + if client != nil { + t.Fatalf("expected nil client when base URL is empty") + } +} + +func TestNewClientFromPlatformCredentials_DisabledWhenAudienceMissing(t *testing.T) { + client, err := NewClientFromPlatformCredentials("https://sss.example", "", testAuth0TokenURL, "id", "secret") + if err != nil { + t.Fatalf("expected no error when disabled, got: %v", err) + } + if client != nil { + t.Fatalf("expected nil client when audience is empty") + } +} + +func TestNewClientFromPlatformCredentials_DerivesAuth0DomainFromTokenURL(t *testing.T) { + client, err := NewClientFromPlatformCredentials( + "https://sanctions-screening.dev.v2.cluster.linuxfound.info", + "https://sanctions-screening.dev.v2.cluster.linuxfound.info/", + testAuth0TokenURL, + "client-id", + "client-secret", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected a configured client") + } + + if got, want := client.cfg.Auth0Domain, testAuth0Domain; got != want { + t.Errorf("Auth0Domain: got %q, want %q", got, want) + } + // The derived domain must yield the original token endpoint, not a doubled path. + if got, want := client.authTokenURL(), testAuth0TokenURL; got != want { + t.Errorf("authTokenURL: got %q, want %q", got, want) + } +} + +func TestNewClientFromPlatformCredentials_DerivesAuth0DomainFromSchemelessTokenURL(t *testing.T) { + cases := []struct { + name string + tokenURL string + }{ + {"scheme-less with path", "linuxfoundation.auth0.com/oauth/token"}, + {"scheme-less host only", "linuxfoundation.auth0.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewClientFromPlatformCredentials( + "https://sanctions-screening.dev.v2.cluster.linuxfound.info", + "https://sanctions-screening.dev.v2.cluster.linuxfound.info/", + tc.tokenURL, + "client-id", + "client-secret", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected a configured client") + } + if got, want := client.cfg.Auth0Domain, testAuth0Domain; got != want { + t.Errorf("Auth0Domain: got %q, want %q", got, want) + } + // A scheme-less token URL must not double the "/oauth/token" path. + if got, want := client.authTokenURL(), testAuth0TokenURL; got != want { + t.Errorf("authTokenURL: got %q, want %q", got, want) + } + }) + } +} + +func TestNewClientFromPlatformCredentials_MissingCredentialsErrors(t *testing.T) { + // base URL + audience present but no Auth0 client credentials -> NewClient validates and errors. + client, err := NewClientFromPlatformCredentials("https://sss.example", "https://sss.example/", testAuth0TokenURL, "", "") + if err == nil { + t.Fatal("expected an error when client credentials are missing") + } + if client != nil { + t.Fatal("expected nil client on error") + } +} + +func TestNewClientFromPlatformCredentials_TrimsValues(t *testing.T) { + client, err := NewClientFromPlatformCredentials( + " https://sanctions-screening.dev.v2.cluster.linuxfound.info ", + " https://sanctions-screening.dev.v2.cluster.linuxfound.info/ ", + " https://linuxfoundation.auth0.com/oauth/token ", + " client-id ", + " client-secret ", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected a configured client") + } + if got, want := client.cfg.BaseURL, "https://sanctions-screening.dev.v2.cluster.linuxfound.info"; got != want { + t.Errorf("BaseURL not trimmed: got %q, want %q", got, want) + } + if got, want := client.cfg.Auth0Audience, "https://sanctions-screening.dev.v2.cluster.linuxfound.info/"; got != want { + t.Errorf("Auth0Audience not trimmed: got %q, want %q", got, want) + } + if got, want := client.cfg.Auth0ClientID, "client-id"; got != want { + t.Errorf("Auth0ClientID not trimmed: got %q, want %q", got, want) + } + if got, want := client.cfg.Auth0ClientSecret, "client-secret"; got != want { + t.Errorf("Auth0ClientSecret not trimmed: got %q, want %q", got, want) + } +}