From a632613658139d920a5c39cb253ea81fb9836062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Gryglicki?= Date: Tue, 25 Aug 2026 09:43:15 +0000 Subject: [PATCH] M2 prod BE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz Gryglicki Assisted by [OpenAI](https://platform.openai.com/) Assisted by [GitHub Copilot](https://github.com/features/copilot) Assisted by [Claude](https://claude.ai) --- cla-backend-go/auth/trusted_caller.go | 262 +++++ cla-backend-go/auth/trusted_caller_test.go | 542 ++++++++++ cla-backend-go/cmd/server.go | 41 +- cla-backend-go/company/models.go | 12 + cla-backend-go/company/projections.go | 1 + cla-backend-go/company/repository.go | 95 +- cla-backend-go/company/repository_test.go | 94 ++ cla-backend-go/config/config.go | 9 + cla-backend-go/config/ssm.go | 48 + cla-backend-go/config/ssm_test.go | 45 + .../emails/contact_cla_manager_templates.go | 56 ++ cla-backend-go/events/event_data.go | 95 ++ cla-backend-go/events/event_data_test.go | 58 ++ cla-backend-go/events/event_types.go | 4 + cla-backend-go/go.mod | 2 +- cla-backend-go/signatures/dbmodels.go | 4 + cla-backend-go/signatures/mocks/mock_repo.go | 15 + .../signatures/mocks/mock_service.go | 17 + cla-backend-go/signatures/repository.go | 91 ++ cla-backend-go/signatures/repository_test.go | 52 + cla-backend-go/signatures/service.go | 29 +- cla-backend-go/signatures/service_test.go | 68 ++ cla-backend-go/swagger/cla.v2.yaml | 266 ++++- .../swagger/common/cla-search-list.yaml | 26 + .../swagger/common/cla-search-org.yaml | 18 + .../swagger/common/cla-search-result.yaml | 53 + cla-backend-go/swagger/common/company.yaml | 4 + .../common/icla-invalidation-input.yaml | 16 + .../swagger/common/my-cla-list.yaml | 17 +- .../swagger/common/my-cla-manager-list.yaml | 41 + .../common/my-cla-manager-request-result.yaml | 28 + .../common/my-cla-manager-request.yaml | 23 + .../swagger/common/my-cla-manager.yaml | 17 + cla-backend-go/swagger/common/my-cla.yaml | 90 +- .../swagger/common/prepare-sign-input.yaml | 49 + .../swagger/common/prepare-sign.yaml | 66 ++ cla-backend-go/utils/const.go | 13 + cla-backend-go/utils/const_test.go | 29 + cla-backend-go/utils/string_utils.go | 31 +- cla-backend-go/utils/string_utils_test.go | 25 + cla-backend-go/v2/cla_search/cache.go | 200 ++++ cla-backend-go/v2/cla_search/cache_test.go | 198 ++++ cla-backend-go/v2/cla_search/handlers.go | 71 ++ cla-backend-go/v2/cla_search/handlers_test.go | 81 ++ cla-backend-go/v2/cla_search/repository.go | 340 +++++++ cla-backend-go/v2/cla_search/service.go | 616 ++++++++++++ cla-backend-go/v2/cla_search/service_test.go | 628 ++++++++++++ .../v2/my_clas/cla_managers_test.go | 549 ++++++++++ cla-backend-go/v2/my_clas/handlers.go | 186 +++- cla-backend-go/v2/my_clas/handlers_test.go | 305 ++++++ cla-backend-go/v2/my_clas/prefetch.go | 311 ++++++ cla-backend-go/v2/my_clas/sanctions.go | 150 +++ cla-backend-go/v2/my_clas/sanctions_test.go | 173 ++++ cla-backend-go/v2/my_clas/service.go | 904 ++++++++++++----- cla-backend-go/v2/my_clas/service_test.go | 736 +++++++++++++- cla-backend-go/v2/project-service/client.go | 5 +- cla-backend-go/v2/self_serve_sign/handlers.go | 68 ++ cla-backend-go/v2/self_serve_sign/service.go | 596 +++++++++++ .../v2/self_serve_sign/service_test.go | 510 ++++++++++ cla-backend-go/v2/sign/handlers.go | 19 + cla-backend-go/v2/sign/helpers.go | 4 + cla-backend-go/v2/sign/icla_block_test.go | 185 ++++ cla-backend-go/v2/sign/self_serve_test.go | 138 +++ cla-backend-go/v2/sign/service.go | 184 ++++ cla-backend-go/v2/signatures/handlers.go | 2 +- cla-backend-go/v2/signatures/service.go | 23 +- cla-backend-go/v2/signatures/service_test.go | 133 +++ cla-backend-legacy/go.mod | 22 +- cla-backend-legacy/go.sum | 44 +- cla-backend-legacy/internal/api/handlers.go | 98 +- .../internal/api/handlers_self_serve_test.go | 65 ++ .../internal/store/companies.go | 91 +- .../internal/store/companies_test.go | 102 ++ docs/MY_CLAS_API.md | 936 ++++++++++-------- utils/cla_search.sh | 111 +++ utils/my_cla_manager_request.sh | 120 +++ utils/my_cla_managers.sh | 98 ++ utils/prepare_sign.sh | 149 +++ utils/update_company_is_sanctioned.sh | 13 +- 79 files changed, 10791 insertions(+), 825 deletions(-) create mode 100644 cla-backend-go/auth/trusted_caller.go create mode 100644 cla-backend-go/auth/trusted_caller_test.go create mode 100644 cla-backend-go/company/repository_test.go create mode 100644 cla-backend-go/config/ssm_test.go create mode 100644 cla-backend-go/emails/contact_cla_manager_templates.go create mode 100644 cla-backend-go/signatures/repository_test.go create mode 100644 cla-backend-go/swagger/common/cla-search-list.yaml create mode 100644 cla-backend-go/swagger/common/cla-search-org.yaml create mode 100644 cla-backend-go/swagger/common/cla-search-result.yaml create mode 100644 cla-backend-go/swagger/common/icla-invalidation-input.yaml create mode 100644 cla-backend-go/swagger/common/my-cla-manager-list.yaml create mode 100644 cla-backend-go/swagger/common/my-cla-manager-request-result.yaml create mode 100644 cla-backend-go/swagger/common/my-cla-manager-request.yaml create mode 100644 cla-backend-go/swagger/common/my-cla-manager.yaml create mode 100644 cla-backend-go/swagger/common/prepare-sign-input.yaml create mode 100644 cla-backend-go/swagger/common/prepare-sign.yaml create mode 100644 cla-backend-go/utils/const_test.go create mode 100644 cla-backend-go/utils/string_utils_test.go create mode 100644 cla-backend-go/v2/cla_search/cache.go create mode 100644 cla-backend-go/v2/cla_search/cache_test.go create mode 100644 cla-backend-go/v2/cla_search/handlers.go create mode 100644 cla-backend-go/v2/cla_search/handlers_test.go create mode 100644 cla-backend-go/v2/cla_search/repository.go create mode 100644 cla-backend-go/v2/cla_search/service.go create mode 100644 cla-backend-go/v2/cla_search/service_test.go create mode 100644 cla-backend-go/v2/my_clas/cla_managers_test.go create mode 100644 cla-backend-go/v2/my_clas/handlers_test.go create mode 100644 cla-backend-go/v2/my_clas/prefetch.go create mode 100644 cla-backend-go/v2/my_clas/sanctions.go create mode 100644 cla-backend-go/v2/my_clas/sanctions_test.go create mode 100644 cla-backend-go/v2/self_serve_sign/handlers.go create mode 100644 cla-backend-go/v2/self_serve_sign/service.go create mode 100644 cla-backend-go/v2/self_serve_sign/service_test.go create mode 100644 cla-backend-go/v2/sign/icla_block_test.go create mode 100644 cla-backend-go/v2/sign/self_serve_test.go create mode 100644 cla-backend-legacy/internal/api/handlers_self_serve_test.go create mode 100644 cla-backend-legacy/internal/store/companies_test.go create mode 100755 utils/cla_search.sh create mode 100755 utils/my_cla_manager_request.sh create mode 100755 utils/my_cla_managers.sh create mode 100755 utils/prepare_sign.sh diff --git a/cla-backend-go/auth/trusted_caller.go b/cla-backend-go/auth/trusted_caller.go new file mode 100644 index 000000000..2d59c7d77 --- /dev/null +++ b/cla-backend-go/auth/trusted_caller.go @@ -0,0 +1,262 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "path" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v4" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" +) + +const ( + bearerPrefix = "bearer " + defaultJWTAlgorithm = "RS256" + jwksCacheTTL = 15 * time.Minute + jwksRefreshCooldown = time.Minute + jwksStaleKeyGrace = 24 * time.Hour + jwksResponseSizeLimit = 1 << 20 + jwksRequestTimeout = 10 * time.Second +) + +// ErrNoBearerToken is returned when the request carries no parseable bearer token. An absent +// header must be denied like an invalid one: the traefik aws-lambda middleware drops duplicated +// headers, so a duplicated Authorization header reaches the lambda as an absent one. +var ErrNoBearerToken = errors.New("no bearer token in the Authorization header") + +// TrustedCaller is the OAuth2 client (azp) and subject of a signature-verified bearer token +type TrustedCaller struct { + ClientID string + Subject string + Trusted bool +} + +// TrustedCallerVerifier verifies bearer tokens against a single Auth0 tenant's JWKS and reports +// whether the token's azp claim names an allow-listed client +type TrustedCallerVerifier struct { + wellKnownURL string + algorithm string + allowedClientIDs map[string]bool + + mu sync.Mutex + keys map[string]*rsa.PublicKey + keysExpireAt time.Time + nextRefreshAt time.Time + + fetchKeys func() (map[string]*rsa.PublicKey, error) + now func() time.Time +} + +// NewTrustedCallerVerifier creates a verifier for the Auth0 tenant at the given domain that +// trusts the given client IDs. An empty allow-list yields a disabled verifier (see Enabled). +func NewTrustedCallerVerifier(domain, algorithm string, allowedClientIDs []string) (*TrustedCallerVerifier, error) { + allowed := make(map[string]bool, len(allowedClientIDs)) + for _, clientID := range allowedClientIDs { + if clientID = strings.TrimSpace(clientID); clientID != "" { + allowed[clientID] = true + } + } + if len(allowed) > 0 && domain == "" { + return nil, errors.New("missing Domain for the trusted caller verifier") + } + if algorithm == "" { + algorithm = defaultJWTAlgorithm + } + + verifier := &TrustedCallerVerifier{ + wellKnownURL: "https://" + path.Join(domain, ".well-known/jwks.json"), + algorithm: algorithm, + allowedClientIDs: allowed, + now: time.Now, + } + verifier.fetchKeys = verifier.fetchJWKS + return verifier, nil +} + +// Enabled reports whether any trusted client ID is configured +func (v *TrustedCallerVerifier) Enabled() bool { + return v != nil && len(v.allowedClientIDs) > 0 +} + +// Verify signature-verifies the bearer token in the Authorization header against the tenant JWKS +// and reports the client (azp) and subject it was issued to +func (v *TrustedCallerVerifier) Verify(authorization string) (*TrustedCaller, error) { + rawToken, err := bearerToken(authorization) + if err != nil { + return nil, err + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser(jwt.WithValidMethods([]string{v.algorithm})) + token, err := parser.ParseWithClaims(rawToken, claims, v.signingKey) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("invalid bearer token") + } + // jwt.MapClaims treats exp as optional + if _, ok := claims["exp"]; !ok { + return nil, errors.New("bearer token carries no expiration") + } + clientID := stringClaim(claims, "azp") + + // Matching azp against an allow-list is sound ONLY while no user can hold a token carrying an + // allow-listed azp - only then does the azp mean "the SS backend built this request". Minting + // server-side with a client secret is NOT sufficient: the token SS currently sends here is + // minted that way and then handed to every logged-in user as v1Token by SS's + // GET /api/profile/developer, so that client ID must not be allow-listed. Allow-list only a + // client whose tokens are never surfaced to a user; otherwise any user can pass any identity. + // + // Transitional mechanism (P3/P9 of the trust-SS decision): at M6, once EasyCLA runs on the + // K8s cluster, it should call lfx.auth-service.user_identity.list itself over NATS and drop + // both this azp check and the caller-supplied identity list it authorizes. + return &TrustedCaller{ + ClientID: clientID, + Subject: stringClaim(claims, "sub"), + Trusted: clientID != "" && v.allowedClientIDs[clientID], + }, nil +} + +func stringClaim(claims jwt.MapClaims, name string) string { + value, ok := claims[name].(string) + if !ok { + return "" + } + return value +} + +func bearerToken(authorization string) (string, error) { + value := strings.TrimSpace(authorization) + if len(value) <= len(bearerPrefix) || !strings.EqualFold(value[:len(bearerPrefix)], bearerPrefix) { + return "", ErrNoBearerToken + } + rawToken := strings.TrimSpace(value[len(bearerPrefix):]) + if rawToken == "" { + return "", ErrNoBearerToken + } + return rawToken, nil +} + +func (v *TrustedCallerVerifier) signingKey(token *jwt.Token) (interface{}, error) { + kid, ok := token.Header["kid"].(string) + if !ok || kid == "" { + return nil, errors.New("bearer token carries no key ID") + } + return v.publicKey(kid) +} + +func (v *TrustedCallerVerifier) publicKey(kid string) (*rsa.PublicKey, error) { + v.mu.Lock() + defer v.mu.Unlock() + + now := v.now() + key, cached := v.keys[kid] + if cached && now.Before(v.keysExpireAt) { + return key, nil + } + usableWhileStale := cached && now.Before(v.keysExpireAt.Add(jwksStaleKeyGrace)) + + // a cache miss means a tenant key rotation or a JWKS outage, so rate limit the reload it + // triggers - without this an outage costs every request a fetch timeout once the TTL expires + if now.Before(v.nextRefreshAt) { + if usableWhileStale { + return key, nil + } + return nil, fmt.Errorf("unknown signing key ID: %s - the JWKS reload is rate limited", kid) + } + + v.nextRefreshAt = now.Add(jwksRefreshCooldown) + keys, err := v.fetchKeys() + if err != nil { + // bounded, so a revoked key cannot stay usable for the whole length of a JWKS outage + if usableWhileStale { + log.WithError(err).Warn("unable to refresh the JWKS - using the cached signing key") + return key, nil + } + return nil, err + } + v.keys = keys + v.keysExpireAt = now.Add(jwksCacheTTL) + + refreshed, ok := keys[kid] + if !ok { + return nil, fmt.Errorf("unknown signing key ID: %s", kid) + } + return refreshed, nil +} + +var jwksClient = &http.Client{Timeout: jwksRequestTimeout} + +func (v *TrustedCallerVerifier) fetchJWKS() (map[string]*rsa.PublicKey, error) { + resp, err := jwksClient.Get(v.wellKnownURL) // nolint + if err != nil { + return nil, err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + log.WithError(closeErr).Warn("problem closing the JWKS response body") + } + }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to load the JWKS from %s - status code: %d", v.wellKnownURL, resp.StatusCode) + } + + var keySet jwks + if err := json.NewDecoder(io.LimitReader(resp.Body, jwksResponseSizeLimit)).Decode(&keySet); err != nil { + return nil, err + } + + keys := make(map[string]*rsa.PublicKey, len(keySet.Keys)) + for _, webKey := range keySet.Keys { + if webKey.Kid == "" || (webKey.Kty != "" && webKey.Kty != "RSA") || (webKey.Use != "" && webKey.Use != "sig") { + continue + } + key, keyErr := parseRSAPublicKey(webKey) + if keyErr != nil { + log.WithError(keyErr).Warnf("unable to parse the JWKS signing key: %s", webKey.Kid) + continue + } + keys[webKey.Kid] = key + } + if len(keys) == 0 { + return nil, fmt.Errorf("no usable RSA signing key at %s", v.wellKnownURL) + } + return keys, nil +} + +func parseRSAPublicKey(webKey jsonWebKeys) (*rsa.PublicKey, error) { + if webKey.N != "" && webKey.E != "" { + modulus, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(webKey.N, "=")) + if err != nil { + return nil, err + } + exponent, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(webKey.E, "=")) + if err != nil { + return nil, err + } + if len(modulus) == 0 || len(exponent) == 0 || len(exponent) > 4 { + return nil, errors.New("key carries a malformed modulus or exponent") + } + return &rsa.PublicKey{ + N: new(big.Int).SetBytes(modulus), + E: int(new(big.Int).SetBytes(exponent).Int64()), + }, nil + } + if len(webKey.X5c) == 0 { + return nil, errors.New("key carries neither a modulus/exponent pair nor a certificate") + } + return jwt.ParseRSAPublicKeyFromPEM([]byte("-----BEGIN CERTIFICATE-----\n" + webKey.X5c[0] + "\n-----END CERTIFICATE-----")) +} diff --git a/cla-backend-go/auth/trusted_caller_test.go b/cla-backend-go/auth/trusted_caller_test.go new file mode 100644 index 000000000..2774e93b5 --- /dev/null +++ b/cla-backend-go/auth/trusted_caller_test.go @@ -0,0 +1,542 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testTrustedClientID = "ss-confidential-client-id" + testUntrustedClientID = "some-other-client-id" + testKeyID = "kid-1" +) + +func testVerifier(t *testing.T, key *rsa.PrivateKey) (*TrustedCallerVerifier, *int) { + t.Helper() + verifier, err := NewTrustedCallerVerifier("linuxfoundation-dev.auth0.com", "RS256", []string{testTrustedClientID}) + require.NoError(t, err) + + fetches := 0 + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { + fetches++ + return map[string]*rsa.PublicKey{testKeyID: &key.PublicKey}, nil + } + return verifier, &fetches +} + +func testToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + if kid != "" { + token.Header["kid"] = kid + } + signed, err := token.SignedString(key) + require.NoError(t, err) + return signed +} + +func testClaims(clientID string) jwt.MapClaims { + return jwt.MapClaims{ + "iss": "https://linuxfoundation-dev.auth0.com/", + "sub": clientID + "@clients", + "azp": clientID, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + } +} + +func testKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return key +} + +func TestTrustedCallerVerifierEnabled(t *testing.T) { + var nilVerifier *TrustedCallerVerifier + assert.False(t, nilVerifier.Enabled()) + + disabled, err := NewTrustedCallerVerifier("", "", nil) + require.NoError(t, err, "an unset allow-list must not fail startup, it only disables the trusted caller path") + assert.False(t, disabled.Enabled()) + + blank, err := NewTrustedCallerVerifier("", "", []string{" ", ""}) + require.NoError(t, err) + assert.False(t, blank.Enabled()) + + _, err = NewTrustedCallerVerifier("", "", []string{testTrustedClientID}) + assert.Error(t, err, "a configured allow-list without an Auth0 domain must fail startup rather than trust blindly") + + enabled, err := NewTrustedCallerVerifier("linuxfoundation-dev.auth0.com", "", []string{" " + testTrustedClientID + " "}) + require.NoError(t, err) + assert.True(t, enabled.Enabled()) + assert.Equal(t, defaultJWTAlgorithm, enabled.algorithm) + assert.Equal(t, "https://linuxfoundation-dev.auth0.com/.well-known/jwks.json", enabled.wellKnownURL) + assert.True(t, enabled.allowedClientIDs[testTrustedClientID], "a padded client ID must be trimmed, not stored verbatim") + + trailingSlash, err := NewTrustedCallerVerifier("linuxfoundation-dev.auth0.com/", "RS512", []string{testTrustedClientID}) + require.NoError(t, err) + assert.Equal(t, "https://linuxfoundation-dev.auth0.com/.well-known/jwks.json", trailingSlash.wellKnownURL) + assert.Equal(t, "RS512", trailingSlash.algorithm) +} + +// an absent header is the same as a malformed one: the traefik aws-lambda middleware drops +// duplicated headers, so a duplicated Authorization header arrives as an absent one +func TestVerifyMissingBearerToken(t *testing.T) { + verifier, fetches := testVerifier(t, testKey(t)) + + basic := "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")) + for _, header := range []string{"", " ", basic, "Bearer", "Bearer ", "Bearer ", "bearer\t", "token abc", "Bearer\n"} { + caller, err := verifier.Verify(header) + assert.Nil(t, caller) + assert.ErrorIs(t, err, ErrNoBearerToken, "header %q must be denied", header) + } + assert.Zero(t, *fetches, "an unparseable header must not trigger a JWKS lookup") +} + +func TestVerifyTrustedAndUntrustedClients(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + + trusted, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + assert.True(t, trusted.Trusted) + assert.Equal(t, testTrustedClientID, trusted.ClientID) + assert.Equal(t, testTrustedClientID+"@clients", trusted.Subject) + + // a valid token from any other client is verified but never trusted + untrusted, err := verifier.Verify("bearer " + testToken(t, key, testKeyID, testClaims(testUntrustedClientID))) + require.NoError(t, err) + assert.False(t, untrusted.Trusted) + assert.Equal(t, testUntrustedClientID, untrusted.ClientID) + + // a client ID that only differs in case must not match the allow-list + mixedCase, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(strings.ToUpper(testTrustedClientID)))) + require.NoError(t, err) + assert.False(t, mixedCase.Trusted) +} + +// a verified token carrying no usable azp claim can never match the allow-list, so it is +// untrusted rather than denied and keeps the pre-existing per-identity verification +func TestVerifyNeverTrustsATokenWithoutAnAzpClaim(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + + absent := testClaims(testTrustedClientID) + delete(absent, "azp") + empty := testClaims(testTrustedClientID) + empty["azp"] = "" + blank := testClaims(testTrustedClientID) + blank["azp"] = " " + numeric := testClaims(testTrustedClientID) + numeric["azp"] = 42 + + for name, claims := range map[string]jwt.MapClaims{"absent": absent, "empty": empty, "blank": blank, "non-string": numeric} { + t.Run(name, func(t *testing.T) { + caller, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, claims)) + require.NoError(t, err) + assert.False(t, caller.Trusted) + }) + } +} + +func TestVerifyRejectsInvalidTokens(t *testing.T) { + key := testKey(t) + otherKey := testKey(t) + verifier, _ := testVerifier(t, key) + + expired := testClaims(testTrustedClientID) + expired["exp"] = time.Now().Add(-time.Minute).Unix() + + noExpiration := testClaims(testTrustedClientID) + delete(noExpiration, "exp") + + notYetValid := testClaims(testTrustedClientID) + notYetValid["nbf"] = time.Now().Add(time.Hour).Unix() + + issuedInTheFuture := testClaims(testTrustedClientID) + issuedInTheFuture["iat"] = time.Now().Add(time.Hour).Unix() + + tests := []struct { + name string + token string + }{ + {"expired", testToken(t, key, testKeyID, expired)}, + {"no expiration", testToken(t, key, testKeyID, noExpiration)}, + {"not yet valid", testToken(t, key, testKeyID, notYetValid)}, + {"issued in the future", testToken(t, key, testKeyID, issuedInTheFuture)}, + {"no key ID", testToken(t, key, "", testClaims(testTrustedClientID))}, + {"unknown key ID", testToken(t, key, "kid-does-not-exist", testClaims(testTrustedClientID))}, + {"signed by another key", testToken(t, otherKey, testKeyID, testClaims(testTrustedClientID))}, + {"not a JWT", "not-a-jwt"}, + {"only a header", base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","kid":"kid-1"}`))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller, err := verifier.Verify("Bearer " + test.token) + assert.Nil(t, caller) + assert.Error(t, err) + }) + } +} + +// the signing algorithm must be pinned: an HMAC token would otherwise be verified with the +// JWKS RSA modulus as its shared secret, and "alg": "none" would skip verification entirely +func TestVerifyRejectsOtherSigningAlgorithms(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + + hmacToken := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims(testTrustedClientID)) + hmacToken.Header["kid"] = testKeyID + signed, err := hmacToken.SignedString(key.PublicKey.N.Bytes()) + require.NoError(t, err) + + caller, err := verifier.Verify("Bearer " + signed) + assert.Nil(t, caller) + assert.Error(t, err) + + noneToken := jwt.NewWithClaims(jwt.SigningMethodNone, testClaims(testTrustedClientID)) + noneToken.Header["kid"] = testKeyID + unsigned, err := noneToken.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + caller, err = verifier.Verify("Bearer " + unsigned) + assert.Nil(t, caller) + assert.Error(t, err) +} + +func TestVerifyCachesTheJWKS(t *testing.T) { + key := testKey(t) + verifier, fetches := testVerifier(t, key) + clock := time.Now() + verifier.now = func() time.Time { return clock } + + for i := 0; i < 3; i++ { + _, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + } + assert.Equal(t, 1, *fetches, "the JWKS must be fetched once and cached") + + // an unknown key ID triggers at most one refresh per cooldown window, so a flood of + // unknown-kid tokens cannot be used to hammer the Auth0 JWKS endpoint + unknown := "Bearer " + testToken(t, key, "kid-does-not-exist", testClaims(testTrustedClientID)) + for i := 0; i < 5; i++ { + _, err := verifier.Verify(unknown) + require.Error(t, err) + } + assert.Equal(t, 1, *fetches, "the cooldown started by the initial fetch is still open") + + clock = clock.Add(jwksRefreshCooldown + time.Second) + _, err := verifier.Verify(unknown) + require.Error(t, err) + assert.Equal(t, 2, *fetches, "after the cooldown an unknown key ID may refresh the key set again") + + clock = clock.Add(jwksCacheTTL) + _, err = verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + assert.Equal(t, 3, *fetches, "an expired key set is reloaded") +} + +// a JWKS endpoint outage must not invalidate an already cached key +func TestVerifyFallsBackToTheCachedKey(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + clock := time.Now() + verifier.now = func() time.Time { return clock } + + _, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { + return nil, assert.AnError + } + clock = clock.Add(jwksCacheTTL + time.Minute) + + caller, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + assert.True(t, caller.Trusted) + + caller, err = verifier.Verify("Bearer " + testToken(t, key, "kid-does-not-exist", testClaims(testTrustedClientID))) + assert.Nil(t, caller, "an unknown key ID must still be denied when the JWKS cannot be reloaded") + assert.Error(t, err) +} + +// the fallback above is bounded: a revoked key must not stay usable for an unlimited outage +func TestVerifyRejectsAStaleCachedKey(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + clock := time.Now() + verifier.now = func() time.Time { return clock } + + _, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { + return nil, assert.AnError + } + + clock = clock.Add(jwksCacheTTL + jwksStaleKeyGrace - time.Minute) + caller, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err, "within the grace period the cached key is still used") + assert.True(t, caller.Trusted) + + clock = clock.Add(2 * time.Minute) + caller, err = verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + assert.Nil(t, caller) + assert.Error(t, err, "past the grace period a key that cannot be re-fetched must be rejected") +} + +// a misconfigured cla-auth0-algorithm cannot open a hole: the verifier only ever hands the parser +// an RSA public key, so a non-RSA algorithm fails closed instead of accepting forged signatures +func TestVerifyRejectsMisconfiguredAlgorithms(t *testing.T) { + key := testKey(t) + + hmac := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims(testTrustedClientID)) + hmac.Header["kid"] = testKeyID + hmacToken, err := hmac.SignedString(key.PublicKey.N.Bytes()) + require.NoError(t, err) + + none := jwt.NewWithClaims(jwt.SigningMethodNone, testClaims(testTrustedClientID)) + none.Header["kid"] = testKeyID + noneToken, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + for algorithm, rawToken := range map[string]string{"HS256": hmacToken, "none": noneToken} { + verifier, verifierErr := NewTrustedCallerVerifier("linuxfoundation-dev.auth0.com", algorithm, []string{testTrustedClientID}) + require.NoError(t, verifierErr) + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { + return map[string]*rsa.PublicKey{testKeyID: &key.PublicKey}, nil + } + + caller, verifyErr := verifier.Verify("Bearer " + rawToken) + assert.Nil(t, caller, "a verifier configured with %s must not accept a token", algorithm) + assert.Error(t, verifyErr) + } +} + +// the grace window is exclusive: at exactly keysExpireAt+jwksStaleKeyGrace the cached key is gone +func TestVerifyRejectsTheCachedKeyAtTheGraceBoundary(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + clock := time.Now() + verifier.now = func() time.Time { return clock } + token := "Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID)) + + _, err := verifier.Verify(token) + require.NoError(t, err) + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { return nil, assert.AnError } + boundary := verifier.keysExpireAt.Add(jwksStaleKeyGrace) + + clock = boundary.Add(-time.Nanosecond) + _, err = verifier.Verify(token) + require.NoError(t, err, "a nanosecond before the boundary the cached key is still served") + + clock = boundary + _, err = verifier.Verify(token) + assert.Error(t, err, "at the boundary the cached key must be rejected") +} + +// the tuning has to keep these relations: a reload is rate limited for less than the cache +// lifetime, one attempt cannot outlast its own rate limit, and the outage grace outlives the cache +func TestJWKSCacheTuning(t *testing.T) { + assert.Positive(t, jwksRefreshCooldown) + assert.Less(t, jwksRefreshCooldown, jwksCacheTTL) + assert.LessOrEqual(t, jwksRequestTimeout, jwksRefreshCooldown) + assert.Greater(t, jwksStaleKeyGrace, jwksCacheTTL) +} + +// a failed reload must not be retried per request: once the TTL expires during a JWKS outage the +// cooldown has to serve the grace-bounded cached key instead of paying the fetch timeout again +func TestVerifyRateLimitsReloadsDuringAnOutage(t *testing.T) { + key := testKey(t) + verifier, _ := testVerifier(t, key) + clock := time.Now() + verifier.now = func() time.Time { return clock } + token := "Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID)) + + _, err := verifier.Verify(token) + require.NoError(t, err) + + attempts := 0 + verifier.fetchKeys = func() (map[string]*rsa.PublicKey, error) { + attempts++ + return nil, assert.AnError + } + clock = clock.Add(jwksCacheTTL + time.Second) + + for i := 0; i < 5; i++ { + caller, verifyErr := verifier.Verify(token) + require.NoError(t, verifyErr) + assert.True(t, caller.Trusted) + } + assert.Equal(t, 1, attempts, "a stale key set must be reloaded at most once per cooldown window") + + clock = clock.Add(jwksRefreshCooldown + time.Second) + _, err = verifier.Verify(token) + require.NoError(t, err) + assert.Equal(t, 2, attempts, "after the cooldown the reload is attempted again") + + caller, err := verifier.Verify("Bearer " + testToken(t, key, "kid-does-not-exist", testClaims(testTrustedClientID))) + assert.Nil(t, caller) + assert.ErrorContains(t, err, "rate limited", "the denial must come from the cooldown, not from a reload attempt") + assert.Equal(t, 2, attempts, "an unknown key ID must not reload the JWKS while the cooldown is open") + + // the grace bound still applies when the cooldown short-circuits the reload + clock = clock.Add(jwksStaleKeyGrace) + _, err = verifier.Verify(token) + require.Error(t, err) + require.Equal(t, 3, attempts) + caller, err = verifier.Verify(token) + assert.Nil(t, caller) + assert.Error(t, err, "past the grace period the cooldown must not serve the cached key either") + assert.Equal(t, 3, attempts) +} + +func TestVerifyIsSafeForConcurrentUse(t *testing.T) { + key := testKey(t) + verifier, fetches := testVerifier(t, key) + token := "Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID)) + + var wg sync.WaitGroup + results := make([]bool, 16) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + caller, err := verifier.Verify(token) + if err == nil && caller != nil { + results[i] = caller.Trusted + } + }(i) + } + wg.Wait() + + for i, trusted := range results { + assert.True(t, trusted, "concurrent verify %d must succeed", i) + } + assert.Equal(t, 1, *fetches, "concurrent verifies must share a single JWKS fetch") +} + +func TestFetchJWKS(t *testing.T) { + key := testKey(t) + body := map[string]interface{}{ + "keys": []map[string]interface{}{ + {"kty": "EC", "kid": "not-rsa", "use": "sig"}, + {"kty": "RSA", "kid": "encryption-key", "use": "enc"}, + {"kty": "RSA", "kid": "malformed", "use": "sig", "n": "!!!", "e": "AQAB"}, + {"kty": "RSA", "kid": testKeyID, "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes())}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(body)) + })) + defer server.Close() + + verifier, err := NewTrustedCallerVerifier("linuxfoundation-dev.auth0.com", "RS256", []string{testTrustedClientID}) + require.NoError(t, err) + verifier.wellKnownURL = server.URL + + keys, err := verifier.fetchJWKS() + require.NoError(t, err) + require.Len(t, keys, 1, "only usable RSA signing keys are kept") + assert.Equal(t, key.PublicKey.N, keys[testKeyID].N) + assert.Equal(t, key.PublicKey.E, keys[testKeyID].E) + + caller, err := verifier.Verify("Bearer " + testToken(t, key, testKeyID, testClaims(testTrustedClientID))) + require.NoError(t, err) + assert.True(t, caller.Trusted) + + failureModes := map[string]http.HandlerFunc{ + "server error": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, + "not found": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, + "unauthorized": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) }, + "not json": func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "{not json") }, + "no keys": func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, `{"keys":[]}`) }, + "only unusable keys": func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"keys":[{"kty":"EC","kid":"ec","use":"sig"}]}`) + }, + } + for name, handler := range failureModes { + t.Run(name, func(t *testing.T) { + failing := httptest.NewServer(handler) + defer failing.Close() + verifier.wellKnownURL = failing.URL + keySet, fetchErr := verifier.fetchJWKS() + assert.Nil(t, keySet) + assert.Error(t, fetchErr) + }) + } + + t.Run("unreachable", func(t *testing.T) { + unreachable := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + verifier.wellKnownURL = unreachable.URL + unreachable.Close() + keySet, fetchErr := verifier.fetchJWKS() + assert.Nil(t, keySet) + assert.Error(t, fetchErr) + }) +} + +func TestParseRSAPublicKey(t *testing.T) { + key := testKey(t) + + parsed, err := parseRSAPublicKey(jsonWebKeys{ + Kid: testKeyID, + N: base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }) + require.NoError(t, err) + assert.True(t, parsed.Equal(&key.PublicKey)) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "trusted-caller-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + parsed, err = parseRSAPublicKey(jsonWebKeys{Kid: testKeyID, X5c: []string{base64.StdEncoding.EncodeToString(der)}}) + require.NoError(t, err) + assert.True(t, parsed.Equal(&key.PublicKey)) + + modulus := base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()) + malformed := map[string]jsonWebKeys{ + "neither a key nor a certificate": {Kid: testKeyID}, + "malformed modulus": {Kid: testKeyID, N: "!!!", E: "AQAB"}, + "malformed exponent": {Kid: testKeyID, N: modulus, E: "!!!"}, + "empty exponent": {Kid: testKeyID, N: modulus, E: "="}, + "oversized exponent": {Kid: testKeyID, N: modulus, E: base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3, 4, 5})}, + "invalid certificate": {Kid: testKeyID, X5c: []string{"not-a-certificate"}}, + } + for name, webKey := range malformed { + t.Run(name, func(t *testing.T) { + key, keyErr := parseRSAPublicKey(webKey) + assert.Nil(t, key) + assert.Error(t, keyErr) + }) + } +} diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index bdfb3e1cc..9759f8e0d 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -103,10 +103,12 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/template" "github.com/linuxfoundation/easycla/cla-backend-go/user" v2ClaManager "github.com/linuxfoundation/easycla/cla-backend-go/v2/cla_manager" + v2ClaSearch "github.com/linuxfoundation/easycla/cla-backend-go/v2/cla_search" v2Company "github.com/linuxfoundation/easycla/cla-backend-go/v2/company" v2CurrentUser "github.com/linuxfoundation/easycla/cla-backend-go/v2/current_user" v2Health "github.com/linuxfoundation/easycla/cla-backend-go/v2/health" v2MyClas "github.com/linuxfoundation/easycla/cla-backend-go/v2/my_clas" + v2SelfServeSign "github.com/linuxfoundation/easycla/cla-backend-go/v2/self_serve_sign" "github.com/linuxfoundation/easycla/cla-backend-go/v2/store" v2Template "github.com/linuxfoundation/easycla/cla-backend-go/v2/template" @@ -438,20 +440,6 @@ func server(localMode bool) http.Handler { gitlabOrganizationsService := gitlab_organizations.NewService(gitlabOrganizationRepo, v2RepositoriesService, v1ProjectClaGroupRepo, storeRepository, usersService, signaturesRepo, v1CompanyRepo) v1SignaturesService := signatures.NewService(signaturesRepo, v1CompanyService, usersService, eventsService, githubOrgValidation, v1RepositoriesService, githubOrganizationsService, v1ProjectService, gitlabApp, configFile.ClaV1ApiURL, configFile.CLALandingPage, configFile.CLALogoURL) v2SignatureService := v2Signatures.NewService(awsSession, configFile.SignatureFilesBucket, v1ProjectService, v1CompanyService, v1SignaturesService, v1ProjectClaGroupRepo, signaturesRepo, usersService, approvalsRepo) - v2MyClasService := v2MyClas.NewService(v2MyClas.NewRepository(awsSession, stage), user_service.GetClient(), v1SignaturesService, v1CompanyRepo, v1ProjectClaGroupRepo, project_service.GetClient()) - v1ClaManagerService := cla_manager.NewService(claManagerReqRepo, v1ProjectClaGroupRepo, v1CompanyService, v1ProjectService, usersService, v1SignaturesService, eventsService, emailTemplateService, configFile.CorporateConsoleV2URL) - v2ClaManagerService := v2ClaManager.NewService(emailTemplateService, v1CompanyService, v1ProjectService, v1ClaManagerService, usersService, v1RepositoriesService, v2CompanyService, eventsService, v1ProjectClaGroupRepo) - v1ApprovalListService := approval_list.NewService(approvalListRepo, v1ProjectClaGroupRepo, v1ProjectService, usersRepo, v1CompanyRepo, v1CLAGroupRepo, signaturesRepo, emailTemplateService, configFile.CorporateConsoleV2URL, http.DefaultClient) - authorizer := auth.NewAuthorizer(authValidator, userRepo) - v2MetricsService := metrics.NewService(metricsRepo, v1ProjectClaGroupRepo) - gitlabActivityService := gitlab_activity.NewService(gitV1Repository, gitV2Repository, usersRepo, signaturesRepo, v1ProjectClaGroupRepo, v1CompanyRepo, signaturesRepo, gitlabOrganizationsService) - gitlabSignService := gitlab_sign.NewService(v2RepositoriesService, usersService, storeRepository, gitlabApp, gitlabOrganizationsService) - v2GithubOrganizationsService := v2GithubOrganizations.NewService(githubOrganizationsRepo, gitV1Repository, v1ProjectClaGroupRepo, githubOrganizationsService) - autoEnableService := dynamo_events.NewAutoEnableService(v1RepositoriesService, gitV1Repository, githubOrganizationsRepo, v1ProjectClaGroupRepo, v1ProjectService) - v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) - - v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - // Initialize SSS (Sanctions Screening Service) client if configured. // The sssRequired flag is controlled by the cla-sss-required-{stage} SSM parameter. sssRequired := configFile.SSS.Required @@ -469,6 +457,26 @@ func server(localMode bool) http.Handler { log.WithFields(f).Fatal("SSS is required but not configured") } + v2ClaSearchService := v2ClaSearch.NewService(v2ClaSearch.NewRepository(awsSession, stage)) + v2MyClasService := v2MyClas.NewService(v2MyClas.NewRepository(awsSession, stage), user_service.GetClient(), v1SignaturesService, v1CompanyRepo, v1ProjectClaGroupRepo, project_service.GetClient(), eventsService, v2MyClas.NewSanctionsScreener(sssClient, sssEnabled, sssRequired)) + v2SelfServeSignService := v2SelfServeSign.NewService(v2MyClasService, usersService, v1ProjectService, v1ProjectClaGroupRepo, storeRepository, configFile.CLAContributorv2Base) + trustedCallerVerifier, err := auth.NewTrustedCallerVerifier(configFile.Auth0.Domain, configFile.Auth0.Algorithm, configFile.SelfServe.TrustedClientIDs) + if err != nil { + logrus.Panic(err) + } + v1ClaManagerService := cla_manager.NewService(claManagerReqRepo, v1ProjectClaGroupRepo, v1CompanyService, v1ProjectService, usersService, v1SignaturesService, eventsService, emailTemplateService, configFile.CorporateConsoleV2URL) + v2ClaManagerService := v2ClaManager.NewService(emailTemplateService, v1CompanyService, v1ProjectService, v1ClaManagerService, usersService, v1RepositoriesService, v2CompanyService, eventsService, v1ProjectClaGroupRepo) + v1ApprovalListService := approval_list.NewService(approvalListRepo, v1ProjectClaGroupRepo, v1ProjectService, usersRepo, v1CompanyRepo, v1CLAGroupRepo, signaturesRepo, emailTemplateService, configFile.CorporateConsoleV2URL, http.DefaultClient) + authorizer := auth.NewAuthorizer(authValidator, userRepo) + v2MetricsService := metrics.NewService(metricsRepo, v1ProjectClaGroupRepo) + gitlabActivityService := gitlab_activity.NewService(gitV1Repository, gitV2Repository, usersRepo, signaturesRepo, v1ProjectClaGroupRepo, v1CompanyRepo, signaturesRepo, gitlabOrganizationsService) + gitlabSignService := gitlab_sign.NewService(v2RepositoriesService, usersService, storeRepository, gitlabApp, gitlabOrganizationsService) + v2GithubOrganizationsService := v2GithubOrganizations.NewService(githubOrganizationsRepo, gitV1Repository, v1ProjectClaGroupRepo, githubOrganizationsService) + autoEnableService := dynamo_events.NewAutoEnableService(v1RepositoriesService, gitV1Repository, githubOrganizationsRepo, v1ProjectClaGroupRepo, v1ProjectService) + v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) + + v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService, gitlabActivityService, gitlabApp, gerritService, sssClient, sssRequired, sssEnabled) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) @@ -513,7 +521,9 @@ func server(localMode bool) http.Handler { v2Gerrits.Configure(v2API, gerritService, v1ProjectService, eventsService, v1ProjectClaGroupRepo) v2Company.Configure(v2API, v2CompanyService, v1ProjectClaGroupRepo, configFile.LFXPortalURL) v2CurrentUser.Configure(v2API, v2CurrentUserService) - v2MyClas.Configure(v2API, v2MyClasService) + v2SelfServeSign.Configure(v2API, v2SelfServeSignService) + v2ClaSearch.Configure(v2API, v2ClaSearchService) + v2MyClas.Configure(v2API, v2MyClasService, trustedCallerVerifier) cla_manager.Configure(api, v1ClaManagerService, v1CompanyService, v1ProjectService, usersService, v1SignaturesService, eventsService, emailTemplateService) v2ClaManager.Configure(v2API, v2ClaManagerService, v1CompanyService, configFile.LFXPortalURL, configFile.CorporateConsoleV2URL, v1ProjectClaGroupRepo, userRepo) cla_groups.Configure(v2API, v2ClaGroupService, v1ProjectService, v1ProjectClaGroupRepo, eventsService) @@ -524,6 +534,7 @@ func server(localMode bool) http.Handler { v2API.AddMiddlewareFor("POST", "/signed/corporate/{project_id}/{company_id}", sign.CCLADocusignMiddleware) v2API.AddMiddlewareFor("POST", "/signed/gitlab/individual/{user_id}/{organization_id}/{gitlab_repository_id}/{merge_request_id}", sign.DocusignMiddleware) v2API.AddMiddlewareFor("POST", "/signed/gerrit/individual/{user_id}", sign.DocusignMiddleware) + v2API.AddMiddlewareFor("POST", "/signed/self-serve/individual/{user_id}", sign.DocusignMiddleware) userCreaterMiddleware := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cla-backend-go/company/models.go b/cla-backend-go/company/models.go index 60aca59d0..6aa8933bb 100644 --- a/cla-backend-go/company/models.go +++ b/cla-backend-go/company/models.go @@ -26,6 +26,7 @@ type DBModel struct { Note string `dynamodbav:"note" json:"note"` IsSanctioned bool `dynamodbav:"is_sanctioned" json:"is_sanctioned"` SanctionOrigin string `dynamodbav:"sanction_origin" json:"sanction_origin,omitempty"` + SanctionedDate string `dynamodbav:"sanctioned_date" json:"sanctioned_date,omitempty"` Version string `dynamodbav:"version" json:"version"` } @@ -56,6 +57,15 @@ type InviteModel struct { Version string `json:"version"` } +// formatSanctionedDate normalizes the stored date - this backend writes RFC3339, the legacy one +// pynamo format - and leaves an unset value empty rather than warning on it. +func formatSanctionedDate(dateStr string) string { + if dateStr == "" { + return "" + } + return utils.FormatTimeString(dateStr) +} + // toModel is a helper routine to convert the (internal) database model to a (public) swagger model func (dbCompanyModel *DBModel) toModel() (*models.Company, error) { // Convert the "string" date time @@ -89,6 +99,7 @@ func (dbCompanyModel *DBModel) toModel() (*models.Company, error) { Note: dbCompanyModel.Note, IsSanctioned: dbCompanyModel.IsSanctioned, SanctionOrigin: dbCompanyModel.SanctionOrigin, + SanctionedDate: formatSanctionedDate(dbCompanyModel.SanctionedDate), Version: dbCompanyModel.Version, }, nil } @@ -151,6 +162,7 @@ func toSwaggerModel(dbCompanyModel *DBModel) (*models.Company, error) { SigningEntityName: dbCompanyModel.SigningEntityName, IsSanctioned: dbCompanyModel.IsSanctioned, SanctionOrigin: dbCompanyModel.SanctionOrigin, + SanctionedDate: formatSanctionedDate(dbCompanyModel.SanctionedDate), CompanyExternalID: dbCompanyModel.CompanyExternalID, CompanyManagerID: dbCompanyModel.CompanyManagerID, Created: strfmt.DateTime(createdDateTime), diff --git a/cla-backend-go/company/projections.go b/cla-backend-go/company/projections.go index ad40e95e9..b75809bc3 100644 --- a/cla-backend-go/company/projections.go +++ b/cla-backend-go/company/projections.go @@ -20,6 +20,7 @@ func buildCompanyProjection() expression.ProjectionBuilder { expression.Name("note"), expression.Name("is_sanctioned"), expression.Name("sanction_origin"), + expression.Name("sanctioned_date"), expression.Name("version"), ) } diff --git a/cla-backend-go/company/repository.go b/cla-backend-go/company/repository.go index 5b8e2fbc8..bfcb8313f 100644 --- a/cla-backend-go/company/repository.go +++ b/cla-backend-go/company/repository.go @@ -787,6 +787,7 @@ func buildCompanyModels(ctx context.Context, results *dynamodb.ScanOutput) ([]mo Created string `json:"date_created"` Note string `json:"note"` IsSanctioned bool `json:"is_sanctioned"` + SanctionedDate string `json:"sanctioned_date"` Modified string `json:"date_modified"` } @@ -829,6 +830,7 @@ func buildCompanyModels(ctx context.Context, results *dynamodb.ScanOutput) ([]mo Created: strfmt.DateTime(createdDateTime), Note: dbCompany.Note, IsSanctioned: dbCompany.IsSanctioned, + SanctionedDate: formatSanctionedDate(dbCompany.SanctionedDate), Updated: strfmt.DateTime(modifiedDateTime), }) } @@ -1282,6 +1284,58 @@ func (repo repository) UpdateCompanyAccessList(ctx context.Context, companyID st // sanctionOriginSSS is the sanction_origin value written by the Sanctions Screening Service. const sanctionOriginSSS = "sss" +// sanctionUpdate is the DynamoDB update for one sanction status change. +type sanctionUpdate struct { + expression string + condition *string + names map[string]*string + values map[string]*dynamodb.AttributeValue +} + +// buildSanctionUpdate assembles the update for UpdateCompanySanctionStatus. All SET assignments +// stay contiguous ahead of any REMOVE, as DynamoDB requires. +func buildSanctionUpdate(sanctioned bool, origin, now string) sanctionUpdate { + update := sanctionUpdate{ + expression: "SET #S = :s, #M = :m", + names: map[string]*string{ + "#S": aws.String("is_sanctioned"), + "#M": aws.String("date_modified"), + "#O": aws.String("sanction_origin"), + }, + values: map[string]*dynamodb.AttributeValue{ + ":s": {BOOL: aws.Bool(sanctioned)}, + ":m": {S: aws.String(now)}, + }, + } + + // Setting the flag stamps sanctioned_date; clearing it leaves the stored date alone. + if sanctioned { + update.names["#D"] = aws.String("sanctioned_date") + update.values[":d"] = &dynamodb.AttributeValue{S: aws.String(now)} + update.expression += ", #D = :d" + } + + if origin != "" { + update.values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)} + update.expression += ", #O = :o" + } else { + // Manual/admin update: remove any stale SSS-set origin so the record becomes a + // sticky admin block (origin absent) that SSS will never auto-clear. + update.expression += " REMOVE #O" + } + + // When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true + // with absent or non-"sss" origin). Only set the SSS flag when the company is + // currently unblocked or already SSS-blocked. A ConditionalCheckFailedException + // therefore means a manual/admin block is already in place and must be preserved. + if sanctioned && origin == sanctionOriginSSS { + update.values[":false"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)} + update.condition = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o") + } + + return update +} + // UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin. // Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates. func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { @@ -1294,50 +1348,21 @@ func (repo repository) UpdateCompanySanctionStatus(ctx context.Context, companyI } _, now := utils.CurrentTime() - - names := map[string]*string{ - "#S": aws.String("is_sanctioned"), - "#M": aws.String("date_modified"), - } - values := map[string]*dynamodb.AttributeValue{ - ":s": {BOOL: aws.Bool(sanctioned)}, - ":m": {S: aws.String(now)}, - } - updateExpr := "SET #S = :s, #M = :m" - - if origin != "" { - names["#O"] = aws.String("sanction_origin") - values[":o"] = &dynamodb.AttributeValue{S: aws.String(origin)} - updateExpr += ", #O = :o" - } else { - // Manual/admin update: remove any stale SSS-set origin so the record becomes a - // sticky admin block (origin absent) that SSS will never auto-clear. - names["#O"] = aws.String("sanction_origin") - updateExpr += " REMOVE #O" - } + update := buildSanctionUpdate(sanctioned, origin, now) input := &dynamodb.UpdateItemInput{ - ExpressionAttributeNames: names, - ExpressionAttributeValues: values, + ExpressionAttributeNames: update.names, + ExpressionAttributeValues: update.values, TableName: aws.String(repo.companyTableName), Key: map[string]*dynamodb.AttributeValue{ "company_id": {S: aws.String(companyID)}, }, - UpdateExpression: aws.String(updateExpr), - } - - // When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true - // with absent or non-"sss" origin). Only set the SSS flag when the company is - // currently unblocked or already SSS-blocked. A ConditionalCheckFailedException - // therefore means a manual/admin block is already in place and must be preserved. - sssSettingBlock := sanctioned && origin == sanctionOriginSSS - if sssSettingBlock { - values[":false"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)} - input.ConditionExpression = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o") + UpdateExpression: aws.String(update.expression), + ConditionExpression: update.condition, } if _, err := repo.dynamoDBClient.UpdateItem(input); err != nil { - if sssSettingBlock { + if update.condition != nil { if aerr, ok := err.(awserr.Error); ok && aerr.Code() == dynamodb.ErrCodeConditionalCheckFailedException { log.WithFields(f).Debugf("company %s already has a manual/admin sanction block; preserving it and not overwriting origin with sss", companyID) return nil diff --git a/cla-backend-go/company/repository_test.go b/cla-backend-go/company/repository_test.go new file mode 100644 index 000000000..bac2ef6df --- /dev/null +++ b/cla-backend-go/company/repository_test.go @@ -0,0 +1,94 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package company + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSanctionUpdate(t *testing.T) { + const now = "2026-08-20T10:11:12Z" + + tests := []struct { + name string + sanctioned bool + origin string + expression string + condition string + stampedDate bool + }{ + { + name: "sss flags the company", + sanctioned: true, + origin: sanctionOriginSSS, + expression: "SET #S = :s, #M = :m, #D = :d, #O = :o", + condition: "attribute_not_exists(#S) OR #S = :false OR #O = :o", + stampedDate: true, + }, + { + name: "sss clears the company", + sanctioned: false, + origin: sanctionOriginSSS, + expression: "SET #S = :s, #M = :m, #O = :o", + }, + { + name: "admin flags the company", + sanctioned: true, + origin: "", + expression: "SET #S = :s, #M = :m, #D = :d REMOVE #O", + stampedDate: true, + }, + { + name: "admin clears the company", + sanctioned: false, + origin: "", + expression: "SET #S = :s, #M = :m REMOVE #O", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + update := buildSanctionUpdate(tc.sanctioned, tc.origin, now) + + assert.Equal(t, tc.expression, update.expression) + if tc.condition == "" { + assert.Nil(t, update.condition, "only an SSS-set flag is conditional") + } else { + require.NotNil(t, update.condition) + assert.Equal(t, tc.condition, *update.condition, "the manual/admin block must stay protected") + } + + if tc.stampedDate { + require.Contains(t, update.names, "#D") + assert.Equal(t, "sanctioned_date", *update.names["#D"]) + require.Contains(t, update.values, ":d") + assert.Equal(t, now, *update.values[":d"].S, "the flag and the date are stamped with the same time") + } else { + assert.NotContains(t, update.names, "#D", "clearing the flag leaves the stored date alone") + assert.NotContains(t, update.values, ":d") + } + + assert.Equal(t, tc.sanctioned, *update.values[":s"].BOOL) + assert.Equal(t, now, *update.values[":m"].S) + + // Every declared name and value has to be referenced, or DynamoDB rejects the update. + for name := range update.names { + assert.Contains(t, update.expression+condition(update), name) + } + for value := range update.values { + assert.Contains(t, update.expression+condition(update), value) + } + }) + } +} + +func condition(update sanctionUpdate) string { + if update.condition == nil { + return "" + } + return " " + *update.condition +} diff --git a/cla-backend-go/config/config.go b/cla-backend-go/config/config.go index 9d1b9eb1e..f0df045e0 100644 --- a/cla-backend-go/config/config.go +++ b/cla-backend-go/config/config.go @@ -101,6 +101,15 @@ type Config struct { // SSS holds the Sanctions Screening Service client configuration SSS SSS `json:"sss"` + + // SelfServe holds the LFX Self Serve trusted-caller configuration + SelfServe SelfServe `json:"self_serve"` +} + +// SelfServe holds the LFX Self Serve trusted-caller configuration: the Auth0 azp (client ID) +// values whose caller-supplied identity list the My CLAs endpoints accept as authorized +type SelfServe struct { + TrustedClientIDs []string `json:"trusted_client_ids"` } // Auth0 model diff --git a/cla-backend-go/config/ssm.go b/cla-backend-go/config/ssm.go index fa78dd2c1..c087bbad3 100644 --- a/cla-backend-go/config/ssm.go +++ b/cla-backend-go/config/ssm.go @@ -4,6 +4,7 @@ package config import ( + "errors" "fmt" "strconv" "strings" @@ -276,9 +277,56 @@ func loadSSMConfig(awsSession *session.Session, stage string) Config { //nolint // environments, and the caller must enforce that (see config.SSS). loadOptionalSSSConfig(ssmClient, stage, &config, f) + loadOptionalSelfServeConfig(ssmClient, stage, &config, f) + return config } +// loadOptionalSelfServeConfig fetches the comma-separated allow-list of trusted LFX Self Serve +// Auth0 client IDs without aborting startup when the key is missing, so the SSM parameter can +// be provisioned independently of this code being deployed. +func loadOptionalSelfServeConfig(ssmClient *ssm.SSM, stage string, config *Config, f logrus.Fields) { + key := fmt.Sprintf("cla-ss-trusted-client-ids-%s", stage) + out, err := ssmClient.GetParameter(&ssm.GetParameterInput{ + Name: aws.String(key), + WithDecryption: aws.Bool(false), + }) + if err != nil { + if isParameterNotFound(err) { + log.WithFields(f).Debugf("optional SSM key %s not provisioned - no Self Serve caller is trusted until it is set", key) + } else { + // no caller is trusted, which is the pre-hardening posture, so this degrades rather + // than aborting every lambda that loads config + log.WithFields(f).WithError(err).Warnf("unable to read the SSM key %s - the trusted Self Serve caller path stays disabled", key) + } + return + } + if out.Parameter == nil || out.Parameter.Value == nil { + return + } + + config.SelfServe.TrustedClientIDs = parseTrustedClientIDs(*out.Parameter.Value) + log.WithFields(f).Debugf("loaded %d trusted Self Serve client ID(s) from the SSM key %s", len(config.SelfServe.TrustedClientIDs), key) +} + +func isParameterNotFound(err error) bool { + var aerr awserr.Error + if errors.As(err, &aerr) { + return aerr.Code() == ssm.ErrCodeParameterNotFound + } + return false +} + +func parseTrustedClientIDs(value string) []string { + var clientIDs []string + for _, clientID := range strings.Split(value, ",") { + if clientID = strings.TrimSpace(clientID); clientID != "" { + clientIDs = append(clientIDs, clientID) + } + } + return clientIDs +} + // 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 diff --git a/cla-backend-go/config/ssm_test.go b/cla-backend-go/config/ssm_test.go new file mode 100644 index 000000000..d2413b5ec --- /dev/null +++ b/cla-backend-go/config/ssm_test.go @@ -0,0 +1,45 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/stretchr/testify/assert" +) + +// a missing key is expected until the allow-list is provisioned, any other failure is not, so +// the two must stay distinguishable in the logs even when the SDK error arrives wrapped +func TestIsParameterNotFound(t *testing.T) { + assert.True(t, isParameterNotFound(awserr.New(ssm.ErrCodeParameterNotFound, "not found", nil))) + assert.True(t, isParameterNotFound(fmt.Errorf("wrapped: %w", awserr.New(ssm.ErrCodeParameterNotFound, "not found", nil)))) + assert.False(t, isParameterNotFound(awserr.New("AccessDeniedException", "denied", nil))) + assert.False(t, isParameterNotFound(awserr.New("ThrottlingException", "slow down", nil))) + assert.False(t, isParameterNotFound(errors.New("connection reset"))) +} + +func TestParseTrustedClientIDs(t *testing.T) { + tests := []struct { + name string + value string + clientIDs []string + }{ + {"unset", "", nil}, + {"blank", " ", nil}, + {"separators only", " , ,, ", nil}, + {"single", "ss-client", []string{"ss-client"}}, + {"padded", " ss-client ", []string{"ss-client"}}, + {"multiple", "ss-client, other-client ,third-client", []string{"ss-client", "other-client", "third-client"}}, + {"trailing separator", "ss-client,", []string{"ss-client"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.clientIDs, parseTrustedClientIDs(test.value)) + }) + } +} diff --git a/cla-backend-go/emails/contact_cla_manager_templates.go b/cla-backend-go/emails/contact_cla_manager_templates.go new file mode 100644 index 000000000..7bdc927fa --- /dev/null +++ b/cla-backend-go/emails/contact_cla_manager_templates.go @@ -0,0 +1,56 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package emails + +import ( + "github.com/linuxfoundation/easycla/cla-backend-go/utils" +) + +// ContactClaManagerTemplateParams is email params for ContactClaManagerTemplate +type ContactClaManagerTemplateParams struct { + RequestAction string + ContributorName string + ContributorIdentity string + ContributorEmail string + CompanyName string + ProjectName string + CLAGroupName string + OptionalMessage string + ContactOnly bool +} + +const ( + // ContactClaManagerTemplateName is email template name for ContactClaManagerTemplate + ContactClaManagerTemplateName = "ContactClaManagerTemplate" + // ContactClaManagerTemplate is the email sent to the selected CLA managers when a + // contributor requests removal/approval or sends a contact-only message + ContactClaManagerTemplate = ` +

Hello CLA Manager,

+

This is a notification email from EasyCLA regarding the project {{.ProjectName}} and CLA Group {{.CLAGroupName}}.

+{{if .ContactOnly}} +

{{.ContributorName}} ({{.ContributorIdentity}}) has sent you a message about their employee acknowledgement +under the {{.CompanyName}} corporate CLA. You are receiving this message as a CLA Manager from {{.CompanyName}} for {{.ProjectName}}.

+

The contributor's message:

+
{{.OptionalMessage}}
+{{if .ContributorEmail}} +

You can reply to the contributor at {{.ContributorEmail}}.

+{{end}} +

This is a message only - no change was requested and none has been made.

+{{else}} +

{{.ContributorName}} ({{.ContributorIdentity}}) has requested {{.RequestAction}} for their employee acknowledgement +under the {{.CompanyName}} corporate CLA. You are receiving this message as a CLA Manager from {{.CompanyName}} for {{.ProjectName}}.

+{{if .OptionalMessage}} +

The contributor included the following message in the request:

+

{{.OptionalMessage}}

+{{end}} +

To act on this request, please log into the EasyCLA Corporate Console and update the Approved List for {{.CompanyName}} accordingly. +No change has been made automatically.

+{{end}} +` +) + +// RenderContactClaManagerTemplate renders ContactClaManagerTemplate +func RenderContactClaManagerTemplate(params ContactClaManagerTemplateParams) (string, error) { + return RenderTemplate(utils.V2, ContactClaManagerTemplateName, ContactClaManagerTemplate, params) +} diff --git a/cla-backend-go/events/event_data.go b/cla-backend-go/events/event_data.go index 4bae6c583..1e1c34a00 100644 --- a/cla-backend-go/events/event_data.go +++ b/cla-backend-go/events/event_data.go @@ -5,6 +5,7 @@ package events import ( "fmt" + "strings" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" "github.com/linuxfoundation/easycla/cla-backend-go/utils" @@ -124,6 +125,10 @@ type GitHubProjectDeletedEventData struct { // SignatureProjectInvalidatedEventData data model type SignatureProjectInvalidatedEventData struct { InvalidatedCount int + SignatureID string + InvalidatedBy string + Reason string + InvalidationNote string } // SignatureInvalidatedApprovalRejectionEventData data model @@ -172,6 +177,9 @@ type CompanyACLUserAddedEventData struct { UserLFID string } +// CompanySanctionedEventData data model +type CompanySanctionedEventData struct{} + // CLATemplateCreatedEventData data model type CLATemplateCreatedEventData struct { TemplateName string @@ -226,6 +234,15 @@ type CCLAApprovalListRequestCreatedEventData struct { RequestID string } +// ContactCLAManagerRequestCreatedEventData data model +type ContactCLAManagerRequestCreatedEventData struct { + RequestID string + RequestType string + SignatureID string + Message string + Recipients []string +} + // CCLAApprovalListRequestApprovedEventData data model type CCLAApprovalListRequestApprovedEventData struct { RequestID string @@ -783,6 +800,13 @@ func (ed *CompanyACLUserAddedEventData) GetEventDetailsString(args *LogEventArgs return data, true } +// GetEventDetailsString returns the details string for this event +func (ed *CompanySanctionedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The company %s was flagged as sanctioned by sanctions screening", args.CompanyName) + data = data + "." + return data, true +} + // GetEventDetailsString returns the details string for this event func (ed *CLATemplateCreatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { data := "A CLA Group template was created or updated" // nolint @@ -1282,6 +1306,20 @@ func (ed *CCLAApprovalListRequestCreatedEventData) GetEventDetailsString(args *L return data, true } +// GetEventDetailsString returns the details string for this event +func (ed *ContactCLAManagerRequestCreatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("A CLA manager %s request was created for the Project: %s, Company: %s, Signature: %s with Request ID: %s addressed to: %s", + ed.RequestType, args.ProjectName, args.CompanyName, ed.SignatureID, ed.RequestID, strings.Join(ed.Recipients, ",")) + if args.UserName != "" { + data = data + fmt.Sprintf(" by the user %s", args.UserName) + } + if ed.Message != "" { + data = data + fmt.Sprintf(" with the message: %s", ed.Message) + } + data = data + "." + return data, true +} + // GetEventDetailsString returns the details string for this event func (ed *ApprovalListGitHubOrganizationAddedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { data := fmt.Sprintf("The GitHub Organization: %s was added to the approval list for the Company %s, Project: %s", @@ -1451,6 +1489,9 @@ func (ed *GitHubProjectDeletedEventData) GetEventDetailsString(args *LogEventArg // GetEventDetailsString returns the details string for this event func (ed *SignatureProjectInvalidatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { + if ed.SignatureID != "" { + return ed.singleSignatureText(args, true), true + } data := fmt.Sprintf("%d Signatures were invalidated (approved set to false) due to CLA Group/Project: %s deletion", ed.InvalidatedCount, args.ProjectName) if args.UserName != "" { @@ -1460,6 +1501,32 @@ func (ed *SignatureProjectInvalidatedEventData) GetEventDetailsString(args *LogE return data, true } +// singleSignatureText renders the admin ICLA invalidation wording (SignatureID set) shared by +// the details and summary strings +func (ed *SignatureProjectInvalidatedEventData) singleSignatureText(args *LogEventArgs, capitalized bool) string { + lead := "the signature" + if capitalized { + lead = "The signature" + } + data := fmt.Sprintf("%s %s was invalidated (approved set to false)", lead, ed.SignatureID) + if args.UserName != "" { + data = data + fmt.Sprintf(" for the user %s", args.UserName) + } + if args.ProjectName != "" { + data = data + fmt.Sprintf(" for the project %s", args.ProjectName) + } + if ed.InvalidatedBy != "" { + data = data + fmt.Sprintf(" by the administrator %s", ed.InvalidatedBy) + } + if ed.Reason != "" { + data = data + fmt.Sprintf(", reason: %s", ed.Reason) + } + if ed.InvalidationNote != "" { + data = data + fmt.Sprintf(", note: %s", ed.InvalidationNote) + } + return data + "." +} + // GetEventDetailsString returns the details string for this event func (ed *SignatureInvalidatedApprovalRejectionEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { reason := noReason @@ -1880,6 +1947,12 @@ func (ed *CompanyACLUserAddedEventData) GetEventSummaryString(args *LogEventArgs return data, true } +// GetEventSummaryString returns the summary string for this event +func (ed *CompanySanctionedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The company %s was flagged as sanctioned by sanctions screening.", args.CompanyName) + return data, true +} + // GetEventSummaryString returns the summary string for this event func (ed *CLATemplateCreatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { // Same output as the details @@ -2371,6 +2444,25 @@ func (ed *CLAApprovalListRemoveGitLabGroupData) GetEventSummaryString(args *LogE return data, true } +// GetEventSummaryString returns the summary string for this event +func (ed *ContactCLAManagerRequestCreatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The user %s asked the CLA managers for %s", args.UserName, ed.RequestType) + if args.CLAGroupName != "" { + data = data + fmt.Sprintf(" for the CLA Group %s", args.CLAGroupName) + } + if args.ProjectName != "" { + data = data + fmt.Sprintf(" for the project %s", args.ProjectName) + } + if args.CompanyName != "" { + data = data + fmt.Sprintf(" for the company %s", args.CompanyName) + } + if ed.Message != "" { + data = data + " with a message" + } + data = data + "." + return data, true +} + // GetEventSummaryString returns the summary string for this event func (ed *CCLAApprovalListRequestCreatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { data := fmt.Sprintf("The user %s created a CCLA Approval Request", args.UserName) @@ -2608,6 +2700,9 @@ func (ed *GitHubProjectDeletedEventData) GetEventSummaryString(args *LogEventArg // GetEventSummaryString returns the summary string for this event func (ed *SignatureProjectInvalidatedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { + if ed.SignatureID != "" { + return ed.singleSignatureText(args, false), true + } data := fmt.Sprintf("%d signatures were invalidated (approved set to false) due to CLA Group/Project %s deletion", ed.InvalidatedCount, args.ProjectName) if args.CLAGroupName != "" { diff --git a/cla-backend-go/events/event_data_test.go b/cla-backend-go/events/event_data_test.go index 4f6bd1440..4d76d0a71 100644 --- a/cla-backend-go/events/event_data_test.go +++ b/cla-backend-go/events/event_data_test.go @@ -146,3 +146,61 @@ func TestCLAGroupUpdatedEventData_GetEventDetailsString(t *testing.T) { }) } } + +func TestContactCLAManagerRequestCreatedEventData(t *testing.T) { + eventData := &ContactCLAManagerRequestCreatedEventData{ + RequestID: "request-1", + RequestType: "removal", + SignatureID: "sig-1", + Recipients: []string{"manager-one"}, + } + args := &LogEventArgs{UserName: testUser, CompanyName: "Good Corp", ProjectName: "My Project"} + + details, _ := eventData.GetEventDetailsString(args) + assert.NotContains(t, details, "with the message") + summary, _ := eventData.GetEventSummaryString(args) + assert.NotContains(t, summary, "with a message") + + eventData.Message = "please remove me" + details, _ = eventData.GetEventDetailsString(args) + assert.Contains(t, details, "with the message: please remove me", "the receipt carries the contributor message") + summary, _ = eventData.GetEventSummaryString(args) + assert.Contains(t, summary, "with a message") +} + +func TestSignatureProjectInvalidatedEventDataSingleSignature(t *testing.T) { + bulk := &SignatureProjectInvalidatedEventData{InvalidatedCount: 3} + args := &LogEventArgs{UserName: testUser, ProjectName: "My Project"} + + details, containsPII := bulk.GetEventDetailsString(args) + assert.True(t, containsPII) + assert.Contains(t, details, "3 Signatures were invalidated (approved set to false) due to CLA Group/Project: My Project deletion") + + single := &SignatureProjectInvalidatedEventData{ + SignatureID: "sig-1", + InvalidatedBy: "admin-user", + Reason: "compliance", + InvalidationNote: "per legal review", + } + details, containsPII = single.GetEventDetailsString(args) + assert.True(t, containsPII) + assert.Equal(t, "The signature sig-1 was invalidated (approved set to false) for the user john for the project My Project by the administrator admin-user, reason: compliance, note: per legal review.", details) + summary, _ := single.GetEventSummaryString(args) + assert.Equal(t, "the signature sig-1 was invalidated (approved set to false) for the user john for the project My Project by the administrator admin-user, reason: compliance, note: per legal review.", summary) + + bare := &SignatureProjectInvalidatedEventData{SignatureID: "sig-2"} + details, _ = bare.GetEventDetailsString(&LogEventArgs{}) + assert.Equal(t, "The signature sig-2 was invalidated (approved set to false).", details) +} + +func TestCompanySanctionedEventData(t *testing.T) { + eventData := &CompanySanctionedEventData{} + args := &LogEventArgs{CompanyName: "Flagged Corp"} + + details, containsPII := eventData.GetEventDetailsString(args) + assert.True(t, containsPII) + assert.Equal(t, "The company Flagged Corp was flagged as sanctioned by sanctions screening.", details) + summary, containsPII := eventData.GetEventSummaryString(args) + assert.True(t, containsPII) + assert.Equal(t, "The company Flagged Corp was flagged as sanctioned by sanctions screening.", summary) +} diff --git a/cla-backend-go/events/event_types.go b/cla-backend-go/events/event_types.go index 10d9c4508..9fa897c62 100644 --- a/cla-backend-go/events/event_types.go +++ b/cla-backend-go/events/event_types.go @@ -59,6 +59,10 @@ const ( CompanyACLRequestApproved = "company_acl.request_approved" CompanyACLRequestDenied = "company_acl.request_denied" + CompanySanctioned = "company.sanctioned" + + ContactCLAManagerRequestCreated = "contact_cla_manager_request.created" + CCLAApprovalListRequestCreated = "ccla_approval_list_request.created" CCLAApprovalListRequestApproved = "ccla_approval_list_request.approved" CCLAApprovalListRequestRejected = "ccla_approval_list_request.rejected" diff --git a/cla-backend-go/go.mod b/cla-backend-go/go.mod index ed17ab70a..2d2cd0c37 100644 --- a/cla-backend-go/go.mod +++ b/cla-backend-go/go.mod @@ -4,7 +4,7 @@ module github.com/linuxfoundation/easycla/cla-backend-go go 1.25.0 -toolchain go1.25.11 +toolchain go1.25.13 replace github.com/awslabs/aws-lambda-go-api-proxy => github.com/LF-Engineering/aws-lambda-go-api-proxy v0.3.2 diff --git a/cla-backend-go/signatures/dbmodels.go b/cla-backend-go/signatures/dbmodels.go index 86c043b58..e1e4b531b 100644 --- a/cla-backend-go/signatures/dbmodels.go +++ b/cla-backend-go/signatures/dbmodels.go @@ -46,6 +46,10 @@ type ItemSignature struct { UserDocusignDateSigned string `json:"user_docusign_date_signed,omitempty"` AutoCreateECLA bool `json:"auto_create_ecla,omitempty"` UserDocusignRawXML string `json:"user_docusign_raw_xml,omitempty"` + DateInvalidated string `json:"date_invalidated,omitempty"` + InvalidatedBy string `json:"invalidated_by,omitempty"` + InvalidationReason string `json:"invalidation_reason,omitempty"` + InvalidationNote string `json:"invalidation_note,omitempty"` } // DBManagersModel is a database model for only the ACL/Manager column diff --git a/cla-backend-go/signatures/mocks/mock_repo.go b/cla-backend-go/signatures/mocks/mock_repo.go index f12194287..a4627eea2 100644 --- a/cla-backend-go/signatures/mocks/mock_repo.go +++ b/cla-backend-go/signatures/mocks/mock_repo.go @@ -1,5 +1,6 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// // Code generated by MockGen. DO NOT EDIT. // Source: signatures/repository.go @@ -526,6 +527,20 @@ func (mr *MockSignatureRepositoryMockRecorder) InvalidateProjectRecord(ctx, sign return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateProjectRecord", reflect.TypeOf((*MockSignatureRepository)(nil).InvalidateProjectRecord), ctx, signatureID, note) } +// InvalidateProjectRecordWithMetadata mocks base method. +func (m *MockSignatureRepository) InvalidateProjectRecordWithMetadata(ctx context.Context, signatureID, note string, metadata *signatures0.InvalidationMetadata) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InvalidateProjectRecordWithMetadata", ctx, signatureID, note, metadata) + ret0, _ := ret[0].(error) + return ret0 +} + +// InvalidateProjectRecordWithMetadata indicates an expected call of InvalidateProjectRecordWithMetadata. +func (mr *MockSignatureRepositoryMockRecorder) InvalidateProjectRecordWithMetadata(ctx, signatureID, note, metadata interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvalidateProjectRecordWithMetadata", reflect.TypeOf((*MockSignatureRepository)(nil).InvalidateProjectRecordWithMetadata), ctx, signatureID, note, metadata) +} + // ProjectSignatures mocks base method. func (m *MockSignatureRepository) ProjectSignatures(ctx context.Context, projectID string) (*models.Signatures, error) { m.ctrl.T.Helper() diff --git a/cla-backend-go/signatures/mocks/mock_service.go b/cla-backend-go/signatures/mocks/mock_service.go index 938bc0a4a..9f2c6a039 100644 --- a/cla-backend-go/signatures/mocks/mock_service.go +++ b/cla-backend-go/signatures/mocks/mock_service.go @@ -1,5 +1,6 @@ // Copyright The Linux Foundation and each contributor to CommunityBridge. // SPDX-License-Identifier: MIT +// // Code generated by MockGen. DO NOT EDIT. // Source: signatures/service.go @@ -130,6 +131,22 @@ func (mr *MockSignatureServiceMockRecorder) DeleteGithubOrganizationFromApproval return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGithubOrganizationFromApprovalList", reflect.TypeOf((*MockSignatureService)(nil).DeleteGithubOrganizationFromApprovalList), ctx, signatureID, approvalListParams, githubAccessToken) } +// EvaluateUserApproval mocks base method. +func (m *MockSignatureService) EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EvaluateUserApproval", ctx, user, cclaSignature) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// EvaluateUserApproval indicates an expected call of EvaluateUserApproval. +func (mr *MockSignatureServiceMockRecorder) EvaluateUserApproval(ctx, user, cclaSignature interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateUserApproval", reflect.TypeOf((*MockSignatureService)(nil).EvaluateUserApproval), ctx, user, cclaSignature) +} + // GetCCLASignatures mocks base method. func (m *MockSignatureService) GetCCLASignatures(ctx context.Context, signed, approved *bool) ([]*signatures0.ItemSignature, error) { m.ctrl.T.Helper() diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index d71b52745..b96e38c3c 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -70,6 +70,7 @@ type SignatureRepository interface { DeleteGithubOrganizationFromApprovalList(ctx context.Context, signatureID, githubOrganizationID string) ([]models.GithubOrg, error) ValidateProjectRecord(ctx context.Context, signatureID, note string) error InvalidateProjectRecord(ctx context.Context, signatureID, note string) error + InvalidateProjectRecordWithMetadata(ctx context.Context, signatureID, note string, metadata *InvalidationMetadata) error UpdateEnvelopeDetails(ctx context.Context, signatureID, envelopeID string, signURL *string) (*models.Signature, error) CreateSignature(ctx context.Context, signature *ItemSignature) error UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error @@ -2085,6 +2086,13 @@ func (repo repository) ProjectSignatures(ctx context.Context, projectID string) }, nil } +// InvalidationMetadata carries the invalidation attribution stored on the signature record +type InvalidationMetadata struct { + InvalidatedBy string + Reason string + Note string +} + // InvalidateProjectRecord invalidates the specified project record by setting the signature_approved flag to false func (repo repository) InvalidateProjectRecord(ctx context.Context, signatureID, note string) error { f := logrus.Fields{ @@ -2129,6 +2137,89 @@ func (repo repository) InvalidateProjectRecord(ctx context.Context, signatureID, return nil } +// InvalidateProjectRecordWithMetadata invalidates the specified project record by setting the +// signature_approved flag to false and records the invalidation attribution. The attribution +// attributes (date_invalidated, invalidated_by, invalidation_reason, invalidation_note) are +// first-write-wins so a re-invalidation never destroys the record of a prior invalidation; +// attributes missing on pre-feature records are still populated. +func (repo repository) InvalidateProjectRecordWithMetadata(ctx context.Context, signatureID, note string, metadata *InvalidationMetadata) error { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.InvalidateProjectRecordWithMetadata", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "signatureID": signatureID, + } + + signatureTableName := fmt.Sprintf("cla-%s-signatures", repo.stage) + + _, now := utils.CurrentTime() + + expressionAttributeNames, expressionAttributeValues, updateExpression := invalidationUpdateExpression(note, now, metadata) + + input := &dynamodb.UpdateItemInput{ + Key: map[string]*dynamodb.AttributeValue{ + "signature_id": { + S: aws.String(signatureID), + }, + }, + ExpressionAttributeNames: expressionAttributeNames, + ExpressionAttributeValues: expressionAttributeValues, + UpdateExpression: &updateExpression, + TableName: aws.String(signatureTableName), + } + + _, updateErr := repo.dynamoDBClient.UpdateItem(input) + if updateErr != nil { + log.WithFields(f).Warnf("error updating signature_approved for signature_id : %s error : %v ", signatureID, updateErr) + return updateErr + } + + return nil +} + +// invalidationUpdateExpression assembles the invalidation update: approval revoked, note replaced, +// every attribution attribute first-write-wins via if_not_exists, date_modified refreshed. +func invalidationUpdateExpression(note, now string, metadata *InvalidationMetadata) (map[string]*string, map[string]*dynamodb.AttributeValue, string) { + expressionAttributeNames := map[string]*string{} + expressionAttributeValues := map[string]*dynamodb.AttributeValue{} + updateExpression := "SET " // nolint + + expressionAttributeNames["#A"] = aws.String("signature_approved") + expressionAttributeValues[":a"] = &dynamodb.AttributeValue{BOOL: aws.Bool(false)} + updateExpression = updateExpression + " #A = :a," + + expressionAttributeNames["#S"] = aws.String("note") + expressionAttributeValues[":s"] = &dynamodb.AttributeValue{S: aws.String(note)} + updateExpression = updateExpression + " #S = :s," + + expressionAttributeNames["#DI"] = aws.String("date_invalidated") + expressionAttributeValues[":di"] = &dynamodb.AttributeValue{S: aws.String(now)} + updateExpression = updateExpression + " #DI = if_not_exists(#DI, :di)," + + if metadata != nil { + if metadata.InvalidatedBy != "" { + expressionAttributeNames["#IB"] = aws.String("invalidated_by") + expressionAttributeValues[":ib"] = &dynamodb.AttributeValue{S: aws.String(metadata.InvalidatedBy)} + updateExpression = updateExpression + " #IB = if_not_exists(#IB, :ib)," + } + if metadata.Reason != "" { + expressionAttributeNames["#IR"] = aws.String("invalidation_reason") + expressionAttributeValues[":ir"] = &dynamodb.AttributeValue{S: aws.String(metadata.Reason)} + updateExpression = updateExpression + " #IR = if_not_exists(#IR, :ir)," + } + if metadata.Note != "" { + expressionAttributeNames["#IN"] = aws.String("invalidation_note") + expressionAttributeValues[":in"] = &dynamodb.AttributeValue{S: aws.String(metadata.Note)} + updateExpression = updateExpression + " #IN = if_not_exists(#IN, :in)," + } + } + + expressionAttributeNames["#M"] = aws.String("date_modified") + expressionAttributeValues[":m"] = &dynamodb.AttributeValue{S: aws.String(now)} + updateExpression = updateExpression + " #M = :m" + + return expressionAttributeNames, expressionAttributeValues, updateExpression +} + // ValidateProjectRecord validates the specified project record by setting the signature_approved flag to true func (repo repository) ValidateProjectRecord(ctx context.Context, signatureID, note string) error { f := logrus.Fields{ diff --git a/cla-backend-go/signatures/repository_test.go b/cla-backend-go/signatures/repository_test.go new file mode 100644 index 000000000..8864db3dd --- /dev/null +++ b/cla-backend-go/signatures/repository_test.go @@ -0,0 +1,52 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package signatures + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInvalidationUpdateExpression(t *testing.T) { + const now = "2024-05-06T07:08:09.000000+0000" + + names, values, expr := invalidationUpdateExpression("a note", now, &InvalidationMetadata{ + InvalidatedBy: "admin-user", + Reason: "compliance", + Note: "per legal review", + }) + + assert.Contains(t, expr, "#A = :a") + assert.Contains(t, expr, "#S = :s") + assert.Contains(t, expr, "#DI = if_not_exists(#DI, :di)") + assert.Contains(t, expr, "#IB = if_not_exists(#IB, :ib)", "a re-invalidation must not overwrite the first actor") + assert.Contains(t, expr, "#IR = if_not_exists(#IR, :ir)", "a re-invalidation must not overwrite the first reason") + assert.Contains(t, expr, "#IN = if_not_exists(#IN, :in)", "a re-invalidation must not overwrite the first note") + assert.Contains(t, expr, "#M = :m") + + assert.Equal(t, "invalidated_by", *names["#IB"]) + assert.Equal(t, "invalidation_reason", *names["#IR"]) + assert.Equal(t, "invalidation_note", *names["#IN"]) + assert.Equal(t, "admin-user", *values[":ib"].S) + assert.Equal(t, "compliance", *values[":ir"].S) + assert.Equal(t, "per legal review", *values[":in"].S) + assert.Equal(t, now, *values[":di"].S) + assert.False(t, *values[":a"].BOOL) +} + +func TestInvalidationUpdateExpressionWithoutMetadata(t *testing.T) { + const now = "2024-05-06T07:08:09.000000+0000" + + for _, metadata := range []*InvalidationMetadata{nil, {}} { + names, values, expr := invalidationUpdateExpression("a note", now, metadata) + + assert.NotContains(t, expr, "#IB") + assert.NotContains(t, expr, "#IR") + assert.NotContains(t, expr, "#IN") + assert.Contains(t, expr, "#DI = if_not_exists(#DI, :di)") + assert.NotContains(t, names, "#IB") + assert.NotContains(t, values, ":ib") + } +} diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index b1500aec7..4960acb3c 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -79,6 +79,7 @@ type SignatureService interface { // handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) UserIsApproved(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, error) + EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (approved bool, githubOrgLookupFailed bool, err error) } type service struct { @@ -1605,16 +1606,25 @@ func (s service) ProcessEmployeeSignature(ctx context.Context, companyModel *mod } func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, error) { + approved, _, err := s.EvaluateUserApproval(ctx, user, cclaSignature) + return approved, err +} + +// EvaluateUserApproval is UserIsApproved plus whether the GitHub public-orgs lookup failed, so a +// false result was "could not tell" rather than "not approved". Access gating uses UserIsApproved. +func (s service) EvaluateUserApproval(ctx context.Context, user *models.User, cclaSignature *models.Signature) (bool, bool, error) { // add lf email to emails f := logrus.Fields{ - "functionName": "v1.signatures.service.UserIsApproved", + "functionName": "v1.signatures.service.EvaluateUserApproval", } + githubOrgLookupFailed := false emails := user.Emails if user.LfEmail != "" { log.WithFields(f).Debugf("adding lf email: %s to emails", user.LfEmail) - emails = append(emails, string(user.LfEmail)) + // copy first - the same user record may be evaluated concurrently + emails = append(append(make([]string, 0, len(emails)+1), emails...), string(user.LfEmail)) // remove duplicates log.WithFields(f).Debug("removing duplicates") emails = utils.RemoveDuplicates(emails) @@ -1626,7 +1636,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign if len(gitHubUsernameApprovalList) > 0 { for _, gitHubUsername := range gitHubUsernameApprovalList { if strings.EqualFold(gitHubUsername, strings.TrimSpace(user.GithubUsername)) { - return true, nil + return true, githubOrgLookupFailed, nil } } } else { @@ -1638,7 +1648,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign if len(gitLabUsernameApprovalList) > 0 { for _, gitLabUsername := range gitLabUsernameApprovalList { if strings.EqualFold(gitLabUsername, strings.TrimSpace(user.GitlabUsername)) { - return true, nil + return true, githubOrgLookupFailed, nil } } } else { @@ -1654,7 +1664,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign // case insensitive search for _, emailApproval := range emailApprovalList { if strings.EqualFold(email, emailApproval) { - return true, nil + return true, githubOrgLookupFailed, nil } } } @@ -1667,10 +1677,10 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign if len(domainApprovalList) > 0 { matched, err := s.processPattern(emails, domainApprovalList) if err != nil { - return false, err + return false, githubOrgLookupFailed, err } if matched != nil && *matched { - return true, nil + return true, githubOrgLookupFailed, nil } } @@ -1692,6 +1702,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign // /v3/sign route and a transient GitHub blip would block // every org-approved contributor across the project. log.WithFields(f).Warnf("could not list public orgs for github user %s; treating as no org-approval match: %v", login, err) + githubOrgLookupFailed = true } else { for _, approvedOrg := range githubOrgApprovalList { approvedOrgTrim := strings.TrimSpace(approvedOrg) @@ -1704,7 +1715,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign } if matched { log.WithFields(f).Debugf("found matching github organization: %s for user: %s", approvedOrg, login) - return true, nil + return true, githubOrgLookupFailed, nil } log.WithFields(f).Debugf("user: %s is not in the organization: %s", login, approvedOrg) } @@ -1712,7 +1723,7 @@ func (s service) UserIsApproved(ctx context.Context, user *models.User, cclaSign } } - return false, nil + return false, githubOrgLookupFailed, nil } func (s service) processPattern(emails []string, patterns []string) (*bool, error) { diff --git a/cla-backend-go/signatures/service_test.go b/cla-backend-go/signatures/service_test.go index fe4f87ca9..1c9530f08 100644 --- a/cla-backend-go/signatures/service_test.go +++ b/cla-backend-go/signatures/service_test.go @@ -243,6 +243,74 @@ func TestUserIsApproved_GithubOrgApprovalList(t *testing.T) { } } +// TestEvaluateUserApproval covers the one thing the UserIsApproved boolean cannot express: +// a false result caused by a failed GitHub public-orgs lookup ("could not tell") rather than +// by a genuine approval-list miss. Callers that must not present a guess to the user - the +// My CLAs listing - key off this second return value. +func TestEvaluateUserApproval(t *testing.T) { + ctx := context.Background() + + cases := []struct { + name string + user *v1Models.User + ccla *v1Models.Signature + userOrgs []string + listErr error + wantApproved bool + wantLookupFailed bool + }{ + { + name: "org match", + user: &v1Models.User{GithubUsername: "alice"}, + ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}}, + userOrgs: []string{"acme"}, + wantApproved: true, + }, + { + name: "no overlap is a genuine miss", + user: &v1Models.User{GithubUsername: "eve"}, + ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}}, + userOrgs: []string{"contoso"}, + }, + { + name: "lookup failure is unevaluable, not a miss", + user: &v1Models.User{GithubUsername: "grace"}, + ccla: &v1Models.Signature{GithubOrgApprovalList: []string{"acme"}}, + listErr: errors.New("simulated 502 from github"), + wantLookupFailed: true, + }, + { + name: "approved by email before github is consulted", + user: &v1Models.User{GithubUsername: "heidi", Emails: []string{"heidi@acme.org"}}, + ccla: &v1Models.Signature{EmailApprovalList: []string{"heidi@acme.org"}, GithubOrgApprovalList: []string{"acme"}}, + listErr: errors.New("would fail if reached"), + wantApproved: true, + }, + { + name: "no approval list at all", + user: &v1Models.User{GithubUsername: "ivan"}, + ccla: &v1Models.Signature{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stubListUserPublicOrgs(t, tc.userOrgs, tc.listErr) + svc := NewService(nil, nil, nil, nil, false, nil, nil, nil, nil, "", "", "") + + approved, lookupFailed, err := svc.EvaluateUserApproval(ctx, tc.user, tc.ccla) + assert.NoError(t, err) + assert.Equal(t, tc.wantApproved, approved) + assert.Equal(t, tc.wantLookupFailed, lookupFailed) + + // UserIsApproved must stay byte-identical for the signing flow + legacyApproved, legacyErr := svc.UserIsApproved(ctx, tc.user, tc.ccla) + assert.NoError(t, legacyErr) + assert.Equal(t, approved, legacyApproved) + }) + } +} + // TestListUserPublicOrgs_RejectsEmptyUser guards the public helper itself: // go-github routes an empty user string to GET /user/orgs (the authenticated // bot's own orgs), so an empty argument must never silently succeed. diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index c2c35bd41..f5e2f6fab 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -2753,7 +2753,7 @@ paths: /my-clas: get: summary: Get My CLAs - description: Returns the signed ICLAs and ECLAs (employee acknowledgements) matching the provided identity - LF username, emails and GitHub/GitLab/Gerrit identities - aggregated across all matching EasyCLA user records and deduplicated, with validity evaluated against the current company CCLA approval lists. Unless the caller is an admin, each provided identity must belong to the authenticated user (per their EasyCLA user record or the identities connected to their LF account in the platform user-service) - identities that cannot be verified are not searched and are reported in skippedIdentities + description: Returns the signed ICLAs and ECLAs (employee acknowledgements) matching the provided identity - LF username, emails, GitHub/GitLab/Gerrit identities - aggregated across all matching EasyCLA user records, deduplicated, with validity evaluated against the current company CCLA approval lists. Unless the caller is an admin or a trusted LFX Self Serve client, every provided identity must belong to the authenticated user (their EasyCLA user records, or the identities connected to their LF account in the platform user-service); unverifiable ones are not searched and are reported in skippedIdentities. A trusted caller has its Authorization bearer token signature-verified against the Auth0 JWKS in-handler and its azp claim on the configured Self Serve client-ID allow-list; while that allow-list is configured every request needs a verifiable bearer token and a missing or unverifiable one is 401 operationId: getMyClas parameters: - $ref: "#/parameters/x-request-id" @@ -2791,7 +2791,7 @@ paths: /my-clas/{signatureID}/pdf: get: summary: Get a signed ICLA PDF download link - description: Returns a time-limited download URL for the signed ICLA PDF when the signature belongs to the provided identity - unknown, not-owned and ECLA signature IDs return 404. The same identity-ownership enforcement as GET /my-clas applies, so a non-admin caller can only download their own signed documents + description: Returns a time-limited download URL for a signed ICLA PDF owned by the provided identity - unknown, not-owned and ECLA signature IDs return 404. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas, so a caller that is neither an admin nor a trusted LFX Self Serve client can only download its own documents operationId: getMyClaPdf parameters: - $ref: "#/parameters/x-request-id" @@ -2834,10 +2834,107 @@ paths: tags: - my_clas + /my-clas/{signatureID}/cla-managers: + get: + summary: Get the CLA managers for an ECLA + description: Returns the CLA managers of the company CCLA covering the given ECLA, so the contributor can pick whom to contact for a removal or approval request - unknown, not-owned and ICLA signature IDs return 404. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas + operationId: getMyClaManagers + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - name: signatureID + description: The signature ID (UUID string) + in: path + type: string + required: true + pattern: '^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$' # this is any UUID, not only v4 + - $ref: "#/parameters/myClasLfUsername" + - $ref: "#/parameters/myClasEmail" + - $ref: "#/parameters/myClasSecondaryEmail" + - $ref: "#/parameters/myClasGithubId" + - $ref: "#/parameters/myClasGithubUsername" + - $ref: "#/parameters/myClasGitlabId" + - $ref: "#/parameters/myClasGitlabUsername" + - $ref: "#/parameters/myClasGerritUsername" + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/my-cla-manager-list' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - my_clas + + /my-clas/{signatureID}/cla-manager-requests: + post: + summary: Request removal or approval from, or send a message to, the CLA managers of an ECLA + description: Records a contributor request against their own ECLA and emails it to the selected CLA managers of the covering company CCLA - removal (take me off the coverage), approval (add me back to the approval list) or contact (just deliver the contributor's free-form message, required and non-blank for this type, no change requested). recipients must be a non-empty subset of the managers from GET /my-clas/{signatureID}/cla-managers; empty only when none resolves, and the request is then recorded without email. No signature state changes. Unknown, not-owned and ICLA signature IDs return 404. An ECLA flagged for a sanctioned company is deliberately still accepted - a removal request is legitimate there. Identity-ownership enforcement and trusted-caller token verification are as in GET /my-clas + operationId: createMyClaManagerRequest + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - name: signatureID + description: The signature ID (UUID string) + in: path + type: string + required: true + pattern: '^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$' # this is any UUID, not only v4 + - $ref: "#/parameters/myClasLfUsername" + - $ref: "#/parameters/myClasEmail" + - $ref: "#/parameters/myClasSecondaryEmail" + - $ref: "#/parameters/myClasGithubId" + - $ref: "#/parameters/myClasGithubUsername" + - $ref: "#/parameters/myClasGitlabId" + - $ref: "#/parameters/myClasGitlabUsername" + - $ref: "#/parameters/myClasGerritUsername" + - name: body + in: body + required: true + schema: + $ref: '#/definitions/my-cla-manager-request' + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/my-cla-manager-request-result' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - my_clas + /my-clas/identities: get: summary: Get My Identities - description: Returns the deduplicated identities connected to the authenticated user, each formatted as ":" - the union of the identities on their EasyCLA user records and the identities connected to their LF account in the platform user-service, i.e. exactly the identity set the My CLAs API authorizes a non-admin caller to search + description: Returns the deduplicated identities connected to the authenticated user, each formatted ":" - the union of the identities on their EasyCLA user records and those connected to their LF account in the platform user-service, i.e. exactly the set a non-admin, non-trusted caller is authorized to search. Always the authenticated principal's own identities; while the trusted Self Serve client-ID allow-list is configured the request still needs a verifiable Authorization bearer token operationId: getMyIdentities parameters: - $ref: "#/parameters/x-request-id" @@ -2862,6 +2959,77 @@ paths: tags: - my_clas + /self-serve/prepare-sign: + post: + summary: Prepare Sign + description: Prepares a proactive (no pull/merge request) signing session started from LFX Self Serve - verifies that the provided identity belongs to the authenticated user with the same rules as GET /my-clas (their EasyCLA user records first, then the identities connected to their LF account in the platform user-service), creates the EasyCLA user record when the verified identity has none, records the return URL for the signing session, and returns the Contributor Console URL to hand off to so the contributor can choose ICLA or ECLA there + operationId: prepareSign + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - name: body + in: body + required: true + schema: + $ref: '#/definitions/prepare-sign-input' + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/prepare-sign' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '404': + $ref: '#/responses/not-found' + '500': + $ref: '#/responses/internal-server-error' + tags: + - self_serve_sign + + /cla-group/search: + get: + summary: Search CLA Groups + description: Unscoped search over the CLA Group name, the Salesforce project (or foundation) name, the names of the linked GitHub organizations, GitLab groups and Gerrit instances, and the repository the search term resolves to - a pasted repository URL or a "owner/repo" path is resolved to the CLA Group owning that repository. A URL that names a known repository resolves to that repository's CLA Group only - the owning organization is used as a fallback when no repository record matches - and a URL is matched against the repositories and organizations of the host it names - the forge for a github.com or gitlab.com URL, the host itself for a self-hosted one - while a bare "owner/repo" path matches either forge. Matching is case-insensitive substring matching performed server-side, results are deduplicated by CLA Group and capped at limit. A searchTerm shorter than 3 characters, or a limit outside its bounds, is rejected with a 422; a searchTerm that is shorter than 3 characters only after whitespace trimming is rejected with a 400. The reference data is served from an in-process cache with a short TTL, so a newly added CLA Group, organization or project mapping can take up to the cache TTL (30 minutes by default) to become searchable + operationId: searchClaGroups + parameters: + - $ref: "#/parameters/x-request-id" + - $ref: "#/parameters/x-acl" + - $ref: "#/parameters/x-username" + - $ref: "#/parameters/x-email" + - $ref: "#/parameters/claSearchTerm" + - $ref: "#/parameters/claSearchLimit" + responses: + '200': + description: 'Success' + headers: + x-request-id: + type: string + description: The unique request ID value - assigned/set by the API Gateway based on the session + schema: + $ref: '#/definitions/cla-search-list' + '400': + $ref: '#/responses/invalid-request' + '401': + $ref: '#/responses/unauthorized' + '403': + $ref: '#/responses/forbidden' + '422': + $ref: '#/responses/unprocessable-entity' + '500': + $ref: '#/responses/internal-server-error' + tags: + - cla_search + /user/{userID}/request-company-admin: post: summary: Request Manager @@ -3633,7 +3801,7 @@ paths: /cla-group/{claGroupID}/user/{userID}/icla: put: summary: Invalidate ICLA record - description: Invalidates a given ICLA record for a user + description: Invalidates a given ICLA record for a user - also stamps date_invalidated, invalidated_by and the optional invalidation_reason/invalidation_note from the body operationId: invalidateICLA parameters: - $ref: "#/parameters/x-request-id" @@ -3642,6 +3810,11 @@ paths: - $ref: "#/parameters/x-username" - $ref: "#/parameters/path-claGroupID" - $ref: "#/parameters/path-userID" + - name: body + in: body + required: false + schema: + $ref: '#/definitions/icla-invalidation-input' responses: '200': description: 'Success' @@ -4488,6 +4661,34 @@ paths: tags: - sign + /signed/self-serve/individual/{user_id}: + post: + summary: Endpoint to receive DocuSign callback for signed documents from LFX Self Serve. + description: Receives XML data when an individual signs a document in DocuSign for a signing session started proactively from LFX Self Serve, i.e. with no pull or merge request context. + operationId: iclaCallbackSelfServe + security: [ ] + consumes: + - text/xml + parameters: + - $ref: "#/parameters/x-request-id" + - name: user_id + in: path + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + additionalProperties: true + responses: + '200': + description: Successfully received and processed the Self Serve callback data. + '400': + description: Invalid request. + tags: + - sign + /signed/corporate/{project_id}/{company_id}: post: summary: Endpoint to receive DocuSign callback for signed corporate documents. @@ -4962,7 +5163,7 @@ parameters: required: true myClasLfUsername: name: lfUsername - description: The LF username (LFID) of the user - when omitted, the username of the authenticated principal is used; unless the caller is an admin, a value different from the authenticated principal is not searched and is reported in skippedIdentities + description: The LF username (LFID) of the user - when omitted, the authenticated principal's username is used; unless the caller is an admin or a trusted LFX Self Serve client, a different value is not searched and is reported in skippedIdentities. The caller-supplied identity list is transitional - at M6, once EasyCLA runs on the K8s cluster, it should call lfx.auth-service.user_identity.list over NATS itself and drop both the list and the azp allow-list authorizing it in: query type: string required: false @@ -4978,7 +5179,7 @@ parameters: required: false myClasSecondaryEmail: name: secondaryEmail - description: Email addresses of the user - matched case-insensitively against the EasyCLA user records additional-emails set; this match is not index-backed (all provided values are matched in a single table scan) so it runs only when explicitly requested, use sparingly + description: Email addresses of the user - matched case-insensitively against the EasyCLA user records additional-emails set; not index-backed (all values are matched in one table scan), so it runs only on request - use sparingly in: query type: array maxItems: 20 @@ -4998,7 +5199,7 @@ parameters: required: false myClasGithubUsername: name: githubUsername - description: GitHub usernames of the user - note that GitHub usernames can be renamed/recycled, the numeric githubId is the authoritative key + description: GitHub usernames of the user - usernames can be renamed/recycled, the numeric githubId is the authoritative key in: query type: array maxItems: 100 @@ -5018,7 +5219,7 @@ parameters: required: false myClasGitlabUsername: name: gitlabUsername - description: GitLab usernames of the user - note that GitLab usernames can be renamed/recycled, the numeric gitlabId is the authoritative key + description: GitLab usernames of the user - usernames can be renamed/recycled, the numeric gitlabId is the authoritative key in: query type: array maxItems: 100 @@ -5028,7 +5229,7 @@ parameters: required: false myClasGerritUsername: name: gerritUsername - description: Gerrit usernames of the user - Gerrit uses LF SSO accounts, so these are (current or historical) LF usernames and are matched against the EasyCLA user records LF username + description: Gerrit usernames of the user - Gerrit uses LF SSO, so these are (current or historical) LF usernames, matched against the EasyCLA user records LF username in: query type: array maxItems: 100 @@ -5036,6 +5237,23 @@ parameters: type: string collectionFormat: multi required: false + claSearchTerm: + name: searchTerm + description: The term to search for - matched case-insensitively as a substring against the CLA Group name, project/foundation name and linked organization names, and resolved as a repository URL or "owner/repo" path + in: query + type: string + minLength: 3 + maxLength: 255 + required: true + claSearchLimit: + name: limit + description: The maximum number of CLA Groups to return + in: query + type: integer + minimum: 1 + maximum: 100 + default: 20 + required: false definitions: # Common definitions @@ -5064,9 +5282,39 @@ definitions: my-cla-pdf: $ref: './common/my-cla-pdf.yaml' + my-cla-manager: + $ref: './common/my-cla-manager.yaml' + + my-cla-manager-list: + $ref: './common/my-cla-manager-list.yaml' + + my-cla-manager-request: + $ref: './common/my-cla-manager-request.yaml' + + my-cla-manager-request-result: + $ref: './common/my-cla-manager-request-result.yaml' + + icla-invalidation-input: + $ref: './common/icla-invalidation-input.yaml' + my-identity-list: $ref: './common/my-identity-list.yaml' + prepare-sign-input: + $ref: './common/prepare-sign-input.yaml' + + prepare-sign: + $ref: './common/prepare-sign.yaml' + + cla-search-list: + $ref: './common/cla-search-list.yaml' + + cla-search-result: + $ref: './common/cla-search-result.yaml' + + cla-search-org: + $ref: './common/cla-search-org.yaml' + #-------------------------------------- # Docusign Webhook Payload #____________________________________________ diff --git a/cla-backend-go/swagger/common/cla-search-list.yaml b/cla-backend-go/swagger/common/cla-search-list.yaml new file mode 100644 index 000000000..781c45296 --- /dev/null +++ b/cla-backend-go/swagger/common/cla-search-list.yaml @@ -0,0 +1,26 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: CLA Search List +description: The CLA Groups matching the search term +properties: + searchTerm: + type: string + description: The search term the results were resolved for + resultCount: + type: integer + format: int64 + x-omitempty: false + description: The number of results returned - at most limit + truncated: + type: boolean + x-omitempty: false + description: True when more CLA Groups matched than limit and the result set was capped - ask the user to refine the term + results: + type: array + x-omitempty: false + description: The matching CLA Groups, best match first + items: + $ref: '#/definitions/cla-search-result' diff --git a/cla-backend-go/swagger/common/cla-search-org.yaml b/cla-backend-go/swagger/common/cla-search-org.yaml new file mode 100644 index 000000000..f9af8f97a --- /dev/null +++ b/cla-backend-go/swagger/common/cla-search-org.yaml @@ -0,0 +1,18 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: CLA Search Organization +description: A repository-hosting organization (GitHub organization, GitLab group or Gerrit instance) linked to the CLA Group +properties: + name: + type: string + description: The organization/group/Gerrit instance name + source: + type: string + enum: [github, gitlab, gerrit] + description: The repository source hosting the organization + url: + type: string + description: The organization URL, omitted when the source record carries none diff --git a/cla-backend-go/swagger/common/cla-search-result.yaml b/cla-backend-go/swagger/common/cla-search-result.yaml new file mode 100644 index 000000000..535a5e374 --- /dev/null +++ b/cla-backend-go/swagger/common/cla-search-result.yaml @@ -0,0 +1,53 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: CLA Search Result +description: A CLA Group matching the search term - the CLA Group is the signing unit, so results are deduplicated by claGroupID +properties: + claGroupID: + type: string + description: The CLA Group ID (UUID) - the signing unit, used for the Contributor Console hand-off + claGroupName: + type: string + description: The CLA Group name, omitted when the CLA Group record could not be resolved + projectName: + type: string + description: The Salesforce project display name the CLA Group belongs to (a foundation-level CLA Group resolves to its foundation), omitted when the CLA Group maps to several projects with no foundation marker + projectSFID: + type: string + description: The Salesforce ID of the project in projectName, omitted when projectName could not be resolved + foundationSFID: + type: string + description: The Salesforce ID of the foundation the CLA Group belongs to, omitted when unknown + projectExternalID: + type: string + description: The external (Salesforce) ID stored on the CLA Group record, omitted when unset + iclaEnabled: + type: boolean + x-omitempty: false + description: The project_icla_enabled flag of the CLA Group + cclaEnabled: + type: boolean + x-omitempty: false + description: The project_ccla_enabled flag of the CLA Group + matchTypes: + type: array + x-omitempty: false + description: Why the CLA Group matched, sorted - claGroup (CLA Group name), project (project or foundation name), organization (linked organization name or URL), repository (the search term resolved to a repository) + items: + type: string + enum: [claGroup, project, organization, repository] + organizations: + type: array + x-omitempty: false + description: All repository-hosting organizations linked to the CLA Group, sorted by source then name - carries the repo-source provenance and backs the "N linked orgs" affordance + items: + $ref: '#/definitions/cla-search-org' + matchedRepositoryName: + type: string + description: The full repository name the search term resolved to, set only when matchTypes contains repository + matchedRepositoryURL: + type: string + description: The URL of the repository in matchedRepositoryName, set only when matchTypes contains repository diff --git a/cla-backend-go/swagger/common/company.yaml b/cla-backend-go/swagger/common/company.yaml index 53b6fd4a5..a55e3fd13 100644 --- a/cla-backend-go/swagger/common/company.yaml +++ b/cla-backend-go/swagger/common/company.yaml @@ -47,6 +47,10 @@ properties: type: string description: "Source of the sanction flag (e.g. sss)" example: "sss" + sanctionedDate: + type: string + description: "When the sanction flag was last set; kept after it is cleared" + example: "2026-08-20T10:11:12Z" version: type: string diff --git a/cla-backend-go/swagger/common/icla-invalidation-input.yaml b/cla-backend-go/swagger/common/icla-invalidation-input.yaml new file mode 100644 index 000000000..1cccf1fce --- /dev/null +++ b/cla-backend-go/swagger/common/icla-invalidation-input.yaml @@ -0,0 +1,16 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: ICLA Invalidation Input +description: Optional invalidation metadata recorded on the signature - omitting the body preserves the previous behavior +properties: + reason: + type: string + enum: [signed-in-error, should-be-corporate, compliance, other] + description: Stored as invalidation_reason on the signature record + note: + type: string + maxLength: 2048 + description: Free-text note stored as invalidation_note, separate from the general-purpose note audit trail diff --git a/cla-backend-go/swagger/common/my-cla-list.yaml b/cla-backend-go/swagger/common/my-cla-list.yaml index 6a557db0f..179838507 100644 --- a/cla-backend-go/swagger/common/my-cla-list.yaml +++ b/cla-backend-go/swagger/common/my-cla-list.yaml @@ -8,24 +8,33 @@ description: The signed ICLAs and ECLAs matching the provided user identity properties: lfUsername: type: string - description: The LF username (LFID) the list was resolved for, omitted when none was provided + description: Effective LF username (LFID) the list was resolved for - the authenticated principal when the query parameter is omitted, absent only when neither resolved userIds: type: array x-omitempty: false - description: The EasyCLA user record IDs (UUIDs) matched from the provided identity + description: EasyCLA user record IDs (UUIDs) matched from the provided identity items: type: string skippedIdentities: type: array x-omitempty: false - description: Identity parameters that were not searched because they could not be verified as belonging to the authenticated user, formatted as ":" + description: Identity parameters not searched because they could not be verified as belonging to the authenticated user, formatted ":" items: type: string + sssMode: + type: string + x-omitempty: false + enum: [required, optional, disabled] + description: > + Sanctions screening mode in effect. required - a live screen is mandatory, so any row with + flaggedCheck unavailable is unverified; optional - a live screen is best effort; disabled - + no screen was attempted and flagged is the persisted company flag. A screening failure + never fails this endpoint resultCount: type: integer format: int64 x-omitempty: false - description: The number of CLA records returned + description: Number of CLA records returned clas: type: array x-omitempty: false diff --git a/cla-backend-go/swagger/common/my-cla-manager-list.yaml b/cla-backend-go/swagger/common/my-cla-manager-list.yaml new file mode 100644 index 000000000..2f3672a40 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-list.yaml @@ -0,0 +1,41 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager List +description: The CLA managers of the CCLA covering the given ECLA signature +properties: + signatureID: + type: string + description: ECLA signature ID (UUID) + claGroupID: + type: string + description: CLA Group ID (UUID) + claGroupName: + type: string + description: CLA Group name, omitted when unresolved + projectName: + type: string + description: Salesforce project display name, omitted when unresolved + companyID: + type: string + description: Employer company ID (UUID) + companyName: + type: string + description: Employer company name, omitted when unresolved + claManager: + type: boolean + x-omitempty: false + description: True when the resolved user is one of the CLA managers + managers: + type: array + x-omitempty: false + description: CLA managers from the CCLA signature ACL - empty when none is currently reachable + items: + $ref: '#/definitions/my-cla-manager' + resultCount: + type: integer + format: int64 + x-omitempty: false + description: Number of CLA managers returned diff --git a/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml b/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml new file mode 100644 index 000000000..4ed396390 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-request-result.yaml @@ -0,0 +1,28 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager Request Result +description: Receipt of a contact-CLA-manager request - recorded as an EasyCLA audit event +properties: + requestID: + type: string + description: Request ID (UUID) carried by the audit event + signatureID: + type: string + description: ECLA signature ID (UUID) the request refers to + requestType: + type: string + enum: [removal, approval, contact] + description: The request type + status: + type: string + enum: [sent, recorded] + description: sent - the email was dispatched to the selected CLA managers that have a resolvable email address (at least one had); recorded - the audit event was written but no email was sent, because the CCLA lists no CLA manager or none of the selected ones has a resolvable email + recipients: + type: array + x-omitempty: false + description: LF usernames of the selected CLA managers + items: + type: string diff --git a/cla-backend-go/swagger/common/my-cla-manager-request.yaml b/cla-backend-go/swagger/common/my-cla-manager-request.yaml new file mode 100644 index 000000000..9aa75359d --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager-request.yaml @@ -0,0 +1,23 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager Request +description: A contributor removal, approval or contact request to the CLA managers of the CCLA covering their ECLA, delivered by email - no signature state changes +required: + - requestType +properties: + requestType: + type: string + enum: [removal, approval, contact] + description: removal - ask to be removed from the CCLA coverage; approval - ask to be (re-)added to the approval list; contact - just send a free-form message to the CLA managers, no change is requested + recipients: + type: array + description: LF usernames of the CLA managers to notify - a non-empty subset of the resolved managers; empty only when zero managers resolve, and the request is then recorded without email + items: + type: string + message: + type: string + maxLength: 4096 + description: Contributor message included in the notification email - optional for removal and approval, required (non-blank) for contact; control characters other than newlines and tabs are stripped diff --git a/cla-backend-go/swagger/common/my-cla-manager.yaml b/cla-backend-go/swagger/common/my-cla-manager.yaml new file mode 100644 index 000000000..8314a4ae6 --- /dev/null +++ b/cla-backend-go/swagger/common/my-cla-manager.yaml @@ -0,0 +1,17 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: My CLA Manager +description: A CLA manager from the CCLA signature ACL of an ECLA's company and CLA Group +properties: + lfUsername: + type: string + description: CLA manager LF username - the recipient key for a contact request + name: + type: string + description: Display name, omitted when unknown + email: + type: string + description: Email address, omitted when the user record carries none diff --git a/cla-backend-go/swagger/common/my-cla.yaml b/cla-backend-go/swagger/common/my-cla.yaml index aee85d6be..3d30c1cb5 100644 --- a/cla-backend-go/swagger/common/my-cla.yaml +++ b/cla-backend-go/swagger/common/my-cla.yaml @@ -4,67 +4,115 @@ type: object x-nullable: false title: My CLA -description: A single signed ICLA or ECLA (employee acknowledgement) belonging to the resolved user identity +description: A signed ICLA or ECLA (employee acknowledgement) of the resolved user identity properties: signatureID: type: string - description: The signature ID (UUID) + description: Signature ID (UUID) claType: type: string enum: [icla, ecla] - description: icla - individual CLA with a downloadable signed PDF, ecla - employee acknowledgement covered by the company CCLA, no PDF + description: icla - individual CLA, PDF downloadable; ecla - employee acknowledgement under the company CCLA, no PDF claGroupID: type: string - description: The CLA Group ID (UUID) the agreement was signed against + description: CLA Group ID (UUID) signed against claGroupName: type: string - description: The CLA Group name, omitted when the CLA Group record could not be resolved + description: CLA Group name, omitted when unresolved projectName: type: string - description: The Salesforce project display name the CLA Group belongs to (a foundation-level CLA Group resolves to its foundation), omitted when it could not be resolved + description: Salesforce project display name (the foundation, for a foundation-level CLA Group), omitted when unresolved projectLogo: type: string - description: The project (or foundation) logo URL, omitted when the project has no logo or could not be resolved + description: Project (or foundation) logo URL, omitted when absent or unresolved companyID: type: string - description: The company ID (UUID) of the employer, ECLA only + description: Employer company ID (UUID), ECLA only companyName: type: string - description: The company name of the employer, ECLA only + description: Employer company name, ECLA only signingEntityName: type: string - description: The company signing entity name of the employer, ECLA only + description: Employer signing entity name, ECLA only userID: type: string - description: The EasyCLA user record ID (UUID) owning this signature + description: EasyCLA user record ID (UUID) owning this signature signedOn: type: string - description: The date/time the agreement was signed or acknowledged + description: Date/time signed or acknowledged signed: type: boolean x-omitempty: false - description: The signature_signed flag - always true, unsigned records are not returned + description: signature_signed flag - always true, unsigned records are not returned approved: type: boolean x-omitempty: false - description: The signature_approved flag - false when the signature was invalidated + description: signature_approved flag - false when the signature was invalidated + invalidatedAt: + type: string + description: Stored date_invalidated, stamped at the first invalidation - only present on records invalidated after this field was introduced valid: type: boolean x-omitempty: false description: > - Computed validity. ICLA: signed and approved. ECLA: signed, approved, the employer is - not sanctioned, the employer still holds an approved+signed CCLA for the CLA Group, and - the user still matches the current CCLA approval lists (email, email domain, - GitHub/GitLab username, GitHub organization) + Computed validity. ICLA: signed and approved. ECLA: also requires an unsanctioned employer + holding a signed+approved CCLA for the CLA Group and the user still matching the current + CCLA approval lists (email, email domain, GitHub/GitLab username, GitHub organization) + status: + type: string + x-omitempty: false + enum: [valid, needs_attention, revoked, invalidated, unknown] + description: > + Contributor-facing standing, independent of approved and valid. revoked - employer flagged + by sanctions screening (system-set, no user action, see flagged/flaggedAt); invalidated - + the stored approval flag is false, which attributes nothing (an Approved List edit, an + invalidated ICLA and CLA Group deletion all produce it); needs_attention - a completed + approval-list check proved the user is no longer covered; unknown - ECLA coverage was not + evaluable; valid otherwise. ICLA is only valid or invalidated. New values may be added in + future revisions + statusReason: + type: string + enum: [not_on_approval_list, unknown] + description: > + Why the standing is not valid; omitted for other statuses and on every ICLA. Keyed on + status, not on valid - the two can disagree. not_on_approval_list - a completed Approved + List miss (what a Request approval action gates on); unknown - any other unevaluable ECLA + coverage outcome documentMajorVersion: type: integer x-omitempty: false - description: The major version of the CLA document that was signed + description: Major version of the signed CLA document documentMinorVersion: type: integer x-omitempty: false - description: The minor version of the CLA document that was signed + description: Minor version of the signed CLA document pdfAvailable: type: boolean x-omitempty: false - description: True when the record is a signed ICLA eligible for PDF retrieval - object availability is verified by the PDF endpoint on request + description: True for a signed ICLA eligible for PDF retrieval - object availability is verified by the PDF endpoint on request + signedVia: + type: string + enum: [github, gitlab, gerrit] + description: Platform signed via - gerrit also covers LF SSO signings identified by email + signedAs: + type: string + description: Account signed as - GitHub/GitLab username (numeric ID when no username was recorded) or email for gerrit/LF SSO, omitted when the signature carries no identity + claManager: + type: boolean + x-omitempty: false + description: True when the resolved user is a CLA manager of the employer's CCLA for this CLA Group - always present, always false on ICLA rows + flagged: + type: boolean + x-omitempty: false + description: True when the employer is currently flagged by sanctions screening - always present, always false on ICLA rows + flaggedAt: + type: string + description: The employer's stored sanctioned_date (the revocation date), stamped at the first live detection and refreshed when a cleared employer is flagged again; present only when flagged is true and a stored date exists + flaggedCheck: + type: string + enum: [live, stored, unavailable] + description: > + How flagged was obtained, ECLA only. live - a screening call answered for this response; + stored - the persisted company flag, because screening is disabled or an administrator set + the block; unavailable - the call did not complete, so flagged is the persisted value and + may be stale - treat it as unknown, more so when sssMode is required diff --git a/cla-backend-go/swagger/common/prepare-sign-input.yaml b/cla-backend-go/swagger/common/prepare-sign-input.yaml new file mode 100644 index 000000000..b704e9e59 --- /dev/null +++ b/cla-backend-go/swagger/common/prepare-sign-input.yaml @@ -0,0 +1,49 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: Prepare Sign Input +description: The CLA Group to sign and the single identity to sign it under - the identity must belong to the authenticated user +required: + - claGroupId + - returnUrl +properties: + claGroupId: + type: string + description: The CLA Group ID (UUID) the contributor selected in Self Serve + pattern: '^[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{4}-?[a-fA-F0-9]{12}$' + returnUrl: + type: string + format: uri + minLength: 1 + maxLength: 1000 + description: The absolute https URL the Contributor Console returns the contributor to once signing completes - the Self Serve My CLAs page; the Console has no other return target for a signing session started outside a pull or merge request + lfUsername: + type: string + maxLength: 255 + description: The LF username (LFID) to sign under - when omitted the authenticated principal is used; a different value is rejected unless the caller is an admin + email: + type: string + maxLength: 255 + description: The email address to sign under + githubId: + type: integer + format: int64 + description: The GitHub numeric user ID to sign under - accepted only when it matches the GitHub account named by githubUsername, or is already linked to one of the caller's EasyCLA user records + githubUsername: + type: string + maxLength: 255 + description: The GitHub username to sign under + gitlabId: + type: integer + format: int64 + description: The GitLab numeric user ID to sign under - accepted only when it is already linked to one of the caller's EasyCLA user records + gitlabUsername: + type: string + maxLength: 255 + description: The GitLab username to sign under + gerritUsername: + type: string + maxLength: 255 + description: The Gerrit username to sign under - Gerrit uses LF SSO accounts, so this is a (current or historical) LF username diff --git a/cla-backend-go/swagger/common/prepare-sign.yaml b/cla-backend-go/swagger/common/prepare-sign.yaml new file mode 100644 index 000000000..d4dfc423b --- /dev/null +++ b/cla-backend-go/swagger/common/prepare-sign.yaml @@ -0,0 +1,66 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: Prepare Sign +description: The EasyCLA user record and CLA Group the contributor was prepared to sign for, plus the Contributor Console URL to hand off to +properties: + userId: + type: string + description: The EasyCLA user record ID (UUID) to sign as - either the pre-existing record matching the verified identity or the record just created for it + userCreated: + type: boolean + x-omitempty: false + description: True when no EasyCLA user record matched the verified identity and a new one was created + lfUsername: + type: string + description: The LF username (LFID) on the EasyCLA user record + userName: + type: string + description: The display name on the EasyCLA user record + userEmail: + type: string + description: The primary email on the EasyCLA user record + identity: + type: array + x-omitempty: false + description: The verified identity keys the user record was resolved (or created) from, formatted as ":" (e.g. github-id:26589865, github-username:octocat, email:a@b.com) + items: + type: string + skippedIdentities: + type: array + x-omitempty: false + description: The provided identity keys that could not be verified as belonging to the authenticated user and were therefore ignored - each entry names the rejected request parameter and its value, for example githubId:999 + items: + type: string + claGroupId: + type: string + description: The CLA Group ID (UUID) + claGroupName: + type: string + description: The CLA Group name + projectSfid: + type: string + description: The Salesforce project ID the CLA Group is mapped to, omitted for a CLA Group mapped to several projects + foundationSfid: + type: string + description: The Salesforce foundation ID the CLA Group belongs to + iclaEnabled: + type: boolean + x-omitempty: false + description: Whether the CLA Group offers an individual CLA + cclaEnabled: + type: boolean + x-omitempty: false + description: Whether the CLA Group offers a corporate CLA (employee acknowledgement) + cclaRequiresIcla: + type: boolean + x-omitempty: false + description: Whether an employee acknowledgement additionally requires an individual CLA + signUrl: + type: string + description: The Contributor Console URL to open - the ICLA/ECLA decision screen for this CLA Group and user, carrying the return URL + returnUrl: + type: string + description: The return URL recorded for this signing session, echoed back diff --git a/cla-backend-go/utils/const.go b/cla-backend-go/utils/const.go index bc69f3844..1e626553e 100644 --- a/cla-backend-go/utils/const.go +++ b/cla-backend-go/utils/const.go @@ -3,6 +3,8 @@ package utils +import "strings" + const ( // Connected status Connected = "connected" @@ -13,3 +15,14 @@ const ( // NoConnection status NoConnection = "no_connection" ) + +// SelfServeSignatureSource is the source marker recorded on an active signature session started +// proactively from LFX Self Serve - such a session carries no pull/merge request context +const SelfServeSignatureSource = "self-serve" + +// IsSelfServeActiveSignature reports whether the active signature metadata belongs to a signing +// session started proactively from LFX Self Serve +func IsSelfServeActiveSignature(metadata map[string]interface{}) bool { + source, ok := metadata["source"].(string) + return ok && strings.EqualFold(source, SelfServeSignatureSource) +} diff --git a/cla-backend-go/utils/const_test.go b/cla-backend-go/utils/const_test.go new file mode 100644 index 000000000..54ba8043a --- /dev/null +++ b/cla-backend-go/utils/const_test.go @@ -0,0 +1,29 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package utils + +import "testing" + +func TestIsSelfServeActiveSignature(t *testing.T) { + tests := []struct { + name string + metadata map[string]interface{} + expected bool + }{ + {"nil metadata", nil, false}, + {"no source", map[string]interface{}{"project_id": "abc"}, false}, + {"non string source", map[string]interface{}{"source": 1}, false}, + {"pull request source", map[string]interface{}{"repository_id": "1", "pull_request_id": "2"}, false}, + {"self serve source", map[string]interface{}{"source": SelfServeSignatureSource}, true}, + {"mixed case source", map[string]interface{}{"source": "Self-Serve"}, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsSelfServeActiveSignature(test.metadata); got != test.expected { + t.Errorf("IsSelfServeActiveSignature(%v) = %v, expected %v", test.metadata, got, test.expected) + } + }) + } +} diff --git a/cla-backend-go/utils/string_utils.go b/cla-backend-go/utils/string_utils.go index 934550ba2..06505d26d 100644 --- a/cla-backend-go/utils/string_utils.go +++ b/cla-backend-go/utils/string_utils.go @@ -3,7 +3,10 @@ package utils -import "strings" +import ( + "strings" + "unicode" +) // TrimRemoveTrailingComma trims the whitespace on the specified string and removes the trailing comma func TrimRemoveTrailingComma(input string) string { @@ -40,3 +43,29 @@ func GetFirstAndLastName(firstAndLastName string) (string, string) { return strings.TrimSpace(userFirstName), strings.TrimSpace(userLastName) } + +// SanitizePlainText normalizes user-supplied free text: CR/LF variants become newlines, other +// control characters are dropped, the result is trimmed (HTML escaping is the renderer's job) +func SanitizePlainText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + var builder strings.Builder + builder.Grow(len(text)) + for _, r := range text { + if r == '\n' || r == '\t' || !unicode.IsControl(r) { + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} + +// SanitizeSingleLine strips every control character so user-influenced values cannot inject +// email header separators +func SanitizeSingleLine(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) +} diff --git a/cla-backend-go/utils/string_utils_test.go b/cla-backend-go/utils/string_utils_test.go new file mode 100644 index 000000000..34eda9711 --- /dev/null +++ b/cla-backend-go/utils/string_utils_test.go @@ -0,0 +1,25 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizePlainText(t *testing.T) { + assert.Equal(t, "", SanitizePlainText("")) + assert.Equal(t, "", SanitizePlainText(" \r\n \x07\x1b \t ")) + assert.Equal(t, "one\ntwo\nthree", SanitizePlainText("one\r\ntwo\rthree"), "CR and CRLF normalize to LF") + assert.Equal(t, "keep\ttabs\nand lines", SanitizePlainText("keep\ttabs\nand lines")) + assert.Equal(t, "bell stripped", SanitizePlainText("bell\x07 stripped\x00")) + assert.Equal(t, "trimmed", SanitizePlainText(" trimmed \n")) +} + +func TestSanitizeSingleLine(t *testing.T) { + assert.Equal(t, "", SanitizeSingleLine("")) + assert.Equal(t, "Subject line", SanitizeSingleLine("Subject\r\n line\x07")) + assert.Equal(t, "no tabs", SanitizeSingleLine("no\t tabs")) +} diff --git a/cla-backend-go/v2/cla_search/cache.go b/cla-backend-go/v2/cla_search/cache.go new file mode 100644 index 000000000..5cdbb9600 --- /dev/null +++ b/cla-backend-go/v2/cla_search/cache.go @@ -0,0 +1,200 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "os" + "sync" + "time" + + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" +) + +// DefaultCacheTTL is how long a scanned table is served from memory before the next search re-scans +// it. It is deliberately longer than a typical Lambda execution environment lives, so a container +// normally scans each table once and never again - the reference data changes on the timescale of +// project onboarding, not of searches. +const DefaultCacheTTL = 30 * time.Minute + +// loadTimeout bounds a table fill, which runs detached from the request that triggered it +const loadTimeout = 30 * time.Second + +// cacheTTLEnvVar overrides DefaultCacheTTL with any duration Go can parse, "0" disabling the cache +const cacheTTLEnvVar = "CLA_SEARCH_CACHE_TTL" + +func cacheTTL() time.Duration { + value := os.Getenv(cacheTTLEnvVar) + if value == "" { + return DefaultCacheTTL + } + ttl, err := time.ParseDuration(value) + if err != nil || ttl < 0 { + log.WithField(cacheTTLEnvVar, value).Warn("unable to parse the CLA Group search cache TTL - using the default") + return DefaultCacheTTL + } + return ttl +} + +// tableCache holds the rows of one scanned table until they age out. The rows are shared with every +// search that reads them and must be treated as read-only. +type tableCache[T any] struct { + name string + ttl time.Duration + load func(context.Context) ([]T, error) + flight singleflight.Group + + mu sync.RWMutex + rows []T + loadedAt time.Time +} + +func newTableCache[T any](name string, ttl time.Duration, load func(context.Context) ([]T, error)) *tableCache[T] { + return &tableCache[T]{name: name, ttl: ttl, load: load} +} + +// get returns the cached rows, scanning the table when they are absent or stale. Concurrent misses +// share a single scan. +func (c *tableCache[T]) get(ctx context.Context) ([]T, error) { + if rows, ok := c.fresh(); ok { + return rows, nil + } + + f := logrus.Fields{"functionName": "v2.cla_search.cache.get", "tableName": c.name} + fill := c.flight.DoChan(c.name, func() (interface{}, error) { + // a scan that finished while this call waited for the lock makes this one unnecessary + if _, ok := c.fresh(); ok { + return nil, nil + } + // the fill outlives the request that happened to trigger it - one caller giving up must not + // fail the fill for every other caller waiting on it + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), loadTimeout) + defer cancel() + rows, loadErr := c.load(loadCtx) + if loadErr != nil { + return nil, loadErr + } + c.mu.Lock() + c.rows, c.loadedAt = rows, time.Now() + c.mu.Unlock() + log.WithFields(f).Debugf("cache miss - loaded %d rows", len(rows)) + return nil, nil + }) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-fill: + if result.Err != nil { + return nil, result.Err + } + if result.Shared { + log.WithFields(f).Debug("cache miss - served by a concurrent load") + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.rows, nil + } +} + +func (c *tableCache[T]) fresh() ([]T, bool) { + c.mu.RLock() + rows, loadedAt := c.rows, c.loadedAt + c.mu.RUnlock() + if loadedAt.IsZero() || time.Since(loadedAt) >= c.ttl { + return nil, false + } + return rows, true +} + +// keyedCache holds one tableCache per lookup key, for a read whose cost justifies caching but whose +// result set depends on the key +type keyedCache[T any] struct { + ttl time.Duration + load func(context.Context, string) ([]T, error) + + mu sync.Mutex + entries map[string]*tableCache[T] +} + +func newKeyedCache[T any](ttl time.Duration, load func(context.Context, string) ([]T, error)) *keyedCache[T] { + return &keyedCache[T]{ttl: ttl, load: load, entries: map[string]*tableCache[T]{}} +} + +func (k *keyedCache[T]) get(ctx context.Context, key string) ([]T, error) { + k.mu.Lock() + entry, ok := k.entries[key] + if !ok { + entry = newTableCache(key, k.ttl, func(loadCtx context.Context) ([]T, error) { return k.load(loadCtx, key) }) + k.entries[key] = entry + } + k.mu.Unlock() + return entry.get(ctx) +} + +// cachedRepository serves the scanned tables from memory and passes the indexed repository lookups, +// whose keys have no useful cache locality, straight through +type cachedRepository struct { + Repository + claGroups *tableCache[*ClaGroupRow] + mappings *tableCache[*ProjectMappingRow] + github *tableCache[*OrgRow] + gitlab *tableCache[*OrgRow] + gerrit *tableCache[*OrgRow] + + // listing every repository of an organization is the one indexed read expensive enough to cache - + // a large organization runs to hundreds of rows, and a pasted URL under it repeats the same listing + orgRepositories *keyedCache[*RepositoryRow] +} + +func newCachedRepository(repo Repository, ttl time.Duration) Repository { + if ttl == 0 { + return repo + } + return &cachedRepository{ + Repository: repo, + orgRepositories: newKeyedCache(ttl, func(ctx context.Context, organizationName string) ([]*RepositoryRow, error) { + return repo.GetRepositoriesByOrganization(ctx, []string{organizationName}) + }), + claGroups: newTableCache("projects", ttl, repo.GetClaGroups), + mappings: newTableCache("projects-cla-groups", ttl, repo.GetProjectMappings), + github: newTableCache("github-orgs", ttl, repo.GetGithubOrgs), + gitlab: newTableCache("gitlab-orgs", ttl, repo.GetGitlabOrgs), + gerrit: newTableCache("gerrit-instances", ttl, repo.GetGerritInstances), + } +} + +func (c *cachedRepository) GetClaGroups(ctx context.Context) ([]*ClaGroupRow, error) { + return c.claGroups.get(ctx) +} + +func (c *cachedRepository) GetProjectMappings(ctx context.Context) ([]*ProjectMappingRow, error) { + return c.mappings.get(ctx) +} + +func (c *cachedRepository) GetGithubOrgs(ctx context.Context) ([]*OrgRow, error) { + return c.github.get(ctx) +} + +func (c *cachedRepository) GetGitlabOrgs(ctx context.Context) ([]*OrgRow, error) { + return c.gitlab.get(ctx) +} + +func (c *cachedRepository) GetGerritInstances(ctx context.Context) ([]*OrgRow, error) { + return c.gerrit.get(ctx) +} + +func (c *cachedRepository) GetRepositoriesByOrganization(ctx context.Context, organizationNames []string) ([]*RepositoryRow, error) { + var rows []*RepositoryRow + for _, organizationName := range organizationNames { + cached, err := c.orgRepositories.get(ctx, organizationName) + if err != nil { + return nil, err + } + rows = append(rows, cached...) + } + return rows, nil +} diff --git a/cla-backend-go/v2/cla_search/cache_test.go b/cla-backend-go/v2/cla_search/cache_test.go new file mode 100644 index 000000000..7b8a03790 --- /dev/null +++ b/cla-backend-go/v2/cla_search/cache_test.go @@ -0,0 +1,198 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func countOf(sequence []string, name string) int { + count := 0 + for _, entry := range sequence { + if entry == name { + count++ + } + } + return count +} + +func TestCacheScansEachTableOnceForRepeatedSearches(t *testing.T) { + repo := sampleRepo() + svc := NewService(newCachedRepository(repo, time.Minute)) + for i := 0; i < 5; i++ { + list, err := svc.Search(context.Background(), "kubernetes cla", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-kube"}, ids(list)) + } + for _, table := range []string{"claGroups", "mappings", "github", "gitlab", "gerrit"} { + assert.Equal(t, 1, countOf(repo.callSequence, table), table) + } +} + +func TestCacheLeavesTheIndexedRepositoryLookupsUncached(t *testing.T) { + repo := sampleRepo() + svc := NewService(newCachedRepository(repo, time.Minute)) + for i := 0; i < 3; i++ { + _, err := svc.Search(context.Background(), "OpenTimelineIO/OpenTimelineIO-Java-Bindings", 0) + require.NoError(t, err) + } + assert.Equal(t, 3, repo.repoCalls) +} + +func TestCacheReusesTheOrganizationRepositoryListing(t *testing.T) { + repo := sampleRepo() + svc := NewService(newCachedRepository(repo, time.Minute)) + for i := 0; i < 3; i++ { + list, err := svc.Search(context.Background(), "https://github.com/opentimelineio/opentimelineio-java-bindings", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-otio"}, ids(list)) + require.Equal(t, "OpenTimelineIO/OpenTimelineIO-Java-Bindings", list.Results[0].MatchedRepositoryName) + } + assert.Equal(t, [][]string{{"OpenTimelineIO"}}, repo.orgQueries) +} + +func TestCacheRescansWhenTheEntryIsStale(t *testing.T) { + var loads int32 + cache := newTableCache("projects", 20*time.Millisecond, func(_ context.Context) ([]*ClaGroupRow, error) { + atomic.AddInt32(&loads, 1) + return []*ClaGroupRow{{ClaGroupID: "cg-1"}}, nil + }) + + rows, err := cache.get(context.Background()) + require.NoError(t, err) + require.Len(t, rows, 1) + + _, err = cache.get(context.Background()) + require.NoError(t, err) + assert.Equal(t, int32(1), atomic.LoadInt32(&loads)) + + time.Sleep(30 * time.Millisecond) + _, err = cache.get(context.Background()) + require.NoError(t, err) + assert.Equal(t, int32(2), atomic.LoadInt32(&loads)) +} + +func TestCacheConcurrentMissesShareASingleScan(t *testing.T) { + var loads int32 + cache := newTableCache("projects", time.Minute, func(_ context.Context) ([]*ClaGroupRow, error) { + atomic.AddInt32(&loads, 1) + time.Sleep(50 * time.Millisecond) + return []*ClaGroupRow{{ClaGroupID: "cg-1"}}, nil + }) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rows, err := cache.get(context.Background()) + assert.NoError(t, err) + assert.Len(t, rows, 1) + }() + } + wg.Wait() + assert.Equal(t, int32(1), atomic.LoadInt32(&loads)) +} + +func TestCacheFillSurvivesTheCancellationOfTheRequestThatTriggeredIt(t *testing.T) { + var loads int32 + started := make(chan struct{}, 1) + release := make(chan struct{}) + cache := newTableCache("projects", time.Minute, func(ctx context.Context) ([]*ClaGroupRow, error) { + atomic.AddInt32(&loads, 1) + started <- struct{}{} + <-release + if err := ctx.Err(); err != nil { + return nil, err + } + return []*ClaGroupRow{{ClaGroupID: "cg-1"}}, nil + }) + + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + leaderErr := make(chan error, 1) + go func() { + _, err := cache.get(leaderCtx) + leaderErr <- err + }() + <-started + + type waiterResult struct { + rows []*ClaGroupRow + err error + } + waiter := make(chan waiterResult, 1) + go func() { + rows, err := cache.get(context.Background()) + waiter <- waiterResult{rows: rows, err: err} + }() + time.Sleep(50 * time.Millisecond) + + cancelLeader() + assert.ErrorIs(t, <-leaderErr, context.Canceled) + + close(release) + result := <-waiter + require.NoError(t, result.err) + require.Len(t, result.rows, 1) + + rows, err := cache.get(context.Background()) + require.NoError(t, err) + assert.Len(t, rows, 1) + assert.Equal(t, int32(1), atomic.LoadInt32(&loads)) +} + +func TestCacheDoesNotRetainAFailedScan(t *testing.T) { + var loads int32 + cache := newTableCache("projects", time.Minute, func(_ context.Context) ([]*ClaGroupRow, error) { + if atomic.AddInt32(&loads, 1) == 1 { + return nil, errors.New("boom") + } + return []*ClaGroupRow{{ClaGroupID: "cg-1"}}, nil + }) + + _, err := cache.get(context.Background()) + require.Error(t, err) + + rows, err := cache.get(context.Background()) + require.NoError(t, err) + assert.Len(t, rows, 1) +} + +func TestCacheDisabledByAZeroTTL(t *testing.T) { + repo := sampleRepo() + assert.Equal(t, Repository(repo), newCachedRepository(repo, 0)) + + svc := NewService(newCachedRepository(repo, 0)) + for i := 0; i < 2; i++ { + _, err := svc.Search(context.Background(), "kubernetes", 0) + require.NoError(t, err) + } + assert.Equal(t, 2, countOf(repo.callSequence, "claGroups")) +} + +func TestCacheTTLFromTheEnvironment(t *testing.T) { + for _, tc := range []struct { + value string + expected time.Duration + }{ + {"", DefaultCacheTTL}, + {"90s", 90 * time.Second}, + {"0", 0}, + {"-1m", DefaultCacheTTL}, + {"not-a-duration", DefaultCacheTTL}, + } { + t.Run(tc.value, func(t *testing.T) { + t.Setenv(cacheTTLEnvVar, tc.value) + assert.Equal(t, tc.expected, cacheTTL()) + }) + } +} diff --git a/cla-backend-go/v2/cla_search/handlers.go b/cla-backend-go/v2/cla_search/handlers.go new file mode 100644 index 000000000..fe53fd82c --- /dev/null +++ b/cla-backend-go/v2/cla_search/handlers.go @@ -0,0 +1,71 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LF-Engineering/lfx-kit/auth" + "github.com/go-openapi/runtime/middleware" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" + claSearchOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/cla_search" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" +) + +const missingUsernameMsg = "the authenticated principal carries no username - unable to search" + +// searchTimeout bounds a search well inside the API Gateway limit, so a stuck DynamoDB call fails +// the request rather than holding the Lambda open +const searchTimeout = 15 * time.Second + +// authorized accepts a principal carrying a username, or an admin principal such as a machine token +func authorized(authUser *auth.User) bool { + return authUser != nil && (authUser.UserName != "" || utils.IsUserAdmin(authUser)) +} + +// Configure sets up the CLA Group search API handlers +func Configure(api *operations.EasyclaAPI, service Service) { + api.ClaSearchSearchClaGroupsHandler = claSearchOps.SearchClaGroupsHandlerFunc( + func(params claSearchOps.SearchClaGroupsParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + if authUser != nil { + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + } + f := logrus.Fields{ + "functionName": "v2.cla_search.handlers.SearchClaGroups", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "searchTerm": params.SearchTerm, + } + + if !authorized(authUser) { + log.WithFields(f).Warn(missingUsernameMsg) + return claSearchOps.NewSearchClaGroupsUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + if len(strings.TrimSpace(params.SearchTerm)) < MinSearchTermLength { + msg := fmt.Sprintf("searchTerm must contain at least %d non-whitespace characters", MinSearchTermLength) + log.WithFields(f).Warn(msg) + return claSearchOps.NewSearchClaGroupsBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, msg)) + } + + searchCtx, cancel := context.WithTimeout(ctx, searchTimeout) + defer cancel() + + result, err := service.Search(searchCtx, params.SearchTerm, utils.Int64Value(params.Limit)) + if err != nil { + msg := "unable to search the CLA Groups for the provided search term" + log.WithFields(f).WithError(err).Warn(msg) + return claSearchOps.NewSearchClaGroupsInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + + return claSearchOps.NewSearchClaGroupsOK().WithXRequestID(reqID).WithPayload(result) + }) +} diff --git a/cla-backend-go/v2/cla_search/handlers_test.go b/cla-backend-go/v2/cla_search/handlers_test.go new file mode 100644 index 000000000..9912cd2d8 --- /dev/null +++ b/cla-backend-go/v2/cla_search/handlers_test.go @@ -0,0 +1,81 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LF-Engineering/lfx-kit/auth" + "github.com/go-openapi/runtime" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" + claSearchOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/cla_search" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubService struct { + list *models.ClaSearchList + err error + term string +} + +func (s *stubService) Search(_ context.Context, searchTerm string, _ int64) (*models.ClaSearchList, error) { + s.term = searchTerm + return s.list, s.err +} + +func invoke(t *testing.T, svc Service, authUser *auth.User, searchTerm string) *httptest.ResponseRecorder { + t.Helper() + api := &operations.EasyclaAPI{} + Configure(api, svc) + require.NotNil(t, api.ClaSearchSearchClaGroupsHandler) + + params := claSearchOps.SearchClaGroupsParams{ + HTTPRequest: httptest.NewRequest(http.MethodGet, "/v4/cla-group/search", nil), + SearchTerm: searchTerm, + } + recorder := httptest.NewRecorder() + api.ClaSearchSearchClaGroupsHandler.Handle(params, authUser).WriteResponse(recorder, runtime.JSONProducer()) + return recorder +} + +func TestHandlerUnauthorizedWithoutPrincipal(t *testing.T) { + for name, authUser := range map[string]*auth.User{"nil": nil, "empty username": {}} { + t.Run(name, func(t *testing.T) { + svc := &stubService{} + assert.Equal(t, http.StatusUnauthorized, invoke(t, svc, authUser, "kubernetes").Code) + assert.Empty(t, svc.term) + }) + } +} + +func TestHandlerAcceptsAdminPrincipalWithoutUsername(t *testing.T) { + svc := &stubService{list: &models.ClaSearchList{SearchTerm: "kubernetes"}} + assert.Equal(t, http.StatusOK, invoke(t, svc, &auth.User{ACL: auth.ACL{Admin: true}}, "kubernetes").Code) + assert.Equal(t, "kubernetes", svc.term) +} + +func TestHandlerBadRequestOnWhitespaceOnlyTerm(t *testing.T) { + svc := &stubService{} + assert.Equal(t, http.StatusBadRequest, invoke(t, svc, &auth.User{UserName: "jdoe"}, " ").Code) + assert.Empty(t, svc.term) +} + +func TestHandlerInternalServerErrorOnServiceFailure(t *testing.T) { + svc := &stubService{err: errors.New("boom")} + assert.Equal(t, http.StatusInternalServerError, invoke(t, svc, &auth.User{UserName: "jdoe"}, "kubernetes").Code) +} + +func TestHandlerSuccess(t *testing.T) { + svc := &stubService{list: &models.ClaSearchList{SearchTerm: "kubernetes", ResultCount: 1, Results: []models.ClaSearchResult{{ClaGroupID: "cg-kube"}}}} + recorder := invoke(t, svc, &auth.User{UserName: "jdoe"}, "kubernetes") + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"claGroupID":"cg-kube"`) + assert.Equal(t, "kubernetes", svc.term) +} diff --git a/cla-backend-go/v2/cla_search/repository.go b/cla-backend-go/v2/cla_search/repository.go new file mode 100644 index 000000000..378dd016b --- /dev/null +++ b/cla-backend-go/v2/cla_search/repository.go @@ -0,0 +1,340 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "fmt" + "net/http" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" + "github.com/aws/aws-sdk-go/service/dynamodb/expression" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/repositories" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +// Scan segment counts, sized so every table is covered by one round of parallel 1MB scan pages - +// the CLA Group table carries the embedded CLA document bodies and is by far the largest +const ( + claGroupScanSegments = 8 + projectMappingScanSegments = 2 + orgScanSegments = 1 + + // the number of DynamoDB calls a single search can have in flight + searchConcurrency = claGroupScanSegments + projectMappingScanSegments + 3*orgScanSegments + 2 +) + +// ClaGroupRow is a CLA Group record, projected to the fields the search results carry. The CLA type +// flags are pointers because a missing attribute means true - the Pynamo default the v1 reader keeps. +type ClaGroupRow struct { + ClaGroupID string `dynamodbav:"project_id"` + Name string `dynamodbav:"project_name"` + ExternalID string `dynamodbav:"project_external_id"` + IclaEnabled *bool `dynamodbav:"project_icla_enabled"` + CclaEnabled *bool `dynamodbav:"project_ccla_enabled"` +} + +// ProjectMappingRow is a projects-cla-groups mapping record, projected to the fields the search results carry +type ProjectMappingRow struct { + ClaGroupID string `dynamodbav:"cla_group_id"` + ClaGroupName string `dynamodbav:"cla_group_name"` + ProjectSFID string `dynamodbav:"project_sfid"` + ProjectName string `dynamodbav:"project_name"` + FoundationSFID string `dynamodbav:"foundation_sfid"` + FoundationName string `dynamodbav:"foundation_name"` +} + +// OrgRow is a repository-hosting organization - a GitHub organization, a GitLab group or a Gerrit instance +type OrgRow struct { + Name string + URL string + Source string + ProjectSFID string + ClaGroupID string + AutoEnabledClaGroupID string +} + +// RepositoryRow is the repository a pasted URL or "owner/repo" path resolved to +type RepositoryRow struct { + Name string `dynamodbav:"repository_name"` + URL string `dynamodbav:"repository_url"` + Type string `dynamodbav:"repository_type"` + ClaGroupID string `dynamodbav:"repository_project_id"` +} + +// Repository interface defines the data access methods for the CLA Group search module +type Repository interface { + GetClaGroups(ctx context.Context) ([]*ClaGroupRow, error) + GetProjectMappings(ctx context.Context) ([]*ProjectMappingRow, error) + GetGithubOrgs(ctx context.Context) ([]*OrgRow, error) + GetGitlabOrgs(ctx context.Context) ([]*OrgRow, error) + GetGerritInstances(ctx context.Context) ([]*OrgRow, error) + GetRepositoriesByName(ctx context.Context, names []string) ([]*RepositoryRow, error) + GetRepositoriesByOrganization(ctx context.Context, organizationNames []string) ([]*RepositoryRow, error) +} + +type repository struct { + dynamoDBClient *dynamodb.DynamoDB + claGroupTableName string + projectMappingTableName string + githubOrgTableName string + gitlabOrgTableName string + gerritTableName string + repositoryTableName string +} + +// NewRepository creates a new instance of the CLA Group search repository, with the scanned tables +// served from the in-process cache +func NewRepository(awsSession *session.Session, stage string) Repository { + return newCachedRepository(newScanRepository(awsSession, stage), cacheTTL()) +} + +func newScanRepository(awsSession *session.Session, stage string) Repository { + // a search fans out to more concurrent DynamoDB calls than the default two idle connections + // per host can serve, which would leave most of them paying for a fresh TLS handshake + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: searchConcurrency, + MaxIdleConnsPerHost: searchConcurrency, + IdleConnTimeout: 90 * time.Second, + } + + return repository{ + dynamoDBClient: dynamodb.New(awsSession.Copy(&aws.Config{HTTPClient: &http.Client{Transport: transport}})), + claGroupTableName: fmt.Sprintf("cla-%s-projects", stage), + projectMappingTableName: fmt.Sprintf("cla-%s-projects-cla-groups", stage), + githubOrgTableName: fmt.Sprintf("cla-%s-github-orgs", stage), + gitlabOrgTableName: fmt.Sprintf("cla-%s-gitlab-orgs", stage), + gerritTableName: fmt.Sprintf("cla-%s-gerrit-instances", stage), + repositoryTableName: fmt.Sprintf("cla-%s-repositories", stage), + } +} + +// enabledFilter keeps the disabled records - organizations unlinked from EasyCLA and repositories +// no longer covered by a CLA Group - out of the search, matching the convention of the +// github_organizations repository +func enabledFilter() expression.ConditionBuilder { + return expression.Name("enabled").Equal(expression.Value(true)) +} + +func (repo repository) GetClaGroups(ctx context.Context) ([]*ClaGroupRow, error) { + var rows []*ClaGroupRow + err := repo.scan(ctx, repo.claGroupTableName, claGroupScanSegments, nil, + []string{"project_id", "project_name", "project_external_id", "project_icla_enabled", "project_ccla_enabled"}, &rows) + return rows, err +} + +func (repo repository) GetProjectMappings(ctx context.Context) ([]*ProjectMappingRow, error) { + var rows []*ProjectMappingRow + err := repo.scan(ctx, repo.projectMappingTableName, projectMappingScanSegments, nil, + []string{"cla_group_id", "cla_group_name", "project_sfid", "project_name", "foundation_sfid", "foundation_name"}, &rows) + return rows, err +} + +type orgDBRow struct { + Name string `dynamodbav:"organization_name"` + URL string `dynamodbav:"organization_url"` + ProjectSFID string `dynamodbav:"project_sfid"` + AutoEnabledClaGroupID string `dynamodbav:"auto_enabled_cla_group_id"` +} + +func (repo repository) GetGithubOrgs(ctx context.Context) ([]*OrgRow, error) { + return repo.scanOrgs(ctx, repo.githubOrgTableName, sourceGitHub) +} + +func (repo repository) GetGitlabOrgs(ctx context.Context) ([]*OrgRow, error) { + return repo.scanOrgs(ctx, repo.gitlabOrgTableName, sourceGitLab) +} + +func (repo repository) scanOrgs(ctx context.Context, tableName, source string) ([]*OrgRow, error) { + var rows []*orgDBRow + filter := enabledFilter() + if err := repo.scan(ctx, tableName, orgScanSegments, &filter, + []string{"organization_name", "organization_url", "project_sfid", "auto_enabled_cla_group_id"}, &rows); err != nil { + return nil, err + } + orgs := make([]*OrgRow, 0, len(rows)) + for _, row := range rows { + orgs = append(orgs, &OrgRow{Name: row.Name, URL: row.URL, Source: source, ProjectSFID: row.ProjectSFID, + AutoEnabledClaGroupID: row.AutoEnabledClaGroupID}) + } + return orgs, nil +} + +type gerritDBRow struct { + Name string `dynamodbav:"gerrit_name"` + URL string `dynamodbav:"gerrit_url"` + ClaGroupID string `dynamodbav:"project_id"` +} + +func (repo repository) GetGerritInstances(ctx context.Context) ([]*OrgRow, error) { + var rows []*gerritDBRow + // the gerrit-instances table carries no enabled flag + if err := repo.scan(ctx, repo.gerritTableName, orgScanSegments, nil, []string{"gerrit_name", "gerrit_url", "project_id"}, &rows); err != nil { + return nil, err + } + orgs := make([]*OrgRow, 0, len(rows)) + for _, row := range rows { + orgs = append(orgs, &OrgRow{Name: row.Name, URL: row.URL, Source: sourceGerrit, ClaGroupID: row.ClaGroupID}) + } + return orgs, nil +} + +// GetRepositoriesByName resolves the given full repository names through the repository-name-index +// GSI - an exact hash-key lookup per name, no scan +func (repo repository) GetRepositoriesByName(ctx context.Context, names []string) ([]*RepositoryRow, error) { + return repo.queryRepositories(ctx, repositories.RepositoryNameIndex, "repository_name", names) +} + +// GetRepositoriesByOrganization returns the repositories of the given organizations through the +// repository-organization-name-index GSI, which is how a repository whose stored name is not +// lower-cased is recovered from a lower-cased pasted URL +func (repo repository) GetRepositoriesByOrganization(ctx context.Context, organizationNames []string) ([]*RepositoryRow, error) { + return repo.queryRepositories(ctx, repositories.RepositoryOrganizationNameIndex, "repository_organization_name", organizationNames) +} + +// queryRepositories runs one enabled-only GSI query per key value, in parallel +func (repo repository) queryRepositories(ctx context.Context, indexName, keyAttribute string, values []string) ([]*RepositoryRow, error) { + f := logrus.Fields{ + "functionName": "v2.cla_search.repository.queryRepositories", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "indexName": indexName, + "values": values, + } + + var ( + mu sync.Mutex + rows []*RepositoryRow + ) + group, groupCtx := errgroup.WithContext(ctx) + for _, value := range values { + keyValue := value + group.Go(func() error { + expr, err := expression.NewBuilder(). + WithKeyCondition(expression.Key(keyAttribute).Equal(expression.Value(keyValue))). + WithFilter(enabledFilter()). + WithProjection(expression.NamesList(expression.Name("repository_name"), expression.Name("repository_url"), + expression.Name("repository_type"), expression.Name("repository_project_id"))). + Build() + if err != nil { + log.WithFields(f).WithError(err).Warn("error building expression for the repository query") + return err + } + + queryInput := &dynamodb.QueryInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + FilterExpression: expr.Filter(), + KeyConditionExpression: expr.KeyCondition(), + ProjectionExpression: expr.Projection(), + TableName: aws.String(repo.repositoryTableName), + IndexName: aws.String(indexName), + } + for { + results, queryErr := repo.dynamoDBClient.QueryWithContext(groupCtx, queryInput) + if queryErr != nil { + log.WithFields(f).WithError(queryErr).Warn("error querying repositories") + return queryErr + } + + var page []*RepositoryRow + if unmarshalErr := dynamodbattribute.UnmarshalListOfMaps(results.Items, &page); unmarshalErr != nil { + log.WithFields(f).WithError(unmarshalErr).Warn("error unmarshalling repositories") + return unmarshalErr + } + + mu.Lock() + rows = append(rows, page...) + mu.Unlock() + + if len(results.LastEvaluatedKey) == 0 { + return nil + } + queryInput.ExclusiveStartKey = results.LastEvaluatedKey + } + }) + } + if err := group.Wait(); err != nil { + return nil, err + } + + return rows, nil +} + +// scan runs a projected full-table scan into out, which must be a pointer to a slice. The table is +// split into segments scanned in parallel so a table spanning several 1MB scan pages costs one +// round trip rather than one per page. +func (repo repository) scan(ctx context.Context, tableName string, segments int, filter *expression.ConditionBuilder, attributes []string, out interface{}) error { + f := logrus.Fields{ + "functionName": "v2.cla_search.repository.scan", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "tableName": tableName, + "segments": segments, + } + + if len(attributes) == 0 { + return fmt.Errorf("no attributes to project from table %s", tableName) + } + + names := make([]expression.NameBuilder, 0, len(attributes)) + for _, attribute := range attributes { + names = append(names, expression.Name(attribute)) + } + builder := expression.NewBuilder().WithProjection(expression.NamesList(names[0], names[1:]...)) + if filter != nil { + builder = builder.WithFilter(*filter) + } + expr, err := builder.Build() + if err != nil { + log.WithFields(f).WithError(err).Warn("error building expression for the scan") + return err + } + + var ( + mu sync.Mutex + items []map[string]*dynamodb.AttributeValue + ) + group, groupCtx := errgroup.WithContext(ctx) + for segment := 0; segment < segments; segment++ { + scanInput := &dynamodb.ScanInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + FilterExpression: expr.Filter(), + ProjectionExpression: expr.Projection(), + TableName: aws.String(tableName), + } + if segments > 1 { + scanInput.Segment = aws.Int64(int64(segment)) + scanInput.TotalSegments = aws.Int64(int64(segments)) + } + group.Go(func() error { + for { + results, scanErr := repo.dynamoDBClient.ScanWithContext(groupCtx, scanInput) + if scanErr != nil { + log.WithFields(f).WithError(scanErr).Warn("error scanning table") + return scanErr + } + mu.Lock() + items = append(items, results.Items...) + mu.Unlock() + if len(results.LastEvaluatedKey) == 0 { + return nil + } + scanInput.ExclusiveStartKey = results.LastEvaluatedKey + } + }) + } + if err := group.Wait(); err != nil { + return err + } + return dynamodbattribute.UnmarshalListOfMaps(items, out) +} diff --git a/cla-backend-go/v2/cla_search/service.go b/cla-backend-go/v2/cla_search/service.go new file mode 100644 index 000000000..46f95cc6d --- /dev/null +++ b/cla-backend-go/v2/cla_search/service.go @@ -0,0 +1,616 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "net/url" + "sort" + "strings" + "sync" + + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "golang.org/x/sync/errgroup" +) + +const ( + sourceGitHub = "github" + sourceGitLab = "gitlab" + sourceGerrit = "gerrit" + + matchClaGroup = "claGroup" + matchProject = "project" + matchOrganization = "organization" + matchRepository = "repository" + + // DefaultLimit is the result cap applied when the caller provides no limit + DefaultLimit = 20 + + // MinSearchTermLength is the shortest search term accepted + MinSearchTermLength = 3 +) + +// match quality, lowest sorts first +const ( + rankExact = 0 + rankPrefix = 1 + rankSubstring = 2 +) + +// forgeHosts are the shared repository hosts whose hostname carries no CLA Group signal, so a +// pasted URL on one of them is matched by its path only, and the forge each one identifies +var forgeHosts = map[string]string{ + "github.com": sourceGitHub, + "www.github.com": sourceGitHub, + "gitlab.com": sourceGitLab, + "www.gitlab.com": sourceGitLab, +} + +// Service interface defines the CLA Group search service +type Service interface { + Search(ctx context.Context, searchTerm string, limit int64) (*models.ClaSearchList, error) +} + +type service struct { + repo Repository +} + +// NewService creates a new instance of the CLA Group search service +func NewService(repo Repository) Service { + return &service{repo: repo} +} + +// sources holds the reference data the four search sources are matched against +type sources struct { + claGroups []*ClaGroupRow + mappings []*ProjectMappingRow + orgs []*OrgRow +} + +// Search resolves the search term against the CLA Group names, the project/foundation names, the +// linked organization names and the repository the term resolves to. Each source is searched in its +// own goroutine and the results are merged by CLA Group - the CLA Group is the signing unit. +func (s *service) Search(ctx context.Context, searchTerm string, limit int64) (*models.ClaSearchList, error) { + rawTerm := strings.TrimSpace(searchTerm) + term := strings.ToLower(rawTerm) + if limit <= 0 { + limit = DefaultLimit + } + + // the reference data of every source and the repository the term addresses are fetched together + var ( + src *sources + repos []*RepositoryRow + ) + path, host := repositoryPath(rawTerm) + fetch, fetchCtx := errgroup.WithContext(ctx) + fetch.Go(func() error { + var err error + src, err = s.loadSources(fetchCtx) + return err + }) + if path != "" { + fetch.Go(func() error { + var err error + repos, err = s.repo.GetRepositoriesByName(fetchCtx, nameVariants(path)) + return err + }) + } + if err := fetch.Wait(); err != nil { + return nil, err + } + sfidToClaGroups := indexProjectSFIDs(src.mappings) + + m := newMatcher() + searchers, searchCtx := errgroup.WithContext(ctx) + searchers.Go(func() error { matchClaGroupNames(src.claGroups, term, m); return nil }) + searchers.Go(func() error { matchProjectNames(src.mappings, term, m); return nil }) + searchers.Go(func() error { matchOrgNames(src.orgs, term, sfidToClaGroups, m); return nil }) + searchers.Go(func() error { return s.matchRepositories(searchCtx, path, host, repos, src, sfidToClaGroups, m) }) + if err := searchers.Wait(); err != nil { + return nil, err + } + + return buildList(searchTerm, limit, m.matches, src, indexOrgs(src.orgs, sfidToClaGroups)), nil +} + +// loadSources loads the reference data of every search source in parallel +func (s *service) loadSources(ctx context.Context) (*sources, error) { + var ( + src sources + orgsMu sync.Mutex + ) + loaders, loadCtx := errgroup.WithContext(ctx) + loaders.Go(func() error { + var err error + src.claGroups, err = s.repo.GetClaGroups(loadCtx) + return err + }) + loaders.Go(func() error { + var err error + src.mappings, err = s.repo.GetProjectMappings(loadCtx) + return err + }) + for _, load := range []func(context.Context) ([]*OrgRow, error){s.repo.GetGithubOrgs, s.repo.GetGitlabOrgs, s.repo.GetGerritInstances} { + loadOrgs := load + loaders.Go(func() error { + rows, err := loadOrgs(loadCtx) + if err != nil { + return err + } + orgsMu.Lock() + defer orgsMu.Unlock() + src.orgs = append(src.orgs, rows...) + return nil + }) + } + if err := loaders.Wait(); err != nil { + return nil, err + } + return &src, nil +} + +// match is the accumulated match state of a single CLA Group +type match struct { + types map[string]bool + rank int + repoName string + repoURL string +} + +// matcher merges the matches the concurrent searchers produce +type matcher struct { + mu sync.Mutex + matches map[string]*match +} + +func newMatcher() *matcher { + return &matcher{matches: map[string]*match{}} +} + +func (m *matcher) record(claGroupID, matchType string, rank int) *match { + if claGroupID == "" { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + entry, ok := m.matches[claGroupID] + if !ok { + entry = &match{types: map[string]bool{}, rank: rank} + m.matches[claGroupID] = entry + } + entry.types[matchType] = true + if rank < entry.rank { + entry.rank = rank + } + return entry +} + +func (m *matcher) recordRepository(claGroupID, name, repoURL string) { + entry := m.record(claGroupID, matchRepository, rankExact) + if entry == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if entry.repoName == "" { + entry.repoName, entry.repoURL = name, repoURL + } +} + +func matchClaGroupNames(claGroups []*ClaGroupRow, term string, m *matcher) { + for _, claGroup := range claGroups { + if rank := rankOf(claGroup.Name, term); rank >= 0 { + m.record(claGroup.ClaGroupID, matchClaGroup, rank) + } + } +} + +func matchProjectNames(mappings []*ProjectMappingRow, term string, m *matcher) { + for _, mapping := range mappings { + if rank := bestRank(term, mapping.ProjectName, mapping.FoundationName); rank >= 0 { + m.record(mapping.ClaGroupID, matchProject, rank) + } + } +} + +func matchOrgNames(orgs []*OrgRow, term string, sfidToClaGroups map[string][]string, m *matcher) { + host := hostOf(term) + for _, org := range orgs { + rank := orgRank(org, term, host) + if rank < 0 { + continue + } + for _, claGroupID := range org.claGroupIDs(sfidToClaGroups) { + m.record(claGroupID, matchOrganization, rank) + } + } +} + +// orgRank matches the term against the organization name, its URL, and - for a self-hosted +// instance such as a Gerrit server - the hostname of a pasted URL under it +func orgRank(org *OrgRow, term, host string) int { + if rank := rankOf(org.Name, term); rank >= 0 { + return rank + } + if org.URL == "" { + return -1 + } + if strings.Contains(urlSignal(org.URL), term) { + return rankSubstring + } + if host != "" && forgeHosts[host] == "" && hostOf(org.URL) == host { + return rankSubstring + } + return -1 +} + +// matchRepositories resolves the pre-fetched repositories of a pasted repository URL or "owner/repo" +// path to the CLA Group owning that repository. A pasted URL names exactly one repository, so its +// owner organization is only consulted when no repository record answers - the case of an +// auto-enabled organization, whose repositories carry no records +func (s *service) matchRepositories(ctx context.Context, path, host string, repos []*RepositoryRow, src *sources, sfidToClaGroups map[string][]string, m *matcher) error { + if path == "" { + return nil + } + owner := path[:strings.Index(path, "/")] + forge := forgeHosts[host] + ownerOrgs := orgsOnHost(orgsNamed(src.orgs, owner), host, forge) + + matched := reposNamed(repos, path, host, forge) + // the repository-name-index GSI is keyed on the case-preserved name, so a lower-cased paste of a + // mixed-case repository misses it - the owner's repositories are then listed through the + // organization GSI and compared case-insensitively + if len(matched) == 0 && len(ownerOrgs) > 0 { + listed, err := s.repo.GetRepositoriesByOrganization(ctx, organizationNames(ownerOrgs)) + if err != nil { + return err + } + matched = reposNamed(listed, path, host, forge) + } + + resolved := false + for _, repo := range matched { + if !displayableClaGroup(src, repo.ClaGroupID) { + continue + } + m.recordRepository(repo.ClaGroupID, repo.Name, repo.URL) + resolved = true + } + if resolved { + return nil + } + + for _, org := range ownerOrgs { + for _, claGroupID := range org.claGroupIDs(sfidToClaGroups) { + m.record(claGroupID, matchOrganization, rankExact) + } + } + return nil +} + +// reposNamed returns the repositories whose full name is the given path, restricted to the forge a +// known host names, or to the host itself when the host is a self-hosted one - a bare "owner/repo" +// names no host and matches either forge +func reposNamed(repos []*RepositoryRow, path, host, forge string) []*RepositoryRow { + lowerPath := strings.ToLower(path) + var matched []*RepositoryRow + for _, repo := range repos { + if strings.ToLower(repo.Name) != lowerPath { + continue + } + switch { + case forge != "": + if repo.Type != "" && !strings.EqualFold(repo.Type, forge) { + continue + } + case host != "" && hostOf(repo.URL) != host: + continue + } + matched = append(matched, repo) + } + return matched +} + +// orgsOnHost drops the organizations the host of a pasted URL rules out - the ones of another forge +// when the host names one, the ones of another host when it does not +func orgsOnHost(orgs []*OrgRow, host, forge string) []*OrgRow { + if host == "" { + return orgs + } + matched := make([]*OrgRow, 0, len(orgs)) + for _, org := range orgs { + if forge != "" { + if org.Source != "" && !strings.EqualFold(org.Source, forge) { + continue + } + } else if hostOf(orgURL(org)) != host { + continue + } + matched = append(matched, org) + } + return matched +} + +// displayableClaGroup reports whether the CLA Group has a record or a mapping to show - a repository +// pointing at a deleted CLA Group resolves to nothing and must not suppress the organization match +func displayableClaGroup(src *sources, claGroupID string) bool { + if claGroupID == "" { + return false + } + for _, claGroup := range src.claGroups { + if claGroup.ClaGroupID == claGroupID { + return true + } + } + for _, mapping := range src.mappings { + if mapping.ClaGroupID == claGroupID { + return true + } + } + return false +} + +// orgsNamed returns the organizations whose name is the given name, compared case-insensitively +func orgsNamed(orgs []*OrgRow, name string) []*OrgRow { + var matched []*OrgRow + for _, org := range orgs { + if org.Name != "" && strings.EqualFold(org.Name, name) { + matched = append(matched, org) + } + } + return matched +} + +func organizationNames(orgs []*OrgRow) []string { + seen := map[string]bool{} + names := make([]string, 0, len(orgs)) + for _, org := range orgs { + if !seen[org.Name] { + seen[org.Name] = true + names = append(names, org.Name) + } + } + return names +} + +// claGroupIDs returns the CLA Groups the organization is linked to - Gerrit instances reference the +// CLA Group directly, while a GitHub organization or GitLab group references it by project SFID, by +// the CLA Group its new repositories are auto-enabled into, or by both +func (o *OrgRow) claGroupIDs(sfidToClaGroups map[string][]string) []string { + if o.ClaGroupID != "" { + return []string{o.ClaGroupID} + } + mapped := sfidToClaGroups[o.ProjectSFID] + if o.AutoEnabledClaGroupID == "" { + return mapped + } + for _, claGroupID := range mapped { + if claGroupID == o.AutoEnabledClaGroupID { + return mapped + } + } + return append(append(make([]string, 0, len(mapped)+1), mapped...), o.AutoEnabledClaGroupID) +} + +func indexProjectSFIDs(mappings []*ProjectMappingRow) map[string][]string { + index := map[string][]string{} + for _, mapping := range mappings { + if mapping.ProjectSFID != "" && mapping.ClaGroupID != "" { + index[mapping.ProjectSFID] = append(index[mapping.ProjectSFID], mapping.ClaGroupID) + } + } + return index +} + +func indexOrgs(orgs []*OrgRow, sfidToClaGroups map[string][]string) map[string][]models.ClaSearchOrg { + index := map[string][]models.ClaSearchOrg{} + for _, org := range orgs { + for _, claGroupID := range org.claGroupIDs(sfidToClaGroups) { + index[claGroupID] = append(index[claGroupID], models.ClaSearchOrg{Name: org.Name, Source: org.Source, URL: orgURL(org)}) + } + } + return index +} + +// orgURL is the organization URL, derived for a GitHub organization - the github-orgs records carry none +func orgURL(org *OrgRow) string { + if org.URL == "" && org.Source == sourceGitHub && org.Name != "" { + return "https://github.com/" + org.Name + } + return org.URL +} + +func buildList(searchTerm string, limit int64, matches map[string]*match, src *sources, orgsByClaGroup map[string][]models.ClaSearchOrg) *models.ClaSearchList { + claGroupByID := map[string]*ClaGroupRow{} + for _, claGroup := range src.claGroups { + claGroupByID[claGroup.ClaGroupID] = claGroup + } + mappingsByClaGroup := map[string][]*ProjectMappingRow{} + for _, mapping := range src.mappings { + mappingsByClaGroup[mapping.ClaGroupID] = append(mappingsByClaGroup[mapping.ClaGroupID], mapping) + } + + results := make([]models.ClaSearchResult, 0, len(matches)) + for claGroupID, m := range matches { + result := buildResult(claGroupID, m, claGroupByID[claGroupID], mappingsByClaGroup[claGroupID], orgsByClaGroup[claGroupID]) + // a CLA Group with neither a record nor a mapping - a deleted one still referenced by an + // organization - has nothing to display + if result.ClaGroupName == "" && result.ProjectName == "" { + continue + } + results = append(results, result) + } + sort.Slice(results, func(i, j int) bool { + if ri, rj := matches[results[i].ClaGroupID].rank, matches[results[j].ClaGroupID].rank; ri != rj { + return ri < rj + } + if a, b := displayName(results[i]), displayName(results[j]); a != b { + return a < b + } + return results[i].ClaGroupID < results[j].ClaGroupID + }) + + truncated := int64(len(results)) > limit + if truncated { + results = results[:limit] + } + + return &models.ClaSearchList{ + SearchTerm: searchTerm, + ResultCount: int64(len(results)), + Truncated: truncated, + Results: results, + } +} + +func buildResult(claGroupID string, m *match, claGroup *ClaGroupRow, mappings []*ProjectMappingRow, orgs []models.ClaSearchOrg) models.ClaSearchResult { + result := models.ClaSearchResult{ + ClaGroupID: claGroupID, + MatchTypes: sortedKeys(m.types), + MatchedRepositoryName: m.repoName, + MatchedRepositoryURL: m.repoURL, + Organizations: sortOrgs(orgs), + } + if claGroup != nil { + result.ClaGroupName = claGroup.Name + result.ProjectExternalID = claGroup.ExternalID + result.IclaEnabled = enabledOrDefault(claGroup.IclaEnabled) + result.CclaEnabled = enabledOrDefault(claGroup.CclaEnabled) + } + + // A foundation-level CLA Group is marked by a mapping whose ProjectSFID equals its + // FoundationSFID (the projects_cla_groups convention) and resolves to its foundation; a single + // project-level mapping resolves to that project. Several project-level mappings with no + // foundation marker are left unresolved rather than picking an arbitrary one. + for _, mapping := range mappings { + if result.ClaGroupName == "" { + result.ClaGroupName = mapping.ClaGroupName + } + if result.FoundationSFID == "" { + result.FoundationSFID = mapping.FoundationSFID + } + if mapping.FoundationSFID != "" && mapping.FoundationSFID == mapping.ProjectSFID { + result.ProjectSFID, result.ProjectName = mapping.FoundationSFID, mapping.FoundationName + return result + } + } + if len(mappings) == 1 { + result.ProjectSFID, result.ProjectName = mappings[0].ProjectSFID, mappings[0].ProjectName + } + return result +} + +// enabledOrDefault reads a CLA type flag, a missing attribute meaning enabled - the Pynamo +// default=True the v1 CLA Group reader also honours +func enabledOrDefault(enabled *bool) bool { + return enabled == nil || *enabled +} + +func displayName(result models.ClaSearchResult) string { + if result.ProjectName != "" { + return strings.ToLower(result.ProjectName) + } + return strings.ToLower(result.ClaGroupName) +} + +func sortOrgs(orgs []models.ClaSearchOrg) []models.ClaSearchOrg { + if orgs == nil { + return []models.ClaSearchOrg{} + } + sort.Slice(orgs, func(i, j int) bool { + if orgs[i].Source != orgs[j].Source { + return orgs[i].Source < orgs[j].Source + } + return orgs[i].Name < orgs[j].Name + }) + return orgs +} + +func sortedKeys(set map[string]bool) []string { + keys := make([]string, 0, len(set)) + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// rankOf scores how well the already lower-cased term matches value - lower is better, -1 is no match +func rankOf(value, term string) int { + value = strings.ToLower(value) + switch { + case value == "" || term == "": + return -1 + case value == term: + return rankExact + case strings.HasPrefix(value, term): + return rankPrefix + case strings.Contains(value, term): + return rankSubstring + default: + return -1 + } +} + +func bestRank(term string, values ...string) int { + best := -1 + for _, value := range values { + if rank := rankOf(value, term); rank >= 0 && (best < 0 || rank < best) { + best = rank + } + } + return best +} + +func hostOf(rawURL string) string { + parsed, err := url.Parse(strings.ToLower(rawURL)) + if err != nil { + return "" + } + return parsed.Hostname() +} + +// repositoryPath derives the full repository name the term addresses - the path of a pasted +// repository URL, or the term itself when it looks like an "owner/repo" path - together with the +// host the URL names, and is empty when the term addresses no repository +func repositoryPath(term string) (string, string) { + path, host := term, "" + if strings.Contains(term, "://") { + parsed, err := url.Parse(term) + if err != nil || parsed.Hostname() == "" { + return "", "" + } + path, host = parsed.Path, strings.ToLower(parsed.Hostname()) + } + path = strings.Trim(path, "/") + path = strings.TrimSuffix(path, ".git") + path = strings.TrimPrefix(path, "groups/") + if !strings.Contains(path, "/") || strings.ContainsAny(path, " \t") { + return "", "" + } + return path, host +} + +// nameVariants are the repository names looked up on the case-preserved repository-name-index GSI +func nameVariants(path string) []string { + if lower := strings.ToLower(path); lower != path { + return []string{path, lower} + } + return []string{path} +} + +// urlSignal is the part of an organization URL that identifies the organization - the host of a +// shared forge is the same for every organization hosted on it and carries no signal, while the +// host of a self-hosted instance such as a Gerrit server is the only thing that does +func urlSignal(rawURL string) string { + parsed, err := url.Parse(strings.ToLower(rawURL)) + if err != nil { + return strings.ToLower(rawURL) + } + if forgeHosts[parsed.Hostname()] != "" { + return strings.Trim(parsed.Path, "/") + } + return parsed.Hostname() + parsed.Path +} diff --git a/cla-backend-go/v2/cla_search/service_test.go b/cla-backend-go/v2/cla_search/service_test.go new file mode 100644 index 000000000..64bbcc744 --- /dev/null +++ b/cla-backend-go/v2/cla_search/service_test.go @@ -0,0 +1,628 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package cla_search + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeRepo struct { + claGroups []*ClaGroupRow + mappings []*ProjectMappingRow + github []*OrgRow + gitlab []*OrgRow + gerrit []*OrgRow + repos map[string][]*RepositoryRow + orgRepos map[string][]*RepositoryRow + + failOn string + + mu sync.Mutex + repoQueries [][]string + orgQueries [][]string + loaderCalls int + repoCalls int + callSequence []string +} + +func (f *fakeRepo) note(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.callSequence = append(f.callSequence, name) + f.loaderCalls++ + if f.failOn == name { + return errors.New("boom: " + name) + } + return nil +} + +func (f *fakeRepo) GetClaGroups(_ context.Context) ([]*ClaGroupRow, error) { + if err := f.note("claGroups"); err != nil { + return nil, err + } + return f.claGroups, nil +} + +func (f *fakeRepo) GetProjectMappings(_ context.Context) ([]*ProjectMappingRow, error) { + if err := f.note("mappings"); err != nil { + return nil, err + } + return f.mappings, nil +} + +func (f *fakeRepo) GetGithubOrgs(_ context.Context) ([]*OrgRow, error) { + if err := f.note("github"); err != nil { + return nil, err + } + return f.github, nil +} + +func (f *fakeRepo) GetGitlabOrgs(_ context.Context) ([]*OrgRow, error) { + if err := f.note("gitlab"); err != nil { + return nil, err + } + return f.gitlab, nil +} + +func (f *fakeRepo) GetGerritInstances(_ context.Context) ([]*OrgRow, error) { + if err := f.note("gerrit"); err != nil { + return nil, err + } + return f.gerrit, nil +} + +func (f *fakeRepo) GetRepositoriesByName(_ context.Context, names []string) ([]*RepositoryRow, error) { + f.mu.Lock() + f.repoQueries = append(f.repoQueries, names) + f.repoCalls++ + f.mu.Unlock() + if f.failOn == "repositories" { + return nil, errors.New("boom: repositories") + } + var rows []*RepositoryRow + for _, name := range names { + rows = append(rows, f.repos[name]...) + } + return rows, nil +} + +func (f *fakeRepo) GetRepositoriesByOrganization(_ context.Context, organizationNames []string) ([]*RepositoryRow, error) { + f.mu.Lock() + f.orgQueries = append(f.orgQueries, organizationNames) + f.mu.Unlock() + if f.failOn == "organizationRepositories" { + return nil, errors.New("boom: organizationRepositories") + } + var rows []*RepositoryRow + for _, name := range organizationNames { + rows = append(rows, f.orgRepos[name]...) + } + return rows, nil +} + +// sampleRepo mirrors the production shape: two CLA groups behind GitHub orgs, one foundation-level +// CLA group behind a Gerrit instance, and one behind a GitLab group +func sampleRepo() *fakeRepo { + return &fakeRepo{ + claGroups: []*ClaGroupRow{ + {ClaGroupID: "cg-kube", Name: "Kubernetes CLA", ExternalID: "a09-kube", IclaEnabled: flag(true), CclaEnabled: flag(true)}, + {ClaGroupID: "cg-otio", Name: "OpenTimelineIO CLA", ExternalID: "a09-otio", IclaEnabled: flag(false), CclaEnabled: flag(true)}, + {ClaGroupID: "cg-onap", Name: "ONAP CLA", ExternalID: "a09-onap-f", IclaEnabled: flag(true), CclaEnabled: flag(false)}, + {ClaGroupID: "cg-orphan", Name: "Kubernetes Edge CLA"}, + }, + mappings: []*ProjectMappingRow{ + {ClaGroupID: "cg-kube", ClaGroupName: "Kubernetes CLA", ProjectSFID: "sfid-kube", ProjectName: "Kubernetes", FoundationSFID: "sfid-cncf", FoundationName: "CNCF"}, + {ClaGroupID: "cg-otio", ClaGroupName: "OpenTimelineIO CLA", ProjectSFID: "sfid-otio", ProjectName: "OpenTimelineIO", FoundationSFID: "sfid-aswf", FoundationName: "Academy Software Foundation"}, + {ClaGroupID: "cg-onap", ClaGroupName: "ONAP CLA", ProjectSFID: "sfid-onap-f", ProjectName: "ONAP Foundation Level", FoundationSFID: "sfid-onap-f", FoundationName: "ONAP"}, + {ClaGroupID: "cg-multi", ClaGroupName: "Shared CLA", ProjectSFID: "sfid-a", ProjectName: "Shared Project A", FoundationSFID: "sfid-root"}, + {ClaGroupID: "cg-multi", ClaGroupName: "Shared CLA", ProjectSFID: "sfid-b", ProjectName: "Shared Project B", FoundationSFID: "sfid-root"}, + }, + github: []*OrgRow{ + {Name: "kubernetes", Source: sourceGitHub, ProjectSFID: "sfid-kube"}, + {Name: "kubernetes-sigs", Source: sourceGitHub, ProjectSFID: "sfid-kube"}, + {Name: "OpenTimelineIO", Source: sourceGitHub, ProjectSFID: "sfid-otio"}, + }, + gitlab: []*OrgRow{ + {Name: "onap", URL: "https://gitlab.com/groups/onap", Source: sourceGitLab, ProjectSFID: "sfid-onap-f"}, + }, + gerrit: []*OrgRow{ + {Name: "ONAP", URL: "https://gerrit.onap.org", Source: sourceGerrit, ClaGroupID: "cg-onap"}, + }, + repos: map[string][]*RepositoryRow{ + "OpenTimelineIO/OpenTimelineIO-Java-Bindings": {{Name: "OpenTimelineIO/OpenTimelineIO-Java-Bindings", URL: "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings", Type: sourceGitHub, ClaGroupID: "cg-otio"}}, + "onap/oom/oom": {{Name: "onap/oom/oom", URL: "https://gitlab.com/onap/oom/oom", Type: sourceGitLab, ClaGroupID: "cg-onap"}}, + }, + orgRepos: map[string][]*RepositoryRow{ + "OpenTimelineIO": { + {Name: "OpenTimelineIO/OpenTimelineIO-Java-Bindings", URL: "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings", Type: sourceGitHub, ClaGroupID: "cg-otio"}, + {Name: "OpenTimelineIO/otio-plugin-template", URL: "https://github.com/OpenTimelineIO/otio-plugin-template", Type: sourceGitHub, ClaGroupID: "cg-otio"}, + }, + }, + } +} + +func flag(value bool) *bool { + return &value +} + +func resultByID(list *models.ClaSearchList, claGroupID string) *models.ClaSearchResult { + for i := range list.Results { + if list.Results[i].ClaGroupID == claGroupID { + return &list.Results[i] + } + } + return nil +} + +func ids(list *models.ClaSearchList) []string { + out := make([]string, 0, len(list.Results)) + for i := range list.Results { + out = append(out, list.Results[i].ClaGroupID) + } + return out +} + +func TestSearchByClaGroupName(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "kubernetes cla", 0) + require.NoError(t, err) + assert.Equal(t, []string{"cg-kube"}, ids(list)) + assert.Equal(t, int64(1), list.ResultCount) + assert.False(t, list.Truncated) + + kube := resultByID(list, "cg-kube") + require.NotNil(t, kube) + assert.Equal(t, []string{matchClaGroup}, kube.MatchTypes) + assert.Equal(t, "Kubernetes CLA", kube.ClaGroupName) + assert.Equal(t, "Kubernetes", kube.ProjectName) + assert.Equal(t, "sfid-kube", kube.ProjectSFID) + assert.Equal(t, "sfid-cncf", kube.FoundationSFID) + assert.Equal(t, "a09-kube", kube.ProjectExternalID) + assert.True(t, kube.IclaEnabled) + assert.True(t, kube.CclaEnabled) +} + +func TestSearchByProjectName(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "opentimelineio", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-otio"}, ids(list)) + assert.Equal(t, []string{matchClaGroup, matchOrganization, matchProject}, list.Results[0].MatchTypes) +} + +func TestSearchByFoundationName(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "academy software", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-otio"}, ids(list)) + assert.Equal(t, []string{matchProject}, list.Results[0].MatchTypes) +} + +func TestSearchByOrgNameCarriesProvenance(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "kubernetes-sigs", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-kube"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) + // every organization linked to the CLA Group is returned, and the GitHub URL is derived + assert.Equal(t, []models.ClaSearchOrg{ + {Name: "kubernetes", Source: sourceGitHub, URL: "https://github.com/kubernetes"}, + {Name: "kubernetes-sigs", Source: sourceGitHub, URL: "https://github.com/kubernetes-sigs"}, + }, list.Results[0].Organizations) +} + +func TestSearchGitlabAndGerritProvenanceAreReturned(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "onap", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-onap"}, ids(list)) + onap := list.Results[0] + assert.Equal(t, []string{matchClaGroup, matchOrganization, matchProject}, onap.MatchTypes) + assert.Equal(t, []models.ClaSearchOrg{ + {Name: "ONAP", Source: sourceGerrit, URL: "https://gerrit.onap.org"}, + {Name: "onap", Source: sourceGitLab, URL: "https://gitlab.com/groups/onap"}, + }, onap.Organizations) + // foundation-level CLA group resolves to its foundation + assert.Equal(t, "ONAP", onap.ProjectName) + assert.Equal(t, "sfid-onap-f", onap.ProjectSFID) +} + +func TestSearchResolvesPastedRepoURL(t *testing.T) { + for _, term := range []string{ + "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings", + "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings.git", + "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings/", + "OpenTimelineIO/OpenTimelineIO-Java-Bindings", + } { + t.Run(term, func(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), term, 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-otio"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) + assert.Equal(t, "OpenTimelineIO/OpenTimelineIO-Java-Bindings", list.Results[0].MatchedRepositoryName) + assert.Equal(t, "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings", list.Results[0].MatchedRepositoryURL) + }) + } +} + +func TestSearchResolvesNestedGitlabRepoURL(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "https://gitlab.com/onap/oom/oom", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-onap"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) +} + +func TestSearchResolvesGerritHostURL(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "https://gerrit.onap.org/r/aai/aai-common", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-onap"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) +} + +func TestSearchDoesNotMatchEveryGroupOnForgeHost(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "https://github.com/unknown-org/unknown-repo", 0) + require.NoError(t, err) + assert.Empty(t, list.Results) + assert.Equal(t, int64(0), list.ResultCount) + assert.NotNil(t, list.Results) +} + +func TestSearchResolvesLowerCasedPasteOfMixedCaseRepoURL(t *testing.T) { + repo := sampleRepo() + svc := NewService(repo) + list, err := svc.Search(context.Background(), "https://github.com/opentimelineio/opentimelineio-java-bindings", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-otio"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) + assert.Equal(t, "OpenTimelineIO/OpenTimelineIO-Java-Bindings", list.Results[0].MatchedRepositoryName) + assert.Equal(t, [][]string{{"OpenTimelineIO"}}, repo.orgQueries) +} + +func TestSearchOwnerOfOrganizationMatchesWhenRepositoryHasNoRecord(t *testing.T) { + repo := sampleRepo() + svc := NewService(repo) + list, err := svc.Search(context.Background(), "https://github.com/kubernetes/a-brand-new-repo", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-kube"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) + assert.Empty(t, list.Results[0].MatchedRepositoryName) +} + +func TestSearchForgeNameDoesNotMatchEveryOrganizationOnIt(t *testing.T) { + for _, term := range []string{"gitlab", "github", "gitlab.com"} { + t.Run(term, func(t *testing.T) { + list, err := NewService(sampleRepo()).Search(context.Background(), term, 0) + require.NoError(t, err) + assert.Empty(t, list.Results) + }) + } +} + +func TestSearchMatchesSelfHostedInstanceHost(t *testing.T) { + list, err := NewService(sampleRepo()).Search(context.Background(), "gerrit.onap.org", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-onap"}, ids(list)) +} + +func TestSearchQueriesRepositoryNameCasePreservedAndLowered(t *testing.T) { + repo := sampleRepo() + svc := NewService(repo) + _, err := svc.Search(context.Background(), "https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings", 0) + require.NoError(t, err) + require.Equal(t, [][]string{{ + "OpenTimelineIO/OpenTimelineIO-Java-Bindings", + "opentimelineio/opentimelineio-java-bindings", + }}, repo.repoQueries) +} + +func TestSearchSkipsRepositoryLookupWithoutAPath(t *testing.T) { + repo := sampleRepo() + svc := NewService(repo) + _, err := svc.Search(context.Background(), "kubernetes", 0) + require.NoError(t, err) + assert.Zero(t, repo.repoCalls) +} + +func TestSearchAmbiguousMultiProjectClaGroupOmitsProjectFields(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), "shared", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-multi"}, ids(list)) + assert.Empty(t, list.Results[0].ProjectName) + assert.Empty(t, list.Results[0].ProjectSFID) + assert.Equal(t, "sfid-root", list.Results[0].FoundationSFID) + // the CLA group record is absent from the projects table, the mapping supplies the name + assert.Equal(t, "Shared CLA", list.Results[0].ClaGroupName) + assert.Equal(t, []models.ClaSearchOrg{}, list.Results[0].Organizations) +} + +func TestSearchIsCaseInsensitiveAndTrimmed(t *testing.T) { + svc := NewService(sampleRepo()) + list, err := svc.Search(context.Background(), " KUBERNETES-SIGS ", 0) + require.NoError(t, err) + assert.Equal(t, []string{"cg-kube"}, ids(list)) + assert.Equal(t, " KUBERNETES-SIGS ", list.SearchTerm) +} + +func TestSearchRanksExactBeforePrefixBeforeSubstring(t *testing.T) { + repo := &fakeRepo{claGroups: []*ClaGroupRow{ + {ClaGroupID: "cg-sub", Name: "The Zeta Project"}, + {ClaGroupID: "cg-exact", Name: "zeta"}, + {ClaGroupID: "cg-prefix", Name: "Zeta Networking"}, + }} + list, err := NewService(repo).Search(context.Background(), "zeta", 0) + require.NoError(t, err) + assert.Equal(t, []string{"cg-exact", "cg-prefix", "cg-sub"}, ids(list)) +} + +func TestSearchTruncatesAtLimit(t *testing.T) { + repo := &fakeRepo{} + for i := 0; i < 5; i++ { + repo.claGroups = append(repo.claGroups, &ClaGroupRow{ClaGroupID: fmt.Sprintf("cg-%d", i), Name: fmt.Sprintf("Zeta %d CLA", i)}) + } + list, err := NewService(repo).Search(context.Background(), "zeta", 3) + require.NoError(t, err) + assert.True(t, list.Truncated) + assert.Equal(t, int64(3), list.ResultCount) + assert.Equal(t, []string{"cg-0", "cg-1", "cg-2"}, ids(list)) +} + +func TestSearchNotTruncatedAtExactlyLimit(t *testing.T) { + repo := &fakeRepo{claGroups: []*ClaGroupRow{ + {ClaGroupID: "cg-0", Name: "Zeta A CLA"}, + {ClaGroupID: "cg-1", Name: "Zeta B CLA"}, + }} + list, err := NewService(repo).Search(context.Background(), "zeta", 2) + require.NoError(t, err) + assert.False(t, list.Truncated) + assert.Equal(t, int64(2), list.ResultCount) +} + +func TestSearchDefaultsLimit(t *testing.T) { + repo := &fakeRepo{} + for i := 0; i < DefaultLimit+1; i++ { + repo.claGroups = append(repo.claGroups, &ClaGroupRow{ClaGroupID: fmt.Sprintf("cg-%02d", i), Name: fmt.Sprintf("Zeta %02d CLA", i)}) + } + list, err := NewService(repo).Search(context.Background(), "zeta", 0) + require.NoError(t, err) + assert.True(t, list.Truncated) + assert.Equal(t, int64(DefaultLimit), list.ResultCount) +} + +func TestSearchNoMatchReturnsEmptyList(t *testing.T) { + list, err := NewService(sampleRepo()).Search(context.Background(), "nothing-matches-this", 0) + require.NoError(t, err) + assert.Equal(t, int64(0), list.ResultCount) + assert.False(t, list.Truncated) + assert.NotNil(t, list.Results) +} + +func TestSearchPropagatesSourceErrors(t *testing.T) { + for _, source := range []string{"claGroups", "mappings", "github", "gitlab", "gerrit", "repositories"} { + t.Run(source, func(t *testing.T) { + repo := sampleRepo() + repo.failOn = source + list, err := NewService(repo).Search(context.Background(), "OpenTimelineIO/OpenTimelineIO-Java-Bindings", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom: "+source) + assert.Nil(t, list) + }) + } +} + +func TestSearchRunsSourcesConcurrently(t *testing.T) { + repo := sampleRepo() + _, err := NewService(repo).Search(context.Background(), "onap/oom/oom", 0) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"claGroups", "mappings", "github", "gitlab", "gerrit"}, repo.callSequence) + assert.Equal(t, 1, repo.repoCalls) +} + +func TestSearchMissingClaTypeFlagsDefaultToEnabled(t *testing.T) { + // a CLA Group row without the flag attributes is enabled for both types - the Pynamo default + list, err := NewService(sampleRepo()).Search(context.Background(), "kubernetes edge", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-orphan"}, ids(list)) + assert.True(t, list.Results[0].IclaEnabled) + assert.True(t, list.Results[0].CclaEnabled) + + otio := resultByID(mustSearch(t, "opentimelineio cla"), "cg-otio") + require.NotNil(t, otio) + assert.False(t, otio.IclaEnabled) + assert.True(t, otio.CclaEnabled) +} + +func mustSearch(t *testing.T, term string) *models.ClaSearchList { + t.Helper() + list, err := NewService(sampleRepo()).Search(context.Background(), term, 0) + require.NoError(t, err) + return list +} + +// autoEnabledRepo mirrors the production shape of an organization whose only link to a CLA Group is +// the group its new repositories are auto-enabled into +func autoEnabledRepo() *fakeRepo { + return &fakeRepo{ + claGroups: []*ClaGroupRow{ + {ClaGroupID: "cg-chips", Name: "CHIPS Alliance"}, + {ClaGroupID: "cg-mapped", Name: "Mapped CLA"}, + }, + mappings: []*ProjectMappingRow{ + {ClaGroupID: "cg-mapped", ClaGroupName: "Mapped CLA", ProjectSFID: "sfid-both", ProjectName: "Mapped Project"}, + }, + github: []*OrgRow{ + {Name: "chipsalliance", Source: sourceGitHub, ProjectSFID: "sfid-unmapped", AutoEnabledClaGroupID: "cg-chips"}, + {Name: "both-org", Source: sourceGitHub, ProjectSFID: "sfid-both", AutoEnabledClaGroupID: "cg-chips"}, + {Name: "same-org", Source: sourceGitHub, ProjectSFID: "sfid-both", AutoEnabledClaGroupID: "cg-mapped"}, + {Name: "blank-org", Source: sourceGitHub, ProjectSFID: "sfid-both", AutoEnabledClaGroupID: ""}, + {Name: "dangling-org", Source: sourceGitHub, AutoEnabledClaGroupID: "cg-deleted"}, + }, + } +} + +func TestSearchAutoEnabledOrgResolvesClaGroupWithoutAMapping(t *testing.T) { + list, err := NewService(autoEnabledRepo()).Search(context.Background(), "chipsalliance", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-chips"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) + assert.Equal(t, "CHIPS Alliance", list.Results[0].ClaGroupName) +} + +func TestSearchAutoEnabledClaGroupUnionsWithTheMappedOnes(t *testing.T) { + list, err := NewService(autoEnabledRepo()).Search(context.Background(), "both-org", 0) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"cg-chips", "cg-mapped"}, ids(list)) +} + +func TestSearchAutoEnabledClaGroupIsNotDuplicated(t *testing.T) { + list, err := NewService(autoEnabledRepo()).Search(context.Background(), "same-org", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-mapped"}, ids(list)) + assert.Equal(t, []models.ClaSearchOrg{ + {Name: "blank-org", Source: sourceGitHub, URL: "https://github.com/blank-org"}, + {Name: "both-org", Source: sourceGitHub, URL: "https://github.com/both-org"}, + {Name: "same-org", Source: sourceGitHub, URL: "https://github.com/same-org"}, + }, list.Results[0].Organizations) +} + +func TestSearchBlankAutoEnabledClaGroupFallsBackToTheMapping(t *testing.T) { + list, err := NewService(autoEnabledRepo()).Search(context.Background(), "blank-org", 0) + require.NoError(t, err) + assert.Equal(t, []string{"cg-mapped"}, ids(list)) +} + +func TestSearchOmitsAClaGroupThatResolvesToNothingDisplayable(t *testing.T) { + list, err := NewService(autoEnabledRepo()).Search(context.Background(), "dangling-org", 0) + require.NoError(t, err) + assert.Empty(t, list.Results) +} + +func TestRepositoryPath(t *testing.T) { + for _, tc := range []struct { + term string + expected string + host string + variants []string + }{ + {"kubernetes", "", "", nil}, + {"has space/repo", "", "", nil}, + {"not-a-url://", "", "", nil}, + {"Owner/Repo", "Owner/Repo", "", []string{"Owner/Repo", "owner/repo"}}, + {"owner/repo", "owner/repo", "", []string{"owner/repo"}}, + {"https://gitlab.com/groups/onap", "", "", nil}, + {"https://gitlab.com/onap/oom/oom", "onap/oom/oom", "gitlab.com", []string{"onap/oom/oom"}}, + {"https://github.com/Owner/Repo.git", "Owner/Repo", "github.com", []string{"Owner/Repo", "owner/repo"}}, + {"https://WWW.GitHub.com/Owner/Repo", "Owner/Repo", "www.github.com", []string{"Owner/Repo", "owner/repo"}}, + {"https://gerrit.onap.org/r/aai/aai-common", "r/aai/aai-common", "gerrit.onap.org", []string{"r/aai/aai-common"}}, + } { + path, host := repositoryPath(tc.term) + assert.Equal(t, tc.expected, path, tc.term) + assert.Equal(t, tc.host, host, tc.term) + if path != "" { + assert.Equal(t, tc.variants, nameVariants(path), tc.term) + } + } +} + +// divergentRepo mirrors the AcademySoftwareFoundation shape: an organization auto-enabled into one +// CLA Group while hosting a repository owned by another, and the same organization name on the other +// forge behind a different CLA Group +func divergentRepo() *fakeRepo { + return &fakeRepo{ + claGroups: []*ClaGroupRow{ + {ClaGroupID: "cg-org", Name: "MoonRay"}, + {ClaGroupID: "cg-repo", Name: "Dailies Notes Assistant"}, + {ClaGroupID: "cg-gitlab", Name: "GitLab Group CLA"}, + }, + github: []*OrgRow{{Name: "aswf", Source: sourceGitHub, AutoEnabledClaGroupID: "cg-org"}}, + gitlab: []*OrgRow{{Name: "aswf", Source: sourceGitLab, AutoEnabledClaGroupID: "cg-gitlab"}}, + repos: map[string][]*RepositoryRow{ + "aswf/dna": {{Name: "aswf/dna", URL: "https://github.com/aswf/dna", Type: sourceGitHub, ClaGroupID: "cg-repo"}}, + "aswf/only-on-gitlab": {{Name: "aswf/only-on-gitlab", URL: "https://gitlab.com/aswf/only-on-gitlab", Type: sourceGitLab, ClaGroupID: "cg-gitlab"}}, + "aswf/ghost": {{Name: "aswf/ghost", URL: "https://github.com/aswf/ghost", Type: sourceGitHub, ClaGroupID: "cg-deleted"}}, + "aswf/self-hosted": {{Name: "aswf/self-hosted", URL: "https://git.aswf.example/aswf/self-hosted", Type: sourceGitHub, ClaGroupID: "cg-repo"}}, + }, + } +} + +func TestSearchPastedURLResolvesToTheRepositoryOwnerNotTheOrganization(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://github.com/aswf/dna", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-repo"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) + assert.Equal(t, "aswf/dna", list.Results[0].MatchedRepositoryName) +} + +func TestSearchPastedURLFallsBackToTheOrganizationWhenTheRepositoryGroupIsGone(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://github.com/aswf/ghost", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-org"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) + assert.Empty(t, list.Results[0].MatchedRepositoryName) +} + +func TestSearchPastedURLIgnoresARepositoryOnAnotherForge(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://github.com/aswf/only-on-gitlab", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-org"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) + + list, err = NewService(divergentRepo()).Search(context.Background(), "https://gitlab.com/aswf/only-on-gitlab", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-gitlab"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) +} + +func TestSearchBareOwnerRepoMatchesEitherForge(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "aswf/only-on-gitlab", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-gitlab"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) +} + +func TestSearchOrganizationFallbackStaysOnTheForgeTheURLNamed(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://github.com/aswf/unknown-repo", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-org"}, ids(list)) + + list, err = NewService(divergentRepo()).Search(context.Background(), "https://gitlab.com/aswf/unknown-repo", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-gitlab"}, ids(list)) +} + +func TestSearchPastedURLOnAnUnknownHostDoesNotMatchAnotherHostsRepository(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://unrelated.example/aswf/dna", 0) + require.NoError(t, err) + assert.Empty(t, list.Results) +} + +func TestSearchPastedURLOnAnUnknownHostResolvesTheRepositoryOfThatHost(t *testing.T) { + list, err := NewService(divergentRepo()).Search(context.Background(), "https://git.aswf.example/aswf/self-hosted", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-repo"}, ids(list)) + assert.Equal(t, []string{matchRepository}, list.Results[0].MatchTypes) + assert.Equal(t, "aswf/self-hosted", list.Results[0].MatchedRepositoryName) +} + +func TestSearchPastedURLOnAnUnknownHostFallsBackToTheOrganizationOfThatHost(t *testing.T) { + repo := divergentRepo() + repo.claGroups = append(repo.claGroups, &ClaGroupRow{ClaGroupID: "cg-gerrit", Name: "Self Hosted CLA"}) + repo.gerrit = []*OrgRow{{Name: "aswf", URL: "https://git.aswf.example", Source: sourceGerrit, ClaGroupID: "cg-gerrit"}} + list, err := NewService(repo).Search(context.Background(), "https://git.aswf.example/aswf/unknown-repo", 0) + require.NoError(t, err) + require.Equal(t, []string{"cg-gerrit"}, ids(list)) + assert.Equal(t, []string{matchOrganization}, list.Results[0].MatchTypes) +} diff --git a/cla-backend-go/v2/my_clas/cla_managers_test.go b/cla-backend-go/v2/my_clas/cla_managers_test.go new file mode 100644 index 000000000..736933e54 --- /dev/null +++ b/cla-backend-go/v2/my_clas/cla_managers_test.go @@ -0,0 +1,549 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/linuxfoundation/easycla/cla-backend-go/events" + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const someoneEmail = "someone@example.org" + +type fakeEvents struct { + logged []*events.LogEventArgs +} + +func (f *fakeEvents) LogEventWithContext(_ context.Context, args *events.LogEventArgs) { + // Mirror the identity gate in events.service.LogEventWithContext: events without a + // top-level UserID or LfUsername are dropped in production. + if args == nil || args.EventType == "" || args.EventData == nil || (args.UserID == "" && args.LfUsername == "") { + return + } + f.logged = append(f.logged, args) +} + +type sentEmail struct { + subject string + body string + recipients []string +} + +func managersFixture() (*fakeRepo, *fakeSignatures, *fakeCompanies) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone", Username: "Some One"} + sig := ecla("sig-ecla", "company-1", "2024-01-01T00:00:00Z", true) + sig.UserEmail = someoneEmail + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + sig, + icla("sig-icla", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1", SignatureACL: []v1Models.User{ + {LfUsername: "manager-one", Username: "Manager One", LfEmail: "manager-one@corp.example.org"}, + {LfUsername: "manager-two", Username: "Manager Two", Emails: []string{"manager-two@corp.example.org"}}, + {Username: "acl-no-lfid"}, + {}, + }}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + return repo, signaturesService, companies +} + +func TestGetMyClasSignedIdentity(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + github := icla("sig-github", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true) + github.UserGithubUsername = "octocat" + github.UserGithubID = "999" + githubIDOnly := icla("sig-github-id", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", true) + githubIDOnly.UserGithubID = "999" + gitlab := icla("sig-gitlab", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true) + gitlab.UserGitlabUsername = "octolab" + gerrit := icla("sig-gerrit", "user-a", "cla-group-1", "2024-04-01T00:00:00Z", true) + gerrit.UserEmail = someoneEmail + sso := icla("sig-sso", "user-a", "cla-group-1", "2024-05-01T00:00:00Z", true) + sso.UserLFUsername = "someone" + anonymous := icla("sig-anonymous", "user-a", "cla-group-1", "2024-06-01T00:00:00Z", true) + + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": {github, githubIDOnly, gitlab, gerrit, sso, anonymous}}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + assert.Equal(t, "github", byID["sig-github"].SignedVia) + assert.Equal(t, "octocat", byID["sig-github"].SignedAs, "the username wins over the numeric ID") + assert.Equal(t, "github", byID["sig-github-id"].SignedVia) + assert.Equal(t, "999", byID["sig-github-id"].SignedAs, "the numeric ID is the fallback account") + assert.Equal(t, "gitlab", byID["sig-gitlab"].SignedVia) + assert.Equal(t, "octolab", byID["sig-gitlab"].SignedAs) + assert.Equal(t, "gerrit", byID["sig-gerrit"].SignedVia) + assert.Equal(t, someoneEmail, byID["sig-gerrit"].SignedAs) + assert.Equal(t, "gerrit", byID["sig-sso"].SignedVia, "LF SSO signings surface as gerrit") + assert.Equal(t, "someone", byID["sig-sso"].SignedAs) + require.Contains(t, byID, "sig-anonymous", "the identity-less record is still returned") + assert.Empty(t, byID["sig-anonymous"].SignedVia, "no identity on the record leaves both fields omitted") + assert.Empty(t, byID["sig-anonymous"].SignedAs) +} + +func TestGetMyClasFlaggedAndClaManager(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + "company-2": {CompanyID: "company-2", CompanyName: "Sanctioned Corp", IsSanctioned: true, SanctionedDate: "2024-01-15T10:11:12.000000+0000"}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1", SignatureACL: []v1Models.User{{LfUsername: "SomeOne"}}}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-good", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-sanctioned", "company-2", "2024-02-01T00:00:00Z", true), + icla("sig-icla", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + good := byID["sig-good"] + assert.False(t, good.Flagged) + assert.Empty(t, good.FlaggedAt) + assert.True(t, good.ClaManager, "the ACL match is case-insensitive") + assert.True(t, good.Valid) + + sanctioned := byID["sig-sanctioned"] + assert.True(t, sanctioned.Flagged, "a sanctioned employer flags the ECLA") + assert.Equal(t, "2024-01-15T10:11:12Z", sanctioned.FlaggedAt, "the stored sanctioned_date is returned in RFC3339, not the response time") + assert.Equal(t, models.MyClaStatusRevoked, sanctioned.Status, "the Revoked state is system-set from sanctions") + assert.False(t, sanctioned.Valid) + assert.False(t, sanctioned.ClaManager, "a sanctioned employer carries no CLA manager action") + + regular := byID["sig-icla"] + assert.False(t, regular.Flagged, "ICLAs are never flagged") + assert.False(t, regular.ClaManager) +} + +func TestGetMyClasNotAClaManager(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + for _, row := range result.Clas { + assert.False(t, row.ClaManager) + } +} + +func TestGetMyClaManagers(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{names: map[string]string{"cla-group-1": "My CLA Group"}}) + caller := &Caller{Username: "someone"} + + result, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "sig-ecla", result.SignatureID) + assert.Equal(t, "cla-group-1", result.ClaGroupID) + assert.Equal(t, "My CLA Group", result.ClaGroupName) + assert.Equal(t, "company-1", result.CompanyID) + assert.Equal(t, "Good Corp", result.CompanyName) + assert.False(t, result.ClaManager) + assert.Equal(t, int64(3), result.ResultCount) + require.Len(t, result.Managers, 3) + assert.Equal(t, models.MyClaManager{LfUsername: "manager-one", Name: "Manager One", Email: "manager-one@corp.example.org"}, result.Managers[0]) + assert.Equal(t, models.MyClaManager{LfUsername: "manager-two", Name: "Manager Two", Email: "manager-two@corp.example.org"}, result.Managers[1], "the additional-emails list is the email fallback") + assert.Equal(t, models.MyClaManager{LfUsername: "acl-no-lfid", Name: "acl-no-lfid"}, result.Managers[2], "the plain username is the LF username fallback") + + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-icla") + require.NoError(t, err) + assert.Nil(t, result, "ICLAs have no CLA managers") + + result, err = svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-of-somebody-else") + require.NoError(t, err) + assert.Nil(t, result, "signatures not owned by the resolved identity are not found") +} + +func TestGetMyClaManagersCallerIsManager(t *testing.T) { + repo, signaturesService, companies := managersFixture() + ccla := signaturesService.cclas["cla-group-1|company-1"] + ccla.SignatureACL = append(ccla.SignatureACL, v1Models.User{LfUsername: "SomeOne"}) + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.ClaManager, "the caller shows up as a CLA manager, case-insensitively") +} + +func TestGetMyClaManagersNoCcla(t *testing.T) { + repo, _, companies := managersFixture() + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla") + require.NoError(t, err) + require.NotNil(t, result) + assert.Empty(t, result.Managers, "no current CCLA yields an empty manager list") + assert.Equal(t, int64(0), result.ResultCount) + assert.Equal(t, "Good Corp", result.CompanyName) +} + +func TestGetMyClaManagersOwnershipEnforced(t *testing.T) { + repo, signaturesService, companies := managersFixture() + victim := &v1Models.User{UserID: "user-v", LfUsername: "victim"} + repo.byLFUsername["victim"] = []*v1Models.User{victim} + repo.byUserID["user-v"] = []*signatures.ItemSignature{ecla("sig-victim", "company-1", "2024-01-01T00:00:00Z", true)} + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClaManagers(context.Background(), &Caller{Username: "someone"}, &Identity{LfUsername: "victim"}, "sig-victim") + require.NoError(t, err) + assert.Nil(t, result, "a non-admin cannot resolve somebody else's ECLA") + + result, err = svc.GetMyClaManagers(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}, "sig-victim") + require.NoError(t, err) + require.NotNil(t, result, "an admin can") +} + +func requestInput(requestType string, recipients []string, message string) *models.MyClaManagerRequest { + return &models.MyClaManagerRequest{RequestType: &requestType, Recipients: recipients, Message: message} +} + +func newRequestTestService(repo *fakeRepo, signaturesService SignaturesService, companies CompanyRepository) (*service, *fakeEvents, *[]sentEmail) { + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{names: map[string]string{"cla-group-1": "My CLA Group"}}) + eventsService := &fakeEvents{} + svc.eventsService = eventsService + sent := &[]sentEmail{} + svc.sendEmail = func(subject, body string, recipients []string) error { + *sent = append(*sent, sentEmail{subject: subject, body: body, recipients: recipients}) + return nil + } + return svc, eventsService, sent +} + +func TestCreateMyClaManagerRequest(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"Manager-One", "manager-two"}, "please remove me")) + require.NoError(t, err) + require.NotNil(t, result) + assert.NotEmpty(t, result.RequestID) + assert.Equal(t, "sig-ecla", result.SignatureID) + assert.Equal(t, "removal", result.RequestType) + assert.Equal(t, "sent", result.Status) + assert.Equal(t, []string{"manager-one", "manager-two"}, result.Recipients, "recipients echo the canonical manager usernames") + + require.Len(t, *sent, 1) + email := (*sent)[0] + assert.Equal(t, []string{"manager-one@corp.example.org", "manager-two@corp.example.org"}, email.recipients) + assert.Contains(t, email.subject, "removal from the corporate CLA coverage") + assert.Contains(t, email.body, "Some One") + assert.Contains(t, email.body, someoneEmail) + assert.Contains(t, email.body, "Good Corp") + assert.Contains(t, email.body, "please remove me") + + require.Len(t, eventsService.logged, 1) + logged := eventsService.logged[0] + assert.Equal(t, events.ContactCLAManagerRequestCreated, logged.EventType) + assert.Equal(t, "user-a", logged.UserID) + assert.Equal(t, "company-1", logged.CompanyID) + eventData, ok := logged.EventData.(*events.ContactCLAManagerRequestCreatedEventData) + require.True(t, ok) + assert.Equal(t, result.RequestID, eventData.RequestID) + assert.Equal(t, "removal", eventData.RequestType) + assert.Equal(t, "please remove me", eventData.Message, "the audit event is the receipt, so it carries the message") + assert.Equal(t, []string{"manager-one", "manager-two"}, eventData.Recipients) +} + +func TestCreateMyClaManagerRequestApprovalWording(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("approval", []string{"manager-one"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "approval", result.RequestType) + require.Len(t, *sent, 1) + assert.Contains(t, (*sent)[0].body, "approval under the corporate CLA") +} + +func TestCreateMyClaManagerRequestRecipientValidation(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + _, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "recipients are required while managers resolve") + + _, err = svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one", "outsider"}, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "a recipient outside the resolved managers is rejected") + + assert.Empty(t, *sent) + assert.Empty(t, eventsService.logged) +} + +func TestCreateMyClaManagerRequestRecipientDedupe(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"Manager-One", "manager-one"}, "")) + require.NoError(t, err) + assert.Equal(t, []string{"manager-one"}, result.Recipients, "case-variant duplicates collapse to one recipient") + + require.Len(t, *sent, 1) + assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients) +} + +func TestCreateMyClaManagerRequestSharedEmailDedupe(t *testing.T) { + repo, signaturesService, companies := managersFixture() + signaturesService.cclas["cla-group-1|company-1"].SignatureACL[1].Emails = []string{"Manager-One@Corp.Example.org"} + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one", "manager-two"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, []string{"manager-one", "manager-two"}, result.Recipients, "both selected managers are still reported") + + require.Len(t, *sent, 1) + assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients, "two managers sharing an address are mailed once") +} + +func TestCreateMyClaManagerRequestZeroManagers(t *testing.T) { + repo, _, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, &fakeSignatures{}, companies) + caller := &Caller{Username: "someone"} + + _, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one"}, "")) + assert.ErrorIs(t, err, ErrInvalidRecipients, "recipients must be empty when no manager resolves") + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "nobody to write to")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "recorded", result.Status, "the request is recorded without sending email") + assert.Empty(t, result.Recipients) + assert.Empty(t, *sent) + require.Len(t, eventsService.logged, 1, "the audit event is still logged") +} + +func TestCreateMyClaManagerRequestNotFound(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, _ := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + result, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-icla", + requestInput("removal", []string{"manager-one"}, "")) + require.NoError(t, err) + assert.Nil(t, result, "an ICLA has no CLA managers to contact") + + result, err = svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-unknown", + requestInput("removal", []string{"manager-one"}, "")) + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestCreateMyClaManagerRequestSendFailure(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, _ := newRequestTestService(repo, signaturesService, companies) + svc.sendEmail = func(_, _ string, _ []string) error { return errors.New("SNS unavailable") } + + _, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one"}, "")) + assert.Error(t, err, "a send failure is surfaced to the caller") + assert.Empty(t, eventsService.logged, "no audit event is logged when the email fails") +} + +func TestCreateMyClaManagerRequestManagerWithoutEmail(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"acl-no-lfid"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "recorded", result.Status, "a selected manager with no email leaves nothing to send") + assert.Equal(t, []string{"acl-no-lfid"}, result.Recipients) + assert.Empty(t, *sent) +} + +func TestCreateMyClaManagerRequestMixedRecipientEmails(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one", "acl-no-lfid"}, "")) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "sent", result.Status, "one reachable manager is enough for sent") + assert.Equal(t, []string{"manager-one", "acl-no-lfid"}, result.Recipients, "recipients are the whole selection, reachable or not") + + require.Len(t, *sent, 1) + assert.Equal(t, []string{"manager-one@corp.example.org"}, (*sent)[0].recipients, "only the reachable manager is emailed") + + require.Len(t, eventsService.logged, 1) + eventData, ok := eventsService.logged[0].EventData.(*events.ContactCLAManagerRequestCreatedEventData) + require.True(t, ok) + assert.Equal(t, []string{"manager-one", "acl-no-lfid"}, eventData.Recipients, "the receipt records the whole selection") +} + +func decodeJSON(t *testing.T, payload interface{}) map[string]interface{} { + t.Helper() + raw, err := json.Marshal(payload) + require.NoError(t, err) + decoded := map[string]interface{}{} + require.NoError(t, json.Unmarshal(raw, &decoded)) + return decoded +} + +// TestMyClasJSONContract pins the false and empty values the console keys on - they must appear +// in the payload rather than being dropped by omitempty +func TestMyClasJSONContract(t *testing.T) { + repo, _, companies := managersFixture() + svc, _, _ := newRequestTestService(repo, &fakeSignatures{}, companies) + caller := &Caller{Username: "someone"} + + list, err := svc.GetMyClas(context.Background(), caller, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range list.Clas { + byID[row.SignatureID] = row + } + for _, signatureID := range []string{"sig-ecla", "sig-icla"} { + row := decodeJSON(t, byID[signatureID]) + for _, field := range []string{"flagged", "claManager"} { + require.Contains(t, row, field, "%s must always carry %s", signatureID, field) + assert.Equal(t, false, row[field]) + } + } + + managers, err := svc.GetMyClaManagers(context.Background(), caller, &Identity{}, "sig-ecla") + require.NoError(t, err) + decoded := decodeJSON(t, managers) + require.Contains(t, decoded, "managers") + assert.NotNil(t, decoded["managers"], "an empty manager list serializes as [], never null") + assert.Empty(t, decoded["managers"]) + + receipt, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("removal", nil, "")) + require.NoError(t, err) + decoded = decodeJSON(t, receipt) + require.Contains(t, decoded, "recipients") + assert.NotNil(t, decoded["recipients"], "a recorded receipt serializes recipients as []") + assert.Empty(t, decoded["recipients"]) +} + +func TestSignedIdentityFallbacks(t *testing.T) { + gitlabIDOnly := &signatures.ItemSignature{UserGitlabID: "42"} + via, as := signedIdentity(gitlabIDOnly) + assert.Equal(t, "gitlab", via) + assert.Equal(t, "42", as) + + assert.False(t, isClaManager(nil, "someone")) + assert.False(t, isClaManager(&v1Models.Signature{SignatureACL: []v1Models.User{{LfUsername: "someone"}}}, "")) + assert.True(t, isClaManager(&v1Models.Signature{SignatureACL: []v1Models.User{{Username: "SomeOne"}}}, "someone"), + "the plain username matches too") +} + +func TestCreateMyClaManagerRequestContact(t *testing.T) { + repo, signaturesService, companies := managersFixture() + repo.byLFUsername["someone"][0].LfEmail = someoneEmail + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + + result, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("contact", []string{"manager-one"}, "Hi there,\r\nplease advise.\x07")) + require.NoError(t, err) + assert.Equal(t, "contact", result.RequestType) + assert.Equal(t, "sent", result.Status) + + require.Len(t, *sent, 1) + email := (*sent)[0] + assert.Equal(t, "EasyCLA: Message from Some One regarding Good Corp", email.subject) + assert.Contains(t, email.body, "Hi <b>there</b>,\nplease advise.", "the message is HTML-escaped, CRLF-normalized and stripped of control characters") + assert.NotContains(t, email.body, "there") + assert.Contains(t, email.body, "You can reply to the contributor at "+someoneEmail) + assert.Contains(t, email.body, "This is a message only - no change was requested and none has been made") + assert.NotContains(t, email.body, "has requested") + + require.Len(t, eventsService.logged, 1) + eventData, ok := eventsService.logged[0].EventData.(*events.ContactCLAManagerRequestCreatedEventData) + require.True(t, ok) + assert.Equal(t, "contact", eventData.RequestType) + assert.Equal(t, "Hi there,\nplease advise.", eventData.Message) +} + +func TestCreateMyClaManagerRequestContactRequiresMessage(t *testing.T) { + repo, signaturesService, companies := managersFixture() + svc, eventsService, sent := newRequestTestService(repo, signaturesService, companies) + caller := &Caller{Username: "someone"} + + _, err := svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("contact", []string{"manager-one"}, "")) + assert.ErrorIs(t, err, ErrMissingMessage) + + _, err = svc.CreateMyClaManagerRequest(context.Background(), caller, &Identity{}, "sig-ecla", + requestInput("contact", []string{"manager-one"}, " \r\n \x07\x1b ")) + assert.ErrorIs(t, err, ErrMissingMessage, "a message that sanitizes to nothing cannot be sent") + + assert.Empty(t, *sent) + assert.Empty(t, eventsService.logged) +} + +func TestCreateMyClaManagerRequestSubjectStaysSingleLine(t *testing.T) { + repo, signaturesService, companies := managersFixture() + repo.byLFUsername["someone"][0].Username = "Evil\r\nBcc: victim@example.org" + svc, _, sent := newRequestTestService(repo, signaturesService, companies) + + _, err := svc.CreateMyClaManagerRequest(context.Background(), &Caller{Username: "someone"}, &Identity{}, "sig-ecla", + requestInput("removal", []string{"manager-one"}, "")) + require.NoError(t, err) + require.Len(t, *sent, 1) + subject := (*sent)[0].subject + assert.NotContains(t, subject, "\n") + assert.NotContains(t, subject, "\r") + assert.Contains(t, subject, "request from EvilBcc: victim@example.org for Good Corp") +} diff --git a/cla-backend-go/v2/my_clas/handlers.go b/cla-backend-go/v2/my_clas/handlers.go index 17b65c023..291fe1bdf 100644 --- a/cla-backend-go/v2/my_clas/handlers.go +++ b/cla-backend-go/v2/my_clas/handlers.go @@ -5,9 +5,12 @@ package my_clas import ( "context" + "errors" + "net/http" "github.com/LF-Engineering/lfx-kit/auth" "github.com/go-openapi/runtime/middleware" + claAuth "github.com/linuxfoundation/easycla/cla-backend-go/auth" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" myClasOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/my_clas" log "github.com/linuxfoundation/easycla/cla-backend-go/logging" @@ -17,9 +20,19 @@ import ( const missingUsernameMsg = "the authenticated principal carries no username - unable to determine whose CLAs to look up" const missingIdentityMsg = "no identity provided - provide at least one of lfUsername, email, secondaryEmail, githubId, githubUsername, gitlabId, gitlabUsername, gerritUsername" +const unverifiedCallerMsg = "unable to verify the caller's bearer token" +const notOwnedEclaMsg = "no signed ECLA with the given signature ID belongs to the provided identity" + +// CallerVerifier re-verifies the request bearer token in-handler - see auth.TrustedCallerVerifier +type CallerVerifier interface { + Enabled() bool + Verify(authorization string) (*claAuth.TrustedCaller, error) +} // Configure sets up the My CLAs API handlers -func Configure(api *operations.EasyclaAPI, service Service) { +// +//nolint:gocyclo +func Configure(api *operations.EasyclaAPI, service Service, callerVerifier CallerVerifier) { api.MyClasGetMyClasHandler = myClasOps.GetMyClasHandlerFunc( func(params myClasOps.GetMyClasParams, authUser *auth.User) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) @@ -32,19 +45,27 @@ func Configure(api *operations.EasyclaAPI, service Service) { "authUserEmail": utils.StringValue(params.XEMAIL), } + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewGetMyClasUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + currentUsername, admin := principal(authUser) - if !admin && currentUsername == "" { + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { log.WithFields(f).Warn(missingUsernameMsg) return myClasOps.NewGetMyClasUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) } requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) - if admin && currentUsername == "" && requested.IsEmpty() { + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { log.WithFields(f).Warn(missingIdentityMsg) return myClasOps.NewGetMyClasBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) } + logCallerIdentity(f, trustedCaller, requested) - result, err := service.GetMyClas(ctx, currentUsername, admin, requested) + result, err := service.GetMyClas(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested) if err != nil { msg := "unable to lookup the CLAs for the provided identity" log.WithFields(f).WithError(err).Warn(msg) @@ -67,19 +88,27 @@ func Configure(api *operations.EasyclaAPI, service Service) { "signatureID": params.SignatureID, } + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewGetMyClaPdfUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + currentUsername, admin := principal(authUser) - if !admin && currentUsername == "" { + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { log.WithFields(f).Warn(missingUsernameMsg) return myClasOps.NewGetMyClaPdfUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) } requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) - if admin && currentUsername == "" && requested.IsEmpty() { + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { log.WithFields(f).Warn(missingIdentityMsg) return myClasOps.NewGetMyClaPdfBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) } + logCallerIdentity(f, trustedCaller, requested) - result, err := service.GetMyClaPdfURL(ctx, currentUsername, admin, requested, params.SignatureID) + result, err := service.GetMyClaPdfURL(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID) if err != nil { msg := "unable to generate the signed document download link" log.WithFields(f).WithError(err).Warn(msg) @@ -94,6 +123,106 @@ func Configure(api *operations.EasyclaAPI, service Service) { return myClasOps.NewGetMyClaPdfOK().WithXRequestID(reqID).WithPayload(result) }) + api.MyClasGetMyClaManagersHandler = myClasOps.GetMyClaManagersHandlerFunc( + func(params myClasOps.GetMyClaManagersParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.my_clas.handlers.GetMyClaManagers", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "authUserEmail": utils.StringValue(params.XEMAIL), + "signatureID": params.SignatureID, + } + + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewGetMyClaManagersUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + + currentUsername, admin := principal(authUser) + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { + log.WithFields(f).Warn(missingUsernameMsg) + return myClasOps.NewGetMyClaManagersUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { + log.WithFields(f).Warn(missingIdentityMsg) + return myClasOps.NewGetMyClaManagersBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) + } + logCallerIdentity(f, trustedCaller, requested) + + result, err := service.GetMyClaManagers(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID) + if err != nil { + msg := "unable to lookup the CLA managers for the given signature" + log.WithFields(f).WithError(err).Warn(msg) + return myClasOps.NewGetMyClaManagersInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + if result == nil { + msg := notOwnedEclaMsg + log.WithFields(f).Warn(msg) + return myClasOps.NewGetMyClaManagersNotFound().WithXRequestID(reqID).WithPayload(utils.ErrorResponseNotFound(reqID, msg)) + } + + return myClasOps.NewGetMyClaManagersOK().WithXRequestID(reqID).WithPayload(result) + }) + + api.MyClasCreateMyClaManagerRequestHandler = myClasOps.CreateMyClaManagerRequestHandlerFunc( + func(params myClasOps.CreateMyClaManagerRequestParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.my_clas.handlers.CreateMyClaManagerRequest", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "authUserEmail": utils.StringValue(params.XEMAIL), + "signatureID": params.SignatureID, + } + + trustedCaller, err := verifyCaller(callerVerifier, params.HTTPRequest, f) + if err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewCreateMyClaManagerRequestUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + + currentUsername, admin := principal(authUser) + trusted := trustedCaller != nil && trustedCaller.Trusted + if !admin && !trusted && currentUsername == "" { + log.WithFields(f).Warn(missingUsernameMsg) + return myClasOps.NewCreateMyClaManagerRequestUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + requested := newIdentity(params.LfUsername, params.Email, params.SecondaryEmail, params.GithubID, params.GithubUsername, params.GitlabID, params.GitlabUsername, params.GerritUsername) + if (admin || trusted) && currentUsername == "" && requested.IsEmpty() { + log.WithFields(f).Warn(missingIdentityMsg) + return myClasOps.NewCreateMyClaManagerRequestBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, missingIdentityMsg)) + } + logCallerIdentity(f, trustedCaller, requested) + + result, err := service.CreateMyClaManagerRequest(ctx, &Caller{Username: currentUsername, Admin: admin, Trusted: trusted}, requested, params.SignatureID, ¶ms.Body) + if err != nil { + if errors.Is(err, ErrInvalidRecipients) || errors.Is(err, ErrMissingMessage) { + log.WithFields(f).WithError(err).Warn("invalid CLA manager request input") + return myClasOps.NewCreateMyClaManagerRequestBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, err.Error())) + } + msg := "unable to create the CLA manager request" + log.WithFields(f).WithError(err).Warn(msg) + return myClasOps.NewCreateMyClaManagerRequestInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + if result == nil { + msg := notOwnedEclaMsg + log.WithFields(f).Warn(msg) + return myClasOps.NewCreateMyClaManagerRequestNotFound().WithXRequestID(reqID).WithPayload(utils.ErrorResponseNotFound(reqID, msg)) + } + + return myClasOps.NewCreateMyClaManagerRequestOK().WithXRequestID(reqID).WithPayload(result) + }) + api.MyClasGetMyIdentitiesHandler = myClasOps.GetMyIdentitiesHandlerFunc( func(params myClasOps.GetMyIdentitiesParams, authUser *auth.User) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) @@ -106,6 +235,11 @@ func Configure(api *operations.EasyclaAPI, service Service) { "authUserEmail": utils.StringValue(params.XEMAIL), } + if _, err := verifyCaller(callerVerifier, params.HTTPRequest, f); err != nil { + log.WithFields(f).WithError(err).Warn(unverifiedCallerMsg) + return myClasOps.NewGetMyIdentitiesUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, unverifiedCallerMsg)) + } + currentUsername, _ := principal(authUser) if currentUsername == "" { log.WithFields(f).Warn(missingUsernameMsg) @@ -123,6 +257,44 @@ func Configure(api *operations.EasyclaAPI, service Service) { }) } +// verifyCaller re-verifies the bearer token because /v4 otherwise trusts its invoke path +// unconditionally: the gateway-injected X-ACL/X-USERNAME headers are decoded but never +// signature-checked, so anything able to invoke the lambda directly could forge them. Returns +// (nil, nil) while no allow-list is configured and no bearer token is required. +func verifyCaller(callerVerifier CallerVerifier, r *http.Request, f logrus.Fields) (*claAuth.TrustedCaller, error) { + if callerVerifier == nil || !callerVerifier.Enabled() { + return nil, nil + } + + authorization := "" + if r != nil { + authorization = r.Header.Get("Authorization") + } + trustedCaller, err := callerVerifier.Verify(authorization) + if err != nil { + return nil, err + } + if trustedCaller == nil { + return nil, errors.New("the caller verifier returned no result") + } + + f["callerClientID"] = trustedCaller.ClientID + f["callerSubject"] = trustedCaller.Subject + f["trustedCaller"] = trustedCaller.Trusted + return trustedCaller, nil +} + +func logCallerIdentity(f logrus.Fields, trustedCaller *claAuth.TrustedCaller, requested *Identity) { + if trustedCaller == nil { + return + } + if trustedCaller.Trusted { + log.WithFields(f).Infof("trusted caller requested the identities: %s", requested.Summary()) + return + } + log.WithFields(f).Debugf("untrusted caller requested the identities: %s", requested.Summary()) +} + func principal(authUser *auth.User) (string, bool) { if authUser == nil { return "", false diff --git a/cla-backend-go/v2/my_clas/handlers_test.go b/cla-backend-go/v2/my_clas/handlers_test.go new file mode 100644 index 000000000..2428b8a8a --- /dev/null +++ b/cla-backend-go/v2/my_clas/handlers_test.go @@ -0,0 +1,305 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/LF-Engineering/lfx-kit/auth" + "github.com/go-openapi/runtime" + claAuth "github.com/linuxfoundation/easycla/cla-backend-go/auth" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" + myClasOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/my_clas" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeService struct { + callers []*Caller + err error + nilPdf bool + nilManagers bool + invalidRecipients bool +} + +func (f *fakeService) GetMyClaManagers(_ context.Context, caller *Caller, _ *Identity, _ string) (*models.MyClaManagerList, error) { + f.callers = append(f.callers, caller) + if f.err != nil { + return nil, f.err + } + if f.nilManagers { + return nil, nil + } + return &models.MyClaManagerList{}, nil +} + +func (f *fakeService) CreateMyClaManagerRequest(_ context.Context, caller *Caller, _ *Identity, _ string, _ *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) { + f.callers = append(f.callers, caller) + if f.invalidRecipients { + return nil, ErrInvalidRecipients + } + if f.err != nil { + return nil, f.err + } + if f.nilManagers { + return nil, nil + } + return &models.MyClaManagerRequestResult{}, nil +} + +func (f *fakeService) GetMyClas(_ context.Context, caller *Caller, _ *Identity) (*models.MyClaList, error) { + f.callers = append(f.callers, caller) + if f.err != nil { + return nil, f.err + } + return &models.MyClaList{}, nil +} + +func (f *fakeService) GetMyClaPdfURL(_ context.Context, caller *Caller, _ *Identity, _ string) (*models.MyClaPdf, error) { + f.callers = append(f.callers, caller) + if f.err != nil { + return nil, f.err + } + if f.nilPdf { + return nil, nil + } + return &models.MyClaPdf{}, nil +} + +func (f *fakeService) GetMyIdentities(_ context.Context, currentUsername string) (*models.MyIdentityList, error) { + f.callers = append(f.callers, &Caller{Username: currentUsername}) + if f.err != nil { + return nil, f.err + } + return &models.MyIdentityList{}, nil +} + +func (f *fakeService) AuthorizeIdentity(_ context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) { + f.callers = append(f.callers, &Caller{Username: currentUsername, Admin: admin}) + if f.err != nil { + return nil, nil, f.err + } + return requested, []string{}, nil +} + +type fakeVerifier struct { + enabled bool + callers map[string]*claAuth.TrustedCaller + seen []string + noop string +} + +func (f *fakeVerifier) Enabled() bool { + return f.enabled +} + +func (f *fakeVerifier) Verify(authorization string) (*claAuth.TrustedCaller, error) { + f.seen = append(f.seen, authorization) + if f.noop != "" && authorization == f.noop { + return nil, nil + } + if caller, ok := f.callers[authorization]; ok { + return caller, nil + } + return nil, errors.New("unable to verify the bearer token") +} + +func configuredAPI(t *testing.T, verifier CallerVerifier) (*operations.EasyclaAPI, *fakeService) { + t.Helper() + api := operations.NewEasyclaAPI(nil) + service := &fakeService{} + Configure(api, service, verifier) + return api, service +} + +func request(t *testing.T, authorization string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v4/my-clas", nil) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + return req +} + +func statusOf(t *testing.T, responder interface { + WriteResponse(http.ResponseWriter, runtime.Producer) +}) int { + t.Helper() + recorder := httptest.NewRecorder() + responder.WriteResponse(recorder, runtime.JSONProducer()) + return recorder.Code +} + +// once the allow-list is configured every request must carry a verifiable bearer token - a +// missing one (the traefik lambda fork drops duplicated headers) is denied, never trusted +func TestHandlersDenyUnverifiedCallers(t *testing.T) { + verifier := &fakeVerifier{enabled: true, callers: map[string]*claAuth.TrustedCaller{ + "Bearer trusted": {ClientID: "ss-client", Subject: "ss-client@clients", Trusted: true}, + }} + api, service := configuredAPI(t, verifier) + authUser := &auth.User{UserName: "someone"} + + // a verifier that reports neither a caller nor an error must deny rather than panic + verifier.noop = "Bearer nothing" + + for _, authorization := range []string{"", "Bearer forged", "Bearer nothing"} { + req := request(t, authorization) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClasHandler.Handle(myClasOps.GetMyClasParams{HTTPRequest: req}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(myClasOps.GetMyClaPdfParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle(myClasOps.GetMyIdentitiesParams{HTTPRequest: req}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + } + assert.Empty(t, service.callers, "an unverified caller must never reach the service") + assert.Len(t, verifier.seen, 15, "every request must be verified") +} + +func TestClaManagerHandlers(t *testing.T) { + api, service := configuredAPI(t, nil) + authUser := &auth.User{UserName: "someone"} + req := request(t, "") + requestType := "removal" + body := models.MyClaManagerRequest{RequestType: &requestType, Recipients: []string{"manager-one"}} + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + require.Len(t, service.callers, 2) + assert.Equal(t, &Caller{Username: "someone"}, service.callers[0]) + + // an unauthenticated caller with no username is denied + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, &auth.User{}))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, &auth.User{}))) + + service.nilManagers = true + assert.Equal(t, http.StatusNotFound, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusNotFound, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + + service.nilManagers = false + service.invalidRecipients = true + assert.Equal(t, http.StatusBadRequest, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) + + service.invalidRecipients = false + service.err = errors.New("boom") + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasGetMyClaManagersHandler.Handle(myClasOps.GetMyClaManagersParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasCreateMyClaManagerRequestHandler.Handle(myClasOps.CreateMyClaManagerRequestParams{HTTPRequest: req, SignatureID: "sig-1", Body: body}, authUser))) +} + +func TestHandlersTrustAllowListedCallers(t *testing.T) { + verifier := &fakeVerifier{enabled: true, callers: map[string]*claAuth.TrustedCaller{ + "Bearer trusted": {ClientID: "ss-client", Subject: "ss-client@clients", Trusted: true}, + "Bearer untrusted": {ClientID: "other-client", Subject: "someone@clients"}, + }} + api, service := configuredAPI(t, verifier) + + // a trusted caller needs no username of its own - its identity list is authoritative + githubID := int64(999) + params := myClasOps.GetMyClasParams{HTTPRequest: request(t, "Bearer trusted"), GithubID: []int64{githubID}} + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{}))) + require.Len(t, service.callers, 1) + assert.Equal(t, &Caller{Trusted: true}, service.callers[0]) + + pdf := myClasOps.GetMyClaPdfParams{HTTPRequest: request(t, "Bearer trusted"), SignatureID: "sig-1", GithubID: []int64{githubID}} + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(pdf, &auth.User{}))) + require.Len(t, service.callers, 2) + assert.Equal(t, &Caller{Trusted: true}, service.callers[1]) + + // a verified token from a client that is not on the allow-list keeps the per-identity checks + params.HTTPRequest = request(t, "Bearer untrusted") + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{UserName: "someone"}))) + require.Len(t, service.callers, 3) + assert.Equal(t, &Caller{Username: "someone"}, service.callers[2]) + + // ... and it is still subject to the username requirement + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{}))) + assert.Len(t, service.callers, 3) + + // an admin whose token is verified but untrusted keeps the admin bypass + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{ACL: auth.ACL{Admin: true}}))) + require.Len(t, service.callers, 4) + assert.Equal(t, &Caller{Admin: true}, service.callers[3]) + + // an allow-listed admin is both, and the identity list stays authoritative + trustedAdmin := myClasOps.GetMyClasParams{HTTPRequest: request(t, "Bearer trusted"), GithubID: []int64{githubID}} + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(trustedAdmin, &auth.User{UserName: "admin", ACL: auth.ACL{Admin: true}}))) + require.Len(t, service.callers, 5) + assert.Equal(t, &Caller{Username: "admin", Admin: true, Trusted: true}, service.callers[4]) + + // a trusted caller with neither a username nor an identity has nothing to look up + empty := myClasOps.GetMyClasParams{HTTPRequest: request(t, "Bearer trusted")} + assert.Equal(t, http.StatusBadRequest, statusOf(t, api.MyClasGetMyClasHandler.Handle(empty, &auth.User{}))) + emptyPdf := myClasOps.GetMyClaPdfParams{HTTPRequest: request(t, "Bearer trusted"), SignatureID: "sig-1"} + assert.Equal(t, http.StatusBadRequest, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(emptyPdf, &auth.User{}))) + assert.Len(t, service.callers, 5, "a request with nothing to look up must not reach the service") + + // GetMyIdentities always reports the authenticated principal's own identities + identities := myClasOps.GetMyIdentitiesParams{HTTPRequest: request(t, "Bearer trusted")} + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle(identities, &auth.User{}))) + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle(identities, &auth.User{UserName: "someone"}))) + + // every request above is verified with the raw Authorization header, in order + assert.Equal(t, []string{ + "Bearer trusted", "Bearer trusted", + "Bearer untrusted", "Bearer untrusted", "Bearer untrusted", + "Bearer trusted", "Bearer trusted", "Bearer trusted", "Bearer trusted", "Bearer trusted", + }, verifier.seen) +} + +func TestHandlersMapServiceFailures(t *testing.T) { + api, service := configuredAPI(t, nil) + service.err = errors.New("boom") + authUser := &auth.User{UserName: "someone"} + req := request(t, "") + + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasGetMyClasHandler.Handle(myClasOps.GetMyClasParams{HTTPRequest: req}, authUser))) + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(myClasOps.GetMyClaPdfParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) + assert.Equal(t, http.StatusInternalServerError, statusOf(t, api.MyClasGetMyIdentitiesHandler.Handle(myClasOps.GetMyIdentitiesParams{HTTPRequest: req}, authUser))) + + service.err = nil + service.nilPdf = true + assert.Equal(t, http.StatusNotFound, statusOf(t, api.MyClasGetMyClaPdfHandler.Handle(myClasOps.GetMyClaPdfParams{HTTPRequest: req, SignatureID: "sig-1"}, authUser))) +} + +func TestPrincipal(t *testing.T) { + username, admin := principal(nil) + assert.Empty(t, username) + assert.False(t, admin) + + username, admin = principal(&auth.User{UserName: "someone"}) + assert.Equal(t, "someone", username) + assert.False(t, admin) + + username, admin = principal(&auth.User{UserName: "admin", ACL: auth.ACL{Admin: true}}) + assert.Equal(t, "admin", username) + assert.True(t, admin) +} + +// while no allow-list is configured nothing is trusted and no bearer token is required, so the +// endpoints keep behaving exactly as they did before +func TestHandlersWithoutAnAllowList(t *testing.T) { + verifier := &fakeVerifier{} + api, service := configuredAPI(t, verifier) + params := myClasOps.GetMyClasParams{HTTPRequest: request(t, "")} + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{UserName: "someone"}))) + assert.Equal(t, http.StatusUnauthorized, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{}))) + assert.Empty(t, verifier.seen, "a disabled verifier must not be consulted") + + admin := myClasOps.GetMyClasParams{HTTPRequest: request(t, ""), LfUsername: &[]string{"victim"}[0]} + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(admin, &auth.User{ACL: auth.ACL{Admin: true}}))) + require.Len(t, service.callers, 2) + assert.Equal(t, &Caller{Username: "someone"}, service.callers[0]) + assert.Equal(t, &Caller{Admin: true}, service.callers[1]) +} + +func TestHandlersWithoutAVerifier(t *testing.T) { + api, _ := configuredAPI(t, nil) + params := myClasOps.GetMyClasParams{HTTPRequest: request(t, "")} + + assert.Equal(t, http.StatusOK, statusOf(t, api.MyClasGetMyClasHandler.Handle(params, &auth.User{UserName: "someone"}))) +} diff --git a/cla-backend-go/v2/my_clas/prefetch.go b/cla-backend-go/v2/my_clas/prefetch.go new file mode 100644 index 000000000..5b60f8f7d --- /dev/null +++ b/cla-backend-go/v2/my_clas/prefetch.go @@ -0,0 +1,311 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/signatures" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +// fetchConcurrency caps the external calls one listing keeps in flight per stage +const fetchConcurrency = 8 + +// claRef is one returned row before resolution: the user record it belongs to and its signature +type claRef struct { + user *v1Models.User + sig *signatures.ItemSignature +} + +// eclaRef is one distinct (CLA Group, employer) pair and the user records holding an ECLA under it +type eclaRef struct { + users []*v1Models.User + claGroupID string + companyID string +} + +// claData is everything the rows need from DynamoDB, the projects and organizations services, the +// sanctions screen and GitHub - resolved once per distinct key, concurrently +type claData struct { + claGroupNames map[string]string + projectInfos map[string]projectInfo + companies map[string]*v1Models.Company + sanctions map[string]sanctionState + cclas map[string]*v1Models.Signature + approvals map[string]eclaCoverage +} + +func cclaKey(claGroupID, companyID string) string { + return claGroupID + "|" + companyID +} + +func approvalKey(claGroupID, companyID, userID string) string { + return cclaKey(claGroupID, companyID) + "|" + userID +} + +// userSignatures loads every user record's signatures concurrently, keeping userModels order so the +// listing stays deterministic +func (s *service) userSignatures(ctx context.Context, userModels []*v1Models.User) ([][]*signatures.ItemSignature, error) { + perUser := make([][]*signatures.ItemSignature, len(userModels)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, userModel := range userModels { + group.Go(func() error { + userSigs, err := s.repo.GetUserCLASignatures(groupCtx, userModel.UserID) + if err != nil { + return err + } + perUser[i] = userSigs + return nil + }) + } + return perUser, group.Wait() +} + +// claRefs flattens the loaded signatures into the rows to return, dropping unsigned and duplicates +func claRefs(userModels []*v1Models.User, perUser [][]*signatures.ItemSignature) []claRef { + seen := make(map[string]bool) + refs := make([]claRef, 0) + for i, userModel := range userModels { + for _, sig := range perUser[i] { + if !sig.SignatureSigned || seen[sig.SignatureID] { + continue + } + seen[sig.SignatureID] = true + refs = append(refs, claRef{user: userModel, sig: sig}) + } + } + return refs +} + +// prefetch resolves every external dependency of the rows up front, running the three independent +// chains - CLA Group and project details, employer and its sanctions screen, corporate signature +// and its approval-list evaluation - concurrently. Only the CLA Group chain can fail the listing: +// an employer, CCLA or approval-list failure degrades its own rows. +func (s *service) prefetch(ctx context.Context, refs []claRef) (*claData, error) { + data := &claData{ + claGroupNames: make(map[string]string), + projectInfos: make(map[string]projectInfo), + companies: make(map[string]*v1Models.Company), + sanctions: make(map[string]sanctionState), + cclas: make(map[string]*v1Models.Signature), + approvals: make(map[string]eclaCoverage), + } + claGroupIDs, companyIDs, companyActors, eclaRefs := distinctRefs(refs) + + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { return s.loadProjects(groupCtx, data, claGroupIDs) }) + group.Go(func() error { return s.loadEmployers(groupCtx, data, companyIDs, companyActors) }) + group.Go(func() error { return s.loadCoverage(groupCtx, data, eclaRefs) }) + if err := group.Wait(); err != nil { + return nil, err + } + return data, nil +} + +// distinctRefs collects the keys to resolve: one per CLA Group, one per employer (with the first +// referencing user as the audit actor) and one per (CLA Group, employer) pair, carrying the user +// records that pair must be evaluated for +func distinctRefs(refs []claRef) ([]string, []string, map[string]*v1Models.User, []eclaRef) { + var claGroupIDs, companyIDs []string + var eclaRefs []eclaRef + companyActors := make(map[string]*v1Models.User) + seenClaGroup := make(map[string]bool) + seenCompany := make(map[string]bool) + seenEcla := make(map[string]int) + seenEclaUser := make(map[string]bool) + for _, ref := range refs { + claGroupID := ref.sig.SignatureProjectID + if claGroupID != "" && !seenClaGroup[claGroupID] { + seenClaGroup[claGroupID] = true + claGroupIDs = append(claGroupIDs, claGroupID) + } + companyID := ref.sig.SignatureUserCompanyID + if companyID == "" { + continue + } + if !seenCompany[companyID] { + seenCompany[companyID] = true + companyIDs = append(companyIDs, companyID) + companyActors[companyID] = ref.user + } + key := cclaKey(claGroupID, companyID) + index, ok := seenEcla[key] + if !ok { + index = len(eclaRefs) + seenEcla[key] = index + eclaRefs = append(eclaRefs, eclaRef{claGroupID: claGroupID, companyID: companyID}) + } + if userKey := approvalKey(claGroupID, companyID, ref.user.UserID); !seenEclaUser[userKey] { + seenEclaUser[userKey] = true + eclaRefs[index].users = append(eclaRefs[index].users, ref.user) + } + } + return claGroupIDs, companyIDs, companyActors, eclaRefs +} + +// loadProjects resolves each distinct CLA Group's name and its project name and logo. These +// failures affect every row alike, so they fail the listing rather than degrade it. +func (s *service) loadProjects(ctx context.Context, data *claData, claGroupIDs []string) error { + names := make([]string, len(claGroupIDs)) + infos := make([]projectInfo, len(claGroupIDs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, claGroupID := range claGroupIDs { + group.Go(func() error { + name, err := s.claGroupName(groupCtx, claGroupID) + if err != nil { + return err + } + names[i] = name + return nil + }) + group.Go(func() error { + info, err := s.projectInfo(groupCtx, claGroupID) + if err != nil { + return err + } + infos[i] = info + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for i, claGroupID := range claGroupIDs { + data.claGroupNames[claGroupID] = names[i] + data.projectInfos[claGroupID] = infos[i] + } + return nil +} + +// loadEmployers looks up each distinct employer and screens it for sanctions in the same chain, +// so a slow screen never delays another employer. A failed lookup degrades that employer's rows. +func (s *service) loadEmployers(ctx context.Context, data *claData, companyIDs []string, companyActors map[string]*v1Models.User) error { + f := logrus.Fields{ + "functionName": "v2.my_clas.prefetch.loadEmployers", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + companyModels := make([]*v1Models.Company, len(companyIDs)) + states := make([]sanctionState, len(companyIDs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, companyID := range companyIDs { + group.Go(func() error { + companyModel, err := s.company(groupCtx, companyID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup employer %s - degrading its rows", companyID) + states[i] = sanctionState{check: models.MyClaFlaggedCheckUnavailable} + return nil + } + companyModels[i] = companyModel + states[i] = s.companySanctions(groupCtx, companyModel, companyActors[companyID]) + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + for i, companyID := range companyIDs { + data.companies[companyID] = companyModels[i] + data.sanctions[companyID] = states[i] + } + return nil +} + +// loadCoverage resolves each distinct (CLA Group, employer) pair's corporate signature and then +// evaluates its approval lists for every user record holding an ECLA under it. Both stages run +// concurrently within themselves; a failure degrades the affected rows only. +func (s *service) loadCoverage(ctx context.Context, data *claData, eclaRefs []eclaRef) error { + f := logrus.Fields{ + "functionName": "v2.my_clas.prefetch.loadCoverage", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + cclas := make([]*v1Models.Signature, len(eclaRefs)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, ref := range eclaRefs { + group.Go(func() error { + approved, signed := true, true + ccla, err := s.signaturesService.GetCorporateSignature(groupCtx, ref.claGroupID, ref.companyID, &approved, &signed) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup the corporate signature of %s for CLA Group %s - degrading its rows", ref.companyID, ref.claGroupID) + return nil + } + cclas[i] = ccla + return nil + }) + } + if err := group.Wait(); err != nil { + return err + } + + type approvalRef struct { + ccla *v1Models.Signature + user *v1Models.User + key string + } + var approvalRefs []approvalRef + for i, ref := range eclaRefs { + data.cclas[cclaKey(ref.claGroupID, ref.companyID)] = cclas[i] + if cclas[i] == nil { + continue + } + for _, userModel := range ref.users { + approvalRefs = append(approvalRefs, approvalRef{ + key: approvalKey(ref.claGroupID, ref.companyID, userModel.UserID), + user: userModel, + ccla: cclas[i], + }) + } + } + + coverages := make([]eclaCoverage, len(approvalRefs)) + approvalGroup, approvalCtx := errgroup.WithContext(ctx) + approvalGroup.SetLimit(fetchConcurrency) + for i, ref := range approvalRefs { + approvalGroup.Go(func() error { + coverages[i] = s.evaluateApproval(approvalCtx, ref.user, ref.ccla) + return nil + }) + } + if err := approvalGroup.Wait(); err != nil { + return err + } + for i, ref := range approvalRefs { + data.approvals[ref.key] = coverages[i] + } + return nil +} + +// coverage is one ECLA row's approval-list outcome. An unreadable or sanctioned employer, a missing +// or unreadable corporate signature and a failed approval-list check are all unevaluable, so a +// false covered never means "no longer approved". +func (d *claData) coverage(sig *signatures.ItemSignature, userModel *v1Models.User, flagged bool) eclaCoverage { + if flagged || d.companies[sig.SignatureUserCompanyID] == nil { + return eclaCoverage{unevaluable: true} + } + if d.cclas[cclaKey(sig.SignatureProjectID, sig.SignatureUserCompanyID)] == nil { + return eclaCoverage{unevaluable: true} + } + if resolved, ok := d.approvals[approvalKey(sig.SignatureProjectID, sig.SignatureUserCompanyID, userModel.UserID)]; ok { + return resolved + } + return eclaCoverage{unevaluable: true} +} + +// claManager reports the caller as a CLA manager only on rows whose coverage was evaluated: a +// revoked or unreadable-employer row carries no action +func (d *claData) claManager(sig *signatures.ItemSignature, lfUsername string, flagged bool) bool { + if flagged || d.companies[sig.SignatureUserCompanyID] == nil { + return false + } + return isClaManager(d.cclas[cclaKey(sig.SignatureProjectID, sig.SignatureUserCompanyID)], lfUsername) +} diff --git a/cla-backend-go/v2/my_clas/sanctions.go b/cla-backend-go/v2/my_clas/sanctions.go new file mode 100644 index 000000000..c950be779 --- /dev/null +++ b/cla-backend-go/v2/my_clas/sanctions.go @@ -0,0 +1,150 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "errors" + "net/url" + "strings" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + organizationService "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" + "github.com/sirupsen/logrus" +) + +const sanctionOriginSSS = "sss" + +// SanctionsScreener answers the sanctions question for an employer. It never errors: a listing +// must not fail because screening is down, so an unusable screen degrades to the persisted company +// flag and reports check=unavailable. +type SanctionsScreener interface { + Mode() string + ScreenCompany(ctx context.Context, company *v1Models.Company) (flagged bool, check string) +} + +type sssStatusClient interface { + GetOrganizationStatus(ctx context.Context, req sss.OrganizationStatusRequest) (*sss.ScreeningResult, error) +} + +type sssScreener struct { + client sssStatusClient + enabled bool + required bool + getOrganization func(ctx context.Context, orgID string) (*orgModels.Organization, error) +} + +// NewSanctionsScreener returns a read-only screener: it answers the question and never writes. +// Persisting a first live detection is the caller's job (see service.persistLiveSanction) +func NewSanctionsScreener(client *sss.Client, enabled, required bool) SanctionsScreener { + screener := &sssScreener{ + enabled: enabled, + required: required, + getOrganization: lookupOrganization, + } + if client != nil { + screener.client = client + } + return screener +} + +func lookupOrganization(ctx context.Context, orgID string) (*orgModels.Organization, error) { + client := organizationService.GetClient() + if client == nil { + return nil, errors.New("organization service client is not configured") + } + return client.GetOrganization(ctx, orgID) +} + +func (s *sssScreener) Mode() string { + if !s.enabled || s.client == nil { + return models.MyClaListSssModeDisabled + } + if s.required { + return models.MyClaListSssModeRequired + } + return models.MyClaListSssModeOptional +} + +func (s *sssScreener) ScreenCompany(ctx context.Context, company *v1Models.Company) (bool, string) { + if company == nil { + return false, "" + } + + f := logrus.Fields{ + "functionName": "v2.my_clas.sanctions.ScreenCompany", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": company.CompanyID, + "mode": s.Mode(), + } + + // An administrator-set block is authoritative and needs no live screen (as in v2/sign + // checkCompanyCompliance) + if company.IsSanctioned && company.SanctionOrigin != sanctionOriginSSS { + return true, models.MyClaFlaggedCheckStored + } + if !s.enabled || s.client == nil { + return company.IsSanctioned, models.MyClaFlaggedCheckStored + } + + unavailable := func(reason string) (bool, string) { + log.WithFields(f).Warnf("live sanctions screening unavailable, honoring the persisted flag: %s", reason) + return company.IsSanctioned, models.MyClaFlaggedCheckUnavailable + } + + externalID := strings.TrimSpace(company.CompanyExternalID) + if externalID == "" { + return unavailable("company has no external ID for domain resolution") + } + org, err := s.getOrganization(ctx, externalID) + if err != nil { + return unavailable("organization lookup failed: " + err.Error()) + } + if org == nil { + return unavailable("organization record is nil for " + externalID) + } + domain := resolveOrgDomain(org) + if domain == "" { + return unavailable("unable to resolve a domain for organization " + externalID) + } + + req := sss.OrganizationStatusRequest{Domain: domain, OrgName: company.CompanyName} + if strings.HasPrefix(externalID, "001") { + req.SFDCID = externalID + } + result, err := s.client.GetOrganizationStatus(ctx, req) + if err != nil { + return unavailable("SSS call failed: " + err.Error()) + } + if result == nil || (result.Status != sss.StatusClean && result.Status != sss.StatusFlagged) { + return unavailable("unexpected SSS status") + } + return result.Status == sss.StatusFlagged, models.MyClaFlaggedCheckLive +} + +// resolveOrgDomain prefers the Domains field and falls back to the host of Link +func resolveOrgDomain(org *orgModels.Organization) string { + for _, domain := range strings.Split(org.Domains, ",") { + if domain = strings.TrimPrefix(strings.TrimSpace(domain), "www."); domain != "" { + return domain + } + } + link := strings.TrimSpace(org.Link) + if link == "" { + return "" + } + if !strings.Contains(link, "://") { + link = "https://" + link + } + parsed, err := url.Parse(link) + if err != nil { + return "" + } + return strings.TrimPrefix(parsed.Hostname(), "www.") +} diff --git a/cla-backend-go/v2/my_clas/sanctions_test.go b/cla-backend-go/v2/my_clas/sanctions_test.go new file mode 100644 index 000000000..cbf96f439 --- /dev/null +++ b/cla-backend-go/v2/my_clas/sanctions_test.go @@ -0,0 +1,173 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package my_clas + +import ( + "context" + "fmt" + "testing" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/sss" + orgModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/organization-service/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeSSSClient struct { + result *sss.ScreeningResult + err error + request sss.OrganizationStatusRequest + calls int +} + +func (f *fakeSSSClient) GetOrganizationStatus(_ context.Context, req sss.OrganizationStatusRequest) (*sss.ScreeningResult, error) { + f.calls++ + f.request = req + return f.result, f.err +} + +func testScreener(client sssStatusClient, required bool, org *orgModels.Organization, orgErr error) *sssScreener { + return &sssScreener{ + client: client, + enabled: true, + required: required, + getOrganization: func(_ context.Context, _ string) (*orgModels.Organization, error) { + return org, orgErr + }, + } +} + +func company(sanctioned bool, origin string) *v1Models.Company { + return &v1Models.Company{ + CompanyID: "company-1", + CompanyName: "Acme Corp", + CompanyExternalID: "0014100000Te0lqAAB", + IsSanctioned: sanctioned, + SanctionOrigin: origin, + } +} + +func TestScreenerMode(t *testing.T) { + assert.Equal(t, models.MyClaListSssModeDisabled, NewSanctionsScreener(nil, true, false).Mode(), "an unconfigured client cannot screen") + assert.Equal(t, models.MyClaListSssModeDisabled, (&sssScreener{client: &fakeSSSClient{}}).Mode(), "the kill switch wins") + assert.Equal(t, models.MyClaListSssModeOptional, (&sssScreener{client: &fakeSSSClient{}, enabled: true}).Mode()) + assert.Equal(t, models.MyClaListSssModeRequired, (&sssScreener{client: &fakeSSSClient{}, enabled: true, required: true}).Mode()) +} + +func TestScreenCompanyLiveResult(t *testing.T) { + org := &orgModels.Organization{Domains: " , www.acme.org "} + + t.Run("flagged", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusFlagged}} + flagged, check := testScreener(client, false, org, nil).ScreenCompany(context.Background(), company(false, "")) + assert.True(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckLive, check) + assert.Equal(t, "acme.org", client.request.Domain) + assert.Equal(t, "Acme Corp", client.request.OrgName) + assert.Equal(t, "0014100000Te0lqAAB", client.request.SFDCID, "only 001-prefixed external IDs are Salesforce accounts") + }) + + t.Run("clean clears a stored sss sanction", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}} + flagged, check := testScreener(client, true, org, nil).ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.False(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckLive, check) + }) +} + +func TestScreenCompanyStoredWithoutLiveCall(t *testing.T) { + t.Run("administrator block", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}} + flagged, check := testScreener(client, true, nil, nil).ScreenCompany(context.Background(), company(true, "manual")) + assert.True(t, flagged, "a non-SSS block is authoritative") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + assert.Zero(t, client.calls) + }) + + t.Run("screening disabled", func(t *testing.T) { + client := &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusFlagged}} + screener := testScreener(client, false, nil, nil) + screener.enabled = false + flagged, check := screener.ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.True(t, flagged, "the persisted flag stands in when screening is off") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + assert.Zero(t, client.calls) + }) + + t.Run("client not configured", func(t *testing.T) { + flagged, check := (&sssScreener{enabled: true}).ScreenCompany(context.Background(), company(false, "")) + assert.False(t, flagged) + assert.Equal(t, models.MyClaFlaggedCheckStored, check) + }) +} + +// The listing must survive every screening failure: the answer degrades to the persisted flag +// and says so, in both required and optional mode. +func TestScreenCompanyUnavailable(t *testing.T) { + org := &orgModels.Organization{Domains: "acme.org"} + + cases := []struct { + name string + client sssStatusClient + org *orgModels.Organization + orgErr error + noID bool + }{ + {name: "no external id", client: &fakeSSSClient{result: &sss.ScreeningResult{Status: sss.StatusClean}}, org: org, noID: true}, + {name: "organization lookup failed", client: &fakeSSSClient{}, orgErr: fmt.Errorf("org-service down")}, + {name: "organization missing", client: &fakeSSSClient{}}, + {name: "no resolvable domain", client: &fakeSSSClient{}, org: &orgModels.Organization{}}, + {name: "sss call failed", client: &fakeSSSClient{err: fmt.Errorf("502 from sss")}, org: org}, + {name: "unexpected status", client: &fakeSSSClient{result: &sss.ScreeningResult{Status: "pending"}}, org: org}, + {name: "nil result", client: &fakeSSSClient{}, org: org}, + } + + for _, tc := range cases { + for _, required := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/required=%v", tc.name, required), func(t *testing.T) { + for _, stored := range []bool{false, true} { + companyModel := company(stored, sanctionOriginSSS) + if tc.noID { + companyModel.CompanyExternalID = "" + } + flagged, check := testScreener(tc.client, required, tc.org, tc.orgErr).ScreenCompany(context.Background(), companyModel) + assert.Equal(t, stored, flagged, "an unusable screen falls back to the persisted flag") + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, check) + } + }) + } + } +} + +func TestScreenCompanyNilCompany(t *testing.T) { + flagged, check := testScreener(&fakeSSSClient{}, true, nil, nil).ScreenCompany(context.Background(), nil) + assert.False(t, flagged) + assert.Empty(t, check, "an unresolved employer has no sanctions answer at all") +} + +func TestResolveOrgDomain(t *testing.T) { + cases := []struct { + org *orgModels.Organization + want string + }{ + {org: &orgModels.Organization{Domains: "acme.org,acme.com"}, want: "acme.org"}, + {org: &orgModels.Organization{Domains: " , www.acme.org"}, want: "acme.org"}, + {org: &orgModels.Organization{Link: "https://www.acme.org/about"}, want: "acme.org"}, + {org: &orgModels.Organization{Link: "acme.org"}, want: "acme.org"}, + {org: &orgModels.Organization{}, want: ""}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, resolveOrgDomain(tc.org)) + } +} + +func TestNewSanctionsScreenerNilClient(t *testing.T) { + screener := NewSanctionsScreener(nil, true, false) + require.NotNil(t, screener) + flagged, check := screener.ScreenCompany(context.Background(), company(true, sanctionOriginSSS)) + assert.True(t, flagged, "a typed nil client must not be treated as usable") + assert.Equal(t, models.MyClaFlaggedCheckStored, check) +} diff --git a/cla-backend-go/v2/my_clas/service.go b/cla-backend-go/v2/my_clas/service.go index 2e0bdd6b6..447a8fa08 100644 --- a/cla-backend-go/v2/my_clas/service.go +++ b/cla-backend-go/v2/my_clas/service.go @@ -11,6 +11,9 @@ import ( "strconv" "strings" + "github.com/gofrs/uuid" + "github.com/linuxfoundation/easycla/cla-backend-go/emails" + "github.com/linuxfoundation/easycla/cla-backend-go/events" v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" log "github.com/linuxfoundation/easycla/cla-backend-go/logging" @@ -20,16 +23,20 @@ import ( v2ProjectServiceModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/project-service/models" platformModels "github.com/linuxfoundation/easycla/cla-backend-go/v2/user-service/models" "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" ) const ( + identitySummaryLimit = 512 identitySourceGithub = "github" identitySourceGitlab = "gitlab" identitySourceGerrit = "gerrit" identityDataSourcePlatform = "platform" ) -// Identity holds the caller-provided identity keys used to resolve EasyCLA user records +// Identity holds the caller-provided identity keys used to resolve EasyCLA user records. +// Caller-supplied keys are transitional (P3/P9 of the trust-SS decision): at M6 EasyCLA should +// call lfx.auth-service.user_identity.list over NATS itself and drop them with the azp allow-list. type Identity struct { LfUsername string Emails []string @@ -48,6 +55,49 @@ func (i *Identity) IsEmpty() bool { !hasValue(i.GitlabUsernames) && !hasValue(i.GerritUsernames) } +// Summary renders the identity keys, length-bounded, for the caller audit log +func (i *Identity) Summary() string { + var parts []string + addStrings := func(param string, values []string) { + if trimmed := trimAll(values); len(trimmed) > 0 { + parts = append(parts, param+":"+strings.Join(trimmed, ",")) + } + } + addIDs := func(param string, values []int64) { + ids := dedupeIDs(values) + if len(ids) == 0 { + return + } + formatted := make([]string, 0, len(ids)) + for _, id := range ids { + formatted = append(formatted, strconv.FormatInt(id, 10)) + } + parts = append(parts, param+":"+strings.Join(formatted, ",")) + } + + addStrings("lfUsername", []string{i.LfUsername}) + addStrings("email", i.Emails) + addStrings("secondaryEmail", i.SecondaryEmails) + addIDs("githubId", i.GithubIDs) + addStrings("githubUsername", i.GithubUsernames) + addIDs("gitlabId", i.GitlabIDs) + addStrings("gitlabUsername", i.GitlabUsernames) + addStrings("gerritUsername", i.GerritUsernames) + + summary := strings.Join(parts, " ") + if len(summary) > identitySummaryLimit { + summary = strings.ToValidUTF8(summary[:identitySummaryLimit], "") + "..." + } + return summary +} + +// Caller is the authenticated principal a My CLAs lookup runs as +type Caller struct { + Username string + Admin bool + Trusted bool +} + func hasValue(values []string) bool { for _, value := range values { if strings.TrimSpace(value) != "" { @@ -57,42 +107,57 @@ func hasValue(values []string) bool { return false } -// PlatformUsersService is the subset of the platform user-service client used to verify -// that identities are connected to the authenticated user's LF account +// PlatformUsersService is the user-service subset used to verify that identities are connected +// to the authenticated user's LF account type PlatformUsersService interface { GetUserByUsernameContext(ctx context.Context, lfUsername string) (*platformModels.User, error) ListUserIdentities(ctx context.Context, userSFID string) ([]*platformModels.UserIdentity, error) } -// SignaturesService is the subset of the v1 signatures service used to evaluate ECLA validity +// SignaturesService is the v1 signatures subset used to evaluate ECLA validity type SignaturesService interface { GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*v1Models.Signature, error) - UserIsApproved(ctx context.Context, user *v1Models.User, cclaSignature *v1Models.Signature) (bool, error) + EvaluateUserApproval(ctx context.Context, user *v1Models.User, cclaSignature *v1Models.Signature) (approved bool, githubOrgLookupFailed bool, err error) } -// CompanyRepository is the subset of the company repository used to resolve employers +// CompanyRepository is the company repository subset used to resolve employers type CompanyRepository interface { GetCompany(ctx context.Context, companyID string) (*v1Models.Company, error) + UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error } -// ProjectsCLAGroupsRepository is the subset of the projects-cla-groups repository used to resolve -// CLA Group names and the Salesforce project(s) a CLA Group is mapped to +// ProjectsCLAGroupsRepository is the projects-cla-groups subset used to resolve CLA Group names +// and their Salesforce project mappings type ProjectsCLAGroupsRepository interface { GetCLAGroupNameByID(ctx context.Context, claGroupID string) (string, error) GetProjectsIdsForClaGroup(ctx context.Context, claGroupID string) ([]*projects_cla_groups.ProjectClaGroup, error) } -// ProjectService is the subset of the project-service client used to resolve a project's -// display name and logo from its Salesforce ID +// ProjectService is the project-service subset used to resolve a project's display name and logo type ProjectService interface { GetProject(projectSFID string) (*v2ProjectServiceModels.ProjectOutputDetailed, error) } +// EventsService is the v1 events subset used to audit contact-CLA-manager requests +type EventsService interface { + LogEventWithContext(ctx context.Context, args *events.LogEventArgs) +} + +// ErrInvalidRecipients is returned when recipients is not a non-empty subset of the resolved CLA +// managers - empty is valid only when none resolves +var ErrInvalidRecipients = errors.New("recipients must be a non-empty subset of the CLA managers returned by the cla-managers endpoint - empty only when no CLA manager resolves") + +// ErrMissingMessage is returned when a contact request carries no (non-blank) message +var ErrMissingMessage = errors.New("message is required for a contact request and must not be blank") + // Service interface defines the My CLAs service methods type Service interface { - GetMyClas(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*models.MyClaList, error) - GetMyClaPdfURL(ctx context.Context, currentUsername string, admin bool, requested *Identity, signatureID string) (*models.MyClaPdf, error) + GetMyClas(ctx context.Context, caller *Caller, requested *Identity) (*models.MyClaList, error) + GetMyClaPdfURL(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaPdf, error) GetMyIdentities(ctx context.Context, currentUsername string) (*models.MyIdentityList, error) + AuthorizeIdentity(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) + GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaManagerList, error) + CreateMyClaManagerRequest(ctx context.Context, caller *Caller, requested *Identity, signatureID string, input *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) } type service struct { @@ -102,12 +167,15 @@ type service struct { companyRepo CompanyRepository projectsClaGroupsRepo ProjectsCLAGroupsRepository projectService ProjectService + eventsService EventsService + sanctions SanctionsScreener presign func(filename string) (string, error) documentExists func(filename string) (bool, error) + sendEmail func(subject string, body string, recipients []string) error } // NewService creates a new instance of the My CLAs service -func NewService(repo Repository, platformUsersService PlatformUsersService, signaturesService SignaturesService, companyRepo CompanyRepository, projectsClaGroupsRepo ProjectsCLAGroupsRepository, projectService ProjectService) Service { +func NewService(repo Repository, platformUsersService PlatformUsersService, signaturesService SignaturesService, companyRepo CompanyRepository, projectsClaGroupsRepo ProjectsCLAGroupsRepository, projectService ProjectService, eventsService EventsService, sanctions SanctionsScreener) Service { return &service{ repo: repo, platformUsersService: platformUsersService, @@ -115,28 +183,32 @@ func NewService(repo Repository, platformUsersService PlatformUsersService, sign companyRepo: companyRepo, projectsClaGroupsRepo: projectsClaGroupsRepo, projectService: projectService, + eventsService: eventsService, + sanctions: sanctions, presign: utils.GetDownloadLink, documentExists: utils.DocumentExists, + sendEmail: utils.SendEmail, } } -// projectInfo holds the resolved Salesforce project display name and logo for a CLA Group +// projectInfo is the resolved Salesforce project display name and logo of a CLA Group type projectInfo struct { name string logo string } -// GetMyClas returns all signed ICLAs and ECLAs of the EasyCLA user records matching the -// given identity, with validity evaluated against the current CCLA approval lists -func (s *service) GetMyClas(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*models.MyClaList, error) { +// GetMyClas returns the signed ICLAs and ECLAs of every EasyCLA user record matching the identity, +// with validity evaluated against the current CCLA approval lists +func (s *service) GetMyClas(ctx context.Context, caller *Caller, requested *Identity) (*models.MyClaList, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.GetMyClas", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), - "currentUsername": currentUsername, - "admin": admin, + "currentUsername": callerUsername(caller), + "admin": caller != nil && caller.Admin, + "trustedCaller": caller != nil && caller.Trusted, } - identity, skipped, err := s.effectiveIdentity(ctx, currentUsername, admin, requested) + identity, skipped, err := s.effectiveIdentity(ctx, caller, requested) if err != nil { return nil, err } @@ -146,80 +218,73 @@ func (s *service) GetMyClas(ctx context.Context, currentUsername string, admin b return nil, err } + perUser, err := s.userSignatures(ctx, userModels) + if err != nil { + return nil, err + } + refs := claRefs(userModels, perUser) + data, err := s.prefetch(ctx, refs) + if err != nil { + return nil, err + } + result := &models.MyClaList{ LfUsername: identity.LfUsername, UserIds: make([]string, 0, len(userModels)), SkippedIdentities: skipped, - Clas: []models.MyCla{}, + Clas: make([]models.MyCla, 0, len(refs)), + SssMode: s.sanctionsMode(), } - - seen := make(map[string]bool) - claGroupNames := make(map[string]string) - projectInfos := make(map[string]projectInfo) - companies := make(map[string]*v1Models.Company) - cclas := make(map[string]*v1Models.Signature) - approvals := make(map[string]bool) - for _, userModel := range userModels { result.UserIds = append(result.UserIds, userModel.UserID) + } - userSignatures, sigErr := s.repo.GetUserCLASignatures(ctx, userModel.UserID) - if sigErr != nil { - return nil, sigErr + for _, ref := range refs { + sig := ref.sig + project := data.projectInfos[sig.SignatureProjectID] + row := models.MyCla{ + SignatureID: sig.SignatureID, + ClaGroupID: sig.SignatureProjectID, + ClaGroupName: data.claGroupNames[sig.SignatureProjectID], + ProjectName: project.name, + ProjectLogo: project.logo, + UserID: sig.SignatureReferenceID, + SignedOn: signedOn(sig), + Signed: sig.SignatureSigned, + Approved: sig.SignatureApproved, + DocumentMajorVersion: int64(sig.SignatureDocumentMajorVersion), + DocumentMinorVersion: int64(sig.SignatureDocumentMinorVersion), + } + row.SignedVia, row.SignedAs = signedIdentity(sig) + if sig.DateInvalidated != "" { + row.InvalidatedAt = utils.FormatTimeString(sig.DateInvalidated) } - for _, sig := range userSignatures { - if !sig.SignatureSigned || seen[sig.SignatureID] { - continue - } - seen[sig.SignatureID] = true - - claGroupName, nameErr := s.claGroupName(ctx, claGroupNames, sig.SignatureProjectID) - if nameErr != nil { - return nil, nameErr - } - project, projectErr := s.projectInfo(ctx, projectInfos, sig.SignatureProjectID) - if projectErr != nil { - return nil, projectErr - } - row := models.MyCla{ - SignatureID: sig.SignatureID, - ClaGroupID: sig.SignatureProjectID, - ClaGroupName: claGroupName, - ProjectName: project.name, - ProjectLogo: project.logo, - UserID: sig.SignatureReferenceID, - SignedOn: signedOn(sig), - Signed: sig.SignatureSigned, - Approved: sig.SignatureApproved, - DocumentMajorVersion: int64(sig.SignatureDocumentMajorVersion), - DocumentMinorVersion: int64(sig.SignatureDocumentMinorVersion), + if sig.SignatureUserCompanyID == "" { + row.ClaType = utils.ClaTypeICLA + row.Valid = sig.SignatureApproved + row.PdfAvailable = true + assignMyClaStatus(&row, eclaCoverage{}) + } else { + row.ClaType = utils.ClaTypeECLA + row.CompanyID = sig.SignatureUserCompanyID + if companyModel := data.companies[sig.SignatureUserCompanyID]; companyModel != nil { + row.CompanyName = companyModel.CompanyName + row.SigningEntityName = companyModel.SigningEntityName } - - if sig.SignatureUserCompanyID == "" { - row.ClaType = utils.ClaTypeICLA - row.Valid = sig.SignatureApproved - row.PdfAvailable = true - } else { - row.ClaType = utils.ClaTypeECLA - row.CompanyID = sig.SignatureUserCompanyID - companyModel, companyErr := s.company(ctx, companies, sig.SignatureUserCompanyID) - if companyErr != nil { - return nil, companyErr - } - if companyModel != nil { - row.CompanyName = companyModel.CompanyName - row.SigningEntityName = companyModel.SigningEntityName - } - covered, coveredErr := s.eclaCoveredByCurrentApprovalList(ctx, cclas, approvals, userModel, companyModel, sig) - if coveredErr != nil { - return nil, coveredErr - } - row.Valid = sig.SignatureApproved && covered + sanction := data.sanctions[sig.SignatureUserCompanyID] + row.Flagged = sanction.flagged + row.FlaggedCheck = sanction.check + if sanction.flagged && sanction.date != "" { + row.FlaggedAt = utils.FormatTimeString(sanction.date) } - - result.Clas = append(result.Clas, row) + coverage := data.coverage(sig, ref.user, sanction.flagged) + row.Valid = sig.SignatureApproved && coverage.covered + row.ClaManager = data.claManager(sig, identity.LfUsername, sanction.flagged) + assignMyClaStatus(&row, coverage) } + + result.Clas = append(result.Clas, row) } sort.SliceStable(result.Clas, func(i, j int) bool { @@ -231,19 +296,19 @@ func (s *service) GetMyClas(ctx context.Context, currentUsername string, admin b return result, nil } -// GetMyClaPdfURL returns a time-limited download URL for the signed ICLA PDF when the -// signature belongs to one of the EasyCLA user records matching the given identity - -// a nil result means unknown, not-owned, unsigned or ECLA signature ID -func (s *service) GetMyClaPdfURL(ctx context.Context, currentUsername string, admin bool, requested *Identity, signatureID string) (*models.MyClaPdf, error) { +// GetMyClaPdfURL returns a time-limited download URL for a signed ICLA PDF owned by the identity - +// nil means unknown, not-owned, unsigned or ECLA signature ID +func (s *service) GetMyClaPdfURL(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaPdf, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.GetMyClaPdfURL", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), - "currentUsername": currentUsername, - "admin": admin, + "currentUsername": callerUsername(caller), + "admin": caller != nil && caller.Admin, + "trustedCaller": caller != nil && caller.Trusted, "signatureID": signatureID, } - identity, _, err := s.effectiveIdentity(ctx, currentUsername, admin, requested) + identity, _, err := s.effectiveIdentity(ctx, caller, requested) if err != nil { return nil, err } @@ -295,9 +360,327 @@ func (s *service) GetMyClaPdfURL(ctx context.Context, currentUsername string, ad return nil, nil } +// GetMyClaManagers returns the CLA managers of the CCLA covering the given ECLA - nil means +// unknown, not-owned, unsigned or ICLA signature ID +func (s *service) GetMyClaManagers(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*models.MyClaManagerList, error) { + identity, sig, _, err := s.findOwnedEcla(ctx, caller, requested, signatureID) + if err != nil || sig == nil { + return nil, err + } + + details, err := s.eclaManagerDetails(ctx, identity, sig) + if err != nil { + return nil, err + } + + return &models.MyClaManagerList{ + SignatureID: sig.SignatureID, + ClaGroupID: sig.SignatureProjectID, + ClaGroupName: details.claGroupName, + ProjectName: details.projectName, + CompanyID: sig.SignatureUserCompanyID, + CompanyName: details.companyName, + ClaManager: details.callerIsManager, + Managers: details.managers, + ResultCount: int64(len(details.managers)), + }, nil +} + +// CreateMyClaManagerRequest emails a removal/approval/contact request against the caller's own +// ECLA to the selected CLA managers and logs the audit event that is its receipt - nil means +// unknown, not-owned, unsigned or ICLA signature ID, ErrInvalidRecipients an invalid recipients +// list, ErrMissingMessage a contact request without a message +func (s *service) CreateMyClaManagerRequest(ctx context.Context, caller *Caller, requested *Identity, signatureID string, input *models.MyClaManagerRequest) (*models.MyClaManagerRequestResult, error) { + f := logrus.Fields{ + "functionName": "v2.my_clas.service.CreateMyClaManagerRequest", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "currentUsername": callerUsername(caller), + "signatureID": signatureID, + } + + identity, sig, userModel, err := s.findOwnedEcla(ctx, caller, requested, signatureID) + if err != nil || sig == nil { + return nil, err + } + + details, err := s.eclaManagerDetails(ctx, identity, sig) + if err != nil { + return nil, err + } + + byUsername := make(map[string]models.MyClaManager, len(details.managers)) + for _, manager := range details.managers { + byUsername[strings.ToLower(manager.LfUsername)] = manager + } + recipients := trimAll(input.Recipients) + if len(details.managers) > 0 && len(recipients) == 0 { + return nil, ErrInvalidRecipients + } + selectedUsernames := make([]string, 0, len(recipients)) + recipientEmails := make([]string, 0, len(recipients)) + selected := make(map[string]bool, len(recipients)) + emailed := make(map[string]bool, len(recipients)) + for _, recipient := range recipients { + key := strings.ToLower(recipient) + manager, ok := byUsername[key] + if !ok { + return nil, ErrInvalidRecipients + } + if selected[key] { + continue + } + selected[key] = true + selectedUsernames = append(selectedUsernames, manager.LfUsername) + // Managers can share an address; mail it once, but report both as recipients. + if emailKey := strings.ToLower(manager.Email); emailKey != "" && !emailed[emailKey] { + emailed[emailKey] = true + recipientEmails = append(recipientEmails, manager.Email) + } + } + + requestType := utils.StringValue(input.RequestType) + message := utils.SanitizePlainText(input.Message) + if requestType == models.MyClaManagerRequestRequestTypeContact && message == "" { + return nil, ErrMissingMessage + } + contributorName := userModel.Username + if contributorName == "" { + contributorName = identity.LfUsername + } + _, contributorIdentity := signedIdentity(sig) + if contributorIdentity == "" { + contributorIdentity = identity.LfUsername + } + + status := models.MyClaManagerRequestResultStatusRecorded + body := "" + if len(recipientEmails) > 0 { + body, err = emails.RenderContactClaManagerTemplate(emails.ContactClaManagerTemplateParams{ + RequestAction: requestAction(requestType), + ContributorName: contributorName, + ContributorIdentity: contributorIdentity, + ContributorEmail: utils.GetBestEmail(userModel), + CompanyName: details.companyName, + ProjectName: details.projectName, + CLAGroupName: details.claGroupName, + OptionalMessage: message, + ContactOnly: requestType == models.MyClaManagerRequestRequestTypeContact, + }) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to render the contact CLA manager email") + return nil, err + } + status = models.MyClaManagerRequestResultStatusSent + } + + requestUUID, err := uuid.NewV4() + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to generate a request ID") + return nil, err + } + requestID := requestUUID.String() + + if len(recipientEmails) > 0 { + subject := utils.SanitizeSingleLine(requestSubject(requestType, contributorName, details.companyName)) + if sendErr := s.sendEmail(subject, body, recipientEmails); sendErr != nil { + log.WithFields(f).WithError(sendErr).Warn("unable to send the contact CLA manager email") + return nil, sendErr + } + } + + if s.eventsService != nil { + s.eventsService.LogEventWithContext(ctx, &events.LogEventArgs{ + EventType: events.ContactCLAManagerRequestCreated, + UserID: userModel.UserID, + LfUsername: identity.LfUsername, + UserName: contributorName, + CLAGroupID: sig.SignatureProjectID, + CLAGroupName: details.claGroupName, + ProjectID: sig.SignatureProjectID, + ProjectName: details.projectName, + CompanyID: sig.SignatureUserCompanyID, + CompanyName: details.companyName, + EventData: &events.ContactCLAManagerRequestCreatedEventData{ + RequestID: requestID, + RequestType: requestType, + SignatureID: sig.SignatureID, + Message: message, + Recipients: selectedUsernames, + }, + }) + } + + return &models.MyClaManagerRequestResult{ + RequestID: requestID, + SignatureID: sig.SignatureID, + RequestType: requestType, + Status: status, + Recipients: selectedUsernames, + }, nil +} + +// findOwnedEcla locates the signed ECLA with this ID among the identity's EasyCLA user records - +// the ownership boundary GetMyClaPdfURL enforces; nil means unknown, not-owned, unsigned or ICLA +func (s *service) findOwnedEcla(ctx context.Context, caller *Caller, requested *Identity, signatureID string) (*Identity, *signatures.ItemSignature, *v1Models.User, error) { + identity, _, err := s.effectiveIdentity(ctx, caller, requested) + if err != nil { + return nil, nil, nil, err + } + + userModels, err := s.resolveUsers(ctx, identity) + if err != nil { + return nil, nil, nil, err + } + + for _, userModel := range userModels { + userSignatures, sigErr := s.repo.GetUserCLASignatures(ctx, userModel.UserID) + if sigErr != nil { + return nil, nil, nil, sigErr + } + for _, sig := range userSignatures { + if sig.SignatureID != signatureID { + continue + } + if !sig.SignatureSigned || sig.SignatureUserCompanyID == "" { + return identity, nil, nil, nil + } + return identity, sig, userModel, nil + } + } + + return identity, nil, nil, nil +} + +type managerDetails struct { + claGroupName string + projectName string + companyName string + managers []models.MyClaManager + callerIsManager bool +} + +// eclaManagerDetails resolves an ECLA's CLA Group/project/company context and the CLA managers +// from the covering CCLA's ACL - no CCLA yields an empty manager list +func (s *service) eclaManagerDetails(ctx context.Context, identity *Identity, sig *signatures.ItemSignature) (*managerDetails, error) { + var ( + claGroupName string + project projectInfo + companyModel *v1Models.Company + ccla *v1Models.Signature + ) + group, groupCtx := errgroup.WithContext(ctx) + group.Go(func() error { + var err error + claGroupName, err = s.claGroupName(groupCtx, sig.SignatureProjectID) + return err + }) + group.Go(func() error { + var err error + project, err = s.projectInfo(groupCtx, sig.SignatureProjectID) + return err + }) + group.Go(func() error { + var err error + companyModel, err = s.company(groupCtx, sig.SignatureUserCompanyID) + return err + }) + group.Go(func() error { + approved, signed := true, true + var err error + ccla, err = s.signaturesService.GetCorporateSignature(groupCtx, sig.SignatureProjectID, sig.SignatureUserCompanyID, &approved, &signed) + return err + }) + if err := group.Wait(); err != nil { + return nil, err + } + + details := &managerDetails{ + claGroupName: claGroupName, + projectName: project.name, + managers: []models.MyClaManager{}, + } + if companyModel != nil { + details.companyName = companyModel.CompanyName + } + if ccla == nil { + return details, nil + } + + for _, aclUser := range ccla.SignatureACL { + lfUsername := aclUser.LfUsername + if lfUsername == "" { + lfUsername = aclUser.Username + } + if lfUsername == "" { + continue + } + email := string(aclUser.LfEmail) + if email == "" && len(aclUser.Emails) > 0 { + email = aclUser.Emails[0] + } + details.managers = append(details.managers, models.MyClaManager{ + LfUsername: lfUsername, + Name: aclUser.Username, + Email: email, + }) + } + details.callerIsManager = isClaManager(ccla, identity.LfUsername) + + return details, nil +} + +func requestAction(requestType string) string { + if requestType == models.MyClaManagerRequestRequestTypeRemoval { + return "removal from the corporate CLA coverage" + } + return "approval under the corporate CLA" +} + +func requestSubject(requestType, contributorName, companyName string) string { + if requestType == models.MyClaManagerRequestRequestTypeContact { + return fmt.Sprintf("EasyCLA: Message from %s regarding %s", contributorName, companyName) + } + return fmt.Sprintf("EasyCLA: %s request from %s for %s", requestAction(requestType), contributorName, companyName) +} + +func isClaManager(ccla *v1Models.Signature, lfUsername string) bool { + if ccla == nil || lfUsername == "" { + return false + } + for _, aclUser := range ccla.SignatureACL { + if strings.EqualFold(aclUser.LfUsername, lfUsername) || strings.EqualFold(aclUser.Username, lfUsername) { + return true + } + } + return false +} + +// signedIdentity derives the platform and account signed via/as from the signature's identity +// attributes +func signedIdentity(sig *signatures.ItemSignature) (string, string) { + switch { + case sig.UserGithubUsername != "" || sig.UserGithubID != "": + if sig.UserGithubUsername != "" { + return models.MyClaSignedViaGithub, sig.UserGithubUsername + } + return models.MyClaSignedViaGithub, sig.UserGithubID + case sig.UserGitlabUsername != "" || sig.UserGitlabID != "": + if sig.UserGitlabUsername != "" { + return models.MyClaSignedViaGitlab, sig.UserGitlabUsername + } + return models.MyClaSignedViaGitlab, sig.UserGitlabID + case sig.UserEmail != "" || sig.UserLFUsername != "": + if sig.UserEmail != "" { + return models.MyClaSignedViaGerrit, sig.UserEmail + } + return models.MyClaSignedViaGerrit, sig.UserLFUsername + } + return "", "" +} + // GetMyIdentities returns the deduplicated ":" identities the authenticated user -// owns - the union of their EasyCLA user records and their platform user-service account, the -// same two sources authorizeIdentity uses to authorize a non-admin caller's identity keys +// owns - the union of their EasyCLA records and platform account, the two sources +// authorizeIdentity checks func (s *service) GetMyIdentities(ctx context.Context, currentUsername string) (*models.MyIdentityList, error) { if currentUsername == "" { return nil, errors.New("no username on the authenticated principal") @@ -356,18 +739,39 @@ func (s *service) GetMyIdentities(ctx context.Context, currentUsername string) ( }, nil } -func (s *service) effectiveIdentity(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) { - if admin { +// AuthorizeIdentity narrows the requested identity keys to those belonging to the authenticated +// user and reports the dropped ones - the boundary GET /my-clas enforces +func (s *service) AuthorizeIdentity(ctx context.Context, currentUsername string, admin bool, requested *Identity) (*Identity, []string, error) { + return s.effectiveIdentity(ctx, &Caller{Username: currentUsername, Admin: admin}, requested) +} + +// effectiveIdentity resolves which identity keys may be searched. An admin or trusted LFX Self +// Serve caller supplies them directly: a trusted list is Auth0-derived and not re-derivable here, +// as the historical GitHub-only signers this endpoint serves carry no lf_username on their EasyCLA +// records, so verifying against them would deny exactly the CLAs the caller may see. Anyone else +// has every key verified against their own records first. +func (s *service) effectiveIdentity(ctx context.Context, caller *Caller, requested *Identity) (*Identity, []string, error) { + if caller == nil { + return nil, nil, errors.New("no authenticated principal") + } + if caller.Admin || caller.Trusted { identity := *requested if identity.LfUsername == "" { - identity.LfUsername = currentUsername + identity.LfUsername = caller.Username } return &identity, []string{}, nil } - if currentUsername == "" { + if caller.Username == "" { return nil, nil, errors.New("no username on the authenticated principal") } - return s.authorizeIdentity(ctx, currentUsername, requested) + return s.authorizeIdentity(ctx, caller.Username, requested) +} + +func callerUsername(caller *Caller) string { + if caller == nil { + return "" + } + return caller.Username } type platformIdentitySet struct { @@ -375,11 +779,10 @@ type platformIdentitySet struct { usernames map[string]map[string][]string } -// authorizeIdentity verifies each requested identity key against all of the -// authenticated user's own EasyCLA records and, when not covered there, against the -// identities connected to their LF account in the platform user-service - unverified -// keys are dropped from the search and reported back; verified usernames are replaced -// by their canonical spellings for the exact-match index lookups +// authorizeIdentity verifies each requested key against all of the authenticated user's own +// EasyCLA records and, when not covered there, the identities connected to their LF account - +// unverified keys are dropped and reported; verified usernames become their canonical spellings +// for the exact-match index lookups func (s *service) authorizeIdentity(ctx context.Context, currentUsername string, requested *Identity) (*Identity, []string, error) { f := logrus.Fields{ "functionName": "v2.my_clas.service.authorizeIdentity", @@ -522,9 +925,8 @@ func appendAllowedUsernames(values []string, param string, canon func(string) [] } } -// loadPlatformIdentities collects the emails and per-source canonical usernames -// connected to the LF account - lookup failures yield an empty set, so the affected -// keys are skipped, never allowed +// loadPlatformIdentities collects the emails and per-source canonical usernames connected to the +// LF account - a lookup failure yields an empty set, so affected keys are skipped, never allowed func (s *service) loadPlatformIdentities(ctx context.Context, lfUsername string) *platformIdentitySet { f := logrus.Fields{ "functionName": "v2.my_clas.service.loadPlatformIdentities", @@ -597,153 +999,212 @@ func (s *service) resolveUsers(ctx context.Context, identity *Identity) ([]*v1Mo utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } - var userModels []*v1Models.User - seen := make(map[string]bool) - add := func(matches ...*v1Models.User) { - for _, userModel := range matches { - if userModel == nil || userModel.UserID == "" || seen[userModel.UserID] { - continue - } - seen[userModel.UserID] = true - userModels = append(userModels, userModel) - } - } - addByLookup := func(values []string, what string, lookup func(context.Context, string) ([]*v1Models.User, error)) error { + var lookups []userLookup + byValue := func(values []string, what string, lookup func(context.Context, string) ([]*v1Models.User, error)) { for _, value := range values { - matches, err := lookup(ctx, value) - if err != nil { - log.WithFields(f).WithError(err).Warnf("unable to lookup users by %s: %s", what, value) - return err - } - add(matches...) + lookups = append(lookups, userLookup{what: what, key: value, run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return lookup(lookupCtx, value) + }}) } - return nil } - addByIDLookup := func(ids []int64, what string, lookup func(context.Context, int64) ([]*v1Models.User, error)) error { + byID := func(ids []int64, what string, lookup func(context.Context, int64) ([]*v1Models.User, error)) { for _, id := range dedupeIDs(ids) { - matches, err := lookup(ctx, id) - if err != nil { - log.WithFields(f).WithError(err).Warnf("unable to lookup users by %s: %d", what, id) - return err - } - add(matches...) + lookups = append(lookups, userLookup{what: what, key: strconv.FormatInt(id, 10), run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return lookup(lookupCtx, id) + }}) } - return nil } - lfUsernames := trimAll(append([]string{identity.LfUsername}, identity.GerritUsernames...)) - if err := addByLookup(lfUsernames, "LF username", s.repo.GetUsersByLFUsername); err != nil { - return nil, err - } - if err := addByLookup(normalizeEmails(identity.Emails), "email", s.repo.GetUsersByPrimaryEmail); err != nil { - return nil, err - } + byValue(trimAll(append([]string{identity.LfUsername}, identity.GerritUsernames...)), "LF username", s.repo.GetUsersByLFUsername) + byValue(normalizeEmails(identity.Emails), "email", s.repo.GetUsersByPrimaryEmail) if secondaryEmails := normalizeEmails(identity.SecondaryEmails); len(secondaryEmails) > 0 { - matches, err := s.repo.GetUsersBySecondaryEmails(ctx, secondaryEmails) - if err != nil { - log.WithFields(f).WithError(err).Warn("unable to lookup users by secondary emails") - return nil, err - } - add(matches...) - } - if err := addByIDLookup(identity.GithubIDs, "GitHub ID", s.repo.GetUsersByGithubID); err != nil { - return nil, err - } - if err := addByLookup(trimAll(identity.GithubUsernames), "GitHub username", s.repo.GetUsersByGithubUsername); err != nil { + lookups = append(lookups, userLookup{what: "secondary email", key: strings.Join(secondaryEmails, ","), run: func(lookupCtx context.Context) ([]*v1Models.User, error) { + return s.repo.GetUsersBySecondaryEmails(lookupCtx, secondaryEmails) + }}) + } + byID(identity.GithubIDs, "GitHub ID", s.repo.GetUsersByGithubID) + byValue(trimAll(identity.GithubUsernames), "GitHub username", s.repo.GetUsersByGithubUsername) + byID(identity.GitlabIDs, "GitLab ID", s.repo.GetUsersByGitlabID) + byValue(trimAll(identity.GitlabUsernames), "GitLab username", s.repo.GetUsersByGitlabUsername) + + matches := make([][]*v1Models.User, len(lookups)) + errs := make([]error, len(lookups)) + group, groupCtx := errgroup.WithContext(ctx) + group.SetLimit(fetchConcurrency) + for i, lookup := range lookups { + group.Go(func() error { + matches[i], errs[i] = lookup.run(groupCtx) + return nil + }) + } + if err := group.Wait(); err != nil { return nil, err } - if err := addByIDLookup(identity.GitlabIDs, "GitLab ID", s.repo.GetUsersByGitlabID); err != nil { - return nil, err - } - if err := addByLookup(trimAll(identity.GitlabUsernames), "GitLab username", s.repo.GetUsersByGitlabUsername); err != nil { - return nil, err + + var userModels []*v1Models.User + seen := make(map[string]bool) + for i, lookup := range lookups { + if errs[i] != nil { + log.WithFields(f).WithError(errs[i]).Warnf("unable to lookup users by %s: %s", lookup.what, lookup.key) + return nil, errs[i] + } + for _, userModel := range matches[i] { + if userModel == nil || userModel.UserID == "" || seen[userModel.UserID] { + continue + } + seen[userModel.UserID] = true + userModels = append(userModels, userModel) + } } return userModels, nil } -// eclaCoveredByCurrentApprovalList mirrors the PR gating logic (signatures service -// ProcessEmployeeSignature/UserIsApproved): the company must not be sanctioned, must -// hold an approved+signed CCLA for the CLA Group, and the user must match its current -// approval lists -func (s *service) eclaCoveredByCurrentApprovalList(ctx context.Context, cclas map[string]*v1Models.Signature, approvals map[string]bool, userModel *v1Models.User, companyModel *v1Models.Company, sig *signatures.ItemSignature) (bool, error) { - f := logrus.Fields{ - "functionName": "v2.my_clas.service.eclaCoveredByCurrentApprovalList", - utils.XREQUESTID: ctx.Value(utils.XREQUESTID), - "signatureID": sig.SignatureID, - "claGroupID": sig.SignatureProjectID, - "companyID": sig.SignatureUserCompanyID, - } +// userLookup is one pending user-record lookup. All run concurrently and merge in declaration +// order, so the resolved set and the reported error match a serial walk. +type userLookup struct { + run func(context.Context) ([]*v1Models.User, error) + what string + key string +} + +// eclaCoverage is one ECLA's coverage outcome. covered drives valid; unevaluable means the +// approval-list check never completed, so a false covered proves nothing. +type eclaCoverage struct { + covered bool + unevaluable bool +} + +// sanctionState is the sanctions answer for one employer: the flag plus how it was obtained +type sanctionState struct { + flagged bool + check string + date string +} - if companyModel == nil || companyModel.IsSanctioned { - return false, nil +func (s *service) sanctionsMode() string { + if s.sanctions == nil { + return models.MyClaListSssModeDisabled } + return s.sanctions.Mode() +} - cclaKey := sig.SignatureProjectID + "|" + sig.SignatureUserCompanyID - ccla, ok := cclas[cclaKey] - if !ok { - approved, signed := true, true - cclaModel, cclaErr := s.signaturesService.GetCorporateSignature(ctx, sig.SignatureProjectID, sig.SignatureUserCompanyID, &approved, &signed) - if cclaErr != nil { - log.WithFields(f).WithError(cclaErr).Warn("unable to lookup the corporate signature for the employee acknowledgement") - return false, cclaErr - } - ccla = cclaModel - cclas[cclaKey] = ccla +// companySanctions screens one employer, live where possible. An unreadable employer is +// unavailable, never an absent answer. +func (s *service) companySanctions(ctx context.Context, companyModel *v1Models.Company, actor *v1Models.User) sanctionState { + if companyModel == nil { + return sanctionState{check: models.MyClaFlaggedCheckUnavailable} } - if ccla == nil { - return false, nil + state := sanctionState{flagged: companyModel.IsSanctioned, check: models.MyClaFlaggedCheckStored, date: companyModel.SanctionedDate} + if s.sanctions != nil { + state.flagged, state.check = s.sanctions.ScreenCompany(ctx, companyModel) + s.persistLiveSanction(ctx, companyModel, &state, actor) } + return state +} - approvalKey := cclaKey + "|" + userModel.UserID - if covered, ok := approvals[approvalKey]; ok { - return covered, nil +// persistLiveSanction stamps sanctioned_date the first time a live screen flags an employer, so +// the reported date stops moving with every listing. A record already carrying the date is left +// alone - restamping it here would drift on each page view - and a failed write only costs this +// employer its stored date, never the listing. +func (s *service) persistLiveSanction(ctx context.Context, companyModel *v1Models.Company, state *sanctionState, actor *v1Models.User) { + if !state.flagged || state.check != models.MyClaFlaggedCheckLive || (companyModel.IsSanctioned && companyModel.SanctionedDate != "") { + return } - covered, approvedErr := s.signaturesService.UserIsApproved(ctx, userModel, ccla) - if approvedErr != nil { - log.WithFields(f).WithError(approvedErr).Warn("unable to evaluate the approval list for the employee acknowledgement") - covered = false + f := logrus.Fields{ + "functionName": "v2.my_clas.service.persistLiveSanction", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "companyID": companyModel.CompanyID, + } + newSanction := !companyModel.IsSanctioned + if err := s.companyRepo.UpdateCompanySanctionStatus(ctx, companyModel.CompanyID, true, sanctionOriginSSS); err != nil { + log.WithFields(f).WithError(err).Warnf("unable to persist the live sanction for company %s - reporting the flag without a date", companyModel.CompanyID) + // A retained date belongs to the previous, cleared sanction - drop it rather than + // report it as this flag's date. + state.date = "" + return } - // UserIsApproved cannot evaluate GitLab group membership (it needs per-group OAuth - // tokens); defer to the signature_approved flag, which the approval-list - // invalidation flow maintains (see docs/MY_CLAS_API.md). - if !covered && approvedErr == nil && len(ccla.GitlabOrgApprovalList) > 0 { - covered = true + log.WithFields(f).Warnf("live screen flagged company %s, persisted the sanction with origin=%s", companyModel.CompanyID, sanctionOriginSSS) + _, state.date = utils.CurrentTime() + if newSanction && s.eventsService != nil && actor != nil { + s.eventsService.LogEventWithContext(ctx, &events.LogEventArgs{ + EventType: events.CompanySanctioned, + UserID: actor.UserID, + LfUsername: actor.LfUsername, + UserModel: actor, + CompanyModel: companyModel, + EventData: &events.CompanySanctionedEventData{}, + }) } - approvals[approvalKey] = covered - return covered, nil } -func (s *service) claGroupName(ctx context.Context, cache map[string]string, claGroupID string) (string, error) { +// assignMyClaStatus sets the contributor-facing status independently of approved/valid. A +// sanctioned employer wins over everything else and carries no user action. +func assignMyClaStatus(row *models.MyCla, coverage eclaCoverage) { + switch { + case row.Flagged: + row.Status = models.MyClaStatusRevoked + case !row.Approved: + row.Status = models.MyClaStatusInvalidated + case row.ClaType == utils.ClaTypeICLA: + row.Status = models.MyClaStatusValid + case coverage.unevaluable: + row.Status = models.MyClaStatusUnknown + row.StatusReason = models.MyClaStatusReasonUnknown + case coverage.covered: + row.Status = models.MyClaStatusValid + default: + row.Status = models.MyClaStatusNeedsAttention + row.StatusReason = models.MyClaStatusReasonNotOnApprovalList + } +} + +// evaluateApproval mirrors the PR gating logic (signatures EvaluateUserApproval): the user must +// match the current approval lists of the employer's approved+signed CCLA. A check that could not +// complete is unevaluable, so a false covered never means "no longer approved". +func (s *service) evaluateApproval(ctx context.Context, userModel *v1Models.User, ccla *v1Models.Signature) eclaCoverage { + covered, githubOrgLookupFailed, err := s.signaturesService.EvaluateUserApproval(ctx, userModel, ccla) + if err != nil { + log.WithFields(logrus.Fields{ + "functionName": "v2.my_clas.service.evaluateApproval", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "claGroupID": ccla.ProjectID, + "companyID": ccla.SignatureReferenceID, + "userID": userModel.UserID, + }).WithError(err).Warn("unable to evaluate the approval list for the employee acknowledgement") + return eclaCoverage{unevaluable: true} + } + // EvaluateUserApproval cannot evaluate GitLab group membership (it needs per-group OAuth + // tokens); defer to signature_approved, which the invalidation flow maintains. Membership was + // never checked, so the row stays unevaluable. + if !covered && len(ccla.GitlabOrgApprovalList) > 0 { + return eclaCoverage{covered: true, unevaluable: true} + } + return eclaCoverage{covered: covered, unevaluable: githubOrgLookupFailed} +} + +func (s *service) claGroupName(ctx context.Context, claGroupID string) (string, error) { if claGroupID == "" { return "", nil } - if name, ok := cache[claGroupID]; ok { - return name, nil - } name, err := s.projectsClaGroupsRepo.GetCLAGroupNameByID(ctx, claGroupID) if err != nil { if !errors.Is(err, projects_cla_groups.ErrCLAGroupDoesNotExist) { return "", err } - name = "" + return "", nil } - cache[claGroupID] = name return name, nil } -// projectInfo resolves the Salesforce project display name and logo the CLA Group belongs to, -// cached per request. The name comes from the projects-cla-groups mapping table; the logo lives -// only in the project-service and is fetched by project SFID (a foundation-level CLA Group -// resolves to its foundation). A project-service lookup miss degrades to an empty logo rather -// than failing the whole listing. -func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, claGroupID string) (projectInfo, error) { +// projectInfo resolves the Salesforce project display name and logo of a CLA Group. The name comes +// from the projects-cla-groups mapping, the logo only from the project-service, fetched by project +// SFID (a foundation-level CLA Group resolves to its foundation). A lookup miss degrades to an +// empty logo rather than failing the listing. +func (s *service) projectInfo(ctx context.Context, claGroupID string) (projectInfo, error) { if claGroupID == "" { return projectInfo{}, nil } - if info, ok := cache[claGroupID]; ok { - return info, nil - } mappings, err := s.projectsClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, claGroupID) if err != nil { @@ -752,12 +1213,11 @@ func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, var info projectInfo var projectSFID string - // Foundation-level CLA Groups are identified by a mapping whose ProjectSFID == FoundationSFID - // (the projects_cla_groups convention used by SignedAtFoundation), NOT by the number of - // mappings: such a group resolves to its foundation, and a single project-level mapping - // resolves to that project. Multiple project-level mappings with no foundation marker are - // left unresolved (empty name/logo, so the consumer falls back to claGroupName) rather than - // inventing an association with an arbitrary one of the mapped projects. + // A foundation-level CLA Group is marked by a mapping with ProjectSFID == FoundationSFID (the + // projects_cla_groups convention used by SignedAtFoundation), not by the mapping count, and + // resolves to its foundation; a single project-level mapping resolves to that project. Several + // project-level mappings with no foundation marker stay unresolved (empty name/logo, so the + // consumer falls back to claGroupName) rather than picking an arbitrary one. switch fm := foundationMapping(mappings); { case fm != nil: projectSFID = fm.FoundationSFID @@ -785,13 +1245,11 @@ func (s *service) projectInfo(ctx context.Context, cache map[string]projectInfo, } } - cache[claGroupID] = info return info, nil } -// foundationMapping returns the mapping row that marks a foundation-level CLA Group -// (ProjectSFID == FoundationSFID, the projects_cla_groups convention used by -// SignedAtFoundation), or nil when the CLA Group is not foundation-level. +// foundationMapping returns the mapping marking a foundation-level CLA Group (ProjectSFID == +// FoundationSFID, the SignedAtFoundation convention), or nil when it is not foundation-level. func foundationMapping(mappings []*projects_cla_groups.ProjectClaGroup) *projects_cla_groups.ProjectClaGroup { for _, m := range mappings { if m.FoundationSFID != "" && m.FoundationSFID == m.ProjectSFID { @@ -801,19 +1259,15 @@ func foundationMapping(mappings []*projects_cla_groups.ProjectClaGroup) *project return nil } -func (s *service) company(ctx context.Context, cache map[string]*v1Models.Company, companyID string) (*v1Models.Company, error) { - if companyModel, ok := cache[companyID]; ok { - return companyModel, nil - } +func (s *service) company(ctx context.Context, companyID string) (*v1Models.Company, error) { companyModel, err := s.companyRepo.GetCompany(ctx, companyID) if err != nil { var companyNotFound *utils.CompanyNotFound if !errors.As(err, &companyNotFound) { return nil, err } - companyModel = nil + return nil, nil } - cache[companyID] = companyModel return companyModel, nil } diff --git a/cla-backend-go/v2/my_clas/service_test.go b/cla-backend-go/v2/my_clas/service_test.go index b2bc482ed..52437be6c 100644 --- a/cla-backend-go/v2/my_clas/service_test.go +++ b/cla-backend-go/v2/my_clas/service_test.go @@ -7,8 +7,12 @@ import ( "context" "errors" "fmt" + "strings" + "sync" "testing" + "time" + "github.com/linuxfoundation/easycla/cla-backend-go/events" v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" @@ -91,30 +95,94 @@ type fakeSignatures struct { cclas map[string]*v1Models.Signature approvedUserIDs map[string]bool userIsApprovedErr error + cclaErr error + mu sync.Mutex + orgLookupFailed bool + cclaCalls int } func (f *fakeSignatures) GetCorporateSignature(_ context.Context, claGroupID, companyID string, _, _ *bool) (*v1Models.Signature, error) { + f.mu.Lock() + f.cclaCalls++ + f.mu.Unlock() + if f.cclaErr != nil { + return nil, f.cclaErr + } return f.cclas[claGroupID+"|"+companyID], nil } -func (f *fakeSignatures) UserIsApproved(_ context.Context, user *v1Models.User, _ *v1Models.Signature) (bool, error) { +func (f *fakeSignatures) EvaluateUserApproval(_ context.Context, user *v1Models.User, _ *v1Models.Signature) (bool, bool, error) { if f.userIsApprovedErr != nil { - return false, f.userIsApprovedErr + return false, false, f.userIsApprovedErr } - return f.approvedUserIDs[user.UserID], nil + return f.approvedUserIDs[user.UserID], f.orgLookupFailed, nil +} + +type sanctionWrite struct { + companyID string + sanctioned bool + origin string } type fakeCompanies struct { - byID map[string]*v1Models.Company + byID map[string]*v1Models.Company + failIDs map[string]bool + mu sync.Mutex + calls int + writes []sanctionWrite + writeErr error } func (f *fakeCompanies) GetCompany(_ context.Context, companyID string) (*v1Models.Company, error) { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + if f.failIDs[companyID] { + return nil, fmt.Errorf("dynamodb unavailable for company %s", companyID) + } if companyModel, ok := f.byID[companyID]; ok { return companyModel, nil } return nil, &utils.CompanyNotFound{CompanyID: companyID} } +func (f *fakeCompanies) UpdateCompanySanctionStatus(_ context.Context, companyID string, sanctioned bool, origin string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.writeErr != nil { + return f.writeErr + } + f.writes = append(f.writes, sanctionWrite{companyID: companyID, sanctioned: sanctioned, origin: origin}) + return nil +} + +// fakeScreener stands in for the live SSS screen and records how often each employer was screened +type fakeScreener struct { + mode string + flagged map[string]bool + checks map[string]string + calls map[string]int + mu sync.Mutex +} + +func (f *fakeScreener) Mode() string { + return f.mode +} + +func (f *fakeScreener) ScreenCompany(_ context.Context, company *v1Models.Company) (bool, string) { + f.mu.Lock() + defer f.mu.Unlock() + if f.calls == nil { + f.calls = map[string]int{} + } + f.calls[company.CompanyID]++ + check, ok := f.checks[company.CompanyID] + if !ok { + check = models.MyClaFlaggedCheckLive + } + return f.flagged[company.CompanyID], check +} + type fakeClaGroups struct { names map[string]string mappings map[string][]*projects_cla_groups.ProjectClaGroup @@ -135,9 +203,12 @@ type fakeProjectService struct { byID map[string]*v2ProjectServiceModels.ProjectOutputDetailed err error calls map[string]int + mu sync.Mutex } func (f *fakeProjectService) GetProject(projectSFID string) (*v2ProjectServiceModels.ProjectOutputDetailed, error) { + f.mu.Lock() + defer f.mu.Unlock() if f.calls != nil { f.calls[projectSFID]++ } @@ -201,7 +272,7 @@ func TestGetMyClasUnionAndDedupe(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{names: map[string]string{"cla-group-1": "My CLA Group"}}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ Emails: []string{"Someone@Example.org ", "someone@example.org"}, GithubIDs: []int64{12345, 12345}, }) @@ -245,7 +316,7 @@ func TestGetMyClasProjectNameAndLogo(t *testing.T) { "found-sfid": {ProjectOutput: v2ProjectServiceModels.ProjectOutput{ProjectCommon: v2ProjectServiceModels.ProjectCommon{Name: "Cloud Native Computing Foundation", ProjectLogo: "https://logos.example.org/cncf.png"}}}, }} - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 2) @@ -283,7 +354,7 @@ func TestGetMyClasProjectLookupDegradesGracefully(t *testing.T) { svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, claGroups) svc.projectService = &fakeProjectService{byID: map[string]*v2ProjectServiceModels.ProjectOutputDetailed{}} - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err, "a project-service miss must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept when the project-service has no record") @@ -316,7 +387,7 @@ func TestGetMyClasMultiProjectNonFoundation(t *testing.T) { "proj-alpha": {ProjectOutput: v2ProjectServiceModels.ProjectOutput{ProjectCommon: v2ProjectServiceModels.ProjectCommon{Name: "Alpha", ProjectLogo: "https://logos.example.org/alpha.png"}}}, }} - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.Empty(t, result.Clas[0].ProjectName, "an ambiguous multi-project non-foundation group invents no project name") @@ -350,7 +421,7 @@ func TestGetMyClasProjectCacheHitPerRequest(t *testing.T) { } svc.projectService = projectSvc - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 2) assert.Equal(t, 1, projectSvc.calls["proj-sfid-1"], "the project-service is queried once per distinct CLA group within a request") @@ -381,7 +452,7 @@ func TestGetMyClasProjectServiceErrorAndNilClient(t *testing.T) { svc := newTestService(newRepo(), &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, newClaGroups()) svc.projectService = &fakeProjectService{err: errors.New("project-service unavailable")} - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err, "a project-service error must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept on a project-service error") @@ -392,7 +463,7 @@ func TestGetMyClasProjectServiceErrorAndNilClient(t *testing.T) { svc := newTestService(newRepo(), &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, newClaGroups()) svc.projectService = nil - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err, "a nil project-service client must not fail the listing") require.Len(t, result.Clas, 1) assert.Equal(t, "Kubernetes", result.Clas[0].ProjectName, "the mapping-table name is kept with no project-service client") @@ -416,13 +487,13 @@ func TestGetMyClasMultipleRecordsSameLFID(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "alice", false, &Identity{GithubIDs: []int64{12345}}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "alice"}, &Identity{GithubIDs: []int64{12345}}) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities, "a numeric ID stored on any of the caller's LFID records is authorized") assert.ElementsMatch(t, []string{"user-a1", "user-a2", "user-a3"}, result.UserIds, "all records per key are unioned") assert.Equal(t, int64(3), result.ResultCount) - pdf, err := svc.GetMyClaPdfURL(context.Background(), "alice", false, &Identity{}, "sig-a2") + pdf, err := svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "alice"}, &Identity{}, "sig-a2") require.NoError(t, err) require.NotNil(t, pdf, "a PDF owned by the second LFID record is downloadable") } @@ -430,7 +501,7 @@ func TestGetMyClasMultipleRecordsSameLFID(t *testing.T) { func TestGetMyClasNoMatches(t *testing.T) { svc := newTestService(&fakeRepo{}, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "missing", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "missing"}, &Identity{}) require.NoError(t, err) assert.Empty(t, result.UserIds) assert.Empty(t, result.Clas) @@ -455,7 +526,7 @@ func TestGetMyClasOwnershipRejectsForeignIdentities(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ LfUsername: "victim", Emails: []string{"victim@example.org"}, SecondaryEmails: []string{"victim-alt@example.org"}, @@ -503,7 +574,7 @@ func TestGetMyClasOwnershipViaEasyCLARecord(t *testing.T) { platform := &fakePlatform{} svc := newTestService(repo, platform, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ SecondaryEmails: []string{"Alt@Example.org", "alt2@example.org", "alt@example.org"}, GitlabIDs: []int64{777}, }) @@ -538,7 +609,7 @@ func TestGetMyClasOwnershipViaPlatformIdentities(t *testing.T) { } svc := newTestService(repo, platform, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"octocat"}, GerritUsernames: []string{"old-ldap-id"}, }) @@ -548,14 +619,14 @@ func TestGetMyClasOwnershipViaPlatformIdentities(t *testing.T) { "the canonical spelling from user-service finds records stored with exact-match keys") assert.Equal(t, 1, platform.lookups, "platform identities are loaded once") - result, err = svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err = svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"not-a-code-identity"}, }) require.NoError(t, err) assert.Equal(t, []string{"githubUsername:not-a-code-identity"}, result.SkippedIdentities, "a slack username must not authorize a github search") - result, err = svc.GetMyClas(context.Background(), "someone", false, &Identity{ + result, err = svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{ GithubUsernames: []string{"lakecat"}, }) require.NoError(t, err) @@ -573,13 +644,110 @@ func TestGetMyClasAdminBypass(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "staff-admin", true, &Identity{LfUsername: "victim"}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}) require.NoError(t, err) assert.Empty(t, result.SkippedIdentities) assert.Equal(t, "victim", result.LfUsername) assert.Equal(t, []string{"user-v"}, result.UserIds) } +// A trusted Self Serve caller's identity list is taken as authorized - the records it names +// typically carry no LF username at all (historical GitHub-only signers), which is exactly the +// case the per-identity verification cannot authorize +func TestGetMyClasTrustedCallerBypass(t *testing.T) { + githubOnly := &v1Models.User{UserID: "user-gh", GithubID: "999", GithubUsername: "octocat"} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-gh": {icla("sig-gh", "user-gh", "cla-group-1", "2024-02-01T00:00:00Z", true)}, + }, + byGithubID: map[int64][]*v1Models.User{999: {githubOnly}}, + byGithubUsername: map[string][]*v1Models.User{"octocat": {githubOnly}}, + } + platform := &fakePlatform{} + svc := newTestService(repo, platform, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Trusted: true}, &Identity{ + GithubIDs: []int64{999}, + GithubUsernames: []string{"octocat"}, + }) + require.NoError(t, err) + assert.Empty(t, result.SkippedIdentities) + assert.Equal(t, []string{"user-gh"}, result.UserIds) + require.Len(t, result.Clas, 1) + assert.Equal(t, "sig-gh", result.Clas[0].SignatureID) + assert.Zero(t, platform.lookups, "a trusted caller's identity list is not verified against the platform user-service") + + pdf, err := svc.GetMyClaPdfURL(context.Background(), &Caller{Trusted: true}, &Identity{GithubIDs: []int64{999}}, "sig-gh") + require.NoError(t, err) + require.NotNil(t, pdf) + assert.Equal(t, "sig-gh", pdf.SignatureID) +} + +func TestEffectiveIdentityRequiresACaller(t *testing.T) { + svc := newTestService(&fakeRepo{}, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + _, err := svc.GetMyClas(context.Background(), nil, &Identity{GithubIDs: []int64{999}}) + assert.Error(t, err, "a nil caller must never be treated as authorized") + + _, err = svc.GetMyClas(context.Background(), &Caller{}, &Identity{GithubIDs: []int64{999}}) + assert.Error(t, err, "an untrusted caller without a username must never be treated as authorized") + + _, err = svc.GetMyClaPdfURL(context.Background(), &Caller{}, &Identity{GithubIDs: []int64{999}}, "sig-1") + assert.Error(t, err) +} + +func TestEffectiveIdentityForPrivilegedCallers(t *testing.T) { + platform := &fakePlatform{} + svc := newTestService(&fakeRepo{}, platform, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + tests := []struct { + name string + caller *Caller + requested *Identity + lfUsername string + }{ + {"trusted caller keeps the requested lfUsername", &Caller{Username: "ss-service", Trusted: true}, &Identity{LfUsername: "someone", GithubIDs: []int64{999}}, "someone"}, + {"trusted caller without one falls back to its own username", &Caller{Username: "someone", Trusted: true}, &Identity{GithubIDs: []int64{999}}, "someone"}, + {"trusted caller with neither stays empty", &Caller{Trusted: true}, &Identity{GithubIDs: []int64{999}}, ""}, + {"admin keeps the requested lfUsername", &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim", GithubIDs: []int64{999}}, "victim"}, + {"admin and trusted at once", &Caller{Username: "staff-admin", Admin: true, Trusted: true}, &Identity{GithubIDs: []int64{999}}, "staff-admin"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + identity, skipped, err := svc.effectiveIdentity(context.Background(), test.caller, test.requested) + require.NoError(t, err) + assert.Equal(t, test.lfUsername, identity.LfUsername) + assert.Empty(t, skipped) + assert.Equal(t, test.requested.GithubIDs, identity.GithubIDs, "the requested keys must pass through untouched") + }) + } + + requested := &Identity{GithubIDs: []int64{999}} + _, _, err := svc.effectiveIdentity(context.Background(), &Caller{Username: "someone", Trusted: true}, requested) + require.NoError(t, err) + assert.Empty(t, requested.LfUsername, "the caller's identity list must not be mutated in place") + assert.Zero(t, platform.lookups, "a privileged caller's identity list is never verified against the platform user-service") +} + +func TestIdentitySummary(t *testing.T) { + identity := &Identity{ + LfUsername: "someone", + Emails: []string{"someone@example.org", " ", "someone@example.org"}, + GithubIDs: []int64{999, 999}, + GithubUsernames: []string{"octocat"}, + } + assert.Equal(t, "lfUsername:someone email:someone@example.org githubId:999 githubUsername:octocat", identity.Summary()) + assert.Empty(t, (&Identity{}).Summary()) + + long := make([]string, 0, 100) + for i := 0; i < 100; i++ { + long = append(long, fmt.Sprintf("user-%d@example.org", i)) + } + summary := (&Identity{Emails: long}).Summary() + assert.LessOrEqual(t, len(summary), identitySummaryLimit+3, "the audit log line must stay bounded") + assert.True(t, strings.HasSuffix(summary, "...")) +} + func TestGetMyClasIclaValidity(t *testing.T) { userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} unsigned := icla("sig-3", "user-a", "cla-group-1", "2024-03-01T00:00:00Z", true) @@ -597,7 +765,7 @@ func TestGetMyClasIclaValidity(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 2, "unsigned records must be excluded") @@ -647,7 +815,7 @@ func TestGetMyClasEclaValidity(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 5) @@ -665,9 +833,13 @@ func TestGetMyClasEclaValidity(t *testing.T) { assert.Equal(t, "company-1", covered.CompanyID) assert.False(t, byID["sig-2"].Valid, "sanctioned company invalidates the ECLA") + assert.Equal(t, models.MyClaStatusRevoked, byID["sig-2"].Status) assert.False(t, byID["sig-3"].Valid, "missing current CCLA invalidates the ECLA") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-3"].Status) assert.False(t, byID["sig-4"].Valid, "signature_approved=false invalidates the ECLA") + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-4"].Status) assert.False(t, byID["sig-5"].Valid, "unknown company invalidates the ECLA") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-5"].Status) assert.Empty(t, byID["sig-5"].CompanyName) } @@ -690,11 +862,12 @@ func TestGetMyClasEclaNotOnCurrentApprovalList(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.True(t, result.Clas[0].Approved) assert.False(t, result.Clas[0].Valid, "ECLA no longer matching the current approval list is invalid") + assert.Equal(t, models.MyClaStatusNeedsAttention, result.Clas[0].Status) } func TestGetMyClasEclaGitlabGroupFallback(t *testing.T) { @@ -716,10 +889,11 @@ func TestGetMyClasEclaGitlabGroupFallback(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err) require.Len(t, result.Clas, 1) assert.True(t, result.Clas[0].Valid, "GitLab-group-approved ECLAs defer to the signature_approved flag") + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "group membership was never evaluated") } func TestGetMyClasEclaApprovalEvaluationError(t *testing.T) { @@ -741,10 +915,409 @@ func TestGetMyClasEclaApprovalEvaluationError(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) - result, err := svc.GetMyClas(context.Background(), "someone", false, &Identity{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) require.NoError(t, err, "approval-list evaluation problems must not fail the listing") require.Len(t, result.Clas, 1) assert.False(t, result.Clas[0].Valid, "evaluation errors leave the ECLA not covered - no GitLab fallback") + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "an evaluation error proves nothing about the approval list") + assert.Equal(t, models.MyClaStatusReasonUnknown, result.Clas[0].StatusReason) +} + +func TestGetMyClasStatus(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + icla("sig-1", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", true), + icla("sig-2", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", false), + ecla("sig-3", "company-1", "2024-03-01T00:00:00Z", true), + ecla("sig-4", "company-1", "2024-04-01T00:00:00Z", false), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 4) + assert.Equal(t, models.MyClaListSssModeDisabled, result.SssMode, "no screener configured reports disabled") + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + assert.Equal(t, models.MyClaStatusValid, byID["sig-1"].Status) + assert.Empty(t, byID["sig-1"].StatusReason, "valid rows carry no reason") + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-2"].Status) + assert.Empty(t, byID["sig-2"].StatusReason, "invalidated attributes nothing") + assert.Equal(t, models.MyClaStatusValid, byID["sig-3"].Status) + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-4"].Status) + assert.Equal(t, models.MyClaFlaggedCheckStored, byID["sig-3"].FlaggedCheck) + assert.Empty(t, byID["sig-1"].FlaggedCheck, "sanctions are an ECLA concept") +} + +func TestGetMyClasStatusNeedsAttentionAndUnknown(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": {ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true)}, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + ccla := map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}} + + t.Run("completed approval-list miss", func(t *testing.T) { + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{cclas: ccla}, companies, &fakeClaGroups{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 1) + assert.Equal(t, models.MyClaStatusNeedsAttention, result.Clas[0].Status) + assert.Equal(t, models.MyClaStatusReasonNotOnApprovalList, result.Clas[0].StatusReason, "the only reason a Request approval action may gate on") + }) + + t.Run("github organization lookup failed", func(t *testing.T) { + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{cclas: ccla, orgLookupFailed: true}, companies, &fakeClaGroups{}) + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + require.Len(t, result.Clas, 1) + assert.Equal(t, models.MyClaStatusUnknown, result.Clas[0].Status, "a failed org lookup must not read as an approval-list miss") + assert.Equal(t, models.MyClaStatusReasonUnknown, result.Clas[0].StatusReason) + }) +} + +func TestGetMyClasDegradesFailedLookups(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + + t.Run("company lookup failure degrades the row", func(t *testing.T) { + companies := &fakeCompanies{ + byID: map[string]*v1Models.Company{"company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}}, + failIDs: map[string]bool{"company-2": true}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-2", "2024-02-01T00:00:00Z", true), + ecla("sig-3", "company-2", "2024-03-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "one unresolvable employer must not fail the whole list") + require.Len(t, result.Clas, 3) + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + assert.Equal(t, models.MyClaStatusValid, byID["sig-1"].Status, "healthy rows keep their status") + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-2"].Status) + assert.Equal(t, models.MyClaStatusUnknown, byID["sig-3"].Status) + assert.Empty(t, byID["sig-2"].CompanyName) + assert.False(t, byID["sig-2"].Flagged) + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, byID["sig-2"].FlaggedCheck, "an unreadable employer cannot be screened, so it is never an absent answer") + assert.Equal(t, models.MyClaFlaggedCheckStored, byID["sig-1"].FlaggedCheck) + assert.Equal(t, 2, companies.calls, "a failed employer lookup is cached, not retried per row") + }) + + t.Run("ccla lookup failure degrades the row", func(t *testing.T) { + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Good Corp"}, + }} + signaturesService := &fakeSignatures{cclaErr: fmt.Errorf("dynamodb unavailable")} + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-1", "2024-02-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "an unresolvable CCLA must not fail the whole list") + require.Len(t, result.Clas, 2) + for _, row := range result.Clas { + assert.Equal(t, models.MyClaStatusUnknown, row.Status) + assert.False(t, row.Valid) + } + assert.Equal(t, 1, signaturesService.cclaCalls, "a failed CCLA lookup is cached, not retried per row") + }) +} + +func TestGetMyClasLiveSanctionsScreening(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{ + "company-1": {CompanyID: "company-1", CompanyName: "Live Flagged Corp"}, + "company-2": {CompanyID: "company-2", CompanyName: "Cleared Corp", IsSanctioned: true, SanctionOrigin: sanctionOriginSSS}, + "company-3": {CompanyID: "company-3", CompanyName: "Unscreenable Corp", IsSanctioned: true}, + }} + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{ + "cla-group-1|company-1": {SignatureID: "ccla-1"}, + "cla-group-1|company-2": {SignatureID: "ccla-2"}, + "cla-group-1|company-3": {SignatureID: "ccla-3"}, + }, + approvedUserIDs: map[string]bool{"user-a": true}, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{ + "user-a": { + ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true), + ecla("sig-2", "company-2", "2024-02-01T00:00:00Z", true), + ecla("sig-3", "company-3", "2024-03-01T00:00:00Z", true), + ecla("sig-4", "company-1", "2024-04-01T00:00:00Z", true), + }, + }, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + screener := &fakeScreener{ + mode: models.MyClaListSssModeRequired, + flagged: map[string]bool{"company-1": true, "company-3": true}, + checks: map[string]string{"company-3": models.MyClaFlaggedCheckUnavailable}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + svc.sanctions = screener + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "a screening failure must never fail the listing") + require.Len(t, result.Clas, 4) + assert.Equal(t, models.MyClaListSssModeRequired, result.SssMode) + + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + liveFlagged := byID["sig-1"] + assert.True(t, liveFlagged.Flagged, "a live flagged result overrides the stored clean flag") + assert.Equal(t, models.MyClaFlaggedCheckLive, liveFlagged.FlaggedCheck) + assert.NotEmpty(t, liveFlagged.FlaggedAt) + assert.Equal(t, models.MyClaStatusRevoked, liveFlagged.Status) + assert.Empty(t, liveFlagged.StatusReason) + assert.False(t, liveFlagged.Valid) + + liveClean := byID["sig-2"] + assert.False(t, liveClean.Flagged, "a live clean result overrides the stored sanction") + assert.Equal(t, models.MyClaFlaggedCheckLive, liveClean.FlaggedCheck) + assert.Empty(t, liveClean.FlaggedAt) + assert.Equal(t, models.MyClaStatusValid, liveClean.Status) + + unavailable := byID["sig-3"] + assert.True(t, unavailable.Flagged, "an unusable screen honors the stored flag") + assert.Equal(t, models.MyClaFlaggedCheckUnavailable, unavailable.FlaggedCheck) + assert.Equal(t, models.MyClaStatusRevoked, unavailable.Status) + + assert.Equal(t, 1, screener.calls["company-1"], "each distinct employer is screened once per response") + assert.Equal(t, []sanctionWrite{{companyID: "company-1", sanctioned: true, origin: sanctionOriginSSS}}, companies.writes, + "only the newly detected sanction is persisted - a live clean and an unusable screen write nothing") +} + +func TestGetMyClasPersistsFirstLiveSanction(t *testing.T) { + const storedDate = "2024-01-15T10:11:12.000000+0000" + + tests := []struct { + name string + company *v1Models.Company + writeErr error + wantWrites int + wantFlaggedAt string + wantNoFlaggedAt bool + }{ + { + name: "first live detection is persisted", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Newly Flagged Corp"}, + wantWrites: 1, + }, + { + name: "an employer flagged again after a clear is restamped", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Repeat Corp", SanctionedDate: storedDate}, + wantWrites: 1, + }, + { + name: "an already stamped employer is left alone", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Known Corp", IsSanctioned: true, SanctionOrigin: sanctionOriginSSS, SanctionedDate: storedDate}, + wantWrites: 0, + wantFlaggedAt: "2024-01-15T10:11:12Z", + }, + { + name: "a failed write reports the flag without a date", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Unwritable Corp", SanctionedDate: storedDate}, + writeErr: errors.New("dynamodb unavailable"), + wantWrites: 0, + wantNoFlaggedAt: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{ + byID: map[string]*v1Models.Company{"company-1": tc.company}, + writeErr: tc.writeErr, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": {ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true)}}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + svc.sanctions = &fakeScreener{mode: models.MyClaListSssModeRequired, flagged: map[string]bool{"company-1": true}} + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err, "persisting must never fail the listing") + require.Len(t, result.Clas, 1) + row := result.Clas[0] + + assert.Len(t, companies.writes, tc.wantWrites) + if tc.wantWrites > 0 { + assert.Equal(t, sanctionWrite{companyID: "company-1", sanctioned: true, origin: sanctionOriginSSS}, companies.writes[0], + "the listing persists through the same SSS-origin write the signing flow uses") + } + assert.True(t, row.Flagged) + assert.Equal(t, models.MyClaFlaggedCheckLive, row.FlaggedCheck) + if tc.wantNoFlaggedAt { + assert.Empty(t, row.FlaggedAt, "a flag without a trustworthy date is reported without one") + } else if tc.wantFlaggedAt != "" { + assert.Equal(t, tc.wantFlaggedAt, row.FlaggedAt, "the stored date is reported, not the response time") + } else { + assert.NotEmpty(t, row.FlaggedAt) + assert.NotEqual(t, "2024-01-15T10:11:12Z", row.FlaggedAt, "a restamped or unwritten employer reports this observation") + } + }) + } +} + +// countingScreener records how many screens run at once and holds the first want of them open, +// so the listing can only complete if that many employers are screened concurrently +type countingScreener struct { + calls map[string]int + gate chan struct{} + mu sync.Mutex + want int + arrived int + inFlight int + maxSeen int + released bool +} + +func (c *countingScreener) Mode() string { + return models.MyClaListSssModeOptional +} + +func (c *countingScreener) peakInFlight() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxSeen +} + +func (c *countingScreener) release() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.released { + c.released = true + close(c.gate) + } +} + +func (c *countingScreener) ScreenCompany(_ context.Context, company *v1Models.Company) (bool, string) { + c.mu.Lock() + c.calls[company.CompanyID]++ + c.arrived++ + c.inFlight++ + if c.inFlight > c.maxSeen { + c.maxSeen = c.inFlight + } + hold := c.arrived <= c.want + full := c.arrived >= c.want + c.mu.Unlock() + + if full { + c.release() + } + if hold { + <-c.gate + } + + c.mu.Lock() + c.inFlight-- + c.mu.Unlock() + return false, models.MyClaFlaggedCheckLive +} + +// TestGetMyClasScreensDistinctEmployersInParallel is the 100-ECLAs-over-20-employers case: one +// screen per distinct employer, wantInFlight of them running at once, results merged. It pins +// fetchConcurrency deliberately - the in-flight count is a documented guarantee of the endpoint. +func TestGetMyClasScreensDistinctEmployersInParallel(t *testing.T) { + const employers, perEmployer, wantInFlight = 20, 5, 8 + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{byID: map[string]*v1Models.Company{}} + cclas := map[string]*v1Models.Signature{} + var userSigs []*signatures.ItemSignature + for i := 1; i <= employers; i++ { + companyID := fmt.Sprintf("company-%d", i) + companies.byID[companyID] = &v1Models.Company{CompanyID: companyID, CompanyName: companyID} + cclas["cla-group-1|"+companyID] = &v1Models.Signature{SignatureID: "ccla-" + companyID} + for j := 1; j <= perEmployer; j++ { + userSigs = append(userSigs, ecla(fmt.Sprintf("sig-%d-%d", i, j), companyID, "2024-01-01T00:00:00Z", true)) + } + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": userSigs}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{cclas: cclas, approvedUserIDs: map[string]bool{"user-a": true}} + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + screener := &countingScreener{calls: map[string]int{}, gate: make(chan struct{}), want: wantInFlight} + svc.sanctions = screener + + type outcome struct { + list *models.MyClaList + err error + } + done := make(chan outcome, 1) + go func() { + list, listErr := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + done <- outcome{list: list, err: listErr} + }() + + select { + case result := <-done: + require.NoError(t, result.err) + require.Len(t, result.list.Clas, employers*perEmployer) + case <-time.After(10 * time.Second): + screener.release() + t.Fatalf("the listing stalled with only %d of %d employers screened concurrently", screener.peakInFlight(), wantInFlight) + } + + assert.Len(t, screener.calls, employers, "every distinct employer is screened") + for companyID, calls := range screener.calls { + assert.Equal(t, 1, calls, "employer %s is screened exactly once for all %d of its rows", companyID, perEmployer) + } + assert.Equal(t, wantInFlight, screener.peakInFlight(), "the screens run fetchConcurrency at a time") } func TestGetMyClaPdfURL(t *testing.T) { @@ -764,27 +1337,27 @@ func TestGetMyClaPdfURL(t *testing.T) { svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) identity := &Identity{} - result, err := svc.GetMyClaPdfURL(context.Background(), "someone", false, identity, "sig-icla") + result, err := svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, identity, "sig-icla") require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "sig-icla", result.SignatureID) assert.Equal(t, "https://s3.example.org/contract-group/cla-group-1/icla/user-a/sig-icla.pdf", result.URL) assert.Equal(t, int64(900), result.ExpiresInSeconds) - result, err = svc.GetMyClaPdfURL(context.Background(), "someone", false, identity, "sig-ecla") + result, err = svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, identity, "sig-ecla") require.NoError(t, err) assert.Nil(t, result, "ECLAs have no signed PDF") - result, err = svc.GetMyClaPdfURL(context.Background(), "someone", false, identity, "sig-unsigned") + result, err = svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, identity, "sig-unsigned") require.NoError(t, err) assert.Nil(t, result, "unsigned records have no signed PDF") - result, err = svc.GetMyClaPdfURL(context.Background(), "someone", false, identity, "sig-of-somebody-else") + result, err = svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, identity, "sig-of-somebody-else") require.NoError(t, err) assert.Nil(t, result, "signatures not owned by the resolved identity are not found") svc.documentExists = func(_ string) (bool, error) { return false, nil } - result, err = svc.GetMyClaPdfURL(context.Background(), "someone", false, identity, "sig-icla") + result, err = svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, identity, "sig-icla") require.NoError(t, err) assert.Nil(t, result, "missing S3 objects are reported as not found instead of returning a dead URL") } @@ -801,14 +1374,14 @@ func TestGetMyClaPdfURLOwnershipEnforced(t *testing.T) { } svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) - result, err := svc.GetMyClaPdfURL(context.Background(), "someone", false, &Identity{ + result, err := svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "someone"}, &Identity{ LfUsername: "victim", Emails: []string{"victim@example.org"}, }, "sig-victim") require.NoError(t, err) assert.Nil(t, result, "a non-admin cannot resolve somebody else's signature") - result, err = svc.GetMyClaPdfURL(context.Background(), "staff-admin", true, &Identity{LfUsername: "victim"}, "sig-victim") + result, err = svc.GetMyClaPdfURL(context.Background(), &Caller{Username: "staff-admin", Admin: true}, &Identity{LfUsername: "victim"}, "sig-victim") require.NoError(t, err) require.NotNil(t, result) } @@ -862,3 +1435,102 @@ func TestIdentityIsEmpty(t *testing.T) { assert.False(t, (&Identity{GitlabUsernames: []string{"someone"}}).IsEmpty()) assert.False(t, (&Identity{GerritUsernames: []string{"someone"}}).IsEmpty()) } +func TestGetMyClasEmitsCompanySanctionedEvent(t *testing.T) { + const storedDate = "2024-01-15T10:11:12.000000+0000" + + tests := []struct { + name string + company *v1Models.Company + writeErr error + wantEvents int + }{ + { + name: "a fresh flag is logged", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Newly Flagged Corp"}, + wantEvents: 1, + }, + { + name: "a re-flag after a clear is logged", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Repeat Corp", SanctionedDate: storedDate}, + wantEvents: 1, + }, + { + name: "an already sanctioned employer is not re-logged", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Known Corp", IsSanctioned: true, SanctionedDate: storedDate}, + wantEvents: 0, + }, + { + name: "a date backfill for a known sanction is not logged", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Dateless Corp", IsSanctioned: true}, + wantEvents: 0, + }, + { + name: "a failed persist is not logged", + company: &v1Models.Company{CompanyID: "company-1", CompanyName: "Unwritable Corp"}, + writeErr: errors.New("dynamodb unavailable"), + wantEvents: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + companies := &fakeCompanies{ + byID: map[string]*v1Models.Company{"company-1": tc.company}, + writeErr: tc.writeErr, + } + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": {ecla("sig-1", "company-1", "2024-01-01T00:00:00Z", true)}}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + signaturesService := &fakeSignatures{ + cclas: map[string]*v1Models.Signature{"cla-group-1|company-1": {SignatureID: "ccla-1"}}, + approvedUserIDs: map[string]bool{"user-a": true}, + } + svc := newTestService(repo, &fakePlatform{}, signaturesService, companies, &fakeClaGroups{}) + svc.sanctions = &fakeScreener{mode: models.MyClaListSssModeRequired, flagged: map[string]bool{"company-1": true}} + eventsLog := &fakeEvents{} + svc.eventsService = eventsLog + + _, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + + require.Len(t, eventsLog.logged, tc.wantEvents) + if tc.wantEvents > 0 { + logged := eventsLog.logged[0] + assert.Equal(t, events.CompanySanctioned, logged.EventType) + assert.Equal(t, "user-a", logged.UserID, "a top-level user identity is required or the events service drops the event") + assert.Same(t, tc.company, logged.CompanyModel, "the company model is passed so the events service needs no extra lookup") + assert.Same(t, userA, logged.UserModel, "the listing user whose employer was screened is the event actor") + _, ok := logged.EventData.(*events.CompanySanctionedEventData) + assert.True(t, ok) + } + }) + } +} + +func TestGetMyClasInvalidatedAt(t *testing.T) { + userA := &v1Models.User{UserID: "user-a", LfUsername: "someone"} + invalidated := icla("sig-invalidated", "user-a", "cla-group-1", "2024-01-01T00:00:00Z", false) + invalidated.DateInvalidated = "2024-03-04T05:06:07.000000+0000" + invalidated.InvalidatedBy = "admin-user" + valid := icla("sig-valid", "user-a", "cla-group-1", "2024-02-01T00:00:00Z", true) + + repo := &fakeRepo{ + byUserID: map[string][]*signatures.ItemSignature{"user-a": {invalidated, valid}}, + byLFUsername: map[string][]*v1Models.User{"someone": {userA}}, + } + svc := newTestService(repo, &fakePlatform{}, &fakeSignatures{}, &fakeCompanies{}, &fakeClaGroups{}) + + result, err := svc.GetMyClas(context.Background(), &Caller{Username: "someone"}, &Identity{}) + require.NoError(t, err) + byID := map[string]models.MyCla{} + for _, row := range result.Clas { + byID[row.SignatureID] = row + } + + assert.Equal(t, "2024-03-04T05:06:07Z", byID["sig-invalidated"].InvalidatedAt) + assert.Equal(t, models.MyClaStatusInvalidated, byID["sig-invalidated"].Status) + assert.Empty(t, byID["sig-valid"].InvalidatedAt) + assert.Equal(t, models.MyClaStatusValid, byID["sig-valid"].Status) +} diff --git a/cla-backend-go/v2/project-service/client.go b/cla-backend-go/v2/project-service/client.go index b222cda0a..60ab67912 100644 --- a/cla-backend-go/v2/project-service/client.go +++ b/cla-backend-go/v2/project-service/client.go @@ -83,13 +83,14 @@ func (pmm *Client) GetProject(projectSFID string) (*models.ProjectOutputDetailed // Lookup in cache first mutex.Lock() // exclusive lock to the shared project service model map existingModel, exists := projectServiceModels[projectSFID] + cacheSize := len(projectServiceModels) mutex.Unlock() if exists { - //log.WithFields(f).Debugf("cache hit - cache size: %d", len(projectServiceModels)) + //log.WithFields(f).Debugf("cache hit - cache size: %d", cacheSize) return existingModel, nil } - log.WithFields(f).Debugf("cache miss - cache size: %d", len(projectServiceModels)) + log.WithFields(f).Debugf("cache miss - cache size: %d", cacheSize) tok, err := token.GetToken() if err != nil { diff --git a/cla-backend-go/v2/self_serve_sign/handlers.go b/cla-backend-go/v2/self_serve_sign/handlers.go new file mode 100644 index 000000000..9903dbc29 --- /dev/null +++ b/cla-backend-go/v2/self_serve_sign/handlers.go @@ -0,0 +1,68 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package self_serve_sign + +import ( + "context" + "errors" + + "github.com/LF-Engineering/lfx-kit/auth" + "github.com/go-openapi/runtime/middleware" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations" + selfServeSignOps "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/restapi/operations/self_serve_sign" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" +) + +const missingUsernameMsg = "the authenticated principal carries no username - unable to determine who is signing" + +// Configure sets up the Self Serve signing API handlers +func Configure(api *operations.EasyclaAPI, service Service) { + api.SelfServeSignPrepareSignHandler = selfServeSignOps.PrepareSignHandlerFunc( + func(params selfServeSignOps.PrepareSignParams, authUser *auth.User) middleware.Responder { + reqID := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTID, reqID) // nolint + utils.SetAuthUserProperties(authUser, params.XUSERNAME, params.XEMAIL) + f := logrus.Fields{ + "functionName": "v2.self_serve_sign.handlers.PrepareSign", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "authUserName": utils.StringValue(params.XUSERNAME), + "authUserEmail": utils.StringValue(params.XEMAIL), + } + + currentUsername, currentEmail, admin := principal(authUser) + if !admin && currentUsername == "" { + log.WithFields(f).Warn(missingUsernameMsg) + return selfServeSignOps.NewPrepareSignUnauthorized().WithXRequestID(reqID).WithPayload(utils.ErrorResponseUnauthorized(reqID, missingUsernameMsg)) + } + + result, err := service.PrepareSign(ctx, currentUsername, currentEmail, admin, ¶ms.Body) + if err != nil { + switch { + case errors.Is(err, ErrCLAGroupNotFound): + log.WithFields(f).WithError(err).Warn(err.Error()) + return selfServeSignOps.NewPrepareSignNotFound().WithXRequestID(reqID).WithPayload(utils.ErrorResponseNotFound(reqID, err.Error())) + case errors.Is(err, ErrIdentityNotVerified): + log.WithFields(f).WithError(err).Warn(err.Error()) + return selfServeSignOps.NewPrepareSignForbidden().WithXRequestID(reqID).WithPayload(utils.ErrorResponseForbidden(reqID, err.Error())) + case errors.Is(err, ErrIdentityRequired), errors.Is(err, ErrSigningNotEnabled), errors.Is(err, ErrReturnURLNotSupported): + log.WithFields(f).WithError(err).Warn(err.Error()) + return selfServeSignOps.NewPrepareSignBadRequest().WithXRequestID(reqID).WithPayload(utils.ErrorResponseBadRequest(reqID, err.Error())) + } + msg := "unable to prepare the signing session" + log.WithFields(f).WithError(err).Warn(msg) + return selfServeSignOps.NewPrepareSignInternalServerError().WithXRequestID(reqID).WithPayload(utils.ErrorResponseInternalServerErrorWithError(reqID, msg, err)) + } + + return selfServeSignOps.NewPrepareSignOK().WithXRequestID(reqID).WithPayload(result) + }) +} + +func principal(authUser *auth.User) (string, string, bool) { + if authUser == nil { + return "", "", false + } + return authUser.UserName, authUser.Email, utils.IsUserAdmin(authUser) +} diff --git a/cla-backend-go/v2/self_serve_sign/service.go b/cla-backend-go/v2/self_serve_sign/service.go new file mode 100644 index 000000000..eae819b1d --- /dev/null +++ b/cla-backend-go/v2/self_serve_sign/service.go @@ -0,0 +1,596 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package self_serve_sign + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + goapierrors "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + githubsdk "github.com/google/go-github/v37/github" + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/github" + log "github.com/linuxfoundation/easycla/cla-backend-go/logging" + "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" + "github.com/linuxfoundation/easycla/cla-backend-go/user" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/linuxfoundation/easycla/cla-backend-go/v2/my_clas" + "github.com/sirupsen/logrus" +) + +// ErrIdentityRequired is returned when the request carries no identity to sign under +var ErrIdentityRequired = errors.New("no identity provided - provide at least one of lfUsername, email, githubId, githubUsername, gitlabId, gitlabUsername, gerritUsername") + +// ErrIdentityNotVerified is returned when the provided identity could not be verified as belonging to the authenticated user +var ErrIdentityNotVerified = errors.New("the provided identity does not belong to the authenticated user") + +// ErrCLAGroupNotFound is returned when the CLA Group does not exist +var ErrCLAGroupNotFound = errors.New("cla group not found") + +// ErrSigningNotEnabled is returned when the CLA Group offers neither an ICLA nor a CCLA +var ErrSigningNotEnabled = errors.New("the cla group has neither an individual nor a corporate CLA enabled") + +// ErrReturnURLNotSupported is returned when the return URL is not an absolute https URL +var ErrReturnURLNotSupported = errors.New("returnUrl must be an absolute https URL") + +const activeSignatureTTLDays = 1 + +// MyClasService is the subset of the My CLAs service used to verify identity ownership +type MyClasService interface { + AuthorizeIdentity(ctx context.Context, currentUsername string, admin bool, requested *my_clas.Identity) (*my_clas.Identity, []string, error) +} + +// UsersService is the subset of the users service used to resolve, enrich and create EasyCLA user records +type UsersService interface { + GetUserByLFUserName(lfUserName string) (*v1Models.User, error) + GetUserByEmail(userEmail string) (*v1Models.User, error) + GetUserByGitHubID(gitHubID string) (*v1Models.User, error) + GetUserByGitHubUsername(gitHubUsername string) (*v1Models.User, error) + GetUserByGitlabID(gitLabID int) (*v1Models.User, error) + GetUserByGitLabUsername(gitLabUsername string) (*v1Models.User, error) + CreateUser(userModel *v1Models.User, claUser *user.CLAUser) (*v1Models.User, error) + UpdateUser(userID string, updates map[string]interface{}) (*v1Models.User, error) +} + +// CLAGroupService is the subset of the CLA Group service used to resolve the selected CLA Group +type CLAGroupService interface { + GetCLAGroupByID(ctx context.Context, claGroupID string) (*v1Models.ClaGroup, error) +} + +// ProjectsCLAGroupsRepository is the subset of the projects-cla-groups repository used to resolve the Salesforce IDs of a CLA Group +type ProjectsCLAGroupsRepository interface { + GetProjectsIdsForClaGroup(ctx context.Context, claGroupID string) ([]*projects_cla_groups.ProjectClaGroup, error) +} + +// StoreRepository is the subset of the store repository used to record the active signing session +type StoreRepository interface { + SetActiveSignatureMetaData(ctx context.Context, key string, expire int64, value string) error +} + +// Service interface defines the Self Serve signing service methods +type Service interface { + PrepareSign(ctx context.Context, currentUsername, currentEmail string, admin bool, input *models.PrepareSignInput) (*models.PrepareSign, error) +} + +type service struct { + myClasService MyClasService + usersService UsersService + claGroupService CLAGroupService + projectsClaGroupsRepo ProjectsCLAGroupsRepository + storeRepo StoreRepository + contributorConsoleURL string + githubUserDetails func(username string) (*githubsdk.User, error) +} + +// NewService creates a new instance of the Self Serve signing service +func NewService(myClasService MyClasService, usersService UsersService, claGroupService CLAGroupService, projectsClaGroupsRepo ProjectsCLAGroupsRepository, storeRepo StoreRepository, contributorConsoleURL string) Service { + return &service{ + myClasService: myClasService, + usersService: usersService, + claGroupService: claGroupService, + projectsClaGroupsRepo: projectsClaGroupsRepo, + storeRepo: storeRepo, + contributorConsoleURL: contributorConsoleURL, + githubUserDetails: github.GetUserDetails, + } +} + +// PrepareSign verifies the requested identity belongs to the authenticated user, resolves or +// creates the EasyCLA user record for it, records the signing session and returns the +// Contributor Console hand-off URL +func (s *service) PrepareSign(ctx context.Context, currentUsername, currentEmail string, admin bool, input *models.PrepareSignInput) (*models.PrepareSign, error) { + claGroupID := strings.TrimSpace(utils.StringValue(input.ClaGroupID)) + f := logrus.Fields{ + "functionName": "v2.self_serve_sign.service.PrepareSign", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "currentUsername": currentUsername, + "claGroupID": claGroupID, + } + + returnURL := "" + if input.ReturnURL != nil { + returnURL = strings.TrimSpace(input.ReturnURL.String()) + } + if !isSupportedReturnURL(returnURL) { + log.WithFields(f).Warn(ErrReturnURLNotSupported.Error()) + return nil, ErrReturnURLNotSupported + } + + claGroup, err := s.claGroupService.GetCLAGroupByID(ctx, claGroupID) + if err != nil || claGroup == nil { + log.WithFields(f).WithError(err).Warn("unable to lookup the cla group") + return nil, ErrCLAGroupNotFound + } + if !claGroup.ProjectICLAEnabled && !claGroup.ProjectCCLAEnabled { + log.WithFields(f).Warn(ErrSigningNotEnabled.Error()) + return nil, ErrSigningNotEnabled + } + + requested := identityFromInput(input) + if requested.IsEmpty() { + if currentUsername == "" { + return nil, ErrIdentityRequired + } + requested.LfUsername = currentUsername + } + + requestedLfUsername := strings.TrimSpace(requested.LfUsername) + preparingForSelf := requestedLfUsername != "" && strings.EqualFold(requestedLfUsername, currentUsername) + + allowed, skipped, err := s.myClasService.AuthorizeIdentity(ctx, currentUsername, admin, requested) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to verify the provided identity") + return nil, err + } + skipped = s.acceptVerifiedGithubID(ctx, input, allowed, skipped) + + // An admin may prepare for somebody else, and My CLAs fills an unset lfUsername with the caller's + // own for read scoping - keep that from binding the caller's LF identity to the signer's record + fallbackEmail := currentEmail + if admin && !preparingForSelf { + if requestedLfUsername == "" { + allowed.LfUsername = "" + } + fallbackEmail = "" + } + if !identityAccepted(requested, allowed) { + log.WithFields(f).WithField("skippedIdentities", skipped).Warn(ErrIdentityNotVerified.Error()) + return nil, ErrIdentityNotVerified + } + + userModel, created, err := s.resolveOrCreateUser(ctx, allowed, currentUsername, fallbackEmail) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to resolve or create the EasyCLA user record") + return nil, err + } + + if err := s.recordSigningSession(ctx, userModel.UserID, claGroupID, returnURL, identityACL(allowed)); err != nil { + log.WithFields(f).WithError(err).Warn("unable to record the active signing session") + return nil, err + } + + result := &models.PrepareSign{ + UserID: userModel.UserID, + UserCreated: created, + LfUsername: userModel.LfUsername, + UserName: userModel.Username, + UserEmail: string(userModel.LfEmail), + Identity: identityKeys(allowed), + SkippedIdentities: skipped, + ClaGroupID: claGroupID, + ClaGroupName: claGroup.ProjectName, + FoundationSfid: claGroup.FoundationSFID, + IclaEnabled: claGroup.ProjectICLAEnabled, + CclaEnabled: claGroup.ProjectCCLAEnabled, + CclaRequiresIcla: claGroup.ProjectCCLARequiresICLA, + ReturnURL: returnURL, + SignURL: s.consoleSignURL(claGroupID, userModel.UserID, returnURL), + } + if result.UserEmail == "" && len(userModel.Emails) > 0 { + result.UserEmail = userModel.Emails[0] + } + result.ProjectSfid = s.projectSFID(ctx, claGroupID, claGroup.ProjectExternalID) + + return result, nil +} + +// acceptVerifiedGithubID admits the requested GitHub numeric ID when it resolves to the GitHub +// account named by an already verified GitHub username - the platform user-service exposes +// usernames only, so a first-time signer's numeric ID cannot be verified any other way +func (s *service) acceptVerifiedGithubID(ctx context.Context, input *models.PrepareSignInput, allowed *my_clas.Identity, skipped []string) []string { + if input.GithubID <= 0 || containsID(allowed.GithubIDs, input.GithubID) { + return skipped + } + f := logrus.Fields{ + "functionName": "v2.self_serve_sign.service.acceptVerifiedGithubID", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "githubID": input.GithubID, + "githubUsername": input.GithubUsername, + } + + username := strings.TrimSpace(input.GithubUsername) + if username == "" || !containsFold(allowed.GithubUsernames, username) { + return skipped + } + githubUser, err := s.githubUserDetails(username) + if err != nil || githubUser == nil || githubUser.GetID() != input.GithubID { + log.WithFields(f).WithError(err).Warn("the provided GitHub ID does not match the verified GitHub username") + return skipped + } + + allowed.GithubIDs = append(allowed.GithubIDs, input.GithubID) + return removeValue(skipped, "githubId:"+strconv.FormatInt(input.GithubID, 10)) +} + +func (s *service) resolveOrCreateUser(ctx context.Context, allowed *my_clas.Identity, currentUsername, fallbackEmail string) (*v1Models.User, bool, error) { + userModel, err := s.resolveUser(ctx, allowed) + if err != nil { + return nil, false, err + } + if userModel != nil { + return s.enrichUser(ctx, userModel, allowed), false, nil + } + + newUser := &v1Models.User{ + LfUsername: allowed.LfUsername, + Username: allowed.LfUsername, + } + if len(allowed.GithubIDs) > 0 { + newUser.GithubID = strconv.FormatInt(allowed.GithubIDs[0], 10) + } + if len(allowed.GithubUsernames) > 0 { + newUser.GithubUsername = allowed.GithubUsernames[0] + newUser.Username = allowed.GithubUsernames[0] + } + if len(allowed.GitlabIDs) > 0 { + newUser.GitlabID = strconv.FormatInt(allowed.GitlabIDs[0], 10) + } + if len(allowed.GitlabUsernames) > 0 { + newUser.GitlabUsername = allowed.GitlabUsernames[0] + } + if email := firstValue(append(allowed.Emails, fallbackEmail)); email != "" { + newUser.LfEmail = strfmt.Email(email) + newUser.Emails = []string{email} + } + if newUser.Username == "" { + newUser.Username = string(newUser.LfEmail) + } + + created, err := s.usersService.CreateUser(newUser, &user.CLAUser{LFUsername: currentUsername}) + if err != nil { + return nil, false, err + } + return created, true, nil +} + +func (s *service) resolveUser(ctx context.Context, allowed *my_clas.Identity) (*v1Models.User, error) { + f := logrus.Fields{ + "functionName": "v2.self_serve_sign.service.resolveUser", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + + for _, githubID := range allowed.GithubIDs { + found, err := s.usersService.GetUserByGitHubID(strconv.FormatInt(githubID, 10)) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found != nil { + return found, nil + } + } + for _, githubUsername := range allowed.GithubUsernames { + found, err := s.usersService.GetUserByGitHubUsername(githubUsername) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found == nil { + continue + } + if !idBelongs(found.GithubID, allowed.GithubIDs) { + log.WithFields(f).Warnf("skipping user record %s matched on github username %s - stored github id %s is not one of the verified ids %v", found.UserID, githubUsername, found.GithubID, allowed.GithubIDs) + continue + } + return found, nil + } + for _, gitlabID := range allowed.GitlabIDs { + found, err := s.usersService.GetUserByGitlabID(int(gitlabID)) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found != nil { + return found, nil + } + } + for _, gitlabUsername := range allowed.GitlabUsernames { + found, err := s.usersService.GetUserByGitLabUsername(gitlabUsername) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found == nil { + continue + } + if !idBelongs(found.GitlabID, allowed.GitlabIDs) { + log.WithFields(f).Warnf("skipping user record %s matched on gitlab username %s - stored gitlab id %s is not one of the verified ids %v", found.UserID, gitlabUsername, found.GitlabID, allowed.GitlabIDs) + continue + } + return found, nil + } + for _, lfUsername := range append([]string{allowed.LfUsername}, allowed.GerritUsernames...) { + if strings.TrimSpace(lfUsername) == "" { + continue + } + found, err := s.usersService.GetUserByLFUserName(lfUsername) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found != nil { + return found, nil + } + } + for _, email := range allowed.Emails { + found, err := s.usersService.GetUserByEmail(email) + if err != nil && !isUserNotFound(err) { + return nil, err + } + if found != nil { + return found, nil + } + } + + log.WithFields(f).Debug("no EasyCLA user record matched the verified identity") + return nil, nil +} + +// isUserNotFound tells an empty lookup apart from a failing one - the user getters report a miss as +// a go-openapi 404, a *utils.UserNotFound or a nil user +func isUserNotFound(err error) bool { + var notFound *utils.UserNotFound + if errors.As(err, ¬Found) { + return true + } + var apiErr goapierrors.Error + if errors.As(err, &apiErr) { + return apiErr.Code() == http.StatusNotFound + } + return false +} + +// enrichUser fills in the verified identity fields the matched record is missing - an existing +// value is never replaced, so a record already bound to another linked identity is left alone. +// The provider IDs go in as int64 - the table and its GSIs key them as DynamoDB numbers +func (s *service) enrichUser(ctx context.Context, userModel *v1Models.User, allowed *my_clas.Identity) *v1Models.User { + updates := make(map[string]interface{}) + if userModel.LfUsername == "" && allowed.LfUsername != "" { + updates["lf_username"] = allowed.LfUsername + } + if userModel.GithubID == "" && len(allowed.GithubIDs) > 0 { + updates["user_github_id"] = allowed.GithubIDs[0] + } + if userModel.GithubUsername == "" && len(allowed.GithubUsernames) > 0 { + updates["user_github_username"] = allowed.GithubUsernames[0] + } + if userModel.GitlabID == "" && len(allowed.GitlabIDs) > 0 { + updates["user_gitlab_id"] = allowed.GitlabIDs[0] + } + if userModel.GitlabUsername == "" && len(allowed.GitlabUsernames) > 0 { + updates["user_gitlab_username"] = allowed.GitlabUsernames[0] + } + if len(updates) == 0 { + return userModel + } + + updates["date_modified"] = time.Now().UTC().Format(time.RFC3339) + updated, err := s.usersService.UpdateUser(userModel.UserID, updates) + if err != nil || updated == nil { + log.WithFields(logrus.Fields{ + "functionName": "v2.self_serve_sign.service.enrichUser", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "userID": userModel.UserID, + }).WithError(err).Warn("unable to store the verified identity on the EasyCLA user record") + return userModel + } + return updated +} + +type activeSignatureMetadata struct { + UserID string `json:"user_id"` + ProjectID string `json:"project_id"` + ReturnURL string `json:"return_url,omitempty"` + ACL string `json:"acl,omitempty"` + Source string `json:"source"` +} + +func (s *service) recordSigningSession(ctx context.Context, userID, claGroupID, returnURL, acl string) error { + value, err := json.Marshal(&activeSignatureMetadata{ + UserID: userID, + ProjectID: claGroupID, + ReturnURL: returnURL, + ACL: acl, + Source: utils.SelfServeSignatureSource, + }) + if err != nil { + return err + } + expire := time.Now().AddDate(0, 0, activeSignatureTTLDays).Unix() + return s.storeRepo.SetActiveSignatureMetaData(ctx, fmt.Sprintf("active_signature:%s", userID), expire, string(value)) +} + +func (s *service) consoleSignURL(claGroupID, userID, returnURL string) string { + signURL := fmt.Sprintf("https://%s/#/cla/project/%s/user/%s", strings.TrimSuffix(s.contributorConsoleURL, "/"), claGroupID, userID) + if returnURL != "" { + signURL += "?redirect=" + url.QueryEscape(returnURL) + } + return signURL +} + +func (s *service) projectSFID(ctx context.Context, claGroupID, fallback string) string { + projects, err := s.projectsClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, claGroupID) + if err != nil { + log.WithFields(logrus.Fields{ + "functionName": "v2.self_serve_sign.service.projectSFID", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "claGroupID": claGroupID, + }).WithError(err).Warn("unable to resolve the Salesforce projects of the cla group") + return fallback + } + if len(projects) == 1 { + return projects[0].ProjectSFID + } + return "" +} + +func identityFromInput(input *models.PrepareSignInput) *my_clas.Identity { + identity := &my_clas.Identity{LfUsername: strings.TrimSpace(input.LfUsername)} + appendValue(&identity.Emails, input.Email) + appendValue(&identity.GithubUsernames, input.GithubUsername) + appendValue(&identity.GitlabUsernames, input.GitlabUsername) + appendValue(&identity.GerritUsernames, input.GerritUsername) + if input.GithubID > 0 { + identity.GithubIDs = []int64{input.GithubID} + } + if input.GitlabID > 0 { + identity.GitlabIDs = []int64{input.GitlabID} + } + return identity +} + +// identityAccepted requires the identity actually asked for to have survived verification - a +// requested key that was dropped must not silently fall back to signing as the LF username +func identityAccepted(requested, allowed *my_clas.Identity) bool { + if allowed == nil { + return false + } + if requested.LfUsername != "" && !strings.EqualFold(requested.LfUsername, allowed.LfUsername) { + return false + } + if onlyLfUsername(requested) { + return strings.TrimSpace(allowed.LfUsername) != "" + } + return len(allowed.Emails) > 0 || len(allowed.GithubIDs) > 0 || len(allowed.GithubUsernames) > 0 || + len(allowed.GitlabIDs) > 0 || len(allowed.GitlabUsernames) > 0 || len(allowed.GerritUsernames) > 0 +} + +func onlyLfUsername(identity *my_clas.Identity) bool { + lfUsername := identity.LfUsername + identity.LfUsername = "" + empty := identity.IsEmpty() + identity.LfUsername = lfUsername + return empty +} + +func identityKeys(allowed *my_clas.Identity) []string { + keys := []string{} + if allowed.LfUsername != "" { + keys = append(keys, "lf-username:"+allowed.LfUsername) + } + for _, email := range allowed.Emails { + keys = append(keys, "email:"+email) + } + for _, githubID := range allowed.GithubIDs { + keys = append(keys, "github-id:"+strconv.FormatInt(githubID, 10)) + } + for _, githubUsername := range allowed.GithubUsernames { + keys = append(keys, "github-username:"+githubUsername) + } + for _, gitlabID := range allowed.GitlabIDs { + keys = append(keys, "gitlab-id:"+strconv.FormatInt(gitlabID, 10)) + } + for _, gitlabUsername := range allowed.GitlabUsernames { + keys = append(keys, "gitlab-username:"+gitlabUsername) + } + for _, gerritUsername := range allowed.GerritUsernames { + keys = append(keys, "gerrit-username:"+gerritUsername) + } + return keys +} + +func appendValue(values *[]string, value string) { + if trimmed := strings.TrimSpace(value); trimmed != "" { + *values = append(*values, trimmed) + } +} + +func firstValue(values []string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +// identityACL names the identity the contributor signs under, so the signature records the one +// that was verified here rather than the first one stored on the user record +func identityACL(allowed *my_clas.Identity) string { + switch { + case len(allowed.GithubIDs) > 0: + return fmt.Sprintf("github:%d", allowed.GithubIDs[0]) + case len(allowed.GitlabIDs) > 0: + return fmt.Sprintf("gitlab:%d", allowed.GitlabIDs[0]) + case strings.TrimSpace(allowed.LfUsername) != "": + return strings.TrimSpace(allowed.LfUsername) + } + return "" +} + +// isSupportedReturnURL keeps a hand-off from minting a return target the Contributor Console would +// open in its own origin - the console opens the stored value with window.open(url, '_self') +func isSupportedReturnURL(returnURL string) bool { + parsed, err := url.Parse(returnURL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Scheme, "https") && parsed.Host != "" +} + +// idBelongs guards the username lookups - provider usernames are recyclable, so a record whose +// stored numeric ID is not one of the verified ones belongs to a previous owner of that username +func idBelongs(storedID string, verifiedIDs []int64) bool { + storedID = strings.TrimSpace(storedID) + if storedID == "" { + return true + } + parsed, err := strconv.ParseInt(storedID, 10, 64) + if err != nil { + return false + } + return containsID(verifiedIDs, parsed) +} + +func containsID(ids []int64, id int64) bool { + for _, value := range ids { + if value == id { + return true + } + } + return false +} + +func containsFold(values []string, value string) bool { + for _, item := range values { + if strings.EqualFold(item, value) { + return true + } + } + return false +} + +func removeValue(values []string, value string) []string { + result := []string{} + for _, item := range values { + if item != value { + result = append(result, item) + } + } + return result +} diff --git a/cla-backend-go/v2/self_serve_sign/service_test.go b/cla-backend-go/v2/self_serve_sign/service_test.go new file mode 100644 index 000000000..fd2065e9d --- /dev/null +++ b/cla-backend-go/v2/self_serve_sign/service_test.go @@ -0,0 +1,510 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package self_serve_sign + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + goapierrors "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + githubsdk "github.com/google/go-github/v37/github" + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/gen/v2/models" + "github.com/linuxfoundation/easycla/cla-backend-go/projects_cla_groups" + "github.com/linuxfoundation/easycla/cla-backend-go/user" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/linuxfoundation/easycla/cla-backend-go/v2/my_clas" + "github.com/stretchr/testify/assert" +) + +type fakeMyClas struct { + allowed *my_clas.Identity + skipped []string + err error +} + +func (f *fakeMyClas) AuthorizeIdentity(_ context.Context, currentUsername string, _ bool, _ *my_clas.Identity) (*my_clas.Identity, []string, error) { + if f.err != nil { + return nil, nil, f.err + } + allowed := *f.allowed + if allowed.LfUsername == "" { + allowed.LfUsername = currentUsername + } + return &allowed, append([]string{}, f.skipped...), nil +} + +type fakeUsers struct { + byGithubID map[string]*v1Models.User + byGithubUsername map[string]*v1Models.User + byGitlabID map[int]*v1Models.User + byGitlabUsername map[string]*v1Models.User + byLFUsername map[string]*v1Models.User + byEmail map[string]*v1Models.User + created *v1Models.User + updates map[string]interface{} + createErr error + notFound func() error + lookupErr error +} + +var errNotFound = errors.New("not found") + +func (f *fakeUsers) GetUserByGitHubID(gitHubID string) (*v1Models.User, error) { + return lookupIn(f, f.byGithubID, gitHubID) +} +func (f *fakeUsers) GetUserByGitHubUsername(gitHubUsername string) (*v1Models.User, error) { + return lookupIn(f, f.byGithubUsername, gitHubUsername) +} +func (f *fakeUsers) GetUserByGitlabID(gitLabID int) (*v1Models.User, error) { + return lookupIn(f, f.byGitlabID, gitLabID) +} +func (f *fakeUsers) GetUserByGitLabUsername(gitLabUsername string) (*v1Models.User, error) { + return lookupIn(f, f.byGitlabUsername, gitLabUsername) +} +func (f *fakeUsers) GetUserByLFUserName(lfUserName string) (*v1Models.User, error) { + return lookupIn(f, f.byLFUsername, lfUserName) +} +func (f *fakeUsers) GetUserByEmail(userEmail string) (*v1Models.User, error) { + return lookupIn(f, f.byEmail, userEmail) +} + +func (f *fakeUsers) CreateUser(userModel *v1Models.User, _ *user.CLAUser) (*v1Models.User, error) { + if f.createErr != nil { + return nil, f.createErr + } + created := *userModel + created.UserID = "created-user-id" + f.created = &created + return &created, nil +} + +func (f *fakeUsers) UpdateUser(userID string, updates map[string]interface{}) (*v1Models.User, error) { + f.updates = updates + return &v1Models.User{UserID: userID}, nil +} + +func lookupIn[K comparable](f *fakeUsers, values map[K]*v1Models.User, key K) (*v1Models.User, error) { + if f.lookupErr != nil { + return nil, f.lookupErr + } + if found, ok := values[key]; ok { + return found, nil + } + if f.notFound != nil { + return nil, f.notFound() + } + return nil, goapierrors.NotFound("user not found") +} + +type fakeCLAGroups struct { + claGroup *v1Models.ClaGroup + err error +} + +func (f *fakeCLAGroups) GetCLAGroupByID(_ context.Context, _ string) (*v1Models.ClaGroup, error) { + return f.claGroup, f.err +} + +type fakeProjectsCLAGroups struct { + projects []*projects_cla_groups.ProjectClaGroup + err error +} + +func (f *fakeProjectsCLAGroups) GetProjectsIdsForClaGroup(_ context.Context, _ string) ([]*projects_cla_groups.ProjectClaGroup, error) { + return f.projects, f.err +} + +type fakeStore struct { + key string + value string + err error +} + +func (f *fakeStore) SetActiveSignatureMetaData(_ context.Context, key string, _ int64, value string) error { + f.key, f.value = key, value + return f.err +} + +const testCLAGroupID = "aa47b3e1-6f9c-4b6a-9f16-0f9d6a2e1c11" + +func newTestService(myClas MyClasService, users UsersService, claGroups CLAGroupService, store StoreRepository) *service { + return &service{ + myClasService: myClas, + usersService: users, + claGroupService: claGroups, + projectsClaGroupsRepo: &fakeProjectsCLAGroups{projects: []*projects_cla_groups.ProjectClaGroup{{ProjectSFID: "a09P000000DsCE6IAN"}}}, + storeRepo: store, + contributorConsoleURL: "contributor.dev.lfx.linuxfoundation.org", + githubUserDetails: func(username string) (*githubsdk.User, error) { + if username != "octocat" { + return nil, errNotFound + } + id := int64(26589865) + return &githubsdk.User{ID: &id}, nil + }, + } +} + +func enabledCLAGroup() *fakeCLAGroups { + return &fakeCLAGroups{claGroup: &v1Models.ClaGroup{ + ProjectID: testCLAGroupID, + ProjectName: "Test CLA Group", + FoundationSFID: "a09P000000DsCE5IAN", + ProjectICLAEnabled: true, + ProjectCCLAEnabled: true, + }} +} + +func stringRef(value string) *string { return &value } + +func uriRef(value string) *strfmt.URI { + uri := strfmt.URI(value) + return &uri +} + +const testReturnURL = "https://openprofile.dev/my-clas" + +func TestPrepareSignResolvesExistingUserByGithubID(t *testing.T) { + existing := &v1Models.User{UserID: "existing-user-id", LfUsername: "lgryglicki", GithubID: "26589865", GithubUsername: "octocat"} + users := &fakeUsers{byGithubID: map[string]*v1Models.User{"26589865": existing}} + store := &fakeStore{} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubIDs: []int64{26589865}, GithubUsernames: []string{"octocat"}}}, + users, enabledCLAGroup(), store) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "l@example.org", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.Equal(t, "existing-user-id", result.UserID) + assert.False(t, result.UserCreated) + assert.Nil(t, users.created) + assert.Equal(t, "a09P000000DsCE6IAN", result.ProjectSfid) + assert.Contains(t, result.Identity, "github-id:26589865") + assert.Equal(t, "https://contributor.dev.lfx.linuxfoundation.org/#/cla/project/"+testCLAGroupID+"/user/existing-user-id?redirect=https%3A%2F%2Fopenprofile.dev%2Fmy-clas", result.SignURL) + + assert.Equal(t, "active_signature:existing-user-id", store.key) + var metadata map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(store.value), &metadata)) + assert.Equal(t, "self-serve", metadata["source"]) + assert.Equal(t, testCLAGroupID, metadata["project_id"]) + assert.Equal(t, "https://openprofile.dev/my-clas", metadata["return_url"]) +} + +func TestPrepareSignCreatesUserForFirstTimeSigner(t *testing.T) { + users := &fakeUsers{} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubUsernames: []string{"octocat"}}, skipped: []string{"githubId:26589865"}}, + users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "l@example.org", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.True(t, result.UserCreated) + assert.Equal(t, "created-user-id", result.UserID) + assert.Equal(t, "26589865", users.created.GithubID) + assert.Equal(t, "octocat", users.created.GithubUsername) + assert.Equal(t, "lgryglicki", users.created.LfUsername) + assert.Equal(t, "l@example.org", string(users.created.LfEmail)) + assert.Empty(t, result.SkippedIdentities) + assert.True(t, strings.HasSuffix(result.SignURL, "?redirect=https%3A%2F%2Fopenprofile.dev%2Fmy-clas")) +} + +func TestPrepareSignKeepsUnmatchedGithubIDSkipped(t *testing.T) { + users := &fakeUsers{} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubUsernames: []string{"octocat"}}, skipped: []string{"githubId:999"}}, + users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 999, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"githubId:999"}, result.SkippedIdentities) + assert.Empty(t, users.created.GithubID) +} + +func TestPrepareSignIgnoresARecordBoundToAnotherGithubID(t *testing.T) { + recycled := &v1Models.User{UserID: "previous-owner-id", GithubUsername: "octocat", GithubID: "999"} + users := &fakeUsers{byGithubUsername: map[string]*v1Models.User{"octocat": recycled}} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubUsernames: []string{"octocat"}}}, + users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.True(t, result.UserCreated) + assert.Equal(t, "created-user-id", result.UserID) + assert.Equal(t, "26589865", users.created.GithubID) +} + +func TestPrepareSignRejectsUnverifiedIdentity(t *testing.T) { + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{}, skipped: []string{"githubId:26589865", "githubUsername:octocat"}}, + &fakeUsers{}, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.ErrorIs(t, err, ErrIdentityNotVerified) +} + +func TestPrepareSignRejectsAnotherLFUsername(t *testing.T) { + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{LfUsername: "lgryglicki"}, skipped: []string{"lfUsername:someone-else"}}, + &fakeUsers{}, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + LfUsername: "someone-else", + }) + + assert.ErrorIs(t, err, ErrIdentityNotVerified) +} + +func TestPrepareSignDefaultsToTheAuthenticatedLFUsername(t *testing.T) { + existing := &v1Models.User{UserID: "existing-user-id", LfUsername: "lgryglicki"} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{}}, + &fakeUsers{byLFUsername: map[string]*v1Models.User{"lgryglicki": existing}}, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + }) + + assert.NoError(t, err) + assert.Equal(t, "existing-user-id", result.UserID) + assert.Contains(t, result.Identity, "lf-username:lgryglicki") +} + +func TestPrepareSignEnrichesOnlyMissingIdentityFields(t *testing.T) { + existing := &v1Models.User{UserID: "existing-user-id", LfUsername: "lgryglicki", GithubID: "111"} + users := &fakeUsers{byGithubID: map[string]*v1Models.User{"26589865": existing}} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubIDs: []int64{26589865}, GithubUsernames: []string{"octocat"}}}, + users, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.Equal(t, "octocat", users.updates["user_github_username"]) + _, updatedGithubID := users.updates["user_github_id"] + assert.False(t, updatedGithubID) +} + +func TestPrepareSignUnknownCLAGroup(t *testing.T) { + svc := newTestService(&fakeMyClas{allowed: &my_clas.Identity{}}, &fakeUsers{}, + &fakeCLAGroups{err: errNotFound}, &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + }) + + assert.ErrorIs(t, err, ErrCLAGroupNotFound) +} + +func TestPrepareSignSigningNotEnabled(t *testing.T) { + svc := newTestService(&fakeMyClas{allowed: &my_clas.Identity{}}, &fakeUsers{}, + &fakeCLAGroups{claGroup: &v1Models.ClaGroup{ProjectID: testCLAGroupID}}, &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + }) + + assert.ErrorIs(t, err, ErrSigningNotEnabled) +} + +func TestPrepareSignRequiresAnIdentityForAnAdminWithoutAPrincipal(t *testing.T) { + svc := newTestService(&fakeMyClas{allowed: &my_clas.Identity{}}, &fakeUsers{}, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "", "", true, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + }) + + assert.ErrorIs(t, err, ErrIdentityRequired) +} + +func TestPrepareSignStoresProviderIDsAsNumbers(t *testing.T) { + existing := &v1Models.User{UserID: "existing-user-id", LfUsername: "lgryglicki", GithubUsername: "octocat"} + users := &fakeUsers{byGithubUsername: map[string]*v1Models.User{"octocat": existing}} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubIDs: []int64{26589865}, GithubUsernames: []string{"octocat"}, GitlabIDs: []int64{77}}}, + users, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + // the user table and its GSIs key both provider IDs as DynamoDB numbers + assert.Equal(t, int64(26589865), users.updates["user_github_id"]) + assert.Equal(t, int64(77), users.updates["user_gitlab_id"]) +} + +func TestPrepareSignRecordsTheVerifiedIdentityACL(t *testing.T) { + sessionACL := func(t *testing.T, allowed *my_clas.Identity, input *models.PrepareSignInput) string { + t.Helper() + store := &fakeStore{} + svc := newTestService(&fakeMyClas{allowed: allowed}, &fakeUsers{}, enabledCLAGroup(), store) + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, input) + assert.NoError(t, err) + var metadata map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(store.value), &metadata)) + acl, ok := metadata["acl"].(string) + assert.True(t, ok) + return acl + } + + assert.Equal(t, "github:26589865", sessionACL(t, + &my_clas.Identity{GithubIDs: []int64{26589865}, GithubUsernames: []string{"octocat"}}, + &models.PrepareSignInput{ClaGroupID: stringRef(testCLAGroupID), ReturnURL: uriRef(testReturnURL), GithubID: 26589865, GithubUsername: "octocat"})) + + assert.Equal(t, "gitlab:77", sessionACL(t, + &my_clas.Identity{GitlabIDs: []int64{77}, GitlabUsernames: []string{"lgryglicki"}}, + &models.PrepareSignInput{ClaGroupID: stringRef(testCLAGroupID), ReturnURL: uriRef(testReturnURL), GitlabID: 77, GitlabUsername: "lgryglicki"})) + + assert.Equal(t, "lgryglicki", sessionACL(t, + &my_clas.Identity{LfUsername: "lgryglicki"}, + &models.PrepareSignInput{ClaGroupID: stringRef(testCLAGroupID), ReturnURL: uriRef(testReturnURL), LfUsername: "lgryglicki"})) +} + +func TestPrepareSignDoesNotBindTheAdminIdentityToAnotherContributor(t *testing.T) { + admin := &v1Models.User{UserID: "admin-user-id", LfUsername: "lfadmin", LfEmail: "admin@example.org"} + users := &fakeUsers{byLFUsername: map[string]*v1Models.User{"lfadmin": admin}} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubIDs: []int64{26589865}, GithubUsernames: []string{"octocat"}}}, + users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lfadmin", "admin@example.org", true, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubID: 26589865, + GithubUsername: "octocat", + }) + + assert.NoError(t, err) + assert.Equal(t, "created-user-id", result.UserID) + assert.True(t, result.UserCreated) + assert.Nil(t, users.updates) + assert.NotNil(t, users.created) + assert.Equal(t, "", users.created.LfUsername) + assert.Equal(t, "", string(users.created.LfEmail)) + assert.Empty(t, users.created.Emails) + assert.Equal(t, "octocat", users.created.Username) +} + +func TestPrepareSignLetsAnAdminPrepareForThemselves(t *testing.T) { + admin := &v1Models.User{UserID: "admin-user-id", LfUsername: "lfadmin"} + users := &fakeUsers{byLFUsername: map[string]*v1Models.User{"lfadmin": admin}} + svc := newTestService(&fakeMyClas{allowed: &my_clas.Identity{LfUsername: "lfadmin"}}, users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lfadmin", "admin@example.org", true, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + LfUsername: "lfadmin", + }) + + assert.NoError(t, err) + assert.Equal(t, "admin-user-id", result.UserID) + assert.Nil(t, users.created) +} + +func TestPrepareSignRejectsANonHTTPSReturnURL(t *testing.T) { + for _, returnURL := range []string{"http://openprofile.dev/my-clas", "javascript:alert(1)", "/my-clas", "https://"} { + svc := newTestService(&fakeMyClas{allowed: &my_clas.Identity{LfUsername: "lgryglicki"}}, &fakeUsers{}, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(returnURL), + }) + + assert.ErrorIs(t, err, ErrReturnURLNotSupported, returnURL) + } +} + +func TestPrepareSignDoesNotCreateAUserWhenTheLookupFails(t *testing.T) { + users := &fakeUsers{lookupErr: errors.New("dynamodb is unavailable")} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubUsernames: []string{"octocat"}}}, + users, enabledCLAGroup(), &fakeStore{}) + + _, err := svc.PrepareSign(context.Background(), "lgryglicki", "l@example.org", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubUsername: "octocat", + }) + + assert.Error(t, err) + assert.Nil(t, users.created) +} + +func TestPrepareSignTreatsEveryNotFoundShapeAsAMiss(t *testing.T) { + shapes := map[string]func() error{ + "go-openapi not found": func() error { return goapierrors.NotFound("user not found") }, + "utils.UserNotFound": func() error { return &utils.UserNotFound{Message: "user not found"} }, + "nil user": func() error { return nil }, + } + + for name, shape := range shapes { + t.Run(name, func(t *testing.T) { + users := &fakeUsers{notFound: shape, byEmail: map[string]*v1Models.User{"l@example.org": {UserID: "existing-user-id"}}} + svc := newTestService( + &fakeMyClas{allowed: &my_clas.Identity{GithubUsernames: []string{"octocat"}, Emails: []string{"l@example.org"}}}, + users, enabledCLAGroup(), &fakeStore{}) + + result, err := svc.PrepareSign(context.Background(), "lgryglicki", "l@example.org", false, &models.PrepareSignInput{ + ClaGroupID: stringRef(testCLAGroupID), + ReturnURL: uriRef(testReturnURL), + GithubUsername: "octocat", + Email: "l@example.org", + }) + + assert.NoError(t, err) + assert.False(t, result.UserCreated) + assert.Equal(t, "existing-user-id", result.UserID) + assert.Nil(t, users.created) + }) + } +} diff --git a/cla-backend-go/v2/sign/handlers.go b/cla-backend-go/v2/sign/handlers.go index 786e9639b..2f3695e49 100644 --- a/cla-backend-go/v2/sign/handlers.go +++ b/cla-backend-go/v2/sign/handlers.go @@ -284,6 +284,25 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se return sign.NewCclaCallbackOK() }) + api.SignIclaCallbackSelfServeHandler = sign.IclaCallbackSelfServeHandlerFunc( + func(params sign.IclaCallbackSelfServeParams) middleware.Responder { + reqId := utils.GetRequestID(params.XREQUESTID) + ctx := context.WithValue(params.HTTPRequest.Context(), utils.XREQUESTIDKey, reqId) + f := logrus.Fields{ + "functionName": "v2.sign.handlers.SignIclaCallbackSelfServeHandler", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + + log.WithFields(f).Debug("self serve callback") + + err := service.SignedIndividualCallbackSelfServe(ctx, iclaGitHubPayload, params.UserID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to process the self serve callback for user: %s", params.UserID) + return sign.NewIclaCallbackSelfServeBadRequest() + } + return sign.NewIclaCallbackSelfServeOK() + }) + api.SignCclaCallbackHandler = sign.CclaCallbackHandlerFunc( func(params sign.CclaCallbackParams) middleware.Responder { reqId := utils.GetRequestID(params.XREQUESTID) diff --git a/cla-backend-go/v2/sign/helpers.go b/cla-backend-go/v2/sign/helpers.go index 33f9b089f..d779eabe4 100644 --- a/cla-backend-go/v2/sign/helpers.go +++ b/cla-backend-go/v2/sign/helpers.go @@ -152,12 +152,16 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID } // Check if company is sanctioned before allowing ECLA acknowledgement + wasSanctioned := companyModel.IsSanctioned sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, companyModel) if sanctionErr != nil { log.WithFields(f).WithError(sanctionErr).Warnf("failed to check company compliance for company: %s", companyID) return &hasSigned, &companyAffiliation, sanctionErr } if sanctioned { + if !wasSanctioned { + s.logCompanySanctionedEvent(ctx, companyModel, user, "") + } sanctionedErr := fmt.Errorf("company %s is sanctioned", companyID) log.WithFields(f).WithError(sanctionedErr).Error("company is sanctioned") return &hasSigned, &companyAffiliation, sanctionedErr diff --git a/cla-backend-go/v2/sign/icla_block_test.go b/cla-backend-go/v2/sign/icla_block_test.go new file mode 100644 index 000000000..c8e753b81 --- /dev/null +++ b/cla-backend-go/v2/sign/icla_block_test.go @@ -0,0 +1,185 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/linuxfoundation/easycla/cla-backend-go/events" + eventsMock "github.com/linuxfoundation/easycla/cla-backend-go/events/mock" + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + sigs "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/restapi/operations/signatures" + mock_v1_signatures "github.com/linuxfoundation/easycla/cla-backend-go/signatures/mocks" + "github.com/stretchr/testify/assert" +) + +func TestHasInvalidatedIcla(t *testing.T) { + assert.False(t, hasInvalidatedIcla(nil)) + assert.False(t, hasInvalidatedIcla([]*v1Models.Signature{nil})) + assert.False(t, hasInvalidatedIcla([]*v1Models.Signature{ + {SignatureID: "in-progress", SignatureSigned: false, SignatureApproved: true}, + {SignatureID: "valid", SignatureSigned: true, SignatureApproved: true}, + {SignatureID: "abandoned", SignatureSigned: false, SignatureApproved: false}, + })) + assert.True(t, hasInvalidatedIcla([]*v1Models.Signature{ + {SignatureID: "valid", SignatureSigned: true, SignatureApproved: true}, + {SignatureID: "invalidated", SignatureSigned: true, SignatureApproved: false}, + }), "a signed but unapproved ICLA marks an administrator invalidation") +} + +func TestUserHasInvalidatedIclaExhaustiveLookup(t *testing.T) { + userName := "contributor" + projectID := "cla-group-1" + + type page struct { + signatures []*v1Models.Signature + lastKeyScanned string + err error + } + valid := &v1Models.Signature{SignatureID: "valid", SignatureSigned: true, SignatureApproved: true} + invalidated := &v1Models.Signature{SignatureID: "invalidated", SignatureSigned: true, SignatureApproved: false} + + tests := []struct { + name string + pages []page + wantBlocked bool + wantErr bool + }{ + { + name: "a hit on the first page blocks without fetching further pages", + pages: []page{{signatures: []*v1Models.Signature{valid, invalidated}, lastKeyScanned: "cursor-1"}}, + wantBlocked: true, + }, + { + name: "an invalidated ICLA beyond the first page still blocks", + pages: []page{ + {signatures: []*v1Models.Signature{valid}, lastKeyScanned: "cursor-1"}, + {signatures: []*v1Models.Signature{invalidated}}, + }, + wantBlocked: true, + }, + { + name: "a clean multi-page history does not block", + pages: []page{ + {signatures: []*v1Models.Signature{valid}, lastKeyScanned: "cursor-1"}, + {signatures: []*v1Models.Signature{valid}}, + }, + }, + { + name: "a clean single page does not block", + pages: []page{{signatures: []*v1Models.Signature{valid}}}, + }, + { + name: "a failed lookup is propagated", + pages: []page{{err: errors.New("dynamodb unavailable")}}, + wantErr: true, + }, + { + name: "a failure on a later page is propagated", + pages: []page{ + {signatures: []*v1Models.Signature{valid}, lastKeyScanned: "cursor-1"}, + {err: errors.New("dynamodb unavailable")}, + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + callParams := sigs.GetUserSignaturesParams{UserID: "user-1", UserName: &userName} + calls := 0 + mockSignatures := mock_v1_signatures.NewMockSignatureService(ctrl) + mockSignatures.EXPECT().GetUserSignatures(gomock.Any(), gomock.Any(), &projectID).DoAndReturn( + func(_ context.Context, params sigs.GetUserSignaturesParams, _ *string) (*v1Models.Signatures, error) { + if assert.Less(t, calls, len(tc.pages), "no lookups expected past the last page") { + p := tc.pages[calls] + calls++ + if assert.NotNil(t, params.PageSize, "the block check must request an exhaustive page size, not the default of 10") { + assert.GreaterOrEqual(t, *params.PageSize, int64(1000)) + } + assert.Equal(t, "user-1", params.UserID) + if calls == 1 { + assert.Nil(t, params.NextKey) + } else if assert.NotNil(t, params.NextKey, "follow-up lookups must carry the pagination cursor") { + assert.Equal(t, tc.pages[calls-2].lastKeyScanned, *params.NextKey) + } + if p.err != nil { + return nil, p.err + } + return &v1Models.Signatures{Signatures: p.signatures, LastKeyScanned: p.lastKeyScanned}, nil + } + return &v1Models.Signatures{}, nil + }).Times(len(tc.pages)) + + svc := &service{signatureService: mockSignatures} + blocked, err := svc.userHasInvalidatedIcla(context.Background(), callParams, &projectID) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.wantBlocked, blocked) + } + assert.Equal(t, len(tc.pages), calls, "every prepared page is consumed and none beyond") + assert.Nil(t, callParams.PageSize, "the caller's default-sized params must stay untouched") + assert.Nil(t, callParams.NextKey, "the caller's cursor must stay untouched") + }) + } +} + +func TestLogCompanySanctionedEventIdentity(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + comp := &v1Models.Company{CompanyID: "company-1", CompanyName: "Flagged Corp"} + tests := []struct { + name string + userModel *v1Models.User + lfUsername string + wantUserID string + wantLfUsername string + }{ + { + name: "a user model supplies the identity the events gate requires", + userModel: &v1Models.User{UserID: "user-1", LfUsername: "contributor"}, + wantUserID: "user-1", + wantLfUsername: "contributor", + }, + { + name: "an explicit lf username is kept", + userModel: &v1Models.User{UserID: "user-1", LfUsername: "contributor"}, + lfUsername: "manager", + wantUserID: "user-1", + wantLfUsername: "manager", + }, + { + name: "an lf username alone passes the gate", + lfUsername: "manager", + wantLfUsername: "manager", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mockEvents := eventsMock.NewMockService(ctrl) + var logged *events.LogEventArgs + mockEvents.EXPECT().LogEventWithContext(gomock.Any(), gomock.Any()).Do( + func(_ context.Context, args *events.LogEventArgs) { + logged = args + }) + svc := &service{eventsService: mockEvents} + svc.logCompanySanctionedEvent(context.Background(), comp, tc.userModel, tc.lfUsername) + if assert.NotNil(t, logged) { + assert.True(t, logged.UserID != "" || logged.LfUsername != "", "the events service drops events without a top-level user identity") + assert.Equal(t, tc.wantUserID, logged.UserID) + assert.Equal(t, tc.wantLfUsername, logged.LfUsername) + assert.Same(t, comp, logged.CompanyModel) + assert.Equal(t, events.CompanySanctioned, logged.EventType) + } + }) + } +} diff --git a/cla-backend-go/v2/sign/self_serve_test.go b/cla-backend-go/v2/sign/self_serve_test.go new file mode 100644 index 000000000..58037fc39 --- /dev/null +++ b/cla-backend-go/v2/sign/self_serve_test.go @@ -0,0 +1,138 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "context" + "testing" + + v1Models "github.com/linuxfoundation/easycla/cla-backend-go/gen/v1/models" + "github.com/linuxfoundation/easycla/cla-backend-go/utils" + "github.com/stretchr/testify/assert" +) + +const selfServeUserID = "6c2d5a11-0e2e-4f5a-9a0f-1f0a3b4c5d6e" + +func selfServeMetadata(returnURL string) map[string]interface{} { + metadata := map[string]interface{}{ + "user_id": selfServeUserID, + "project_id": "aa47b3e1-6f9c-4b6a-9f16-0f9d6a2e1c11", + "source": utils.SelfServeSignatureSource, + } + if returnURL != "" { + metadata["return_url"] = returnURL + } + return metadata +} + +func TestGetIndividualSignatureCallbackURLSelfServe(t *testing.T) { + svc := &service{ClaV4ApiURL: "https://api.dev.lfx.linuxfoundation.org"} + + callbackURL, err := svc.getIndividualSignatureCallbackURL(context.Background(), selfServeUserID, selfServeMetadata("")) + + assert.NoError(t, err) + assert.Equal(t, "https://api.dev.lfx.linuxfoundation.org/v4/signed/self-serve/individual/"+selfServeUserID, callbackURL) +} + +func TestGetActiveSignatureReturnURLSelfServe(t *testing.T) { + svc := &service{} + + returnURL, err := svc.getActiveSignatureReturnURL(context.Background(), selfServeUserID, selfServeMetadata("https://openprofile.dev/my-clas")) + assert.NoError(t, err) + assert.Equal(t, "https://openprofile.dev/my-clas", returnURL) + + returnURL, err = svc.getActiveSignatureReturnURL(context.Background(), selfServeUserID, selfServeMetadata("")) + assert.NoError(t, err) + assert.Equal(t, "", returnURL) +} + +func TestSelfServeSignatureACL(t *testing.T) { + noACL := map[string]interface{}{} + assert.Equal(t, "github:26589865", selfServeSignatureACL(noACL, &v1Models.User{GithubID: "26589865", GitlabID: "77", LfUsername: "lgryglicki"})) + assert.Equal(t, "gitlab:77", selfServeSignatureACL(noACL, &v1Models.User{GitlabID: "77", LfUsername: "lgryglicki"})) + assert.Equal(t, "lgryglicki", selfServeSignatureACL(noACL, &v1Models.User{LfUsername: "lgryglicki"})) +} + +func TestSelfServeSignatureACLPrefersTheSessionIdentity(t *testing.T) { + metadata := selfServeMetadata("") + metadata["acl"] = "gitlab:77" + + // the record's GitHub identity would otherwise win, but the session was prepared under GitLab + assert.Equal(t, "gitlab:77", selfServeSignatureACL(metadata, &v1Models.User{GithubID: "26589865", GitlabID: "77"})) + + metadata["acl"] = " " + assert.Equal(t, "github:26589865", selfServeSignatureACL(metadata, &v1Models.User{GithubID: "26589865", GitlabID: "77"})) + + metadata["acl"] = 26589865 + assert.Equal(t, "github:26589865", selfServeSignatureACL(metadata, &v1Models.User{GithubID: "26589865", GitlabID: "77"})) +} + +func TestGetIndividualSignatureCallbackURLGitlabSelfServe(t *testing.T) { + svc := &service{ClaV4ApiURL: "https://api.dev.lfx.linuxfoundation.org"} + + callbackURL, err := svc.getIndividualSignatureCallbackURLGitlab(context.Background(), selfServeUserID, selfServeMetadata("")) + + assert.NoError(t, err) + assert.Equal(t, "https://api.dev.lfx.linuxfoundation.org/v4/signed/self-serve/individual/"+selfServeUserID, callbackURL) +} + +const envelopeDocumentStatuses = `1` + +func envelopeXML(recipientStatuses, documentStatuses string) []byte { + return []byte(`e1` + + recipientStatuses + documentStatuses + ``) +} + +func recipientStatuses(status string) string { + return `` + status + `s1` +} + +func TestParseEnvelope(t *testing.T) { + info, err := parseEnvelope(envelopeXML(recipientStatuses(DocusignCompleted), envelopeDocumentStatuses)) + assert.NoError(t, err) + assert.Equal(t, DocusignCompleted, info.EnvelopeStatus.RecipientStatuses[0].Status) + + info, err = parseEnvelope(envelopeXML(recipientStatuses("Sent"), envelopeDocumentStatuses)) + assert.NoError(t, err) + assert.Equal(t, "Sent", info.EnvelopeStatus.RecipientStatuses[0].Status) + + // the shared processing indexes both lists, so neither may be empty + _, err = parseEnvelope(envelopeXML("", envelopeDocumentStatuses)) + assert.Error(t, err) + + _, err = parseEnvelope(envelopeXML(recipientStatuses(DocusignCompleted), "")) + assert.Error(t, err) + + _, err = parseEnvelope([]byte(``)) + assert.Error(t, err) + + _, err = parseEnvelope([]byte("not xml")) + assert.Error(t, err) +} + +func TestSignedIndividualCallbackSelfServeRejectsAnEmptyEnvelope(t *testing.T) { + // no collaborators are wired, so reaching the shared processing or the store would panic - + // the validation has to happen before either + svc := &service{} + + for _, payload := range [][]byte{ + []byte("not xml"), + envelopeXML("", envelopeDocumentStatuses), + envelopeXML(recipientStatuses(DocusignCompleted), ""), + } { + assert.Error(t, svc.SignedIndividualCallbackSelfServe(context.Background(), payload, selfServeUserID)) + } +} + +func TestSelfServeSessionMatchesProject(t *testing.T) { + claGroupID := "aa47b3e1-6f9c-4b6a-9f16-0f9d6a2e1c11" + + assert.True(t, selfServeSessionMatchesProject(selfServeMetadata(""), claGroupID)) + assert.False(t, selfServeSessionMatchesProject(selfServeMetadata(""), "62db1b81-6f4a-4b2e-9a4a-0f2d9f0a1b22")) + + // a session written without the key, or with a non-string one, keeps the previous behaviour + assert.True(t, selfServeSessionMatchesProject(map[string]interface{}{"source": utils.SelfServeSignatureSource}, claGroupID)) + assert.True(t, selfServeSessionMatchesProject(map[string]interface{}{"project_id": 7}, claGroupID)) + assert.True(t, selfServeSessionMatchesProject(map[string]interface{}{"project_id": " "}, claGroupID)) +} diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index 825648544..8054d6c02 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -72,6 +72,7 @@ var ( ErrCCLANotEnabled = errors.New("corporate license agreement is not enabled with this project") ErrTemplateNotConfigured = errors.New("cla template not configured for this project") ErrNotInOrg error + ErrIclaInvalidated = errors.New("an individual CLA for this CLA Group was invalidated by an administrator - signing a new individual CLA for this CLA Group is not permitted") ) // ProjectRepo contains project repo methods @@ -96,6 +97,7 @@ type Service interface { SignedIndividualCallbackGithub(ctx context.Context, payload []byte, installationID, changeRequestID, repositoryID string) error SignedIndividualCallbackGitlab(ctx context.Context, payload []byte, userID, organizationID, repositoryID, mergeRequestID string) error SignedIndividualCallbackGerrit(ctx context.Context, payload []byte, userID string) error + SignedIndividualCallbackSelfServe(ctx context.Context, payload []byte, userID string) error SignedCorporateCallback(ctx context.Context, payload []byte, companyID, projectID string) error GetUserActiveSignature(ctx context.Context, userID string) (*models.UserActiveSignature, error) } @@ -271,12 +273,16 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri return nil, fmt.Errorf("company not found") } + wasSanctioned := comp.IsSanctioned sanctioned, sanctionErr := s.checkCompanyCompliance(ctx, comp) if sanctionErr != nil { log.WithFields(f).WithError(sanctionErr).Error("failed to check company compliance") return nil, sanctionErr } if sanctioned { + if !wasSanctioned { + s.logCompanySanctionedEvent(ctx, comp, nil, lfUsername) + } if input.CompanySfid != nil { err = fmt.Errorf("company %s requires further review for trade compliance", *input.CompanySfid) } else { @@ -1109,6 +1115,42 @@ func (s *service) SignedIndividualCallbackGerrit(ctx context.Context, payload [] return nil } +// SignedIndividualCallbackSelfServe handles the DocuSign callback of an individual signature +// started proactively from LFX Self Serve - it carries no pull/merge request context, which is +// exactly the Gerrit callback's shape +func (s *service) SignedIndividualCallbackSelfServe(ctx context.Context, payload []byte, userID string) error { + f := logrus.Fields{ + "functionName": "sign.SignedIndividualCallbackSelfServe", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "userID": userID, + } + + // This callback is reachable without a token, and the shared processing indexes the envelope's + // recipient and document statuses - reject a payload missing either before delegating + info, err := parseEnvelope(payload) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to process the docusign payload") + return err + } + + if err := s.SignedIndividualCallbackGerrit(ctx, payload, userID); err != nil { + return err + } + + // The Gerrit callback leaves the session in place because the Gerrit flow never writes one - + // a Self Serve session does, so remove it here once the envelope is complete + if info.EnvelopeStatus.RecipientStatuses[0].Status != DocusignCompleted { + return nil + } + + log.WithFields(f).Debugf("removing active signature metadata for user: %s", userID) + if err := s.storeRepository.DeleteActiveSignatureMetaData(ctx, fmt.Sprintf("active_signature:%s", userID)); err != nil { + log.WithFields(f).WithError(err).Warnf("unable to remove active signature metadata for user: %s", userID) + return err + } + return nil +} + func (s *service) SignedCorporateCallback(ctx context.Context, payload []byte, companyID, projectID string) error { f := logrus.Fields{ "functionName": "sign.SignedCorporateCallback", @@ -1222,10 +1264,14 @@ func (s *service) SignedCorporateCallback(ctx context.Context, payload []byte, c // Sanctions gate: re-screen the company before finalizing the CCLA. A company can // become blocked (manual/admin or SSS) between the DocuSign request and this // completion callback; do not finalize a corporate CLA for a sanctioned company. + wasSanctioned := companyModel.IsSanctioned if sanctioned, complianceErr := s.checkCompanyCompliance(ctx, companyModel); complianceErr != nil { log.WithFields(f).WithError(complianceErr).Warnf("company compliance check failed in corporate callback for company %s; not finalizing CCLA", companyID) return complianceErr } else if sanctioned { + if !wasSanctioned { + s.logCompanySanctionedEvent(ctx, companyModel, user, "") + } log.WithFields(f).Warnf("company %s requires further review for trade compliance; refusing to finalize corporate CLA in callback", companyID) return fmt.Errorf("company %s requires further review for trade compliance; corporate CLA cannot be finalized", companyID) } @@ -1375,6 +1421,15 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. return nil, err } log.WithFields(f).Debugf("found %d signatures for user: %s", len(userSignatures.Signatures), *input.UserID) + blocked, blockErr := s.userHasInvalidatedIcla(ctx, sigParams, input.ProjectID) + if blockErr != nil { + log.WithFields(f).WithError(blockErr).Warnf("unable to check for an invalidated ICLA for user: %s", *input.UserID) + return nil, blockErr + } + if blocked { + log.WithFields(f).Warnf("user %s has an invalidated ICLA for project %s - blocking a new individual signature", *input.UserID, *input.ProjectID) + return nil, ErrIclaInvalidated + } latestSignature := getLatestSignature(userSignatures.Signatures) // loading latest document @@ -1435,6 +1490,14 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. acl = fmt.Sprintf("%s:%s", strings.ToLower(input.ReturnURLType), user.GitlabID) } + if utils.IsSelfServeActiveSignature(activeSignatureMetadata) { + if !selfServeSessionMatchesProject(activeSignatureMetadata, *input.ProjectID) { + log.WithFields(f).Warnf("self serve signing session does not belong to cla group: %s", *input.ProjectID) + return nil, errors.New("the active signing session belongs to a different cla group") + } + acl = selfServeSignatureACL(activeSignatureMetadata, user) + } + log.WithFields(f).Debugf("acl: %s", acl) majorVersion, err := strconv.Atoi(latestDocument.DocumentMajorVersion) @@ -1576,6 +1639,44 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. }, nil } +// selfServeSessionMatchesProject keeps a session prepared for one CLA group from driving a signing +// request for another - the request endpoint carries no token of its own +func selfServeSessionMatchesProject(metadata map[string]interface{}, projectID string) bool { + sessionProjectID, ok := metadata["project_id"].(string) + if !ok || strings.TrimSpace(sessionProjectID) == "" { + return true + } + return sessionProjectID == projectID +} + +func parseEnvelope(payload []byte) (*DocuSignEnvelopeInformation, error) { + var info DocuSignEnvelopeInformation + if err := xml.Unmarshal(payload, &info); err != nil { + return nil, err + } + if len(info.EnvelopeStatus.RecipientStatuses) == 0 || len(info.EnvelopeStatus.DocumentStatuses) == 0 { + return nil, errors.New("docusign envelope carries no recipient or document statuses") + } + return &info, nil +} + +// selfServeSignatureACL prefers the identity the session was prepared under and falls back to the +// user record - a Self Serve session carries no pull or merge request, so the return URL type the +// console sends does not identify the signer +func selfServeSignatureACL(metadata map[string]interface{}, user *v1Models.User) string { + if acl, ok := metadata["acl"].(string); ok && strings.TrimSpace(acl) != "" { + return strings.TrimSpace(acl) + } + switch { + case user.GithubID != "": + return fmt.Sprintf("github:%s", user.GithubID) + case user.GitlabID != "": + return fmt.Sprintf("gitlab:%s", user.GitlabID) + default: + return user.LfUsername + } +} + func getUserName(user *v1Models.User) string { if user.Username != "" { @@ -1629,6 +1730,11 @@ func (s *service) getIndividualSignatureCallbackURLGitlab(ctx context.Context, u } } + if utils.IsSelfServeActiveSignature(metadata) { + log.WithFields(f).Debug("self serve signing session - using the no merge request callback") + return fmt.Sprintf("%s/v4/signed/self-serve/individual/%s", s.ClaV4ApiURL, userID), nil + } + repositoryID, err = metadataStringValue(metadata, "repository_id") if err != nil { log.WithFields(f).WithError(err).Warnf("unable to get repository ID for user: %s", userID) @@ -1687,6 +1793,11 @@ func (s *service) getIndividualSignatureCallbackURL(ctx context.Context, userID } } + if utils.IsSelfServeActiveSignature(metadata) { + log.WithFields(f).Debug("self serve signing session - using the no pull request callback") + return fmt.Sprintf("%s/v4/signed/self-serve/individual/%s", s.ClaV4ApiURL, userID), nil + } + repositoryID, err = metadataStringValue(metadata, "repository_id") if err != nil { log.WithFields(f).WithError(err).Warnf("unable to get repository ID for user: %s", userID) @@ -2362,6 +2473,40 @@ func getLatestSignature(signatures []*v1Models.Signature) *v1Models.Signature { return latestSignature } +func hasInvalidatedIcla(signatures []*v1Models.Signature) bool { + for _, signature := range signatures { + if signature != nil && signature.SignatureSigned && !signature.SignatureApproved { + return true + } + } + return false +} + +// iclaBlockPageSize is the per-call cap for the invalidated-ICLA lookup, far above the +// default page of 10; the helper pages past it when needed. +const iclaBlockPageSize int64 = 1000 + +// userHasInvalidatedIcla runs the invalidated-ICLA check on a dedicated lookup, following the +// pagination cursor until a hit or exhaustion, leaving the caller's default-sized lookup untouched. +func (s *service) userHasInvalidatedIcla(ctx context.Context, params sigs.GetUserSignaturesParams, projectID *string) (bool, error) { + pageSize := iclaBlockPageSize + params.PageSize = &pageSize + for { + userSignatures, err := s.signatureService.GetUserSignatures(ctx, params, projectID) + if err != nil { + return false, err + } + if hasInvalidatedIcla(userSignatures.Signatures) { + return true, nil + } + if userSignatures.LastKeyScanned == "" { + return false, nil + } + nextKey := userSignatures.LastKeyScanned + params.NextKey = &nextKey + } +} + func (s *service) RequestIndividualSignatureGerrit(ctx context.Context, input *models.IndividualSignatureInput) (*models.IndividualSignatureOutput, error) { f := logrus.Fields{ "functionName": "sign.RequestIndividualSignatureGerrit", @@ -2409,6 +2554,16 @@ func (s *service) RequestIndividualSignatureGerrit(ctx context.Context, input *m return nil, err } + blocked, blockErr := s.userHasInvalidatedIcla(ctx, sigParams, input.ProjectID) + if blockErr != nil { + log.WithFields(f).WithError(blockErr).Warnf("unable to check for an invalidated ICLA for user: %s", *input.UserID) + return nil, blockErr + } + if blocked { + log.WithFields(f).Warnf("user %s has an invalidated ICLA for project %s - blocking a new individual signature", *input.UserID, *input.ProjectID) + return nil, ErrIclaInvalidated + } + latestSignature := getLatestSignature(userSignatures.Signatures) //loading latest document @@ -2879,6 +3034,13 @@ func (s *service) getActiveSignatureReturnURL(ctx context.Context, userID string var repositoryID int64 var installationID int64 + if utils.IsSelfServeActiveSignature(metadata) { + if selfServeReturnURL, ok := metadata["return_url"].(string); ok { + returnURL = selfServeReturnURL + } + return returnURL, nil + } + if found, ok := metadata["pull_request_id"]; ok && found != nil { prId := fmt.Sprintf("%v", found) pullRequestID, err2 = strconv.Atoi(prId) @@ -3132,6 +3294,28 @@ func (s *service) checkCompanyCompliance(ctx context.Context, company *v1Models. return sanctioned, nil } +// logCompanySanctionedEvent records the audit event for a company newly flagged as sanctioned +func (s *service) logCompanySanctionedEvent(ctx context.Context, comp *v1Models.Company, userModel *v1Models.User, lfUsername string) { + if s.eventsService == nil || comp == nil || (userModel == nil && lfUsername == "") { + return + } + args := &events.LogEventArgs{ + EventType: events.CompanySanctioned, + UserModel: userModel, + LfUsername: lfUsername, + CompanyModel: comp, + EventData: &events.CompanySanctionedEventData{}, + } + // LogEventWithContext requires a top-level UserID or LfUsername before it consults UserModel. + if userModel != nil { + args.UserID = userModel.UserID + if args.LfUsername == "" { + args.LfUsername = userModel.LfUsername + } + } + s.eventsService.LogEventWithContext(ctx, args) +} + // complianceUnavailable returns the screening decision for a path that could not // produce a live SSS result: block when SSS is required, otherwise honor the // persisted sanction state. The specific cause is carried by resultErr. diff --git a/cla-backend-go/v2/signatures/handlers.go b/cla-backend-go/v2/signatures/handlers.go index 86db493e7..1136cbed5 100644 --- a/cla-backend-go/v2/signatures/handlers.go +++ b/cla-backend-go/v2/signatures/handlers.go @@ -1269,7 +1269,7 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj InvalidatedCount: 1, }, } - err := v2SignatureService.InvalidateICLA(ctx, params.ClaGroupID, params.UserID, authUser, eventsService, eventArgs) + err := v2SignatureService.InvalidateICLA(ctx, params.ClaGroupID, params.UserID, authUser, eventsService, eventArgs, ¶ms.Body) if err != nil { msg := "unable to invalidate icla" log.WithFields(f).Warn(msg) diff --git a/cla-backend-go/v2/signatures/service.go b/cla-backend-go/v2/signatures/service.go index 4e8bb08ff..83d77a907 100644 --- a/cla-backend-go/v2/signatures/service.go +++ b/cla-backend-go/v2/signatures/service.go @@ -57,7 +57,7 @@ type ServiceInterface interface { GetSignedDocument(ctx context.Context, signatureID string) (*models.SignedDocument, error) GetSignedIclaZipPdf(claGroupID string) (*models.URLObject, error) GetSignedCclaZipPdf(claGroupID string) (*models.URLObject, error) - InvalidateICLA(ctx context.Context, claGroupID string, userID string, authUser *auth.User, eventsService events.Service, eventArgs *events.LogEventArgs) error + InvalidateICLA(ctx context.Context, claGroupID string, userID string, authUser *auth.User, eventsService events.Service, eventArgs *events.LogEventArgs, input *models.IclaInvalidationInput) error EclaAutoCreate(ctx context.Context, signatureID string, autoCreateECLA bool) error IsUserAuthorized(ctx context.Context, lfid, claGroupId string) (*models.LfidAuthorizedResponse, error) } @@ -351,8 +351,9 @@ func (s *Service) GetClaGroupCorporateContributors(ctx context.Context, params v return &resp, nil } -// InvalidateICLA invalidates the specified signature record using the supplied parameters -func (s *Service) InvalidateICLA(ctx context.Context, claGroupID string, userID string, authUser *auth.User, eventsService events.Service, eventArgs *events.LogEventArgs) error { +// InvalidateICLA invalidates the specified signature record using the supplied parameters - +// input optionally carries the invalidation reason and note recorded on the record +func (s *Service) InvalidateICLA(ctx context.Context, claGroupID string, userID string, authUser *auth.User, eventsService events.Service, eventArgs *events.LogEventArgs, input *models.IclaInvalidationInput) error { f := logrus.Fields{ "functionName": "v2.signatures.service.InvalidateICLA", "claGroupID": claGroupID, @@ -388,7 +389,14 @@ func (s *Service) InvalidateICLA(ctx context.Context, claGroupID string, userID log.WithFields(f).Debug("invalidating signature record ...") note := fmt.Sprintf("Signature invalidated (approved set to false) by %s for %s ", authUser.UserName, utils.GetBestUsername(user)) - err := s.v1SignatureRepo.InvalidateProjectRecord(ctx, icla.SignatureID, note) + metadata := &signatures.InvalidationMetadata{ + InvalidatedBy: authUser.UserName, + } + if input != nil { + metadata.Reason = input.Reason + metadata.Note = utils.SanitizePlainText(input.Note) + } + err := s.v1SignatureRepo.InvalidateProjectRecordWithMetadata(ctx, icla.SignatureID, note, metadata) if err != nil { log.WithFields(f).Debug("unable to invalidate icla record") return err @@ -414,7 +422,14 @@ func (s *Service) InvalidateICLA(ctx context.Context, claGroupID string, userID eventArgs.UserName = utils.GetBestUsername(user) eventArgs.UserModel = user + eventArgs.UserID = user.UserID eventArgs.ProjectName = claGroup.ProjectName + if eventData, ok := eventArgs.EventData.(*events.SignatureProjectInvalidatedEventData); ok { + eventData.SignatureID = icla.SignatureID + eventData.InvalidatedBy = authUser.UserName + eventData.Reason = metadata.Reason + eventData.InvalidationNote = metadata.Note + } // Log event eventsService.LogEventWithContext(ctx, eventArgs) diff --git a/cla-backend-go/v2/signatures/service_test.go b/cla-backend-go/v2/signatures/service_test.go index 99935bc29..bef89ee8a 100644 --- a/cla-backend-go/v2/signatures/service_test.go +++ b/cla-backend-go/v2/signatures/service_test.go @@ -13,10 +13,14 @@ import ( "github.com/linuxfoundation/easycla/cla-backend-go/utils" // mock_signatures "github.com/linuxfoundation/easycla/cla-backend-go/v2/signatures/mock_v1_signatures" + "github.com/LF-Engineering/lfx-kit/auth" "github.com/golang/mock/gomock" mock_company "github.com/linuxfoundation/easycla/cla-backend-go/company/mocks" + "github.com/linuxfoundation/easycla/cla-backend-go/events" + eventsMock "github.com/linuxfoundation/easycla/cla-backend-go/events/mock" ini "github.com/linuxfoundation/easycla/cla-backend-go/init" mock_project "github.com/linuxfoundation/easycla/cla-backend-go/project/mocks" + v1Signatures "github.com/linuxfoundation/easycla/cla-backend-go/signatures" mock_v1_signatures "github.com/linuxfoundation/easycla/cla-backend-go/signatures/mocks" mock_users "github.com/linuxfoundation/easycla/cla-backend-go/v2/signatures/mock_users" "github.com/stretchr/testify/assert" @@ -287,3 +291,132 @@ func TestService_IsUserAuthorized(t *testing.T) { }) } } + +func TestService_InvalidateICLA(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + awsSession, err := ini.GetAWSSession() + if err != nil { + assert.Fail(t, "unable to create AWS session") + } + + ctx := context.Background() + approved, signed := true, true + + mockSignatureService := mock_v1_signatures.NewMockSignatureService(ctrl) + mockSignatureService.EXPECT().GetIndividualSignature(ctx, "cla-group-1", "user-1", &approved, &signed). + Return(&v1Models.Signature{SignatureID: "sig-1"}, nil) + + mockProjectService := mock_project.NewMockService(ctrl) + mockProjectService.EXPECT().GetCLAGroupByID(ctx, "cla-group-1"). + Return(&v1Models.ClaGroup{ProjectName: "My Project", Version: "v2"}, nil) + + mockUserService := mock_users.NewMockService(ctrl) + mockUserService.EXPECT().GetUser("user-1"). + Return(&v1Models.User{UserID: "user-1", LfUsername: "contributor", Username: "Contributor"}, nil) + + mockRepo := mock_v1_signatures.NewMockSignatureRepository(ctrl) + var gotNote string + var gotMetadata *v1Signatures.InvalidationMetadata + mockRepo.EXPECT().InvalidateProjectRecordWithMetadata(ctx, "sig-1", gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, note string, metadata *v1Signatures.InvalidationMetadata) error { + gotNote = note + gotMetadata = metadata + return nil + }) + + mockEvents := eventsMock.NewMockService(ctrl) + var logged *events.LogEventArgs + mockEvents.EXPECT().LogEventWithContext(ctx, gomock.Any()).Do( + func(_ context.Context, args *events.LogEventArgs) { + logged = args + }) + + service := NewService(awsSession, "", mockProjectService, nil, mockSignatureService, nil, mockRepo, mockUserService, nil) + + eventArgs := &events.LogEventArgs{ + EventType: events.InvalidatedSignature, + EventData: &events.SignatureProjectInvalidatedEventData{InvalidatedCount: 1}, + } + input := &models.IclaInvalidationInput{Reason: "compliance", Note: "per legal\r\nreview\x07"} + err = service.InvalidateICLA(ctx, "cla-group-1", "user-1", &auth.User{UserName: "admin-user"}, mockEvents, eventArgs, input) + assert.Nil(t, err) + + assert.Contains(t, gotNote, "Signature invalidated (approved set to false) by admin-user for Contributor") + if assert.NotNil(t, gotMetadata) { + assert.Equal(t, "admin-user", gotMetadata.InvalidatedBy) + assert.Equal(t, "compliance", gotMetadata.Reason) + assert.Equal(t, "per legal\nreview", gotMetadata.Note, "the note is sanitized before it is stored") + } + + if assert.NotNil(t, logged) { + eventData, ok := logged.EventData.(*events.SignatureProjectInvalidatedEventData) + if assert.True(t, ok) { + assert.Equal(t, "sig-1", eventData.SignatureID) + assert.Equal(t, "admin-user", eventData.InvalidatedBy) + assert.Equal(t, "compliance", eventData.Reason) + assert.Equal(t, "per legal\nreview", eventData.InvalidationNote) + } + assert.Equal(t, "Contributor", logged.UserName) + assert.Equal(t, "user-1", logged.UserID, "a top-level user identity is required or the events service drops the event") + assert.Equal(t, "My Project", logged.ProjectName) + } +} + +func TestService_InvalidateICLAWithoutBody(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + awsSession, err := ini.GetAWSSession() + if err != nil { + assert.Fail(t, "unable to create AWS session") + } + + ctx := context.Background() + approved, signed := true, true + + mockSignatureService := mock_v1_signatures.NewMockSignatureService(ctrl) + mockSignatureService.EXPECT().GetIndividualSignature(ctx, "cla-group-1", "user-1", &approved, &signed). + Return(&v1Models.Signature{SignatureID: "sig-1"}, nil) + + mockProjectService := mock_project.NewMockService(ctrl) + mockProjectService.EXPECT().GetCLAGroupByID(ctx, "cla-group-1"). + Return(&v1Models.ClaGroup{ProjectName: "My Project", Version: "v2"}, nil) + + mockUserService := mock_users.NewMockService(ctrl) + mockUserService.EXPECT().GetUser("user-1"). + Return(&v1Models.User{UserID: "user-1", LfUsername: "contributor"}, nil) + + mockRepo := mock_v1_signatures.NewMockSignatureRepository(ctrl) + var gotMetadata *v1Signatures.InvalidationMetadata + mockRepo.EXPECT().InvalidateProjectRecordWithMetadata(ctx, "sig-1", gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, metadata *v1Signatures.InvalidationMetadata) error { + gotMetadata = metadata + return nil + }) + + mockEvents := eventsMock.NewMockService(ctrl) + var logged *events.LogEventArgs + mockEvents.EXPECT().LogEventWithContext(ctx, gomock.Any()).Do( + func(_ context.Context, args *events.LogEventArgs) { + logged = args + }) + + service := NewService(awsSession, "", mockProjectService, nil, mockSignatureService, nil, mockRepo, mockUserService, nil) + + eventArgs := &events.LogEventArgs{ + EventType: events.InvalidatedSignature, + EventData: &events.SignatureProjectInvalidatedEventData{InvalidatedCount: 1}, + } + err = service.InvalidateICLA(ctx, "cla-group-1", "user-1", &auth.User{UserName: "admin-user"}, mockEvents, eventArgs, nil) + assert.Nil(t, err) + if assert.NotNil(t, logged) { + assert.Equal(t, "user-1", logged.UserID, "a top-level user identity is required or the events service drops the event") + } + if assert.NotNil(t, gotMetadata) { + assert.Equal(t, "admin-user", gotMetadata.InvalidatedBy) + assert.Empty(t, gotMetadata.Reason) + assert.Empty(t, gotMetadata.Note) + } +} diff --git a/cla-backend-legacy/go.mod b/cla-backend-legacy/go.mod index 88d159ab8..d0e5a1730 100644 --- a/cla-backend-legacy/go.mod +++ b/cla-backend-legacy/go.mod @@ -2,7 +2,7 @@ module github.com/linuxfoundation/easycla/cla-backend-legacy go 1.25.0 -toolchain go1.25.11 +toolchain go1.25.13 replace github.com/linuxfoundation/easycla/cla-sss-base => ../cla-sss-base @@ -23,10 +23,10 @@ require ( github.com/linuxfoundation/easycla/cla-sss-base v0.0.0 github.com/sirupsen/logrus v1.9.3 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 - go.opentelemetry.io/otel/sdk v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 ) require ( @@ -52,16 +52,16 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/cla-backend-legacy/go.sum b/cla-backend-legacy/go.sum index 67fe332a5..e73ea351c 100644 --- a/cla-backend-legacy/go.sum +++ b/cla-backend-legacy/go.sum @@ -78,8 +78,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -98,20 +98,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -125,12 +125,12 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cla-backend-legacy/internal/api/handlers.go b/cla-backend-legacy/internal/api/handlers.go index 100e07858..3cdc4c7c8 100644 --- a/cla-backend-legacy/internal/api/handlers.go +++ b/cla-backend-legacy/internal/api/handlers.go @@ -2796,6 +2796,23 @@ func metadataString(metadata map[string]any, key string) string { return s } +// isSelfServeSignatureMetadata reports whether the active signature session was started +// proactively from LFX Self Serve - such a session carries no pull or merge request to update +func isSelfServeSignatureMetadata(metadata map[string]any) bool { + return metadata != nil && strings.EqualFold(metadataString(metadata, "source"), "self-serve") +} + +// selfServeSessionMatchesProject keeps a session prepared for one CLA group from driving the self +// serve handling of a request for another - a session stored without the key still matches +func selfServeSessionMatchesProject(metadata map[string]any, projectID string) bool { + if metadata == nil { + return true + } + raw, _ := metadata["project_id"].(string) + sessionProjectID := strings.TrimSpace(raw) + return sessionProjectID == "" || sessionProjectID == projectID +} + func (h *Handlers) computeReturnURLFromActiveSignatureMetadata(ctx context.Context, metadata map[string]any) (string, error) { if metadata == nil { return "", nil @@ -5158,6 +5175,9 @@ func (h *Handlers) PostCompanyV1(w http.ResponseWriter, r *http.Request) { "date_modified": &types.AttributeValueMemberS{Value: formatPynamoDateTimeUTC(now)}, "version": &types.AttributeValueMemberS{Value: "v1"}, } + if isSanctioned { + item["sanctioned_date"] = &types.AttributeValueMemberS{Value: formatPynamoDateTimeUTC(now)} + } if err := h.companies.PutItem(ctx, item); err != nil { respond.JSON(w, http.StatusInternalServerError, map[string]any{"errors": map[string]any{"server": err.Error()}}) @@ -5243,6 +5263,7 @@ func (h *Handlers) PutCompanyV1(w http.ResponseWriter, r *http.Request) { return } + now := time.Now().UTC() updateStr := "" if req.CompanyName != nil { item["company_name"] = &types.AttributeValueMemberS{Value: *req.CompanyName} @@ -5263,10 +5284,12 @@ func (h *Handlers) PutCompanyV1(w http.ResponseWriter, r *http.Request) { // Manual/admin sanction change: drop any SSS-set origin so this becomes an // admin-controlled state (sticky when true; never later auto-cleared by SSS). delete(item, "sanction_origin") + if *req.IsSanctioned { + item["sanctioned_date"] = &types.AttributeValueMemberS{Value: formatPynamoDateTimeUTC(now)} + } updateStr += fmt.Sprintf("The company is_sanctioned was updated to %t. ", *req.IsSanctioned) } - now := time.Now().UTC() item["date_modified"] = &types.AttributeValueMemberS{Value: formatPynamoDateTimeUTC(now)} if err := h.companies.PutItem(ctx, item); err != nil { @@ -9140,6 +9163,40 @@ func (h *Handlers) parseDomain(s string) string { return strings.TrimPrefix(u.Hostname(), "www.") } +func (h *Handlers) addSelfServeEmployeeSignerToGerritGroups(ctx context.Context, projectID, userID, lfUsername string) { + if h.gerritInstances == nil || lfUsername == "" || lfUsername == "None" { + return + } + + lfGroupConfigured := h.lfGroup != nil && + strings.TrimSpace(h.lfGroup.BaseURL) != "" && + strings.TrimSpace(h.lfGroup.ClientID) != "" && + strings.TrimSpace(h.lfGroup.ClientSecret) != "" && + strings.TrimSpace(h.lfGroup.RefreshToken) != "" + if !lfGroupConfigured { + logging.Debugf("request_employee_signature skipping the legacy LFGroup update for a self serve signing session; LFGroup client not configured project=%s user=%s", projectID, userID) + return + } + + gerrits, err := h.gerritInstances.QueryByProjectID(ctx, projectID) + if err != nil { + logging.Warnf("request_employee_signature ignored gerrit instance lookup failure for a self serve signing session project=%s user=%s: %v", projectID, userID, err) + return + } + + for _, gerrit := range gerrits { + groupID := strings.TrimSpace(getAttrString(gerrit, "group_id_ccla")) + if groupID == "" { + continue + } + if res := h.lfGroup.AddUserToGroup(ctx, groupID, lfUsername); res != nil { + if _, bad := res["error"]; bad { + logging.Warnf("request_employee_signature ignored legacy LFGroup update failure for a self serve signing session group_id=%s user=%s result=%v", groupID, lfUsername, res) + } + } + } +} + func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Request) { ctx := r.Context() req := parseEmployeeSignatureRequestV2(r) @@ -9242,14 +9299,21 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req // Python derives the return URL from active signature metadata when not provided. var signatureMetadata map[string]any + selfServeSession := false if h.kv != nil { metadata, ok, lookupErr := h.loadActiveSignatureMetadata(ctx, req.UserID) if lookupErr != nil { logging.Warnf("active signature metadata lookup failed for employee signature user=%s err=%v", req.UserID, lookupErr) } else if ok { signatureMetadata = metadata - if strings.TrimSpace(req.ReturnURL) == "" { - if ru, rerr := h.computeReturnURLFromActiveSignatureMetadata(ctx, metadata); rerr == nil && strings.TrimSpace(ru) != "" { + selfServeSession = isSelfServeSignatureMetadata(signatureMetadata) + if selfServeSession && !selfServeSessionMatchesProject(signatureMetadata, req.ProjectID) { + logging.Debugf("request_employee_signature ignoring a self serve signing session prepared for another cla group user=%s session_cla_group=%s request_cla_group=%s", req.UserID, metadataString(signatureMetadata, "project_id"), req.ProjectID) + selfServeSession = false + signatureMetadata = nil + } + if signatureMetadata != nil && strings.TrimSpace(req.ReturnURL) == "" { + if ru, rerr := h.computeReturnURLFromActiveSignatureMetadata(ctx, signatureMetadata); rerr == nil && strings.TrimSpace(ru) != "" { req.ReturnURL = ru } } @@ -9301,6 +9365,21 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req aclValue = "github:" + githubID } + // A Self Serve signing session carries no pull or merge request, so the return URL type the + // console sends does not identify the signer - take the ACL from the user record instead + if selfServeSession { + switch sessionACL := strings.TrimSpace(metadataString(signatureMetadata, "acl")); { + case sessionACL != "": + aclValue = sessionACL + case githubID != "" && githubID != "None": + aclValue = "github:" + githubID + case gitlabID != "" && gitlabID != "None": + aclValue = "gitlab:" + gitlabID + case lfUsername != "" && lfUsername != "None": + aclValue = lfUsername + } + } + now := time.Now().UTC() // Match the rest of the codebase's signature writes — pynamodb // UTCDateTimeAttribute format ("YYYY-MM-DDTHH:MM:SS.ffffff+0000"), not @@ -9382,6 +9461,12 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req h.putAuditEventBestEffort(ctx, auditEventInput{EventType: "EmployeeSignatureCreated", EventCompanyID: req.CompanyID, EventCLAGroupID: req.ProjectID, EventUserID: req.UserID, EventData: eventData, EventSummary: eventSummary, ContainsPII: true}) h.putAuditEventBestEffort(ctx, auditEventInput{EventType: "EmployeeSignatureSigned", EventCompanyID: req.CompanyID, EventCLAGroupID: req.ProjectID, EventUserID: req.UserID, EventData: eventData, EventSummary: eventSummary, ContainsPII: true}) + // A Self Serve session reaches this branch even for a Gerrit backed CLA group, so mirror the + // gerrit branch's best effort LDAP group add that the return URL type would otherwise skip + if selfServeSession { + h.addSelfServeEmployeeSignerToGerritGroups(ctx, req.ProjectID, req.UserID, lfUsername) + } + if strings.EqualFold(returnURLType, "github") { uid := strings.TrimSpace(getAttrString(user, "user_id")) aff := strings.TrimSpace(getAttrString(user, "user_company_id")) != "" @@ -9392,7 +9477,12 @@ func (h *Handlers) RequestEmployeeSignatureV2(w http.ResponseWriter, r *http.Req // EASYCLA_PARITY_FLAG: legacy Python also updates the repository provider when the project // does not require a separate ICLA, and only removes active signature metadata after that side effect succeeds. if av, ok := project["project_ccla_requires_icla_signature"].(*types.AttributeValueMemberBOOL); ok && !av.Value { - switch strings.ToLower(returnURLType) { + providerUpdateType := strings.ToLower(returnURLType) + if selfServeSession { + logging.Debugf("request_employee_signature skipping the repository provider update for a self serve signing session user=%s", req.UserID) + providerUpdateType = "" + } + switch providerUpdateType { case "github": if signatureMetadata == nil { respond.JSON(w, http.StatusInternalServerError, map[string]any{"errors": map[string]any{"server": legacyPythonNilSubscriptError().Error()}}) diff --git a/cla-backend-legacy/internal/api/handlers_self_serve_test.go b/cla-backend-legacy/internal/api/handlers_self_serve_test.go new file mode 100644 index 000000000..f4c93bf4d --- /dev/null +++ b/cla-backend-legacy/internal/api/handlers_self_serve_test.go @@ -0,0 +1,65 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + "testing" +) + +func TestIsSelfServeSignatureMetadata(t *testing.T) { + tests := []struct { + name string + metadata map[string]any + expected bool + }{ + {"nil metadata", nil, false}, + {"no source", map[string]any{"repository_id": "1", "pull_request_id": "2"}, false}, + {"nil source", map[string]any{"source": nil}, false}, + {"self serve source", map[string]any{"source": "self-serve"}, true}, + {"mixed case source", map[string]any{"source": "Self-Serve"}, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isSelfServeSignatureMetadata(test.metadata); got != test.expected { + t.Errorf("isSelfServeSignatureMetadata(%v) = %v, expected %v", test.metadata, got, test.expected) + } + }) + } +} + +func TestAddSelfServeEmployeeSignerToGerritGroupsIsANoOpWithoutDependencies(t *testing.T) { + h := &Handlers{} + for _, lfUsername := range []string{"", "None", "someuser"} { + h.addSelfServeEmployeeSignerToGerritGroups(context.Background(), "project", "user", lfUsername) + } +} + +func TestSelfServeSessionMatchesProject(t *testing.T) { + const claGroupID = "aa47b3e1-6f9c-4b6a-9f16-0f9d6a2e1c11" + + tests := []struct { + name string + metadata map[string]any + expected bool + }{ + {"same cla group", map[string]any{"project_id": claGroupID}, true}, + {"another cla group", map[string]any{"project_id": "62db1b81-6f4a-4b2e-9a4a-0f2d9f0a1b22"}, false}, + {"missing project", map[string]any{"source": "self-serve"}, true}, + {"blank project", map[string]any{"project_id": " "}, true}, + {"nil project", map[string]any{"project_id": nil}, true}, + {"numeric project", map[string]any{"project_id": 7}, true}, + {"object project", map[string]any{"project_id": map[string]any{"id": claGroupID}}, true}, + {"nil metadata", nil, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := selfServeSessionMatchesProject(test.metadata, claGroupID); got != test.expected { + t.Errorf("selfServeSessionMatchesProject(%v) = %v, expected %v", test.metadata, got, test.expected) + } + }) + } +} diff --git a/cla-backend-legacy/internal/store/companies.go b/cla-backend-legacy/internal/store/companies.go index f79e8efdb..5a6ac81cf 100644 --- a/cla-backend-legacy/internal/store/companies.go +++ b/cla-backend-legacy/internal/store/companies.go @@ -141,59 +141,82 @@ func (s *CompaniesStore) DeleteByID(ctx context.Context, companyID string) error return err } -// UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin. -// Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates. -func (s *CompaniesStore) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { - if s == nil || s.client == nil { - return nil - } - - now := time.Now().UTC().Format("2006-01-02T15:04:05.000000-0700") // Best effort for date_modified parity +// sanctionUpdate is the DynamoDB update for one sanction status change. +type sanctionUpdate struct { + expression string + condition *string + names map[string]string + values map[string]types.AttributeValue +} - names := map[string]string{ - "#S": "is_sanctioned", - "#M": "date_modified", +// buildSanctionUpdate assembles the update for UpdateCompanySanctionStatus. All SET assignments +// stay contiguous ahead of any REMOVE, as DynamoDB requires. +func buildSanctionUpdate(sanctioned bool, origin, now string) sanctionUpdate { + update := sanctionUpdate{ + expression: "SET #S = :s, #M = :m", + names: map[string]string{ + "#S": "is_sanctioned", + "#M": "date_modified", + "#O": "sanction_origin", + }, + values: map[string]types.AttributeValue{ + ":s": &types.AttributeValueMemberBOOL{Value: sanctioned}, + ":m": &types.AttributeValueMemberS{Value: now}, + }, } - values := map[string]types.AttributeValue{ - ":s": &types.AttributeValueMemberBOOL{Value: sanctioned}, - ":m": &types.AttributeValueMemberS{Value: now}, + + // Setting the flag stamps sanctioned_date; clearing it leaves the stored date alone. + if sanctioned { + update.names["#D"] = "sanctioned_date" + update.values[":d"] = &types.AttributeValueMemberS{Value: now} + update.expression += ", #D = :d" } - updateExpr := "SET #S = :s, #M = :m" if origin != "" { - names["#O"] = "sanction_origin" - values[":o"] = &types.AttributeValueMemberS{Value: origin} - updateExpr += ", #O = :o" + update.values[":o"] = &types.AttributeValueMemberS{Value: origin} + update.expression += ", #O = :o" } else { // Manual/admin update: remove any stale SSS-set origin so the record becomes a // sticky admin block (origin absent) that SSS will never auto-clear. - names["#O"] = "sanction_origin" - updateExpr += " REMOVE #O" + update.expression += " REMOVE #O" + } + + // When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true + // with absent or non-"sss" origin). Only set the SSS flag when the company is + // currently unblocked or already SSS-blocked; a ConditionalCheckFailedException + // means a manual/admin block is already present and must be preserved. + if sanctioned && origin == "sss" { + update.values[":false"] = &types.AttributeValueMemberBOOL{Value: false} + update.condition = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o") } + return update +} + +// UpdateCompanySanctionStatus sets is_sanctioned and, when origin is non-empty, sanction_origin. +// Pass origin="sss" when flagging via SSS; pass origin="" for manual admin updates. +func (s *CompaniesStore) UpdateCompanySanctionStatus(ctx context.Context, companyID string, sanctioned bool, origin string) error { + if s == nil || s.client == nil { + return nil + } + + now := time.Now().UTC().Format("2006-01-02T15:04:05.000000-0700") // Best effort for date_modified parity + update := buildSanctionUpdate(sanctioned, origin, now) + input := &dynamodb.UpdateItemInput{ TableName: aws.String(s.table), Key: map[string]types.AttributeValue{ "company_id": &types.AttributeValueMemberS{Value: companyID}, }, - UpdateExpression: aws.String(updateExpr), - ExpressionAttributeNames: names, - ExpressionAttributeValues: values, - } - - // When SSS sets a block, never overwrite a manual/admin block (is_sanctioned=true - // with absent or non-"sss" origin). Only set the SSS flag when the company is - // currently unblocked or already SSS-blocked; a ConditionalCheckFailedException - // means a manual/admin block is already present and must be preserved. - sssSettingBlock := sanctioned && origin == "sss" - if sssSettingBlock { - values[":false"] = &types.AttributeValueMemberBOOL{Value: false} - input.ConditionExpression = aws.String("attribute_not_exists(#S) OR #S = :false OR #O = :o") + UpdateExpression: aws.String(update.expression), + ConditionExpression: update.condition, + ExpressionAttributeNames: update.names, + ExpressionAttributeValues: update.values, } _, err := s.client.UpdateItem(ctx, input) if err != nil { - if sssSettingBlock { + if update.condition != nil { var condErr *types.ConditionalCheckFailedException if errors.As(err, &condErr) { return nil // Preserve the existing manual/admin block diff --git a/cla-backend-legacy/internal/store/companies_test.go b/cla-backend-legacy/internal/store/companies_test.go new file mode 100644 index 000000000..f8aea886d --- /dev/null +++ b/cla-backend-legacy/internal/store/companies_test.go @@ -0,0 +1,102 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package store + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +// TestBuildSanctionUpdate locks in the sanctioned_date semantics: the date is stamped on every +// flag-set and never touched when the flag is cleared. +func TestBuildSanctionUpdate(t *testing.T) { + const now = "2026-08-20T10:11:12.000000+0000" + + tests := []struct { + name string + sanctioned bool + origin string + expression string + condition string + stampedDate bool + }{ + { + name: "sss flags the company", + sanctioned: true, + origin: "sss", + expression: "SET #S = :s, #M = :m, #D = :d, #O = :o", + condition: "attribute_not_exists(#S) OR #S = :false OR #O = :o", + stampedDate: true, + }, + { + name: "sss clears the company", + sanctioned: false, + origin: "sss", + expression: "SET #S = :s, #M = :m, #O = :o", + }, + { + name: "admin flags the company", + sanctioned: true, + origin: "", + expression: "SET #S = :s, #M = :m, #D = :d REMOVE #O", + stampedDate: true, + }, + { + name: "admin clears the company", + sanctioned: false, + origin: "", + expression: "SET #S = :s, #M = :m REMOVE #O", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + update := buildSanctionUpdate(tc.sanctioned, tc.origin, now) + + if update.expression != tc.expression { + t.Fatalf("expression = %q, want %q", update.expression, tc.expression) + } + switch { + case tc.condition == "" && update.condition != nil: + t.Fatalf("condition = %q, want none: only an SSS-set flag is conditional", *update.condition) + case tc.condition != "" && update.condition == nil: + t.Fatal("missing condition: the manual/admin block must stay protected") + case tc.condition != "" && *update.condition != tc.condition: + t.Fatalf("condition = %q, want %q", *update.condition, tc.condition) + } + + _, hasName := update.names["#D"] + date, hasValue := update.values[":d"] + if hasName != tc.stampedDate || hasValue != tc.stampedDate { + t.Fatalf("sanctioned_date stamped = %v/%v, want %v", hasName, hasValue, tc.stampedDate) + } + if tc.stampedDate { + if update.names["#D"] != "sanctioned_date" { + t.Fatalf("#D = %q, want sanctioned_date", update.names["#D"]) + } + if got := date.(*types.AttributeValueMemberS).Value; got != now { + t.Fatalf("sanctioned_date = %q, want %q: stamped with the same time as the flag", got, now) + } + } + + // Every declared name and value has to be referenced, or DynamoDB rejects the update. + full := update.expression + if update.condition != nil { + full += " " + *update.condition + } + for name := range update.names { + if !strings.Contains(full, name) { + t.Errorf("name %s declared but never referenced", name) + } + } + for value := range update.values { + if !strings.Contains(full, value) { + t.Errorf("value %s declared but never referenced", value) + } + } + }) + } +} diff --git a/docs/MY_CLAS_API.md b/docs/MY_CLAS_API.md index ed4edfd04..61a2febb0 100644 --- a/docs/MY_CLAS_API.md +++ b/docs/MY_CLAS_API.md @@ -1,82 +1,125 @@ -# My CLAs API — EasyCLA backend for LFX Self Serve M1 +# My CLAs API — EasyCLA backend for LFX Self Serve M1/M2 Copyright The Linux Foundation and each contributor to CommunityBridge. SPDX-License-Identifier: CC-BY-4.0 -Backend implementation of the **M1 — Read-only "My CLAs" (Me lens)** milestone of the -EasyCLA → LFX Self Serve integration program -([epic linuxfoundation/lfx-self-serve#1157](https://github.com/linuxfoundation/lfx-self-serve/issues/1157), +Backend for the **M1 "My CLAs" (Me lens)** and **M2 "My CLAs actions"** milestones of the +EasyCLA → LFX Self Serve program +([M1 epic linuxfoundation/lfx-self-serve#1157](https://github.com/linuxfoundation/lfx-self-serve/issues/1157), specs in [`specs/001-easycla-ss-integration-fable/m1-my-cla/`](https://github.com/linuxfoundation/easycla/tree/001-easycla-ss-integration/specs/001-easycla-ss-integration-fable/m1-my-cla) on the `001-easycla-ss-integration` branch). -Three new **read-only** EasyCLA v2 endpoints (served under `/v4`, i.e. -`/cla-service/v4/...` through lfx-gateway) let the authenticated user retrieve **all -their current and historical ICLAs and ECLAs** — matched across their LF username, -emails and GitHub/GitLab/Gerrit identities, with per-record validity evaluated against -the *current* company CCLA approval lists — download their signed ICLA PDFs via -time-limited links, and list the deduplicated identity set they own (the same set the -list endpoint authorizes them to search). Every provided identity key is **verified to belong to the -authenticated user** before it is searched (see "Identity ownership enforcement"), so -the endpoints cannot be used to freely enumerate other people's CLA history. -"Belongs to" means the identity is *currently* attached to the caller's LF account -(their EasyCLA user record or their platform user-service profile/identities) — the -accepted product bar for this read-only surface; the recycled-alias trade-off this -implies is documented under "Known limitations": +Five EasyCLA v2 endpoints — four read-only plus the M2 contact-request POST — under `/v4` +(`/cla-service/v4/...` through lfx-gateway). They let the authenticated user list **all +their current and historical ICLAs and ECLAs** (matched across LF username, emails and +GitHub/GitLab/Gerrit identities, validity evaluated against the *current* company CCLA +approval lists), download signed ICLA PDFs via time-limited links, and list the +deduplicated identity set they own (the set the list endpoint authorizes them to search). +For non-admin, untrusted callers every identity key is **verified to belong to the +authenticated user** before it is searched (admin/trusted exceptions below), so the +endpoints cannot enumerate other people's CLA history. "Belongs to" means *currently* +attached to the caller's LF account (their EasyCLA user records or their platform +user-service profile/identities) — the accepted bar for this read-only surface; the +recycled-alias trade-off is under "Known limitations". | Method | Path | Purpose | |---|---|---| | `GET` | `/v4/my-clas` | List all signed ICLAs/ECLAs matching the provided identity, with computed validity | | `GET` | `/v4/my-clas/{signatureID}/pdf` | Time-limited (15 min) presigned S3 URL for a signed ICLA PDF owned by the provided identity | | `GET` | `/v4/my-clas/identities` | List the deduplicated `:` identities the authenticated user owns (no query params) | +| `GET` | `/v4/my-clas/{signatureID}/cla-managers` | List the CLA managers of the CCLA covering an ECLA owned by the provided identity | +| `POST` | `/v4/my-clas/{signatureID}/cla-manager-requests` | Email a removal/approval request or a contact-only message for an owned ECLA to selected CLA managers (M2; swagger-documented; `requestType=contact` requires a non-blank `message`) | ## Changed repositories and branches | Repository | Branch | Change | |---|---|---| | `easycla` | `unicron-easycla-my-clas-api` | New swagger paths/models, new `cla-backend-go/v2/my_clas` module, wiring in `cmd/server.go`, unit tests, this document | -| `acs-cli` | `unicron-easycla-my-clas-api` | ACS resource/policy/role registration for the three new paths (`services/11-cla-service.yaml`) — must be `acs-cli sync`'d per environment before the endpoints are reachable through the gateway | +| `acs-cli` | `unicron-easycla-my-clas-api` | ACS resource/policy/role registration for the five paths (`services/11-cla-service.yaml`; PR #1137 covers the two M2 additions) — must be `acs-cli sync`'d per environment before the endpoints are reachable through the gateway | Files changed in `easycla`: -- `cla-backend-go/swagger/cla.v2.yaml` — three new paths, eight new shared query parameters, four new definitions -- `cla-backend-go/swagger/common/my-cla-list.yaml`, `my-cla.yaml`, `my-cla-pdf.yaml`, `my-identity-list.yaml` — new response models +- `cla-backend-go/swagger/cla.v2.yaml` — five paths, eight shared query parameters, eight definitions +- `cla-backend-go/swagger/common/my-cla-list.yaml`, `my-cla.yaml`, `my-cla-pdf.yaml`, `my-identity-list.yaml`, `my-cla-manager.yaml`, `my-cla-manager-list.yaml`, `my-cla-manager-request.yaml`, `my-cla-manager-request-result.yaml` — request/response models - `cla-backend-go/v2/my_clas/handlers.go` — swagger operation wiring (`Configure`) -- `cla-backend-go/v2/my_clas/service.go` — identity ownership enforcement, resolution, aggregation, validity evaluation -- `cla-backend-go/v2/my_clas/repository.go` — plural, paginated GSI queries for identity resolution, the user's ICLA/ECLA records query, and the single-scan secondary-email lookup -- `cla-backend-go/v2/my_clas/service_test.go` — unit tests -- `cla-backend-go/v2/user-service/client.go` — two new (additive) context-aware read helpers, `GetUserByUsernameContext` and `ListUserIdentities` (paginated), mirroring the existing `ListUsersByUsername` pattern with bounded HTTP clients; no existing method changed +- `cla-backend-go/v2/my_clas/service.go` — ownership enforcement, identity resolution, aggregation, validity evaluation +- `cla-backend-go/v2/my_clas/prefetch.go` — per-request concurrent prefetch of every distinct external lookup +- `cla-backend-go/v2/my_clas/sanctions.go` — sanctions screener (live SSS lookup; the screener never writes, the service persists a first detection) +- `cla-backend-go/v2/my_clas/repository.go` — plural, paginated GSI queries for identity resolution and the user's ICLA/ECLA records, plus the single-scan secondary-email lookup +- `cla-backend-go/emails/contact_cla_manager_templates.go`, `cla-backend-go/events/event_data.go`, `event_types.go` — the contact-request email and its audit event +- `cla-backend-go/v2/my_clas/*_test.go` — unit tests +- `cla-backend-go/v2/user-service/client.go` — two additive context-aware read helpers, `GetUserByUsernameContext` and `ListUserIdentities` (paginated), mirroring `ListUsersByUsername` with bounded HTTP clients; no existing method changed - `cla-backend-go/cmd/server.go` — module registration (three lines) - `cla-backend-go/gen/**` is generated by `make swagger` and not committed -The module follows the repo's three-layer v2 module pattern (`handlers.go` / -`service.go` / `repository.go`) and is **additive and isolated**: no existing -endpoint, service method, or repository method changes behavior — the shared-code -touch points are the three registration lines in `cmd/server.go` and the two new -helper methods added to `v2/user-service/client.go`. +The module follows the repo's three-layer v2 pattern and is **additive**: no existing +endpoint, service or repository method changes behavior. Shared-code touch points: the +three registration lines in `cmd/server.go`, the two new helpers in +`v2/user-service/client.go`, one new method (`EvaluateUserApproval`) on the v1 signatures +service with `UserIsApproved` reduced to a call through it (signing flow unchanged), and +`v2/project-service/client.go`, where a `len()` in a debug message moved under the mutex +that already guards the cache — logging only. ## Authentication -All three endpoints use the standard v4 `lf-auth` security (base64 `X-ACL` header injected -by lfx-gateway), exactly like every other secured v4 endpoint: +All five endpoints use the standard v4 `lf-auth` security (base64 `X-ACL` header injected +by lfx-gateway), like every other secured v4 endpoint: 1. The caller (LFX Self Serve server) sends `GET /cla-service/v4/my-clas` with a user - bearer token minted for the api-gw audience (the token model verified in + bearer token minted for the api-gw audience (token model verified in [`docs/easycla-ss-migration/role-mapping-feasibility.md`](https://github.com/linuxfoundation/easycla/blob/001-easycla-ss-integration/docs/easycla-ss-migration/role-mapping-feasibility.md)). -2. lfx-gateway's `secured` chain validates the JWT (signature + issuer) and asks the - ACS warden whether the username may access this path/method. With the `acs-cli` - change, all three paths are registered `anyRole: true` and attached to the `user` role - (`ViewMyClas` policy — resources `my_clas`, `my_clas_pdf`, `my_clas_identities`) — - mirroring `user_from_token` / `active_signature` — so **any authenticated LF user** is - authorized. `/v4/my-clas/identities` is registered as its own resource because the - `/v4/my-clas` path is matched exactly (the PDF route likewise needs its own - `/v4/my-clas/*/pdf` entry); an unregistered sub-path would be denied by the warden. - Without a valid token the gateway returns 401/403 and the request never reaches EasyCLA. +2. lfx-gateway's `secured` chain validates the JWT (signature + issuer) and asks the ACS + warden whether the username may access this path/method. With the `acs-cli` change all + five paths are registered `anyRole: true` and attached to the `user` role — the + `ViewMyClas` policy (resources `my_clas`, `my_clas_pdf`, `my_clas_identities`) and the + `ContactMyClaManagers` policy (resources `my_clas_managers`, + `my_clas_manager_requests`) — mirroring `user_from_token` / `active_signature`, so + **any authenticated LF user** is authorized. `/v4/my-clas/identities` is its own + resource because `/v4/my-clas` is matched exactly (the PDF route likewise needs its own + `/v4/my-clas/*/pdf` entry); an unregistered sub-path is denied by the warden. Without a + valid token the gateway returns 401/403 and the request never reaches EasyCLA. 3. The gateway injects `X-ACL`/`X-USERNAME`/`X-EMAIL`; the Lambda decodes them into the handler's `authUser` principal. -Note the ACS warden caches authorize responses for ~30 minutes; that only affects the -first rollout (after `acs-cli sync`), not steady-state behavior. +The ACS warden caches authorize responses for ~30 minutes; that affects only the first +rollout (after `acs-cli sync`), not steady state. + +### Trusted Self Serve caller (in-handler JWT verification + `azp` allow-list) + +The gateway-injected `X-ACL`/`X-USERNAME` headers are **decoded but never signature +checked**, so anything able to invoke the Lambda directly could forge them. To make the +identity-list bypass below safe, the handlers re-verify the request bearer token +themselves (`cla-backend-go/auth/trusted_caller.go`): the signing algorithm is pinned to +the configured Auth0 algorithm, the signature is verified against the tenant JWKS by `kid` +(`https://{cla-auth0-domain}/.well-known/jwks.json`, cached 15 min; a cache miss reloads +at most once a minute; a JWKS outage keeps serving the cached key for at most 24 h), `exp` +must be present and unexpired, and the caller is **trusted** when the token's `azp` is +listed in the SSM parameter `cla-ss-trusted-client-ids-{stage}` (comma-separated Auth0 +client IDs). + +| Request, once the allow-list is configured | Result | +|---|---| +| No `Authorization` header, or an unparseable/unverifiable/expired token | **401**, the service is never reached | +| Verified token, `azp` **on** the allow-list | trusted: the caller-supplied identity list is searched as given, no per-identity verification | +| Verified token, `azp` **not** on the allow-list (or absent) | untrusted: unchanged behavior — admin bypass or per-identity ownership enforcement | + +An absent header is denied exactly like an invalid one: the traefik `aws-lambda` +middleware drops duplicated headers, so a duplicated `Authorization` header arrives as an +absent one. `iss` and `aud` are deliberately not re-checked in-handler: the JWKS already +binds the token to this one tenant, and which audience an SS token carries is still open in +the [trust-SS decision](https://github.com/linuxfoundation/lfx-self-serve/issues/1216) +(session access token vs. the P3 api-gw-audience token), so pinning one would 401 the +other. A token this tenant minted for another API therefore also verifies — accepted +because `azp` stays the client signal. + +While the SSM parameter is unset the verifier is **disabled** — no bearer token is +required and nothing is trusted, so the endpoints behave exactly as before. A local +`./bin/cla` run loads the stage's SSM, so once the parameter is set local requests need a +real token too (`utils/my_clas.sh` sends one; its token-less `PRINCIPAL=...` mode does +not). A non-empty allow-list without `cla-auth0-domain` panics at startup rather than +trusting blindly. Each request logs `callerClientID` (`azp`), `callerSubject` (`sub`), +`trustedCaller` and the requested identity list (length-bounded) for anomaly detection. ## `GET /v4/my-clas` @@ -84,18 +127,19 @@ first rollout (after `acs-cli sync`), not steady-state behavior. | Parameter | Type | Repeatable | Meaning | |---|---|---|---| -| `lfUsername` | string | no | LF username (LFID). **When omitted, defaults to the username of the authenticated principal** (from the token via `X-USERNAME`), so a plain `GET /v4/my-clas` returns the caller's own CLAs. For non-admin callers a value different from the token username is never searched (reported in `skippedIdentities`). | -| `email` | string | yes | Email addresses of the user. Lowercased/trimmed and matched against the EasyCLA user records' primary email (`lf_email` GSI — index-backed). All repeatable parameters are capped (`maxItems: 100`, `secondaryEmail: 20`) and deduplicated server-side. | -| `secondaryEmail` | string | yes | Email addresses additionally matched against the EasyCLA user records' additional-emails set (`user_emails`). **This match is not index-backed** (the attribute is a DynamoDB string set, which cannot carry a GSI): all provided values (max 20, normalized + deduplicated) are matched in **one single table scan** — never one scan per value, and never for plain `email` values. Use sparingly. | +| `lfUsername` | string | no | LF username (LFID). **When omitted, defaults to the authenticated principal's username** (from the token via `X-USERNAME`), so a plain `GET /v4/my-clas` returns the caller's own CLAs. For non-admin, untrusted callers a value different from the token username is never searched (reported in `skippedIdentities`). | +| `email` | string | yes | Lowercased/trimmed and matched against the EasyCLA user records' primary email (`lf_email` GSI — index-backed). All repeatable parameters are capped (`maxItems: 100`, `secondaryEmail: 20`) and deduplicated server-side. | +| `secondaryEmail` | string | yes | Matched against the records' additional-emails set (`user_emails`). **Not index-backed** (a DynamoDB string set cannot carry a GSI): all provided values (max 20, normalized + deduplicated) are matched in **one single table scan** — never one scan per value, and never for plain `email` values. Use sparingly. | | `githubId` | integer | yes | GitHub numeric user IDs linked to the LF identity (from Auth0 identities). The highest-precision key for pre-LF-login history. | | `githubUsername` | string | yes | GitHub usernames (hint only — usernames can be renamed/recycled; prefer `githubId`). | | `gitlabId` | integer | yes | GitLab numeric user IDs linked to the LF identity. | | `gitlabUsername` | string | yes | GitLab usernames (hint only). | -| `gerritUsername` | string | yes | Gerrit usernames. Gerrit authenticates via LF SSO, so these are (current or historical) **LF usernames** — matched against the EasyCLA records' `lf_username`. Useful for historical gerrit-era records tied to an older LDAP/LF username connected to the account. | +| `gerritUsername` | string | yes | Gerrit usernames. Gerrit authenticates via LF SSO, so these are (current or historical) **LF usernames** — matched against the records' `lf_username`. Useful for gerrit-era records tied to an older LDAP/LF username on the account. | -If the token carries no username (and the caller is not an admin), the endpoint -returns `401`. There is deliberately **no pagination**: a person's CLA set is small -(typically well under 50 records) and the upstream queries paginate internally. +If the token carries no username and the caller is neither an admin nor a trusted Self +Serve client, the endpoint returns `401`. There is deliberately **no pagination**: a +person's CLA set is small (typically well under 50 records) and the upstream queries +paginate internally. Example (through the gateway): @@ -106,62 +150,67 @@ curl -H "Authorization: Bearer $TOKEN" \ ### Identity ownership enforcement (step 0) -Without enforcement, an endpoint of this shape would let any authenticated user list -anybody's CLA history by guessing an email or GitHub ID. Therefore, for **non-admin** -callers every provided identity key must be verified to belong to the authenticated -user (the token's username claim) before it is searched: +Unenforced, an endpoint of this shape would let any authenticated user list anybody's CLA +history by guessing an email or GitHub ID. So for **non-admin** callers every identity key +is verified against the authenticated user (the token's username claim) before it is +searched: 1. **All** EasyCLA user records matching the token username are fetched - (`lf-username-index`, plural + paginated — one person may hold several LFID rows) - and their identity fields are merged. A key is allowed when it matches any of - them: emails against `lf_email` + `user_emails` (case-insensitive), `githubId` - against `user_github_id`, `githubUsername` against `user_github_username` + (`lf-username-index`, plural + paginated — one person may hold several LFID rows) and + their identity fields merged. A key is allowed when it matches any of them: emails + against `lf_email` + `user_emails` (case-insensitive), `githubId` against + `user_github_id`, `githubUsername` against `user_github_username` (case-insensitive), `gitlabId`/`gitlabUsername` likewise, and `gerritUsername`/`lfUsername` against the token username. -2. Username keys and keys not covered there are checked against the **LF-wide - identities** connected to the account in the platform user-service (loaded - lazily, at most once per request): `GET /user-service/v1/users?username={lfid}` - for the profile (SFID + all non-deleted profile emails) and - `GET /user-service/v1/users/{sfid}/identities` for connected identities - (paginated — all pages are fetched, so accounts with more than 100 identities are - fully covered; identities with a `DataSource` other than `platform` are ignored). - Usernames are accepted per source (`github`, `gitlab`, `gerrit` — a Slack +2. Username keys and keys not covered there are checked against the **LF-wide identities** + connected to the account in the platform user-service (loaded lazily, at most once per + request): `GET /user-service/v1/users?username={lfid}` for the profile (SFID + all + non-deleted profile emails) and `GET /user-service/v1/users/{sfid}/identities` for + connected identities (paginated — all pages fetched, so accounts with more than 100 + identities are fully covered; identities whose `DataSource` is not `platform` are + ignored). Usernames are accepted per source (`github`, `gitlab`, `gerrit` — a Slack identity never authorizes a GitHub search); identity emails are accepted from any platform-sourced identity. Numeric GitHub/GitLab IDs cannot be validated through user-service (identities carry usernames, not provider IDs), so they validate only - against the EasyCLA LFID records. - Because the users-table username indexes are exact-match while verification is - case-insensitive, allowed usernames are expanded to their **canonical spellings** - (from the EasyCLA records and user-service, plus the requested spelling) before - the index lookups — `githubUsername=octocat` finds a record stored as `Octocat`. -3. Keys verified by neither source are **not searched** and are reported back in the - response's `skippedIdentities` array (formatted `":"`, e.g. - `"email:bob@corp.com"`). They do not fail the request; the caller can surface or - log them (they are also the natural telemetry signal for identity-mapping gaps). - A user-service outage degrades the same way: unverifiable keys are skipped and - reported, never allowed. - -Callers whose gateway-injected `X-ACL` carries the **admin** flag -(`utils.IsUserAdmin`) bypass enforcement entirely — preserving a support/parity- -sampling path (e.g. the SC-001 comparison script) without weakening the contributor -case. The token username is always searched for non-admin callers, so a bare -`GET /v4/my-clas` works even when every extra key is skipped. + against the EasyCLA LFID records. Because the users-table username indexes are + exact-match while verification is case-insensitive, allowed usernames are expanded to + their **canonical spellings** (from the EasyCLA records and user-service, plus the + requested spelling) before the index lookups — `githubUsername=octocat` finds a record + stored as `Octocat`. +3. Keys verified by neither source are **not searched** and are reported in the response's + `skippedIdentities` array (formatted `":"`, e.g. + `"email:bob@corp.com"`). They do not fail the request; the caller can surface or log + them (they are the natural telemetry signal for identity-mapping gaps). A user-service + outage degrades the same way: unverifiable keys are skipped and reported, never allowed. + +Callers whose gateway-injected `X-ACL` carries the **admin** flag (`utils.IsUserAdmin`) +bypass enforcement entirely — preserving a support/parity-sampling path (e.g. the SC-001 +comparison script) without weakening the contributor case. The token username is always +searched for non-admin callers, so a bare `GET /v4/my-clas` works even when every extra key +is skipped. + +**A trusted Self Serve caller** (see [Trusted Self Serve caller](#trusted-self-serve-caller-in-handler-jwt-verification--azp-allow-list)) +also bypasses enforcement — deliberately, because here it is the *wrong* check: SS derives +the list from the caller's Auth0 identities (authoritative, and not reconstructible by +EasyCLA), while the historical GitHub-only signers this API exists to surface hold **no +`lf_username`** on their EasyCLA records, so verifying against those records would deny +exactly the CLAs the caller is entitled to see. Such a caller needs no username of its own; +supplying neither a username nor any identity key is a `400`, not an "everyone" query. ### Identity resolution (step 1) The allowed keys are resolved to EasyCLA user records — **union of all matches, -deduplicated by `user_id`, in parameter order**. One person may hold several EasyCLA -user records (e.g. a pre-LF-login GitHub-derived record without `lf_username` plus a -console-created record); this is why aggregation happens across all matches -(spec FR-005). Every lookup is a DynamoDB GSI query on the `cla-{stage}-users` table -except the explicitly opt-in `secondaryEmail` scan (the existing `/v3/users/search` -full-scan-per-request pattern is exactly what this avoids, per -[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): - -All lookups are **plural and paginated** module-repository queries returning every -matching record — the legacy singular `users` service helpers were deliberately not -reused because they return only the first row when a GSI holds several matches for -the same key (one person with multiple records), which would silently drop history: +deduplicated by `user_id`, in parameter order**. One person may hold several EasyCLA user +records (e.g. a pre-LF-login GitHub-derived record without `lf_username` plus a +console-created one); hence aggregation across all matches (spec FR-005). Every lookup is +a DynamoDB GSI query on `cla-{stage}-users` except the opt-in `secondaryEmail` scan (the +existing `/v3/users/search` full-scan-per-request pattern is exactly what this avoids, per +[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)). + +All lookups are **plural and paginated** module-repository queries returning every matching +record — the legacy singular `users` service helpers were deliberately not reused because +they return only the first row when a GSI holds several matches for the same key (one +person with multiple records), which would silently drop history: | Identity key | Users-table access | Module repository method | |---|---|---| @@ -174,126 +223,189 @@ the same key (one person with multiple records), which would silently drop histo | `gitlabId` | `gitlab-id-index` GSI (N-typed key) | `GetUsersByGitlabID` | | `gitlabUsername` | `gitlab-username-index` GSI | `GetUsersByGitlabUsername` | -Empty results are simply empty; any EasyCLA repository lookup error fails the request (`500`) — user-service failures instead skip and report the affected keys rather -than silently returning a partial history — an incomplete list would erode user -trust (spec: "an incomplete list here erodes trust in every later milestone"). The -same rule applies to the display lookups: a *missing* CLA group or company record -degrades gracefully (name omitted / ECLA marked invalid), while any other data-layer -error fails the request. - -On `user_emails` (why `secondaryEmail` is separate): the attribute is a DynamoDB -string set and **cannot be GSI-indexed**, so matching it requires a table scan. The -default `email` parameter therefore stays strictly index-backed (`lf_email`), and a -single scan (matching every provided value at once) runs only when the caller -explicitly passes `secondaryEmail` values. In practice GitHub/GitLab-derived records -(the ones whose `user_emails` would matter most) are matched precisely by the numeric -`githubId`/`gitlabId` keys instead. +Empty results are simply empty. An EasyCLA repository error during identity resolution or +signature retrieval fails the request (`500`) rather than returning a partial history — +user-service failures instead skip and report the affected keys — because "an incomplete +list here erodes trust in every later milestone" (spec). A *missing* CLA group or company +record degrades gracefully (name omitted / ECLA marked invalid), and a **failed company or +CCLA lookup degrades that single row** — name omitted, `status` becomes `unknown` — instead +of failing the whole list, so one unreachable company cannot blank out a user's entire CLA +history (the failure is cached per company, so sibling rows do not retry it). Any other +data-layer error, including a CLA-group/project mapping failure that would affect every row +alike, still fails the request. No row is ever silently dropped. + +On `user_emails` (why `secondaryEmail` is separate): the attribute is a DynamoDB string set +and **cannot be GSI-indexed**, so matching it requires a table scan. The default `email` +parameter therefore stays strictly index-backed (`lf_email`), and a single scan (matching +every provided value at once) runs only when the caller explicitly passes `secondaryEmail` +values. In practice GitHub/GitLab-derived records — the ones whose `user_emails` would +matter most — are matched precisely by the numeric `githubId`/`gitlabId` keys instead. ### Gerrit support -Gerrit has no separate identity space in EasyCLA: Gerrit instances are LF-hosted and -contributors authenticate with their **LF SSO account**, and the gerrit user-creation -path (`POST /v1/user/gerrit`, `cla-backend-legacy/internal/api/handlers.go` +Gerrit has no separate identity space in EasyCLA: instances are LF-hosted, contributors +authenticate with their **LF SSO account**, and the gerrit user-creation path +(`POST /v1/user/gerrit`, `cla-backend-legacy/internal/api/handlers.go` `PostOrGetUserGerritV1` → `getOrCreateUser` from the auth token) keys the EasyCLA user record on `lf_username`/`lf_email`. Consequently: -- CLAs signed via Gerrit are found automatically through the token username and email - keys — nothing extra to pass for the common case. -- The `gerritUsername` parameter covers the historical case where the account is - connected (per user-service `Source=gerrit` identities) to an **older/different LF - username** than the current one — those values resolve through the same - `lf-username-index`. -- Gerrit's internal numeric account IDs exist only inside Gerrit itself; neither - EasyCLA nor the platform user-service stores them, so there is nothing to look up by - — usernames are the supported Gerrit key. +- CLAs signed via Gerrit are found automatically through the token username and email keys + — nothing extra to pass for the common case. +- `gerritUsername` covers the historical case where the account is connected (per + user-service `Source=gerrit` identities) to an **older/different LF username** — those + values resolve through the same `lf-username-index`. +- Gerrit's internal numeric account IDs exist only inside Gerrit; neither EasyCLA nor the + platform user-service stores them, so usernames are the supported Gerrit key. ### Signature retrieval (step 2) -For each matched user record, the module queries the `cla-{stage}-signatures` table on -the `reference-signature-index` GSI (`signature_reference_id = user_id`), filtered to -`signature_reference_type = user` and `signature_type IN (cla, ecla)`, and paginates -until exhausted. - -Two facts discovered during implementation (both verified against the dev DynamoDB -data) shaped this query — a new module-private query was required because **no existing -endpoint returns a user's ECLAs**: - -- The existing `GET /v4/signatures/user/{userID}` (v1 `signatures` repository - `GetUserSignatures`) explicitly filters **out** every record with - `signature_user_ccla_company_id` set — i.e. it returns ICLAs only, contrary to what - the M1 research doc assumed. -- ECLA records exist with **two spellings of `signature_type`**: DocuSign-era ECLAs - carry `signature_type=cla`, while ECLAs auto-created from approval-list changes +For each matched user record the module queries `cla-{stage}-signatures` on the +`reference-signature-index` GSI (`signature_reference_id = user_id`), filtered to +`signature_reference_type = user` and `signature_type IN (cla, ecla)`, paginating until +exhausted. + +Two facts found during implementation (both verified against dev DynamoDB data) shaped this +query — a module-private query was required because **no existing endpoint returns a user's +ECLAs**: + +- `GET /v4/signatures/user/{userID}` (v1 `signatures` repository `GetUserSignatures`) + explicitly filters **out** every record with `signature_user_ccla_company_id` set, i.e. + it returns ICLAs only, contrary to what the M1 research doc assumed. +- ECLA records exist with **two spellings of `signature_type`**: DocuSign-era ECLAs carry + `signature_type=cla`, ECLAs auto-created from approval-list changes (`signatures/repository.go` `CreateOrUpdateEmployeeSignature`) carry - `signature_type=ecla`. Dev sample: 177 user-referenced `cla`-typed records with a - company ID vs 77 `ecla`-typed; every `ecla`-typed record has a company ID. + `signature_type=ecla`. Dev sample: 177 user-referenced `cla`-typed records with a company + ID vs. 77 `ecla`-typed; every `ecla`-typed record has a company ID. ### Classification, filtering, deduplication (step 3) -- Records with `signature_signed = false` (abandoned/incomplete signing ceremonies) are +- Records with `signature_signed = false` (abandoned/incomplete ceremonies) are **excluded** — the milestone lists signed agreements only. -- **ICLA vs ECLA** is decided by `signature_user_ccla_company_id` presence (absent ⇒ - ICLA, set ⇒ ECLA) — the same invariant the v1→v2 signature converters and the - dynamo-events lambda use, and the one that holds for both `signature_type` spellings. - CCLA records (`signature_reference_type=company`) never match the query; corporate - data is out of M1 scope. +- **ICLA vs ECLA** is decided by `signature_user_ccla_company_id` presence (absent ⇒ ICLA, + set ⇒ ECLA) — the same invariant the v1→v2 signature converters and the dynamo-events + lambda use, and the one that holds for both `signature_type` spellings. CCLA records + (`signature_reference_type=company`) never match the query; corporate data is out of M1 + scope. - Results are **deduplicated by `signatureID`** across the matched user records; each - distinct signature is a distinct legal record and is shown even when several exist - for the same CLA group (per the M1 data-model decision). -- Rows are sorted by `signedOn` **descending**. `signedOn` mirrors the v1 converter - behavior: `signed_on` attribute, falling back to `date_created` for older records. + distinct signature is a distinct legal record and is shown even when several exist for the + same CLA group (per the M1 data-model decision). +- Rows are sorted by `signedOn` **descending**. `signedOn` mirrors the v1 converter: + `signed_on`, falling back to `date_created` for older records. ### Validity evaluation (step 4) -Each returned row carries the raw flags (`signed`, `approved`) plus a computed `valid` -boolean: +Each row carries the raw flags (`signed`, `approved`) plus a computed `valid`: - **ICLA**: `valid = signed && approved`. `approved=false` means the signature was - invalidated (PM invalidation or approval-criteria removal set - `signature_approved=false`; the stored `note` records why). + invalidated (PM invalidation or approval-criteria removal set `signature_approved=false`; + the stored `note` records why). - **ECLA** (employee acknowledgement): `valid` requires **all** of: 1. `signature_signed && signature_approved`; - 2. the employer (company record) exists and is **not sanctioned** - (`is_sanctioned` — the same persisted gate `ProcessEmployeeSignature` enforces - for PR checks); - 3. the employer **currently holds an approved + signed CCLA** for the CLA group - (v1 `signatures.Service.GetCorporateSignature`); - 4. the user **still matches that CCLA's current approval lists** — evaluated by - reusing v1 `signatures.Service.UserIsApproved`, the *exact* function PR gating - uses (issue [#1164](https://github.com/linuxfoundation/lfx-self-serve/issues/1164) - demands the real gating logic, not an approximation): - - GitHub username approval list — case-insensitive exact match; - - GitLab username approval list — case-insensitive exact match; - - email approval list — case-insensitive exact match over the user record's - emails (`user_emails` + `lf_email`); - - email-domain approval list — regex patterns (`*.corp.com`, `*corp.com`, - `.corp.com`, `corp.com` forms); - - GitHub org approval list — live lookup of the user's **public** GitHub org - memberships (transient GitHub API failures are treated as "no match", never as - an error — same as gating); - - GitLab group approval list — `UserIsApproved` cannot evaluate group membership - live (that needs per-group OAuth tokens held by the MR-gating service), so when - the CCLA carries GitLab group approvals and nothing else matched, the check - **defers to the `signature_approved` flag** (which the approval-list - invalidation flow maintains) instead of wrongly reporting the ECLA invalid. - - This is checked **at request time inside the API**, because approval lists change - after acknowledgements are recorded. Approval-list edits do synchronously invalidate - affected ECLAs (`signature_approved=false` + note), so `approved` usually already - reflects removals — the live re-check is the belt-and-braces guarantee the task - demands, and it also catches drift (e.g. a user who left the company's GitHub org, - or edits where the invalidation pass missed a record). + 2. the employer (company record) exists and is **not flagged** by sanctions screening — a + live screen when screening is enabled, otherwise the persisted `is_sanctioned` gate + `ProcessEmployeeSignature` enforces for PR checks (step 5); + 3. the employer **currently holds an approved + signed CCLA** for the CLA group (v1 + `signatures.Service.GetCorporateSignature`); + 4. the user **still matches that CCLA's current approval lists** — evaluated by reusing v1 + `signatures.Service.EvaluateUserApproval`, the *exact* function PR gating uses (issue + [#1164](https://github.com/linuxfoundation/lfx-self-serve/issues/1164) demands the real + gating logic, not an approximation): + - GitHub username list — case-insensitive exact match; + - GitLab username list — case-insensitive exact match; + - email list — case-insensitive exact match over the record's emails (`user_emails` + + `lf_email`); + - email-domain list — regex patterns (`*.corp.com`, `*corp.com`, `.corp.com`, + `corp.com` forms); + - GitHub org list — live lookup of the user's **public** GitHub org memberships + (transient GitHub API failures count as "no match", never as an error — same as + gating); + - GitLab group list — `EvaluateUserApproval` cannot evaluate group membership live + (that needs per-group OAuth tokens held by the MR-gating service), so when the CCLA + carries GitLab group approvals and nothing else matched, the check **defers to the + `signature_approved` flag** (which the invalidation flow maintains) instead of + wrongly reporting the ECLA invalid. + + This runs **at request time inside the API** because approval lists change after + acknowledgements are recorded. Edits do synchronously invalidate affected ECLAs + (`signature_approved=false` + note), so `approved` usually already reflects removals; the + live re-check is the guarantee the task demands and catches drift (a user who left the + company's GitHub org, or edits the invalidation pass missed). The user record used for the approval-list check is the record that **owns** the ECLA -(`signature_reference_id`), matching how gating evaluates that user — not the union of -all provided identities. +(`signature_reference_id`), matching how gating evaluates that user — not the union of all +provided identities. + +The endpoint deliberately returns **invalid rows too** (`valid=false`): story +[#1158](https://github.com/linuxfoundation/lfx-self-serve/issues/1158) wants ICLAs in *all* +statuses, and faithful data keeps the API useful for parity sampling (SC-001) and support. + +FR-002's "display ECLAs only when valid" predates the computed `status`, so **do not filter +on `valid=false`**: `needs_attention` rows are `valid=false` by construction and are exactly +the rows carrying the "Request approval" action +([#1372](https://github.com/linuxfoundation/lfx-self-serve/issues/1372)); an `unknown` row +whose coverage could not be determined is `valid=false` too, yet should render as covered. +Filter, if at all, on `status` (e.g. hide only `invalidated`). -The endpoint intentionally returns **invalid rows too** (flagged `valid=false`) instead -of hiding them: story [#1158](https://github.com/linuxfoundation/lfx-self-serve/issues/1158) -wants ICLAs in *all* statuses, while ECLAs are only *displayed* when valid (FR-002) — -that final display filter is one line in the consumer (`clas.filter(c => c.claType !== -'ecla' || c.valid)`), and keeping the data faithful makes the API useful for parity -sampling (SC-001) and support. +### Contributor-facing status and sanctions screening (step 5) + +`valid` answers "does this agreement attribute contributions right now"; the console needs +the *reason*, so each row also carries a computed `status` (plus `statusReason` when it is +not `valid`), evaluated in this precedence: + +| `status` | When | `statusReason` | +|---|---|---| +| `revoked` | The employer is flagged by sanctions screening — system-set, no user action | — | +| `invalidated` | The stored `approved` flag is `false` (an Approved List edit, an invalidated ICLA, a deleted CLA Group all produce it) | — | +| `unknown` | ECLA coverage could not be evaluated (company/CCLA record unreadable, the GitHub public-orgs lookup failed, or the GitLab-group fallback applied) | `unknown` | +| `valid` | ICLA that is signed + approved, or an ECLA whose employer's CCLA still covers the user | — | +| `needs_attention` | A *completed* approval-list check proved the user is no longer covered | `not_on_approval_list` | + +ICLAs are only ever `valid` or `invalidated`. New `status` values may be added in a future +spec revision (a generated client then needs a spec refresh, since go-swagger validates the +enum strictly). `not_on_approval_list` is the one reason a "Request approval" action can act +on; anything else is informational. + +`status` and `valid` can legitimately disagree, and the pair carries more than either alone: +the GitLab-group deferral (step 4) returns `valid: true` with `status: unknown` — displayed +as covered, but the coverage was never independently verified. A consumer that wants #1256's +three-value column can safely render `unknown && valid` as Valid; the reverse (recovering +"unverified" from `valid` alone) is impossible. + +The full #1256 pill derivation: Valid = `valid` (and `unknown`, per above), Needs attention += `needs_attention`, Revoked = `revoked` **∪** `invalidated` — #1256 defines Revoked as +"`approved = false` / invalidated by the system", which is exactly our `invalidated`, while +`revoked` is the sanctions case it names; the API keeps the two apart so the *cause* stays +distinguishable until #1370's durable revocation metadata exists. #1256 also calls a +needs-attention row "still a valid signature": that means still signed and not revoked, not +that `valid` is `true` — `valid` answers current attribution, so those rows carry +`valid: false` (see the filtering note in step 4). + +`flagged` is not read from the stored company flag alone. When sanctions screening is +enabled the listing screens **each distinct employer once per response** against the +Sanctions Screening Service for a live answer, and reports how the answer was obtained in +`flaggedCheck`: + +| Situation | `flagged` | `flaggedCheck` | +|---|---|---| +| Employer blocked by an administrator (`sanction_origin != sss`) | `true` (authoritative, no call made) | `stored` | +| Screening disabled or unconfigured | the persisted `is_sanctioned` flag | `stored` | +| Live screen answered | the live verdict (it overrides a stale persisted flag either way) | `live` | +| Live screen could not be completed (no company external ID, org lookup failure, unresolvable domain, SSS error/unexpected status) | the persisted `is_sanctioned` flag — possibly stale | `unavailable` | +| Employer record itself could not be read | `false` (no persisted flag to fall back to) | `unavailable` | + +The response-level `sssMode` (`required` / `optional` / `disabled`) tells the consumer how +much weight `unavailable` carries: in `required` mode an unverified row is a real gap, in +`optional` mode best-effort. **A screening failure never fails this endpoint** in either +mode — unlike the signing flow, which may fail closed. The screener itself only answers the +question (the lookup/domain/status logic is duplicated from `v2/sign`'s +`checkCompanyCompliance` rather than shared), but a *first* live detection is persisted — +`is_sanctioned` plus `sanctioned_date` with origin `sss` — so `flaggedAt` stops moving between +listings. An employer *currently* sanctioned and already carrying the date is never restamped +by the listing; one that was cleared but retained its date is restamped on the next live +detection, and the signing and legacy SSS flows restamp on every flagged detection by design. +A failed write costs that employer only its stored date (the flag is then reported without a +date), and the listing never clears a flag. The first persist of a new sanction also logs a +`company.sanctioned` event. ### Response — `200 my-cla-list` @@ -302,7 +414,8 @@ sampling (SC-001) and support. "lfUsername": "jdoe", "userIds": ["6e29e1a9-...", "a3b1c2d3-..."], "skippedIdentities": [], - "resultCount": 3, + "sssMode": "optional", + "resultCount": 2, "clas": [ { "signatureID": "3c1e5d7a-...", @@ -316,12 +429,13 @@ sampling (SC-001) and support. "signed": true, "approved": true, "valid": true, + "status": "valid", "documentMajorVersion": 2, "documentMinorVersion": 0, "pdfAvailable": true }, { - "signatureID": "9ab2f4c1-...", + "signatureID": "207b003b-...", "claType": "ecla", "claGroupID": "88bc3d21-...", "claGroupName": "LF Energy", @@ -329,26 +443,14 @@ sampling (SC-001) and support. "companyName": "Example Corp", "signingEntityName": "Example Corp LLC", "userID": "a3b1c2d3-...", - "signedOn": "2026-01-18T08:00:00Z", - "signed": true, - "approved": true, - "valid": true, - "documentMajorVersion": 2, - "documentMinorVersion": 0, - "pdfAvailable": false - }, - { - "signatureID": "207b003b-...", - "claType": "ecla", - "claGroupID": "01af041c-...", - "claGroupName": "CNCF - Kubernetes", - "companyID": "f7c7ac9c-...", - "companyName": "Example Corp", - "userID": "6e29e1a9-...", "signedOn": "2024-05-05T09:16:19Z", "signed": true, "approved": false, "valid": false, + "status": "invalidated", + "claManager": false, + "flagged": false, + "flaggedCheck": "live", "documentMajorVersion": 2, "documentMinorVersion": 0, "pdfAvailable": false @@ -364,36 +466,47 @@ Field reference (`my-cla` rows): | `signatureID` | string | Signature UUID; input to the PDF endpoint | | `claType` | `icla` \| `ecla` | See classification above; the UI renders `ICLA` / `ECLA · ` pills | | `claGroupID` | string | CLA Group UUID (`signature_project_id`) | -| `claGroupName` | string | Resolved via `projects_cla_groups` repo (single `GetItem`, cached per request); **omitted from the JSON** (string fields marshal with `omitempty`) if the CLA group record is gone — solves the "payload carries no project display name" gap noted in M1 research R6. No v1 user-service/org-service IDs are exposed (architecture-proposal P9) | -| `projectName` | string | The Salesforce project display name the CLA Group belongs to (a foundation-level CLA Group — identified by a `projects_cla_groups` mapping whose `project_sfid == foundation_sfid` — resolves to its foundation). Name comes from the `projects_cla_groups` mapping table and is upgraded to the project-service `Name` when available; both cached per request. Rendered as the bold top line of the UI's Project cell (with `claGroupName` as the subtext). Omitted when it could not be resolved | -| `projectLogo` | string | The project (or foundation) logo URL, fetched from the project-service by project SFID (cached per request). Rendered as the Project cell's logo tile (the consumer supplies a default-icon fallback). A project-service miss degrades to an empty logo without failing the listing; omitted when empty | -| `companyID` / `companyName` / `signingEntityName` | string | ECLA only; resolved from the companies table (cached per request) | -| `userID` | string | The owning EasyCLA user record — lets the consumer correlate rows with `userIds` and with other per-user endpoints | +| `claGroupName` | string | From the `projects_cla_groups` repo (single `GetItem`, cached per request); **omitted from the JSON** (string fields marshal with `omitempty`) when the CLA group record is gone — closes the "payload carries no project display name" gap from M1 research R6. No v1 user-service/org-service IDs are exposed (architecture-proposal P9) | +| `projectName` | string | Salesforce project display name of the CLA Group (a foundation-level CLA Group — a `projects_cla_groups` mapping whose `project_sfid == foundation_sfid` — resolves to its foundation). From the mapping table, upgraded to the project-service `Name` when available; both cached per request. Bold top line of the UI's Project cell, with `claGroupName` as subtext. Omitted when unresolved | +| `projectLogo` | string | Project (or foundation) logo URL from the project-service by project SFID (cached per request). The Project cell's logo tile (the consumer supplies the default-icon fallback). A miss degrades to an empty logo without failing the listing; omitted when empty | +| `companyID` / `companyName` / `signingEntityName` | string | ECLA only; from the companies table (cached per request) | +| `userID` | string | The owning EasyCLA user record — correlates rows with `userIds` and with other per-user endpoints | | `signedOn` | string | Signing/acknowledgement date (fallback: record creation date) | | `signed` / `approved` | bool | Raw signature flags | | `valid` | bool | Computed as defined above | -| `documentMajorVersion` / `documentMinorVersion` | int | CLA document version that was signed (display/superseded detection is the consumer's choice) | -| `pdfAvailable` | bool | `true` when the record is a signed ICLA eligible for PDF retrieval (ECLAs have no signed document — FR-002); invalidated ICLAs stay eligible (it is the user's own signed legal record); actual S3 object availability is verified by the PDF endpoint on request | +| `status` | `valid` \| `needs_attention` \| `revoked` \| `invalidated` \| `unknown` | Contributor-facing standing, computed independently of `approved`/`valid` (see step 5). New values may be added in a future spec revision (generated clients validate the enum strictly) | +| `statusReason` | `not_on_approval_list` \| `unknown` | Why the standing is not `valid`; omitted for every other status and on every ICLA | +| `flagged` / `flaggedAt` | bool / string | ECLA only: the employer is currently flagged by sanctions screening, and the company's stored `sanctioned_date` — stamped at the first live detection; `flaggedAt` is omitted when no stored date exists (issue #1370: the revocation date) | +| `invalidatedAt` | string | The record's `date_invalidated` — stamped by the PCC admin ICLA invalidation (kept from the first invalidation); omitted for records invalidated before the field existed (issue #1732) | +| `flaggedCheck` | `live` \| `stored` \| `unavailable` | ECLA only: how `flagged` was obtained (see step 5). `unavailable` means the value is the persisted flag and may be stale | +| `signedVia` / `signedAs` | string | The platform signed via (`github`, `gitlab`, `gerrit` — the last also covers LF SSO signings identified by email) and the account signed as; omitted when the record carries no such identity | +| `claManager` | bool | ECLA only: the owning user is a CLA manager of the employer's CCLA for this CLA Group | +| `documentMajorVersion` / `documentMinorVersion` | int | Signed CLA document version (display/superseded detection is the consumer's choice) | +| `pdfAvailable` | bool | `true` for a signed ICLA eligible for PDF retrieval (ECLAs have no signed document — FR-002); invalidated ICLAs stay eligible (the user's own signed legal record); actual S3 object availability is verified by the PDF endpoint on request | List-level fields: `lfUsername` (the effective username the list was resolved for), `userIds` (matched EasyCLA user record IDs), `skippedIdentities` (identity parameters dropped by the ownership enforcement, `":"` strings, always present — -`[]` when nothing was skipped), `resultCount`. +`[]` when nothing was skipped), `sssMode` (the sanctions screening mode in effect, always +present), `resultCount`. Errors: `401` (token carries no username — also returned by the gateway for a -missing/invalid token before the request reaches EasyCLA), `400` (admin caller with no -username and no identity keys at all), `403` (ACS deny at the gateway), `500` -(upstream data-layer failure — no partial results are returned). +missing/invalid token before the request reaches EasyCLA), `400` (an admin or trusted +caller with no username and no identity keys at all), `403` (ACS deny at the gateway), +`500` (a data-layer failure affecting the whole list — no partial results). Per-row failures are +not errors: a company/CCLA lookup or a sanctions screen that fails degrades that row +(`status: unknown`, or `flaggedCheck: unavailable`) and still returns `200`, as described +under "Identity resolution (step 1)". -An identity that resolves to zero user records returns `200` with empty -`userIds`/`clas` (`resultCount: 0`) — that is the Self Serve "unmatched" empty state, -not an error. +An identity that resolves to zero user records returns `200` with empty `userIds`/`clas` +(`resultCount: 0`) — the Self Serve "unmatched" empty state, not an error. ## `GET /v4/my-clas/{signatureID}/pdf` -Issues the signed-PDF download link for an ICLA **owned by the authenticated user**. -Takes the **same identity query parameters** as `/v4/my-clas` (same defaulting from -the token) plus the path parameter: +Issues the signed-PDF download link for an ICLA **owned by the resolved identity** — the +authenticated user's own, unless the caller is an admin or trusted (step 0). Takes the +**same identity query parameters** as `/v4/my-clas` (same defaulting from the token) plus +the path parameter: ```bash curl -H "Authorization: Bearer $TOKEN" \ @@ -402,15 +515,15 @@ curl -H "Authorization: Bearer $TOKEN" \ Behavior: -1. Applies the **same identity-ownership enforcement** as the list endpoint (step 0) - and resolves the allowed identity to user records — so for a non-admin caller the - resolvable set can only ever contain their own records, and a signature ID - belonging to somebody else is a guaranteed 404 even if the caller passes that - person's email/GitHub keys explicitly (covered by a dedicated unit test). -2. Verifies the `signatureID` belongs to one of those records **and** is a signed - ICLA, then verifies the PDF object actually exists in S3 (`HeadObject`) — a missing - document returns 404 instead of a dead presigned URL. -3. On success, returns a presigned S3 GET URL for +1. Applies the **same identity-ownership enforcement** as the list endpoint (step 0) and + resolves the allowed identity to user records — so for a non-admin, untrusted caller the + resolvable set can only contain their own records, and a signature ID belonging to + somebody else is a guaranteed 404 even if the caller passes that person's email/GitHub + keys explicitly (covered by a dedicated unit test). +2. Verifies the `signatureID` belongs to one of those records **and** is a signed ICLA, then + verifies the PDF object exists in S3 (`HeadObject`) — a missing document returns 404 + instead of a dead presigned URL. +3. On success returns a presigned S3 GET URL for `contract-group/{claGroupID}/icla/{userID}/{signatureID}.pdf` in the `cla-signature-files-{stage}` bucket, valid for **15 minutes**: @@ -422,54 +535,50 @@ Behavior: } ``` -4. Unknown, not-owned, unsigned, or ECLA signature IDs all return **`404`** (never - `403`), so the endpoint is not an existence oracle — matching the M1 SS contract +4. Unknown, not-owned, unsigned and ECLA signature IDs all return **`404`** (never `403`), + so the endpoint is not an existence oracle — matching the M1 SS contract (`ss-me-clas-api.md`). -Because the URL TTL is 15 minutes, the consumer must fetch it **on click**, never on -page load, and hand it straight to the browser (issues +Because the URL TTL is 15 minutes, the consumer must fetch it **on click**, never on page +load, and hand it straight to the browser (issues [#1166](https://github.com/linuxfoundation/lfx-self-serve/issues/1166), -[#1167](https://github.com/linuxfoundation/lfx-self-serve/issues/1167) — SS never -stores documents). +[#1167](https://github.com/linuxfoundation/lfx-self-serve/issues/1167) — SS never stores +documents). ### Why a new PDF endpoint instead of the existing one? -`GET /v4/signatures/{signatureID}/signed-document` already exists, but its access check +`GET /v4/signatures/{signatureID}/signed-document` exists, but its access check (`v2/signatures/handlers.go` `isUserHaveAccessOfSignedSignaturePDF`) requires -**project-scoped ACL authority** (project manager / project-org scopes). A plain -contributor holds no such scopes, so the Me-lens flow could not use it. The new -endpoint is modeled on that implementation — it reuses the very same S3 key layout and -presign helper (`utils.SignedCLAFilename` + `utils.GetDownloadLink`, 15-minute TTL) and -the same ECLA exclusion (`v2/signatures/service.go` `GetSignedDocument` rejects -employee signatures for the same reason: no document exists) — but replaces the -role-based check with the **token-anchored identity-ownership check** described above, -which is the right authorization model for "download *my own* signed document". +**project-scoped ACL authority** (project manager / project-org scopes), which a contributor +does not hold. The new endpoint reuses that implementation's S3 key layout and presign helper +(`utils.SignedCLAFilename` + `utils.GetDownloadLink`, 15-minute TTL) and its ECLA exclusion +(`v2/signatures/service.go` `GetSignedDocument` rejects employee signatures — no document +exists), replacing the role-based check with the **token-anchored identity-ownership check** +above: the right model for "download *my own* signed document". ## `GET /v4/my-clas/identities` -Returns the deduplicated identities the **authenticated user** owns — no query -parameters, always scoped to the token holder (an admin token returns the admin's own -identities, not anyone else's). This is the identity-resolution counterpart to -`lfx-self-serve` issue [#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161): -instead of the Sanctions-Screening/SS side scanning `cla-*-users` client-side to map an -identity back to an EasyCLA user, it can read the exact identity set EasyCLA already -associates with the caller. +Returns the deduplicated identities the **authenticated user** owns — no query parameters, +always scoped to the token holder (an admin token returns the admin's own identities). This +is the identity-resolution counterpart to +[lfx-self-serve#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161): +instead of the SS side scanning `cla-*-users` client-side to map an identity back to an +EasyCLA user, it can read the exact identity set EasyCLA already associates with the caller. ```bash curl -H "Authorization: Bearer $TOKEN" "$GW/cla-service/v4/my-clas/identities" ``` -The set is the **union of the two sources the list endpoint's ownership enforcement -(step 0) trusts** — the identities on the caller's EasyCLA user records -(`GetUsersByLFUsername`) and the identities connected to their LF account in the platform -user-service (`loadPlatformIdentities`: profile emails + non-deleted connected identities). -The service method `GetMyIdentities` reuses those same two calls, so this endpoint can -never surface an identity that `/v4/my-clas` would refuse to search for that caller, and -the My CLAs / PDF endpoints are left unchanged. +The set is the **union of the two sources the ownership enforcement (step 0) trusts** — the +identities on the caller's EasyCLA user records (`GetUsersByLFUsername`) and those connected +to their LF account in the platform user-service (`loadPlatformIdentities`: profile emails + +non-deleted connected identities). `GetMyIdentities` reuses those same two calls, so this +endpoint can never surface an identity that `/v4/my-clas` would refuse to search for that +caller, and the My CLAs / PDF endpoints are unchanged. -Each entry is `":"`, deduplicated and sorted; types are `lf-username`, -`email`, `github-id`, `github-username`, `gitlab-id`, `gitlab-username`, -`gerrit-username`. Response `200 my-identity-list`: +Each entry is `":"`, deduplicated and sorted; types are `lf-username`, `email`, +`github-id`, `github-username`, `gitlab-id`, `gitlab-username`, `gerrit-username`. Response +`200 my-identity-list`: ```json { @@ -483,154 +592,209 @@ Each entry is `":"`, deduplicated and sorted; types are `lf-usernam } ``` -A token carrying no username returns `401` (same as the list endpoint). +A token carrying no username returns `401` — unconditionally here, with no admin or +trusted-caller exemption, since the endpoint is scoped to the token holder. ## How LFX Self Serve consumes this (M1 mapping) -The SS server slice already on `lfx-self-serve` branch `feat/easycla-my-clas-server` -resolves identity from the session (LF username via `getEffectiveUsername`, verified -emails, GitHub numeric IDs from Auth0 identities) and currently unions per-user calls -with a TODO for the missing lookup endpoint. With this API it collapses to: - -- `GET /api/me/clas` → one upstream call - `GET /cla-service/v4/my-clas?email=…&secondaryEmail=…&githubId=…&githubUsername=…&gitlabId=…&gitlabUsername=…&gerritUsername=…` - — SS MUST forward **all** available session-derived identity keys: each verified - email as both `email` and `secondaryEmail` (they search different attributes), and - provider usernames alongside numeric IDs (an ID present only on a detached - pre-LFID record cannot be authorized by itself; the verified username recovers - it). The same full set goes on the PDF call (server-derived values from the - session — and EasyCLA now **re-verifies** every key against the LF account - server-side, so the enforcement is defense-in-depth rather than SS-only). Mapping to +The SS server slice on `lfx-self-serve` branch `feat/easycla-my-clas-server` resolves +identity from the session (LF username via `getEffectiveUsername`, verified emails, GitHub +numeric IDs from Auth0 identities) and currently unions per-user calls with a TODO for the +missing lookup endpoint. With this API it collapses to: + +- `GET /api/me/clas` → one upstream + `GET /cla-service/v4/my-clas?email=…&secondaryEmail=…&githubId=…&githubUsername=…&gitlabId=…&gitlabUsername=…&gerritUsername=…`. + SS MUST forward **all** session-derived identity keys: each verified email as both `email` + and `secondaryEmail` (they search different attributes), and provider usernames alongside + numeric IDs (an ID present only on a detached pre-LFID record cannot authorize itself; the + verified username recovers it). The same set goes on the PDF call. All values are + server-derived from the session; EasyCLA **re-verifies** every key against the LF account + until SS is allow-listed, after which SS's Auth0-derived list is authoritative. Mapping to `MyClaAgreement`: `kind = claType`, `projectName = projectName` (bold top line of the - Project cell) with `claGroupName` as its subtext and `projectLogo` as the logo tile - (falling back to `claGroupName` / a default icon when a field is absent — the endpoint - now supplies the distinct project name + logo, so the old `projectName = claGroupName` - UUID fallback is retired), `status` from `valid`, drop ECLAs with `valid=false` - (FR-002), `pdfAvailable` as-is; identity telemetry from `userIds` and - `skippedIdentities` (`matchedUserIds = userIds.length`, `unmatched = resultCount === - 0 && userIds.length === 0` — skipped keys are the direct signal for issue - [#1165](https://github.com/linuxfoundation/lfx-self-serve/issues/1165)'s + Project cell) with `claGroupName` as subtext and `projectLogo` as the logo tile (falling + back to `claGroupName` / a default icon when absent — the old + `projectName = claGroupName` UUID fallback is retired), the status pill from + `status`/`statusReason` (`valid` stays the boolean shortcut but is **not** the display + filter — see step 4), `pdfAvailable` as-is; identity telemetry from `userIds` and + `skippedIdentities` (`matchedUserIds = userIds.length`, and + `unmatched = resultCount === 0 && userIds.length === 0`; skipped keys are the direct signal + for [#1165](https://github.com/linuxfoundation/lfx-self-serve/issues/1165)'s identity-mapping-gap telemetry). -- `GET /api/me/clas/:signatureId/pdf-url` → `GET /cla-service/v4/my-clas/{id}/pdf` - with the same identity params; upstream 404 maps to SS 404 (never 403). +- `GET /api/me/clas/:signatureId/pdf-url` → `GET /cla-service/v4/my-clas/{id}/pdf` with the + same identity params; upstream 404 maps to SS 404 (never 403). This also reshapes the originally-contingent `GET /v4/users/by-identity` endpoint -(issue [#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): the -GSI-backed identity→user resolution now runs *inside* EasyCLA where the approval-list -validity check — impossible to perform from SS, which cannot see approval lists — also -lives, so SS never needs to scan `cla-*-users` itself. What #1161 actually needs is the -inverse — the identity set already attached to the caller — and that is served directly -by `GET /v4/my-clas/identities` (above). If a bare arbitrary identity→user lookup is -ever still wanted, the `resolveUsers` service function is the ready-made core of it. +([#1161](https://github.com/linuxfoundation/lfx-self-serve/issues/1161)): the GSI-backed +identity→user resolution now runs *inside* EasyCLA, where the approval-list validity check — +impossible from SS, which cannot see approval lists — also lives, so SS never scans +`cla-*-users` itself. What #1161 actually needs is the inverse, the identity set already +attached to the caller, served by `GET /v4/my-clas/identities`. For a bare identity→user +lookup, the `resolveUsers` service function is its ready-made core. ## Performance notes -Per request, with all caches request-scoped: +Per request, with every distinct key resolved exactly once: - one GSI query for the caller's own EasyCLA records (enforcement) + zero, one or two - platform user-service HTTP calls (profile + paginated identities — loaded lazily, - at most once, whenever a username key is present or another key is not covered by - the EasyCLA records); + platform user-service HTTP calls (profile + paginated identities — loaded lazily, at most + once, whenever a username key is present or another key is not covered by the EasyCLA + records); - one GSI query per allowed identity key (typically 2–4); - one paginated GSI query per matched user record (typically 1–2); - one `GetItem` per distinct CLA group (name), one per distinct company (ECLAs only); -- one `projects_cla_groups` GSI query per distinct CLA group (project name/logo resolution) - plus, when it resolves to a project/foundation SFID, up to one project-service HTTP call per - distinct SFID (both cached per request; a lookup miss degrades to an empty logo); -- one CCLA query per distinct (CLA group, company) pair and one approval-list - evaluation per (pair, user) — ECLAs only; the GitHub-org check may add one GitHub - API call per evaluation when the CCLA actually uses org-based approval. - -No table scans except the explicitly opt-in `secondaryEmail` match (a single scan -covering all provided values — callers should still treat it as a slow path). Each -logical query above may issue multiple paginated calls; the latency-envelope -assumption is to be confirmed on dev. +- one `projects_cla_groups` GSI query per distinct CLA group (project name/logo) plus, when + it resolves to a project/foundation SFID, up to one project-service HTTP call per distinct + SFID (both cached per request; a miss degrades to an empty logo); +- one CCLA query per distinct (CLA group, company) pair and one approval-list evaluation per + (pair, user) — ECLAs only; the GitHub-org check may add one GitHub API call per evaluation + when the CCLA actually uses org-based approval; +- when screening is enabled, one organization-service lookup (for the domain) plus one SSS + call per **distinct employer** — not per row, and none at all for administrator-blocked + employers or when screening is off. + +Those calls are issued **concurrently**, at most 8 in flight per stage (three parallel +chains, so ≤ ~24 total): the matched records' signatures load together, then the +CLA-group/project, employer/sanctions and CCLA/approval-list chains run in parallel, and the +rows are assembled from the results. Latency is ~4 dependent stages deep rather than +proportional to the row count, so a slow SSS screen overlaps the rest of the work instead of +adding to it. One consequence: a revoked or unreadable employer may still incur its CCLA and +approval-list reads, whose results are then discarded. + +No table scans except the opt-in `secondaryEmail` match (a single scan covering all provided +values — still a slow path). Each logical query above may issue multiple paginated calls; +the latency envelope is to be confirmed on dev. ## Deployment / rollout 1. Merge the `easycla` branch; the endpoints deploy with the normal v4 Lambda pipeline (`gen/` is rebuilt by `make swagger` during the build). 2. Run `acs-cli sync` with the updated `services/11-cla-service.yaml` against each - environment (dev → staging → prod) so the ACS warden allows the paths; until then - the gateway returns 403 for them (fail-closed — nothing else can regress). + environment (dev → staging → prod) so the ACS warden allows the paths; until then the + gateway returns 403 for them (fail-closed — nothing else can regress). 3. No lfx-gateway deploy is needed. -4. Read-only rollback: revert the ACS sync (or simply never flip the SS feature flag); - the endpoints write nothing. +4. To switch on the trusted Self Serve caller path, provision the SSM parameter with the ID + of a Self Serve client whose tokens are **never returned to a user** — the only + infrastructure change the trust-SS hardening needs (the key matches the existing `cla-*` + `ssm:GetParameter` grant). The client SS uses today does not qualify, so this is on hold + (see "Known limitations"): + + ```bash + aws --profile lfproduct-dev ssm put-parameter --name cla-ss-trusted-client-ids-dev \ + --type String --value '[,]' --overwrite + ``` + + Both partial states are safe, so deploy order does not matter, but the allow-list is read + at cold start only: after `put-parameter`, force a Lambda restart/redeploy, otherwise warm + containers keep the path disabled while fresh ones enable it. Rollback is + `ssm delete-parameter` plus the same restart. A read failure other than a missing parameter + is logged as a warning and leaves the path disabled — non-admin callers keep having every + identity verified per request — and never aborts the other lambdas that load this config. +5. Read-only rollback: revert the ACS sync (or never flip the SS feature flag). The only write + these endpoints make is a sanction stamp on the company row at the first live detection of + each sanction episode — ordinary company-table data that is safe to leave in place and + never needs reverting. ## Verification performed -- `make swagger` (v2 spec compiled + validated), `make build-linux`, full `make test` - (all packages pass), `make lint` (golangci-lint + license-header check) — all clean. -- Unit tests (`cla-backend-go/v2/my_clas/service_test.go`): identity union + dedupe + - email normalization; no-match empty result; **ownership enforcement** (every foreign - identity key skipped + reported while the caller's own record still resolves; - validation via the EasyCLA record incl. `user_emails`/GitLab keys; validation via - platform user-service identities incl. gerrit usernames and source-scoping — a - Slack identity never authorizes a GitHub search; user-service loaded lazily and at - most once; admin bypass); `secondaryEmail` scan runs only for explicitly provided - values; ICLA validity (approved/invalidated, unsigned exclusion); ECLA validity - matrix (covered, sanctioned company, missing CCLA, `approved=false`, - not-on-current-approval-list) across both `signature_type` spellings; PDF - ownership/eligibility (owned ICLA, ECLA, unsigned, not-owned, cross-user attempts - with explicit foreign keys → 404, admin path) and S3 key shape; `Identity.IsEmpty`; - not-found error classification; `GetMyIdentities` union/dedupe/sort of the - `:` set across EasyCLA records + platform identities (deleted platform - emails and non-code sources excluded, empty-username error). -- Read-only checks against the shared **dev** AWS environment: confirmed - `reference-signature-index` on `cla-dev-signatures` and all six identity GSIs on - `cla-dev-users`; sampled real ICLA/ECLA records to verify the +- `make swagger` (v2 spec compiled + validated), `make build-linux`, `make test`, `make lint` + (golangci-lint + license headers) — all clean. +- Unit tests (`v2/my_clas/service_test.go`): identity union/dedupe/email normalization; + no-match empty result; **ownership enforcement** (foreign keys skipped + reported while the + caller's own record still resolves; validation via the EasyCLA record incl. + `user_emails`/GitLab keys; validation via platform identities incl. gerrit usernames and + source scoping — a Slack identity never authorizes a GitHub search; user-service loaded + lazily, at most once; admin bypass); `secondaryEmail` scan only when values are passed; + ICLA validity (approved/invalidated, unsigned exclusion); ECLA validity matrix (covered, + sanctioned company, missing CCLA, `approved=false`, not-on-current-approval-list) across + both `signature_type` spellings; PDF ownership/eligibility (owned ICLA, ECLA, unsigned, + not-owned, cross-user attempt with explicit foreign keys → 404, admin path) and S3 key + shape; `Identity.IsEmpty`; not-found classification; `GetMyIdentities` union/dedupe/sort of + the `:` set (deleted platform emails and non-platform sources excluded, + empty-username error). +- Trusted-caller tests (`auth/trusted_caller_test.go`, `config/ssm_test.go`, + `v2/my_clas/handlers_test.go`, `service_test.go`): allow-list parsing and on/off (a + configured allow-list without an Auth0 domain fails startup); missing/blank/`Basic`/ + `Bearer`-only headers denied without a JWKS lookup; trusted vs. verified-but-not-listed and + `azp`-less tokens; rejection of expired, `exp`-less, `nbf`/`iat`-future, `kid`-less, + unknown-`kid`, wrong-key, `HS256` and `alg: none` tokens (algorithm pinning); JWKS caching, + refresh cooldown, TTL reload, cached-key fallback and its 24 h bound, concurrent use, + fetch/decode failures, both the `n`+`e` and `x5c` key forms; per-handler 401 on an + unverifiable caller (service never reached), trusted caller reaching the service with + `Trusted=true` and no username, `400` when it supplies no identity, service-error/404 + mapping, unchanged behavior with the verifier disabled or absent; trusted bypass (a + GitHub-only record with no `lf_username` resolves, its PDF downloads, user-service never + consulted, requested identity not mutated) and the bounded identity-summary log line. +- Read-only **dev** checks: `reference-signature-index` on `cla-dev-signatures` and all six + identity GSIs on `cla-dev-users` exist; sampled records confirm the `signature_type=cla|ecla` split, that every `ecla`-typed record carries - `signature_user_ccla_company_id`, and the `signed_on`/`date_created`/invalidation - `note` field semantics. -- Read-only checks against **prod**: zero signature records carrying a - `signature_reference_id` lack `signature_type` or `signature_reference_type`, so the - repository's filter cannot drop legacy records (the only attribute-less rows, 9 on - dev, are empty stubs with no reference ID and never appear in the queried GSI). + `signature_user_ccla_company_id`, and the `signed_on`/`date_created`/invalidation `note` + semantics. +- Read-only **prod** check: no signature record carrying a `signature_reference_id` lacks + `signature_type` or `signature_reference_type`, so the repository's filter cannot drop + legacy records (the only attribute-less rows, 9 on dev, are stubs with no reference ID, + absent from the queried GSI). - Not verified here (needs a deployed dev build + ACS sync): end-to-end curl through - lfx-gateway. Suggested smoke test afterwards: - `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas"` for a dev user - with known ICLA/ECLA fixtures, then the returned `signatureID` through - `/my-clas/{id}/pdf` and download the URL (SC-001's ≥99% download check), and - `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas/identities"` to - confirm the identity set (`IDENTITIES=1 ./utils/my_clas.sh`). + lfx-gateway. Smoke test: `curl -H "Authorization: Bearer $TOK" "$GW/cla-service/v4/my-clas"` + for a dev user with known ICLA/ECLA fixtures, the returned `signatureID` through + `/my-clas/{id}/pdf` plus a download of that URL (SC-001's ≥99% check), and + `/v4/my-clas/identities` (`IDENTITIES=1 ./utils/my_clas.sh`). ## Known limitations / follow-ups -- **Current possession of an identity grants access to its historical records — - accepted product decision.** A verified-as-currently-owned email or SCM username is - the bar for surfacing (and downloading) historical CLAs recorded under that - identity; a recycled/reassigned alias (e.g. a corporate email handed to a new - employee, a renamed GitHub handle re-registered by someone else, both also linked to - the new holder's LF account) can therefore surface the previous holder's records. - This matches how EasyCLA itself keys pre-LF-login history, keeps the M1 read-only - surface simple, and was explicitly chosen over a stable-ID corroboration scheme; - numeric `githubId`/`gitlabId` keys are immune (immutable) and remain the preferred - high-precision parameters. -- **GitLab group approval lists** (`gitlab_org_approval_list`) are not re-evaluated - live: group membership requires the GitLab group's OAuth token (the MR-gating - service holds per-group credentials). When a CCLA uses GitLab group approvals and no - other criterion matched, validity defers to the `signature_approved` flag (see - validity evaluation above) — so a member removed from the group whose ECLA was not - yet invalidated shows `valid=true` until the invalidation flow catches up. A - follow-up could add the live group check via the gitlab-activity service. -- **Secondary emails** (`user_emails`) are matchable only via the opt-in - `secondaryEmail` parameter, which costs one table scan for all values (set - attribute, not indexable) — the default `email` parameter stays index-backed; - numeric GitHub/GitLab IDs remain the preferred high-precision keys. -- **Numeric GitHub/GitLab IDs can only be verified against the caller's EasyCLA - LFID records** — the platform user-service identities carry usernames, not provider - IDs. An ID not present on any of those records is skipped even if the matching - username would have been allowed; callers should pass the username alongside the ID. -- **Superseded-document detection** is not computed server-side; the signed document - version is exposed (`documentMajorVersion/MinorVersion`) so a consumer can compare - against the CLA group's current template version if the product wants a - "superseded" badge later (the final mockup dropped the status column). +- **Current possession of an identity grants access to its historical records — accepted + product decision.** A verified-as-currently-owned email or SCM username is the bar for + surfacing (and downloading) historical CLAs recorded under it, so a recycled/reassigned + alias (a corporate email handed to a new employee, a renamed GitHub handle re-registered by + someone else, both linked to the new holder's LF account) can surface the previous holder's + records. This matches how EasyCLA itself keys pre-LF-login history and was chosen over a + stable-ID corroboration scheme; the immutable `githubId`/`gitlabId` keys are immune and stay + the preferred high-precision parameters. +- **GitLab group approval lists** (`gitlab_org_approval_list`) are not re-evaluated live: + membership needs the group's OAuth token, held per group by the MR-gating service. When a + CCLA uses group approvals and nothing else matched, validity defers to `signature_approved` + (step 4) — a member removed from the group whose ECLA is not yet invalidated shows + `valid=true` with `status: unknown` until the invalidation flow catches up. A follow-up + could add the live check via the gitlab-activity service. +- **Secondary emails** (`user_emails`) are matchable only via the opt-in `secondaryEmail` + parameter, one table scan for all values (set attribute, not indexable); the default `email` + parameter stays index-backed. +- **Numeric GitHub/GitLab IDs can only be verified against the caller's EasyCLA LFID + records** — platform identities carry usernames, not provider IDs. An ID on no such record + is skipped even when the matching username would have been allowed, so pass the username + alongside the ID. +- **Superseded-document detection** is not computed server-side; the signed document version + is exposed (`documentMajorVersion/MinorVersion`) so a consumer can compare it against the + CLA group's current template version if a "superseded" badge is wanted later (the final + mockup dropped the status column). - **Unmatched-identity telemetry** (an M1 exit criterion) is a Self Serve concern; the - response deliberately exposes `userIds`/`resultCount` so SS can emit it. -- Unlike the existing `GET /v4/signatures/user/{userID}` (which performs no ownership - check at all — the upstream-hardening observation from M1 research R3), these - endpoints **enforce identity ownership server-side** for non-admin callers. The - residual trust decisions are: the token's username claim is the identity anchor - (that is the platform-wide model — the gateway/ACS chain keys on the same claim), - and admin-flagged principals bypass enforcement (needed for support/parity - sampling; remove the `utils.IsUserAdmin` branch in `handlers.go` to revoke it). + response exposes `userIds`/`resultCount` so SS can emit it. +- Unlike `GET /v4/signatures/user/{userID}`, which performs no ownership check at all (the + upstream-hardening observation from M1 research R3), these endpoints **enforce identity + ownership server-side** for non-admin callers. Residual trust decisions: the token's + username claim is the identity anchor (the platform-wide model — the gateway/ACS chain keys + on the same claim), and admin-flagged principals bypass enforcement (needed for + support/parity sampling; remove the `utils.IsUserAdmin` branch in `handlers.go` to revoke + it). +- **The `azp` allow-list is only as sound as no user being able to hold a token carrying an + allow-listed `azp`.** Server-side minting with a client secret is not sufficient: the token + SS sends here (`req.apiGatewayToken`, a refresh-token exchange on `PCC_AUTH0_CLIENT_ID`) is + minted that way and then returned to every logged-in user as `v1Token` by SS's + `GET /api/profile/developer` + ([lfx-self-serve#1045](https://github.com/linuxfoundation/lfx-self-serve/pull/1045)), and the + v2 session token shares that `azp`. Allow-listing that client would let any logged-in user + pass any identity — including to the PDF endpoint, whose presigned URL exposes a signed + ICLA. So allow-list only a client whose tokens are never surfaced to a user; SS needs a + dedicated client for this hop first. The same caveat is recorded in code at the `azp` check. +- **Both the caller-supplied identity list and the `azp` allow-list are transitional (P3/P9 + of the trust-SS decision).** At M6, once EasyCLA runs on K8s, it should call + `lfx.auth-service.user_identity.list` itself over NATS for the token's subject — at which + point the `githubId`/`githubUsername`/… parameters, the allow-list and the in-handler JWT + verification all go away together. `swagger/cla.v2.yaml` carries the same note. +- **The in-handler verification covers the bearer token, not the gateway's `X-ACL` headers.** + Those are still consumed unverified (`lfx-kit/auth.SwaggerAuth`), so what the configured + allow-list closes is the trust decision that matters here: nothing reaches the handlers + without a JWKS-verified tenant token, and nothing can claim to be Self Serve without an + allow-listed `azp`. A forged `X-ACL` can still assert a username, or the admin flag that + bypasses ownership checks — both pre-existing, both now additionally requiring a valid + token. Binding the principal to a token claim belongs to the v4 invoke-path trust work + (spike 4); the M6 move removes the header trust entirely. diff --git a/utils/cla_search.sh b/utils/cla_search.sh new file mode 100755 index 000000000..a1558d1bc --- /dev/null +++ b/utils/cla_search.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls the CLA Group search API (GET /v4/cla-group/search) through lfx-gateway and reports the HTTP status and total time. +# SEARCH_TERM (or 1st arg): the term to search for - a CLA Group name, project/foundation name, organization name, or a pasted repository URL or "owner/repo" path (min 3 characters). +# LIMIT: the result cap (1-100, default 20 server-side). +# TOKEN: bearer access token (env, or ./cla_search.token.secret / ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# Local mode (against a standalone backend, bypassing the gateway): set PRINCIPAL to the token username (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# RUNS: when >1, repeats the call that many times and reports the min/p50/p95/max server time (FR-001a's < 300 ms p95 budget); the body is printed only for the first run. +# Examples: +# ./utils/cla_search.sh kubernetes +# SEARCH_TERM="https://github.com/OpenTimelineIO/OpenTimelineIO-Java-Bindings" ./utils/cla_search.sh +# STAGE=prod TOKEN="$(~/get_oauth_token_prod.sh)" RUNS=30 ./utils/cla_search.sh onap +# PRINCIPAL=lgryglicki ./utils/cla_search.sh kube # local standalone backend + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":false,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + for f in ./cla_search.token.secret ./my_clas.token.secret ./auth0.token.secret + do + [ -n "$TOKEN" ] && break + [ -f "$f" ] && TOKEN="$(cat "$f")" + done + if [ -z "$TOKEN" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + [ -z "$STAGE" ] && STAGE=dev + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +[ -z "$SEARCH_TERM" ] && SEARCH_TERM="$1" +if [ -z "$SEARCH_TERM" ] +then + echo "$0: SEARCH_TERM not set - pass it as the first argument or in the environment" + exit 3 +fi + +URL="${API_URL}/v4/cla-group/search" +args=(--data-urlencode "searchTerm=${SEARCH_TERM}") +[ -n "$LIMIT" ] && args+=(--data-urlencode "limit=${LIMIT}") + +if [ -n "$DEBUG" ] +then + echo "curl -sS -G -XGET ${auth[0]} '' -H 'Content-Type: application/json' ${args[*]} '${URL}'" +fi + +[ -z "$RUNS" ] && RUNS=1 +if ! printf '%s' "$RUNS" | grep -Eq '^[1-9][0-9]*$' +then + echo "$0: RUNS must be a positive integer, got '${RUNS}'" + exit 4 +fi + +body="$(mktemp)" +times="$(mktemp)" +trap 'rm -f "$body" "$times"' EXIT INT TERM +failed=0 +for i in $(seq 1 "$RUNS") +do + timing="$(curl -sS -G -XGET "${auth[@]}" -H "Content-Type: application/json" "${args[@]}" -w '%{http_code} %{time_total}' -o "$body" "$URL")" + code="${timing% *}" + secs="${timing#* }" + echo "$secs" >> "$times" + case "$code" in 2??) ;; *) failed=$((failed+1)) ;; esac + if [ "$i" = "1" ] + then + if command -v jq >/dev/null 2>&1 + then + jq -r '.' < "$body" 2>/dev/null || cat "$body" + else + cat "$body" + fi + echo + fi + [ "$RUNS" = "1" ] && echo "HTTP ${code} in ${secs}s" +done + +if [ "$RUNS" != "1" ] +then + sort -n "$times" | awk -v runs="$RUNS" -v failed="$failed" '{t[NR]=$1} END { + i50=int((NR+1)*0.50+0.5); if (i50>NR) i50=NR; if (i50<1) i50=1 + i95=int((NR+1)*0.95+0.5); if (i95>NR) i95=NR; if (i95<1) i95=1 + printf "runs=%d min=%.3fs p50=%.3fs p95=%.3fs max=%.3fs", runs, t[1], t[i50], t[i95], t[NR] + if (failed+0 > 0) printf " NON-2XX=%d (timings above are not a valid measurement)", failed + printf "\n" + }' + + if [ "$failed" != "0" ] + then + exit 5 + fi +fi diff --git a/utils/my_cla_manager_request.sh b/utils/my_cla_manager_request.sh new file mode 100755 index 000000000..c7e91f690 --- /dev/null +++ b/utils/my_cla_manager_request.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls POST /v4/my-clas/{signatureID}/cla-manager-requests through lfx-gateway and reports the HTTP status and total time. +# SIGNATURE_ID (or 1st arg): the ECLA signature ID - required. +# REQUEST_TYPE (or 2nd arg): removal (default) | approval. +# RECIPIENTS (or 3rd arg): comma-separated CLA manager LF usernames (must come from ./utils/my_cla_managers.sh output; empty only when no manager resolves). +# MESSAGE: optional message included in the notification email. +# TOKEN: bearer access token (env, or ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# Identity params (each comma-separated, repeated in the query): LF_USERNAME EMAIL SECONDARY_EMAIL GITHUB_ID GITHUB_USERNAME GITLAB_ID GITLAB_USERNAME GERRIT_USERNAME +# Local mode (against a standalone backend, bypassing the gateway): set PRINCIPAL to the token username and ADMIN=true|false (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# Examples: +# SIGNATURE_ID=3c1e5d7a-... RECIPIENTS=manager1,manager2 MESSAGE='please remove me' ./utils/my_cla_manager_request.sh +# ./utils/my_cla_manager_request.sh 3c1e5d7a-... approval manager1 + +[ -z "$SIGNATURE_ID" ] && SIGNATURE_ID="$1" +[ -z "$REQUEST_TYPE" ] && REQUEST_TYPE="${2:-removal}" +[ -z "$RECIPIENTS" ] && RECIPIENTS="$3" +if [ -z "$SIGNATURE_ID" ] +then + echo "$0: SIGNATURE_ID (or 1st arg) is required" + exit 3 +fi +if ! command -v jq >/dev/null 2>&1 +then + echo "$0: jq is required to build the request body" + exit 4 +fi + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + admin=false + [ "$ADMIN" = "true" ] && admin=true + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":%s,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" "$admin" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + if [ -z "$TOKEN" ] + then + [ -f ./my_clas.token.secret ] && TOKEN="$(cat ./my_clas.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./auth0.token.secret ] && TOKEN="$(cat ./auth0.token.secret)" + fi + if [ -z "$TOKEN" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + if [ -z "$STAGE" ] + then + STAGE=dev + fi + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +query="" +add_param() { + local name="$1" values="$2" + [ -z "$values" ] && return + local IFS=, + for v in $values + do + [ -n "$v" ] && query="${query}${query:+&}${name}=$(jq -rn --arg v "$v" '$v|@uri')" + done +} +add_param lfUsername "$LF_USERNAME" +add_param email "$EMAIL" +add_param secondaryEmail "$SECONDARY_EMAIL" +add_param githubId "$GITHUB_ID" +add_param githubUsername "$GITHUB_USERNAME" +add_param gitlabId "$GITLAB_ID" +add_param gitlabUsername "$GITLAB_USERNAME" +add_param gerritUsername "$GERRIT_USERNAME" + +URL="${API_URL}/v4/my-clas/${SIGNATURE_ID}/cla-manager-requests${query:+?}${query}" + +recipients_json="[]" +if [ -n "$RECIPIENTS" ] +then + recipients_json="$(printf '%s' "$RECIPIENTS" | jq -Rc 'split(",")|map(select(length>0))')" +fi +if [ -n "$MESSAGE" ] +then + payload="$(jq -nc --arg requestType "$REQUEST_TYPE" --argjson recipients "$recipients_json" --arg message "$MESSAGE" '{requestType:$requestType,recipients:$recipients,message:$message}')" +else + payload="$(jq -nc --arg requestType "$REQUEST_TYPE" --argjson recipients "$recipients_json" '{requestType:$requestType,recipients:$recipients}')" +fi + +if [ -n "$DEBUG" ] +then + echo "curl -sS -XPOST ${auth[0]} '${auth[1]%%:*}: ' -H 'Content-Type: application/json' -d '${payload}' '${URL}'" +fi + +body="$(mktemp)" +timing="$(curl -sS -XPOST "${auth[@]}" -H "Content-Type: application/json" -d "$payload" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +if command -v jq >/dev/null 2>&1 +then + jq -r '.' < "$body" 2>/dev/null || cat "$body" +else + cat "$body" +fi +echo +echo "HTTP ${timing% *} in ${timing#* }s" +rm -f "$body" diff --git a/utils/my_cla_managers.sh b/utils/my_cla_managers.sh new file mode 100755 index 000000000..79ed1fdcd --- /dev/null +++ b/utils/my_cla_managers.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls GET /v4/my-clas/{signatureID}/cla-managers through lfx-gateway and reports the HTTP status and total time. +# SIGNATURE_ID (or 1st arg): the ECLA signature ID - required. +# TOKEN: bearer access token (env, or ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# Identity params (each comma-separated, repeated in the query): LF_USERNAME EMAIL SECONDARY_EMAIL GITHUB_ID GITHUB_USERNAME GITLAB_ID GITLAB_USERNAME GERRIT_USERNAME +# Local mode (against a standalone backend, bypassing the gateway): set PRINCIPAL to the token username and ADMIN=true|false (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# Examples: +# SIGNATURE_ID=3c1e5d7a-... ./utils/my_cla_managers.sh +# PRINCIPAL=lgryglicki ADMIN=false ./utils/my_cla_managers.sh 3c1e5d7a-... + +[ -z "$SIGNATURE_ID" ] && SIGNATURE_ID="$1" +if [ -z "$SIGNATURE_ID" ] +then + echo "$0: SIGNATURE_ID (or 1st arg) is required" + exit 3 +fi + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + admin=false + [ "$ADMIN" = "true" ] && admin=true + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":%s,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" "$admin" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + if [ -z "$TOKEN" ] + then + [ -f ./my_clas.token.secret ] && TOKEN="$(cat ./my_clas.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./auth0.token.secret ] && TOKEN="$(cat ./auth0.token.secret)" + fi + if [ -z "$TOKEN" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + if [ -z "$STAGE" ] + then + STAGE=dev + fi + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +URL="${API_URL}/v4/my-clas/${SIGNATURE_ID}/cla-managers" + +args=() +add_param() { + local name="$1" values="$2" + [ -z "$values" ] && return + local IFS=, + for v in $values + do + [ -n "$v" ] && args+=(--data-urlencode "${name}=${v}") + done +} +add_param lfUsername "$LF_USERNAME" +add_param email "$EMAIL" +add_param secondaryEmail "$SECONDARY_EMAIL" +add_param githubId "$GITHUB_ID" +add_param githubUsername "$GITHUB_USERNAME" +add_param gitlabId "$GITLAB_ID" +add_param gitlabUsername "$GITLAB_USERNAME" +add_param gerritUsername "$GERRIT_USERNAME" + +if [ -n "$DEBUG" ] +then + echo "curl -sS -G -XGET ${auth[0]} '${auth[1]%%:*}: ' -H 'Content-Type: application/json' ${args[*]} '${URL}'" +fi + +body="$(mktemp)" +timing="$(curl -sS -G -XGET "${auth[@]}" -H "Content-Type: application/json" "${args[@]}" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +if command -v jq >/dev/null 2>&1 +then + jq -r '.' < "$body" 2>/dev/null || cat "$body" +else + cat "$body" +fi +echo +echo "HTTP ${timing% *} in ${timing#* }s" +rm -f "$body" diff --git a/utils/prepare_sign.sh b/utils/prepare_sign.sh new file mode 100755 index 000000000..ac34e156d --- /dev/null +++ b/utils/prepare_sign.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +# Calls the Self Serve sign APIs and reports the HTTP status and total time. +# Default: POST /v4/self-serve/prepare-sign - confirms the given identity belongs to the token's LFID, creates the EasyCLA user record when missing, and returns the Contributor Console sign URL. +# SIGN_ICLA=1: POST /v4/request-individual-signature the way the Contributor Console does after a prepare - needs USER_ID and CLA_GROUP_ID, prints the DocuSign sign_url. +# CALLBACK=: POST /v4/signed/self-serve/individual/{user_id} - the DocuSign callback of a Self Serve started ICLA (XML body from PAYLOAD, default ./docusign_payload.xml); no token, DocuSign HMAC applies. +# TOKEN: bearer access token (env, or ./prepare_sign.token.secret / ./my_clas.token.secret / ./auth0.token.secret). Get one with ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod). +# STAGE: dev (default) | test | staging | prod - selects the api-gw host. +# CLA_GROUP_ID (or 1st arg): the CLA Group UUID to sign - required. +# RETURN_URL: where the Console sends the contributor once signing completes - the Self Serve My CLAs page; required for prepare-sign. +# Identity params (a single value each): LF_USERNAME EMAIL GITHUB_ID GITHUB_USERNAME GITLAB_ID GITLAB_USERNAME GERRIT_USERNAME +# Local mode (against a standalone backend, bypassing the gateway - lets you test the non-admin ownership enforcement): set PRINCIPAL to the token username and ADMIN=true|false (or pass a raw base64 X_ACL). Defaults API_URL to http://localhost:8080. +# Examples: +# CLA_GROUP_ID=01af041c-... GITHUB_ID=2469783 GITHUB_USERNAME=jdoe ./utils/prepare_sign.sh +# CLA_GROUP_ID=01af041c-... RETURN_URL=https://openprofile.dev/my-clas ./utils/prepare_sign.sh # dev deployed +# STAGE=prod TOKEN="$(~/get_oauth_token_prod.sh)" CLA_GROUP_ID=01af041c-... ./utils/prepare_sign.sh # prod deployed +# PRINCIPAL=lgryglicki ADMIN=false CLA_GROUP_ID=01af041c-... GITHUB_ID=2469783 ./utils/prepare_sign.sh # local +# SIGN_ICLA=1 CLA_GROUP_ID=01af041c-... USER_ID=6c2d5a11-... ./utils/prepare_sign.sh # console leg: prepare -> request -> sign_url +# CALLBACK=6c2d5a11-... PAYLOAD=./docusign_payload.xml API_URL=http://localhost:8080 ./utils/prepare_sign.sh + +if [ -n "$PRINCIPAL" ] && [ -z "$X_ACL" ] +then + admin=false + [ "$ADMIN" = "true" ] && admin=true + X_ACL="$(printf '{"user_name":"%s","email":"%s","isAdmin":%s,"allowed":true}' "$PRINCIPAL" "${PRINCIPAL_EMAIL:-$PRINCIPAL}" "$admin" | base64 | tr -d '\n')" +fi + +if [ -n "$X_ACL" ] +then + auth=(-H "X-ACL: ${X_ACL}") + [ -z "$API_URL" ] && API_URL="http://localhost:${PORT:-8080}" +else + if [ -z "$TOKEN" ] + then + [ -f ./prepare_sign.token.secret ] && TOKEN="$(cat ./prepare_sign.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./my_clas.token.secret ] && TOKEN="$(cat ./my_clas.token.secret)" + fi + if [ -z "$TOKEN" ] + then + [ -f ./auth0.token.secret ] && TOKEN="$(cat ./auth0.token.secret)" + fi + if [ -z "$TOKEN" ] && [ -z "$CALLBACK" ] + then + echo "$0: TOKEN not set - run ~/get_oauth_token.sh (dev) or ~/get_oauth_token_prod.sh (prod) and export TOKEN (or use PRINCIPAL=... for local mode)" + exit 1 + fi + auth=(-H "Authorization: Bearer ${TOKEN}") + if [ -z "$STAGE" ] + then + STAGE=dev + fi + case "$STAGE" in + prod) GW="https://api-gw.platform.linuxfoundation.org" ;; + staging) GW="https://api-gw.staging.platform.linuxfoundation.org" ;; + test) GW="https://api-gw.test.platform.linuxfoundation.org" ;; + dev) GW="https://api-gw.dev.platform.linuxfoundation.org" ;; + *) echo "$0: unknown STAGE '$STAGE'"; exit 2 ;; + esac + [ -z "$API_URL" ] && API_URL="${GW}/cla-service" +fi + +body="$(mktemp)" +if [ -n "$CALLBACK" ] +then + [ -z "$PAYLOAD" ] && PAYLOAD="./docusign_payload.xml" + if [ ! -f "$PAYLOAD" ] + then + echo "$0: PAYLOAD file '$PAYLOAD' not found - DocuSign callback needs the signed envelope XML" + rm -f "$body" + exit 3 + fi + URL="${API_URL}/v4/signed/self-serve/individual/${CALLBACK}" + [ -n "$DEBUG" ] && echo "curl -sS -XPOST -H 'Content-Type: text/xml' --data-binary @${PAYLOAD} '${URL}'" + timing="$(curl -sS -XPOST -H "Content-Type: text/xml" --data-binary "@${PAYLOAD}" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +else + [ -z "$CLA_GROUP_ID" ] && CLA_GROUP_ID="$1" + if [ -z "$CLA_GROUP_ID" ] + then + echo "$0: CLA_GROUP_ID not set - pass the CLA Group UUID to sign as CLA_GROUP_ID or as the first argument" + rm -f "$body" + exit 4 + fi + if [ -z "$SIGN_ICLA" ] && [ -z "$RETURN_URL" ] + then + echo "$0: RETURN_URL not set - prepare-sign requires the URL the Contributor Console returns the contributor to, e.g. RETURN_URL=https://openprofile.dev/my-clas" + rm -f "$body" + exit 7 + fi + if ! command -v jq >/dev/null 2>&1 + then + echo "$0: jq is required to build the request body" + rm -f "$body" + exit 5 + fi + payload="$(jq -nc \ + --arg claGroupId "$CLA_GROUP_ID" \ + --arg returnUrl "$RETURN_URL" \ + --arg lfUsername "$LF_USERNAME" \ + --arg email "$EMAIL" \ + --arg githubId "$GITHUB_ID" \ + --arg githubUsername "$GITHUB_USERNAME" \ + --arg gitlabId "$GITLAB_ID" \ + --arg gitlabUsername "$GITLAB_USERNAME" \ + --arg gerritUsername "$GERRIT_USERNAME" \ + '{claGroupId: $claGroupId} + + (if $returnUrl == "" then {} else {returnUrl: $returnUrl} end) + + (if $lfUsername == "" then {} else {lfUsername: $lfUsername} end) + + (if $email == "" then {} else {email: $email} end) + + (if $githubId == "" then {} else {githubId: ($githubId | tonumber)} end) + + (if $githubUsername == "" then {} else {githubUsername: $githubUsername} end) + + (if $gitlabId == "" then {} else {gitlabId: ($gitlabId | tonumber)} end) + + (if $gitlabUsername == "" then {} else {gitlabUsername: $gitlabUsername} end) + + (if $gerritUsername == "" then {} else {gerritUsername: $gerritUsername} end)')" + URL="${API_URL}/v4/self-serve/prepare-sign" + if [ -n "$SIGN_ICLA" ] + then + if [ -z "$USER_ID" ] + then + echo "$0: USER_ID not set - pass the EasyCLA user UUID returned by prepare-sign" + rm -f "$body" + exit 6 + fi + payload="$(jq -nc \ + --arg projectId "$CLA_GROUP_ID" \ + --arg userId "$USER_ID" \ + --arg returnUrlType "${RETURN_URL_TYPE:-Github}" \ + --arg returnUrl "$RETURN_URL" \ + '{project_id: $projectId, user_id: $userId, return_url_type: $returnUrlType} + + (if $returnUrl == "" then {} else {return_url: $returnUrl} end)')" + URL="${API_URL}/v4/request-individual-signature" + fi + [ -n "$DEBUG" ] && echo "curl -sS -XPOST ${auth[0]} '' -H 'Content-Type: application/json' -d '${payload}' '${URL}'" + timing="$(curl -sS -XPOST "${auth[@]}" -H "Content-Type: application/json" -d "$payload" -w '%{http_code} %{time_total}' -o "$body" "$URL")" +fi + +if command -v jq >/dev/null 2>&1 +then + jq -r '.' < "$body" 2>/dev/null || cat "$body" +else + cat "$body" +fi +echo +echo "HTTP ${timing% *} in ${timing#* }s" +rm -f "$body" diff --git a/utils/update_company_is_sanctioned.sh b/utils/update_company_is_sanctioned.sh index d2807abd3..c6adfc68e 100755 --- a/utils/update_company_is_sanctioned.sh +++ b/utils/update_company_is_sanctioned.sh @@ -16,8 +16,17 @@ then echo "$0: you need to value: true|false" exit 2 fi +# Mirrors the backends' admin path: stamp sanctioned_date on set, keep it on clear, and drop +# sanction_origin so the manual state is sticky and SSS never auto-clears it. +upd_expr="SET is_sanctioned = :val REMOVE sanction_origin" +values="{\":val\":{\"BOOL\":${2}}}" +if [ "$2" = "true" ] +then + upd_expr="SET is_sanctioned = :val, sanctioned_date = :now REMOVE sanction_origin" + values="{\":val\":{\"BOOL\":true},\":now\":{\"S\":\"$(date -u '+%Y-%m-%dT%H:%M:%S').000000+0000\"}}" +fi if [ ! -z "$DEBUG" ] then - echo aws --profile "lfproduct-$STAGE" dynamodb update-item --table-name "cla-${STAGE}-companies" --key "{\"company_id\":{\"S\":\"${1}\"}}" --update-expression '"SET is_sanctioned = :val"' --expression-attribute-values "{\":val\":{\"BOOL\":${2}}}" + echo aws --profile "lfproduct-$STAGE" dynamodb update-item --table-name "cla-${STAGE}-companies" --key "{\"company_id\":{\"S\":\"${1}\"}}" --update-expression "\"${upd_expr}\"" --expression-attribute-values "$values" fi -aws --profile "lfproduct-$STAGE" dynamodb update-item --table-name "cla-${STAGE}-companies" --key "{\"company_id\":{\"S\":\"${1}\"}}" --update-expression "SET is_sanctioned = :val" --expression-attribute-values "{\":val\":{\"BOOL\":${2}}}" +aws --profile "lfproduct-$STAGE" dynamodb update-item --table-name "cla-${STAGE}-companies" --key "{\"company_id\":{\"S\":\"${1}\"}}" --update-expression "$upd_expr" --expression-attribute-values "$values"