diff --git a/README.md b/README.md index c386230e..7f3ac73e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Check this other documents for: * [Configuration](./docs/configuration.md) * [Development](./docs/development.md) +* [Authentication](./docs/authentication.md) — OpenShift OAuth, Keycloak, AWS Cognito, and API/M2M tokens * [Connecting to MongoDB](./docs/connecting-to-mongodb.md) — configuring ExploitIQ to connect to an external or self-managed MongoDB instance * [SBOM Requirements](./docs/sbom-requirements.md) — SPDX 2.3 structure, OCI image labels, and example fixtures * [Tests](./src/test/README.md) — REST `@QuarkusTest` notes and CI test image for pipelines diff --git a/docs/authentication.md b/docs/authentication.md index 7a29027b..e1988454 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -14,7 +14,7 @@ limitations under the License. # Authentication -This guide covers authentication configuration for ExploitIQ Client, including OpenShift OAuth, external identity providers, and development setups. +This guide covers authentication configuration for ExploitIQ Client, including OpenShift OAuth, external identity providers (Keycloak, AWS Cognito, Google, and others), and development setups. ## Overview @@ -23,8 +23,10 @@ ExploitIQ supports multiple authentication modes via Quarkus profiles: | Profile | Use Case | Identity Provider | |---------|----------|-------------------| | `prod` | OpenShift | OpenShift OAuth | -| `external-idp` | External identity providers | Keycloak, Google, Azure AD, Okta | -| `dev` | Local development | Keycloak DevServices | +| `external-idp` | External identity providers | Keycloak, AWS Cognito, Google, Azure AD, Okta | +| `dev` | Local development | Keycloak DevServices (OIDC off by default) | + +No Cognito-specific Quarkus profile is required. Point the existing `external-idp` profile at a Cognito User Pool (discovery, hybrid app type, and access-token role source already fit Cognito). ### Authentication Methods @@ -33,13 +35,14 @@ All profiles support both browser and API authentication: | Method | Use Case | Flow | |--------|----------|------| | Browser | Web UI | Authorization Code Flow (redirects to IdP) | -| API | CLI, scripts, services | Bearer JWT token in `Authorization` header | +| API | CLI, scripts, services, agent | Bearer JWT token in `Authorization` header | -**Token acquisition differs by profile:** +**Token acquisition differs by profile / IdP:** - `prod` (OpenShift): Use `oc whoami -t` or ServiceAccount tokens -- `external-idp` (Keycloak): Use OIDC token endpoint with password grant -- `dev`: Same as `external-idp` (DevServices Keycloak) +- `external-idp` (Keycloak): OIDC token endpoint with password or client_credentials grant +- `external-idp` (AWS Cognito): Hosted UI / managed login for browsers; `client_credentials` with **Basic auth** for M2M (agent) +- `dev`: OIDC disabled by default; optional Keycloak DevServices when enabled ## OpenShift OAuth (Production) @@ -179,16 +182,143 @@ env: key: client-secret ``` +### AWS Cognito + +Use the `external-idp` profile with an AWS Cognito User Pool for browser login and (with extra config) agent M2M bearer tokens. + +Cognito differs from Keycloak in important ways: + +| Topic | Cognito behavior | +|-------|------------------| +| Human roles | JWT `cognito:groups` claim (group names must match ExploitIQ roles exactly) | +| M2M tokens | `client_credentials` tokens have **no** `cognito:groups`; authorize via OAuth2 `scope` → role mapping | +| Token endpoint | `https://{domain}.auth.{region}.amazoncognito.com/oauth2/token` | +| Client credentials | Requires **HTTP Basic** auth (`client_id:client_secret`), not body-only client auth | +| Browser scopes | `openid`, `profile`, `email` (custom Resource Server scopes are for M2M only) | + +Role extraction is implemented in `RoleMappingAugmentor` (additive alongside OpenShift `groups` and Keycloak `realm_access` / `resource_access`). Do **not** set `quarkus.oidc.roles.role-claim-path=cognito:groups` on the shared `external-idp` profile — that would break Keycloak on the same profile. + +#### Cognito prerequisites (AWS Console) + +1. **User Pool** in your region. +2. **Groups** named exactly: + - `exploit-iq-admin` + - `exploit-iq-view` + - `exploit-iq-prodsec` + - optionally `exploitiq-api-access` (for human users that should act like the API service role) +3. **App client** (confidential / client secret) for the web UI: + - Authorization code grant + - Callback / sign-out URLs: your app origin (include both with and without trailing slash if needed), e.g. `http://localhost:8080` and `http://localhost:8080/` + - OpenID scopes: `openid`, `email`, `profile` +4. **Cognito domain** (Amazon Cognito domain prefix is enough; custom domain is optional). +5. **Users** in the pool, assigned to the groups above; set a **permanent** password for local testing (Forgot password needs email/SES configured). +6. For **agent M2M** (optional, separate from browser client if desired): + - Resource Server with a custom scope, e.g. identifier `exploitiq-resource-server`, scope `exploitiq-api-access` + - App client with **client_credentials** grant and that custom scope enabled + +#### Environment variables (exploit-iq-client) + +| Variable | Description | Example | +|----------|-------------|---------| +| `QUARKUS_PROFILE` | Use `external-idp` (locally prefer `dev,external-idp` so `%dev` defaults still apply) | `external-idp` or `dev,external-idp` | +| `QUARKUS_OIDC_AUTH_SERVER_URL` | Cognito **issuer** URL (User Pool), **not** the Hosted UI domain and **not** `.../.well-known/openid-configuration` | `https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_AbCdEf123` | +| `QUARKUS_OIDC_CLIENT_ID` | App client ID | Cognito console → App clients | +| `QUARKUS_OIDC_CREDENTIALS_SECRET` | App client secret | Cognito console → App clients | +| `EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS` | Optional M2M mapping: comma-separated `scope=role` pairs | `exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access` | +| `NAMESPACE` | Required for service-account role string expansion | OpenShift namespace, or `local-dev` locally | +| `CREDENTIAL_ENCRYPTION_KEY` | 32-byte key for credential store (unrelated to Cognito) | Deployment secret | + +Discover endpoints automatically via: + +`https://cognito-idp.{region}.amazonaws.com/{user-pool-id}/.well-known/openid-configuration` + +Quarkus appends `/.well-known/openid-configuration` itself — set `QUARKUS_OIDC_AUTH_SERVER_URL` to the issuer only. + +#### Deployment example + +```yaml +env: +- name: QUARKUS_PROFILE + value: "external-idp" +- name: QUARKUS_OIDC_AUTH_SERVER_URL + value: "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_AbCdEf123" +- name: QUARKUS_OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: cognito-oidc + key: client-id +- name: QUARKUS_OIDC_CREDENTIALS_SECRET + valueFrom: + secretKeyRef: + name: cognito-oidc + key: client-secret +# Required for agent/M2M bearer tokens (client_credentials) — omit for browser-only +- name: EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS + value: "exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access" +``` + +#### Local development with Cognito + +```bash +export NAMESPACE=local-dev +export CREDENTIAL_ENCRYPTION_KEY='dev-test-key-must-be-32bytes-long!' +export QUARKUS_PROFILE=dev,external-idp +export QUARKUS_OIDC_AUTH_SERVER_URL='https://cognito-idp.{region}.amazonaws.com/{user-pool-id}' +export QUARKUS_OIDC_CLIENT_ID='{cognito-app-client-id}' +export QUARKUS_OIDC_CREDENTIALS_SECRET='{cognito-app-client-secret}' +# Optional M2M: +# export EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS='exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access' + +./mvnw quarkus:dev \ + -Dquarkus.rest-client.exploit-iq.url=http://localhost:26466/generate +``` + +Open `http://localhost:8080` — you should be redirected to Cognito managed login / Hosted UI. + +**Session cookies:** After login, Quarkus stores an **HttpOnly** session cookie (typically `q_session`) on the app origin (`localhost:8080`). You will not see the Cognito JWT in `document.cookie` or as a readable Cognito token cookie. Check DevTools → Application → Cookies → `http://localhost:8080`. + +#### Verify browser roles (`cognito:groups`) + +1. Confirm the user is a member of `exploit-iq-admin` (or `view` / `prodsec`) in Cognito. +2. After login, APIs should return **200** (not **403**). +3. Quarkus logs should include: `Mapping user to role 'exploit-iq-admin' from source: Cognito Group`. +4. Optional: decode the **access** token (not only the ID token) and confirm a `cognito:groups` array with those exact names. + +#### Agent / M2M bearer tokens (client side) + +Cognito `client_credentials` access tokens do **not** include `cognito:groups`. The client authorizes them by mapping the token `scope` claim via `exploitiq.security.oidc.scope-role-mappings` (env: `EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS`) onto an allowed role such as `exploitiq-api-access` (already listed in `exploitiq.security.service-account-roles`). + +Fetch a token (agent-side code lives in the vulnerability-analysis repo; this shows the Cognito contract): + +```bash +COGNITO_DOMAIN="{prefix}.auth.{region}.amazoncognito.com" # from Cognito Domain, not cognito-idp issuer +CLIENT_ID="{m2m-app-client-id}" +CLIENT_SECRET="{m2m-app-client-secret}" +SCOPE="exploitiq-resource-server/exploitiq-api-access" + +TOKEN=$(curl -s -X POST "https://${COGNITO_DOMAIN}/oauth2/token" \ + -u "${CLIENT_ID}:${CLIENT_SECRET}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&scope=${SCOPE}" | jq -r .access_token) + +curl -i -H "Authorization: Bearer ${TOKEN}" \ + http://localhost:8080/api/v1/reports +``` + +Expect **200** when scope mapping is configured on the client. Without `EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS`, the token may authenticate but fail authorization (**403**). + +**Note:** Implementing Cognito token fetch in the Python agent (`AUTH_TYPE=cognito`, `COGNITO_DOMAIN`, etc.) is outside this repository. + ### Other OIDC Providers -The same approach works with any OIDC-compliant provider: +The same `external-idp` approach works with other OIDC-compliant providers: | Provider | Auth Server URL | |----------|-----------------| | Azure AD | `https://login.microsoftonline.com/{tenant}/v2.0` | | Okta | `https://dev-xxxxx.okta.com/oauth2/default` | | Auth0 | `https://your-domain.auth0.com` | -| AWS Cognito | `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` | +| AWS Cognito | See [AWS Cognito](#aws-cognito) above | **Note:** GitHub does not support OIDC. Use Keycloak as an identity broker for GitHub authentication. @@ -241,7 +371,7 @@ curl -H "Authorization: Bearer $USER_TOKEN" \ ### Service-to-Service Authentication (Optional) -For machine-to-machine communication, use the client credentials grant: +For machine-to-machine communication with **Keycloak**, use the client credentials grant: ```bash # Get service token @@ -257,6 +387,8 @@ curl -H "Authorization: Bearer $SERVICE_TOKEN" \ **Note:** Requires a separate Keycloak client configured for service accounts. +For **AWS Cognito** M2M (`client_credentials` + Basic auth + custom scope), see [AWS Cognito — Agent / M2M bearer tokens](#agent--m2m-bearer-tokens-client-side). + ### Token Validation The application validates JWT tokens by: @@ -375,38 +507,50 @@ curl -H "Authorization: Bearer $USER_TOKEN" \ ## User Display -The application displays user information with this priority: +The application resolves a display / actor name with this priority (`UserService`): 1. `email` claim (primary) 2. `upn` claim (User Principal Name) 3. `metadata.name` (OpenShift) -4. `anonymous` (fallback) +4. `preferred_username` +5. `sub` +6. `anonymous` (fallback) -Ensure your identity provider or Keycloak is configured to include the `email` claim in tokens. +For browser sessions, Quarkus may use UserInfo; for pure bearer M2M calls, the JWT principal name (`sub`, often the Cognito app client id) is typically used. Ensure your IdP includes `email` (or another claim above) for human users when you care about UI display names. ## Role Mapping -The application implements a **Unified Role Mapping** strategy, allowing you to manage permissions using either OpenShift Groups or OIDC Roles (Keycloak), depending on your environment. +The application implements a **unified role mapping** strategy in `RoleMappingAugmentor`. On every authenticated request it inspects the JWT (and, for OpenShift `prod`, UserInfo-backed claims) and grants only roles that appear in the configured allow-list (`quarkus.http.auth.policy.role-policy.roles-allowed`, which includes human roles plus `exploitiq.security.service-account-roles`). + +**Target human roles:** -The application looks for specific **Target Roles** (configurable via `exploit-iq.security.target-roles`): - `exploit-iq-admin`: Admin access - `exploit-iq-view`: Read-only access - `exploit-iq-prodsec`: Product Security access -### OpenShift Groups (Production) -In the `prod` profile, OpenShift Groups are automatically mapped to these roles. -- Group `exploit-iq-admin` -> Mapped to `exploit-iq-admin` -- Group `exploit-iq-view` -> Mapped to `exploit-iq-view` -- Group `exploit-iq-prodsec` -> Mapped to `exploit-iq-prodsec` +**Service-account style roles** (skip report owner checks when held): configured via `exploitiq.security.service-account-roles`, including OpenShift SA names and `exploitiq-api-access`. + +### OpenShift Groups (`prod`) + +OpenShift Groups from UserInfo / `groups` are mapped when the group name matches a target role: + +- Group `exploit-iq-admin` → `exploit-iq-admin` +- Group `exploit-iq-view` → `exploit-iq-view` +- Group `exploit-iq-prodsec` → `exploit-iq-prodsec` + +Kubernetes ServiceAccount JWTs may also map via the `kubernetes.io` claim / `sub` when configured in the allow-list. + +### OIDC Roles (Keycloak / `external-idp` / `dev`) + +- **Realm roles:** `realm_access.roles` +- **Client roles:** `resource_access.{client-id}.roles` for `exploit-iq-client` + +### AWS Cognito (`external-idp`) -### OIDC Roles (Keycloak / External) -In `external-idp` or `dev` profiles, roles are extracted from the OIDC token: -- **Realm Roles:** `exploit-iq-admin`, `exploit-iq-view` -- **Resource Access (Client Roles):** Roles defined specifically for the `exploit-iq-client` client. +- **Browser / user tokens:** `cognito:groups` — each group name that matches a target role is granted (e.g. Cognito group `exploit-iq-admin` → role `exploit-iq-admin`). +- **M2M / `client_credentials` tokens:** no group claim; configure `exploitiq.security.oidc.scope-role-mappings` (env `EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS`) as `scope=role` pairs, e.g. `exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access`. -This flexibility allows you to choose the management style that fits your platform: -- **OpenShift Native:** Access is controlled by OpenShift Groups. -- **Identity Provider:** Access is controlled by Keycloak/IdP roles. +When Cognito env vars and scope mappings are unset, Cognito-specific paths are no-ops; OpenShift and Keycloak behavior is unchanged. ## Troubleshooting @@ -435,6 +579,49 @@ This flexibility allows you to choose the management style that fits your platfo 2. Verify token is not expired 3. Check Keycloak logs for "Missing openid scope" error +### API Returns 403 Forbidden (authenticated but no role) + +**Cause:** Token validated but no matching ExploitIQ role was mapped. + +**Solution (Cognito browser):** + +1. Confirm the user is in a Cognito group named exactly `exploit-iq-admin`, `exploit-iq-view`, or `exploit-iq-prodsec` +2. Confirm the **access** token contains `cognito:groups` with those names +3. Check logs for `Mapping user to role ... from source: Cognito Group` + +**Solution (Cognito M2M):** + +1. Set `EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS` to map your custom scope to `exploitiq-api-access` +2. Ensure the token `scope` claim contains that exact scope string +3. Check logs for `Mapping user to role ... from source: Cognito M2M Scope` + +### Cognito: invalid_scope / Client is not enabled for OAuth2.0 flows + +**Cause:** App client Hosted UI / OAuth settings incomplete. + +**Solution:** + +1. Enable Authorization code grant +2. Allow scopes `openid`, `email`, `profile` for browser clients +3. Set callback URLs to the exact app origin (with and without trailing `/`) +4. Ensure a Cognito domain exists and managed login status is Available + +### Cognito: OIDC Server is not available / BadRequest on discovery + +**Cause:** Wrong `QUARKUS_OIDC_AUTH_SERVER_URL`. + +**Solution:** Use the issuer only: + +`https://cognito-idp.{region}.amazonaws.com/{user-pool-id}` + +Do **not** append `/.well-known/openid-configuration` (Quarkus adds it). Do **not** use the `{prefix}.auth.{region}.amazoncognito.com` Hosted UI domain as `auth-server-url`. + +### Cognito: no cookies visible after login + +**Cause:** Looking in the wrong place, or expecting a readable JWT cookie. + +**Solution:** Quarkus sets an HttpOnly session cookie (often `q_session`) on the **application** origin. Check DevTools → Application → Cookies → `http://localhost:8080` (not the Cognito domain). `document.cookie` will not show HttpOnly cookies. + ### HTTPS Required Error (Keycloak) **Cause:** Keycloak 26.x requires HTTPS by default, even in development. @@ -470,5 +657,7 @@ Or run the testing script with debug flag: - [Quarkus OIDC Bearer Token Authentication](https://quarkus.io/guides/security-oidc-bearer-token-authentication) - [Quarkus Configuring Well-Known OpenID Connect Providers](https://quarkus.io/guides/security-openid-connect-providers) - [Keycloak Documentation](https://www.keycloak.org/documentation) +- [Amazon Cognito User Pools](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html) +- [Amazon Cognito OAuth 2.0 / OIDC endpoints](https://docs.aws.amazon.com/cognito/latest/developerguide/federation-endpoints.html) - [GitHub OAuth Apps](https://docs.github.com/en/developers/apps/building-oauth-apps) - [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2) \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 6b681ec8..30914b77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,7 +16,7 @@ limitations under the License. ## Authentication -For detailed authentication configuration including OpenShift OAuth, Keycloak, and external identity providers (Google, GitHub, Azure AD), see the [Authentication Guide](./authentication.md). +For detailed authentication configuration including OpenShift OAuth, Keycloak, AWS Cognito, and other external identity providers (Google, Azure AD, Okta), see the [Authentication Guide](./authentication.md). ## External Services (GitHub / ExploitIQ) diff --git a/docs/development.md b/docs/development.md index 2cc4bd66..0485f043 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,7 +18,7 @@ limitations under the License. To see all the configuration options check the [configuration](./configuration.md) guide. -For authentication setup (Keycloak, external identity providers, testing), see the [authentication](./authentication.md) guide. +For authentication setup (Keycloak, AWS Cognito, other external IdPs, testing), see the [authentication](./authentication.md) guide. To connect the application to an external MongoDB instance, refer to [Connecting to MongoDB](./connecting-to-mongodb.md). @@ -38,6 +38,8 @@ By default, this runs with **authentication disabled**. To enable Keycloak DevSe ./mvnw quarkus:dev -Dquarkus.oidc.enabled=true -Dquarkus.keycloak.devservices.enabled=true ``` +To use **AWS Cognito** locally, set `QUARKUS_PROFILE=dev,external-idp` and the Cognito OIDC env vars documented in [authentication.md — AWS Cognito](./authentication.md#aws-cognito). + This runs both the backend and frontend together, with the UI served through Quarkus at `http://localhost:8080`. ### Standalone Frontend Development @@ -153,7 +155,7 @@ You can then execute your native executable with: `./target/exploit-iq-client-1. Some Quarkus properties are **build-time only** and cannot be changed at runtime. When building for a specific deployment target, include the profile: ```shell -# For external-idp deployments (Keycloak, Google, etc.) +# For external-idp deployments (Keycloak, AWS Cognito, Google, etc.) ./mvnw package -Dnative -Dquarkus.profile=external-idp # For prod deployments (OpenShift OAuth) - default diff --git a/openspec/specs/oidc-authentication/spec.md b/openspec/specs/oidc-authentication/spec.md new file mode 100644 index 00000000..5b3ff29a --- /dev/null +++ b/openspec/specs/oidc-authentication/spec.md @@ -0,0 +1,64 @@ +# oidc-authentication Specification + +## Purpose +Define how ExploitIQ maps OIDC identity-provider JWT claims to application roles across OpenShift OAuth, Keycloak, and AWS Cognito (browser login and M2M), including non-regression guarantees for existing IdP paths. +## Requirements +### Requirement: AWS Cognito browser login role mapping + +`RoleMappingAugmentor` SHALL extract application roles from the `cognito:groups` claim (a JSON array of group names) on the identity's JWT, in addition to the existing `groups` (OpenShift), `realm_access.roles`/`resource_access.{client-id}.roles` (Keycloak), and Kubernetes service-account (`kubernetes.io`) claim checks. For each value in `cognito:groups` that matches a configured target role (`quarkus.http.auth.policy.role-policy.roles-allowed`), the augmentor SHALL grant that role to the identity, without duplicating roles already granted by another claim path. This claim check SHALL run unconditionally alongside the existing checks on every authenticated request, requiring no new Quarkus profile: the `external-idp` profile's existing `discovery-enabled=true`, `roles.source=accesstoken`, and `application-type=hybrid` settings SHALL work unmodified against a Cognito User Pool's `.well-known/openid-configuration`. + +#### Scenario: Cognito group matching a target role is granted + +- **WHEN** an authenticated request carries a JWT with `cognito:groups` containing `exploit-iq-admin` +- **AND** `exploit-iq-admin` is present in the configured target roles +- **THEN** the augmented identity is granted the `exploit-iq-admin` role + +#### Scenario: Cognito group not matching any target role is ignored + +- **WHEN** an authenticated request carries a JWT with `cognito:groups` containing a group name that is not in the configured target roles +- **THEN** the augmented identity is not granted a role for that group name +- **AND** no error is raised + +#### Scenario: Missing cognito:groups claim does not affect other providers + +- **WHEN** an authenticated request carries a JWT without a `cognito:groups` claim +- **THEN** the augmentor skips Cognito group role mapping +- **AND** continues to evaluate `groups`, `realm_access`/`resource_access`, and `kubernetes.io` claim paths unaffected + +### Requirement: AWS Cognito M2M scope-to-role mapping + +`RoleMappingAugmentor` SHALL support authorizing AWS Cognito `client_credentials` (M2M) bearer tokens, which carry no group claim, by mapping the token's OAuth2 `scope` claim (a space-delimited string) to application roles using a configurable `exploitiq.security.oidc.scope-role-mappings` property (a map of scope value to role name, empty by default). For each configured mapping whose scope value is present in the token's `scope` claim, the augmentor SHALL grant the mapped role, provided that role is also present in the configured target roles. This mechanism SHALL be provider-agnostic (keyed only on the standard `scope` claim), opt-in via configuration, and SHALL NOT alter role resolution for tokens where `exploitiq.security.oidc.scope-role-mappings` is unset or the `scope` claim is absent. + +#### Scenario: M2M token scope matching a configured mapping is granted the mapped role + +- **WHEN** an authenticated request carries a JWT with a `scope` claim containing `exploitiq-resource-server/exploitiq-api-access` +- **AND** `exploitiq.security.oidc.scope-role-mappings` maps `exploitiq-resource-server/exploitiq-api-access` to `exploitiq-api-access` +- **AND** `exploitiq-api-access` is present in the configured target roles +- **THEN** the augmented identity is granted the `exploitiq-api-access` role + +#### Scenario: M2M token without a matching configured scope mapping is not granted a role + +- **WHEN** an authenticated request carries a JWT with a `scope` claim that does not match any configured `exploitiq.security.oidc.scope-role-mappings` entry +- **THEN** no role is granted via scope-based mapping +- **AND** the request is still evaluated against roles granted by other claim paths + +#### Scenario: Cognito M2M token is authorized to call the API + +- **WHEN** the `exploit-iq-agent` service calls the `exploit-iq-client` API with a bearer token obtained from Cognito's token endpoint via `client_credentials` grant, whose `scope` claim matches a configured `exploitiq-api-access` mapping +- **THEN** the request is authorized by the global `role-policy` (which allows `exploitiq-api-access`) +- **AND** the caller identity resolves to the JWT `sub` claim (the Cognito App Client ID) for actor attribution, since no `UserInfo` is available for M2M requests + +### Requirement: Existing OpenShift and Keycloak role mapping unaffected + +Adding AWS Cognito claim support SHALL NOT change role resolution behavior for OpenShift OAuth (`prod` profile, `groups`/`userinfo` roles source) or Keycloak (`external-idp`/`dev` profiles, `realm_access`/`resource_access` roles source) identities. The new `cognito:groups` and `scope`-based checks SHALL be no-ops when their respective claims or configuration are absent. + +#### Scenario: OpenShift identity unaffected by Cognito support + +- **WHEN** an authenticated request carries an OpenShift JWT/UserInfo with a `groups` claim and no `cognito:groups` or matching `scope` mapping +- **THEN** roles are granted exactly as before this change, via the `groups` claim path only + +#### Scenario: Keycloak identity unaffected by Cognito support + +- **WHEN** an authenticated request carries a Keycloak JWT with `realm_access.roles` and/or `resource_access.{client-id}.roles` and no `cognito:groups` claim +- **THEN** roles are granted exactly as before this change, via the Keycloak claim paths only + diff --git a/src/main/docker/Dockerfile.multi-stage b/src/main/docker/Dockerfile.multi-stage index 384c8493..68201034 100644 --- a/src/main/docker/Dockerfile.multi-stage +++ b/src/main/docker/Dockerfile.multi-stage @@ -28,7 +28,7 @@ RUN curl -sSfL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSIO && rm /tmp/syft.tar.gz ## Stage 2 : create the docker final image -FROM registry.redhat.io/ubi9/ubi-minimal:9.7 +FROM registry.redhat.io/ubi9/ubi-minimal:9.8-1785339117 LABEL description="Red Hat ExploitIQ - UI" LABEL io.k8s.description="Red Hat ExploitIQ - UI" diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/NewRpmReportRequest.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/NewRpmReportRequest.java index e7f0cb41..57360de5 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/NewRpmReportRequest.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/model/NewRpmReportRequest.java @@ -17,6 +17,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import com.fasterxml.jackson.annotation.JsonInclude; +import com.redhat.ecosystemappeng.exploitiq.validation.NotCveIdAsRpmNvr; import io.quarkus.runtime.annotations.RegisterForReflection; @@ -24,6 +25,7 @@ @Schema(name = "NewRpmReportRequest", description = "RPM package plus CVE for new analysis request") @JsonInclude(JsonInclude.Include.NON_EMPTY) @RegisterForReflection +@NotCveIdAsRpmNvr public record NewRpmReportRequest( @Schema(required = true, description = "RPM package name") String name, @Schema(required = true, description = "RPM package version") String version, diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ProductEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ProductEndpoint.java index eda87dc9..87d6f84e 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ProductEndpoint.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/ProductEndpoint.java @@ -226,6 +226,17 @@ public Response remove( responseCode = "400", description = "Validation error with field-specific error messages" ), + @APIResponse( + responseCode = "429", + description = "Per-user concurrent request limit exceeded or global queue is full", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + type = SchemaType.OBJECT, + example = "{\"error\": \"Per-user concurrent request limit exceeded\"}" + ) + ) + ), @APIResponse( responseCode = "500", description = "Internal server error" @@ -356,7 +367,7 @@ public Response mapSbomValidationException(SbomValidationException e) { description = "Uploads an SPDX SBOM file, parses it, creates a product, and starts async processing. Requires a vulnerability ID to include in all component reports. Accepts optional credentials for private repository access.") @APIResponses({ @APIResponse( - responseCode = "202", + responseCode = "202", description = "Product creation request accepted", content = @Content( mediaType = MediaType.APPLICATION_JSON, @@ -366,11 +377,22 @@ public Response mapSbomValidationException(SbomValidationException e) { ) ), @APIResponse( - responseCode = "400", + responseCode = "400", description = "Invalid SPDX file, missing required data, missing CVE ID, or credential validation error" ), @APIResponse( - responseCode = "500", + responseCode = "429", + description = "Per-user concurrent request limit exceeded or global queue is full", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + type = SchemaType.OBJECT, + example = "{\"error\": \"Per-user concurrent request limit exceeded\"}" + ) + ) + ), + @APIResponse( + responseCode = "500", description = "Internal server error" ) }) diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResource.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResource.java index 6b6b39ab..dcac8b88 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResource.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResource.java @@ -21,18 +21,38 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; import com.redhat.ecosystemappeng.exploitiq.service.UserService; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + @Path("/user") public class TokenResource { + private static final Logger LOG = Logger.getLogger(TokenResource.class); + @Inject UserService userService; + @ConfigProperty(name = "quarkus.oidc.client-id") + Optional clientId; + + @ConfigProperty(name = "cognito.domain") + Optional cognitoDomain; + @GET @Produces("application/json") @Operation(hidden = true) @@ -41,17 +61,111 @@ public String getUserName() { } /** - * Performs a local logout using the standard 'Clear-Site-Data' header. - * This feature is available only in secure contexts (HTTPS) - * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Clear-Site-Data + * Logout endpoint with AWS Cognito support. + * For Cognito (when cognito.domain is configured), redirects to Cognito's logout endpoint. + * For other OIDC providers, performs local logout with Clear-Site-Data header. + * + * NOTE: Cognito behind a reverse proxy is currently not supported. */ @POST @Path("/logout") @Produces(MediaType.TEXT_HTML) @Operation(hidden = true) @PermitAll - public Response logout() { - return Response.ok(LOGGED_OUT_HTML) + public Response logout(@Context UriInfo uriInfo) { + String username = getUsernameForLogging(); + + if (cognitoDomain.isPresent() && clientId.isPresent()) { + String domain = cognitoDomain.get().trim(); + // Validate domain is not empty or just "/" + if (domain.isEmpty() || domain.equals("/")) { + LOG.warnf("User %s: Cognito domain configured but invalid after trim ('%s'), falling back to local logout", + username, domain); + return performLocalLogout(username); + } + + // Strip trailing slash + if (domain.endsWith("/")) { + StringBuilder domainSb = new StringBuilder(domain); + domainSb.deleteCharAt(domainSb.length() - 1); + domain = domainSb.toString(); + } + + // Validate domain is a valid URL + try { + new URL(domain); + } catch (MalformedURLException e) { + LOG.errorf(e, "User %s: Invalid Cognito domain URL '%s', falling back to local logout", username, domain); + return performLocalLogout(username); + } + + // Build logout redirect URI to the logged-out page + // uriInfo.getBaseUri() returns https://host/api/v1/, we need https://host/api/v1/user/logged-out + URI baseUri = uriInfo.getBaseUri(); + String logoutRedirectUri = baseUri.getScheme() + "://" + baseUri.getAuthority() + baseUri.getPath() + "user/logged-out"; + + // Build Cognito logout URL with required parameters + String cognitoLogoutUrl = String.format("%s/logout?client_id=%s&logout_uri=%s", + domain, + URLEncoder.encode(clientId.get(), StandardCharsets.UTF_8), + URLEncoder.encode(logoutRedirectUri, StandardCharsets.UTF_8)); + + // Create URI with error handling + try { + URI cognitoUri = URI.create(cognitoLogoutUrl); + LOG.infof("User %s: Logging out via Cognito, redirecting to %s", username, domain + "/logout"); + return buildLogoutResponse(Response.seeOther(cognitoUri)); + } catch (IllegalArgumentException e) { + LOG.errorf(e, "User %s: Failed to create logout URI from '%s', falling back to local logout", + username, cognitoLogoutUrl); + return performLocalLogout(username); + } + } + + // For non-Cognito providers, perform local logout + return buildLogoutResponse(Response.ok(LOGGED_OUT_HTML)); + } + + private Response performLocalLogout(String username) { + LOG.infof("User %s: Performing local logout", username); + return buildLogoutResponse(Response.ok(LOGGED_OUT_HTML)); + } + + private String getUsernameForLogging() { + try { + return userService.getUserName(); + } catch (Exception e) { + LOG.debugf(e, "Could not retrieve username for logging"); + return "anonymous"; + } + } + + /** + * Exception mapper for logout-related exceptions. + * Catches any unexpected RuntimeException from logout endpoints to prevent + * them from being logged by BaseAuditEndpoint, which would cause confusion. + */ + @ServerExceptionMapper + public Response mapLogoutException(RuntimeException e) { + LOG.errorf(e, "Unexpected error during logout, falling back to local logout page"); + return buildLogoutResponse(Response.ok(LOGGED_OUT_HTML)); + } + + /** + * Logged-out page endpoint. + * Displays the logout success message after Cognito completes its logout. + */ + @GET + @Path("/logged-out") + @Produces(MediaType.TEXT_HTML) + @Operation(hidden = true) + @PermitAll + public Response loggedOut() { + return buildLogoutResponse(Response.ok(LOGGED_OUT_HTML)); + } + + private Response buildLogoutResponse(Response.ResponseBuilder responseBuilder) { + return responseBuilder .header("Clear-Site-Data", "\"cookies\", \"storage\"") .build(); } diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentor.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentor.java index a26b735f..63b6eb73 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentor.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentor.java @@ -21,6 +21,7 @@ import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.smallrye.mutiny.Uni; +import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.json.JsonArray; @@ -31,18 +32,25 @@ import org.jboss.logging.Logger; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; /** * Security Augmentor to map external Identity Provider roles to internal * application roles. * - * Different Identity Providers (OpenShift, Keycloak) store role information in - * different JWT claims: + * Different Identity Providers (OpenShift, Keycloak, AWS Cognito) store role + * information in different JWT claims: * - OpenShift: 'groups' * - Keycloak: 'realm_access.roles' or 'resource_access.{client_id}.roles' + * - AWS Cognito (browser login): 'cognito:groups' + * - AWS Cognito (M2M client_credentials): no group claim; roles are derived + * from the standard OAuth2 'scope' claim via the configurable + * {@code exploitiq.security.oidc.scope-role-mappings} property. * * This augmentor unifies role extraction logic to ensure consistent * authorization regardless of the IDP. @@ -69,8 +77,45 @@ public class RoleMappingAugmentor implements SecurityIdentityAugmentor { @ConfigProperty(name = "quarkus.oidc.enabled", defaultValue = "true") boolean oidcEnabled; + /** + * AWS Cognito M2M (client_credentials) role mapping, expressed as a set of + * "scope=role" pairs, e.g. + * {@code exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access}. + * Unset/empty by default (an unconfigured/blank property yields an empty + * {@link Optional}, never a validation failure). Provider-agnostic: keyed + * only on the standard OAuth2 'scope' claim, so it has no effect for any IdP + * unless explicitly configured. + */ + @ConfigProperty(name = "exploitiq.security.oidc.scope-role-mappings") + Optional> scopeRoleMappings; + + private Map parsedScopeRoleMappings = Map.of(); + private boolean loggedDevModeWarning = false; + @PostConstruct + void initScopeRoleMappings() { + if (scopeRoleMappings == null || scopeRoleMappings.isEmpty()) { + parsedScopeRoleMappings = Map.of(); + return; + } + Map mappings = new HashMap<>(); + for (String pair : scopeRoleMappings.get()) { + if (pair == null || pair.isBlank()) { + continue; + } + int idx = pair.indexOf('='); + if (idx <= 0 || idx == pair.length() - 1) { + LOG.warnf( + "Ignoring malformed exploitiq.security.oidc.scope-role-mappings entry: '%s'. Expected format 'scope=role'.", + pair); + continue; + } + mappings.put(pair.substring(0, idx).trim(), pair.substring(idx + 1).trim()); + } + parsedScopeRoleMappings = Map.copyOf(mappings); + } + /** * Augments the security identity. * @@ -166,6 +211,31 @@ private SecurityIdentity augmentIdentity(SecurityIdentity identity) { } } } + + // AWS Cognito (browser login): 'cognito:groups' claim (list of group names) + Object cognitoGroupsObj = jwt.getClaim("cognito:groups"); + checkClaimAndMapRoles(cognitoGroupsObj, "Cognito Group", addedRoles, builder); + + // AWS Cognito (M2M client_credentials): no group claim is present, so map the + // standard OAuth2 'scope' claim to roles via the configured scope-role mappings. + // No-op unless exploitiq.security.oidc.scope-role-mappings is configured. + Object scopeClaim = jwt.getClaim("scope"); + if (scopeClaim instanceof String scopeStr && !scopeStr.isBlank() && !parsedScopeRoleMappings.isEmpty()) { + Set tokenScopes = Set.of(scopeStr.trim().split("\\s+")); + for (Map.Entry entry : parsedScopeRoleMappings.entrySet()) { + if (tokenScopes.contains(entry.getKey())) { + String mappedRole = entry.getValue(); + if (!targetRoles.contains(mappedRole)) { + LOG.warnf( + "Ignoring scope-role mapping '%s=%s': role '%s' is not in the configured target roles %s", + entry.getKey(), mappedRole, mappedRole, targetRoles); + continue; + } + processRole(mappedRole, "Cognito M2M Scope", addedRoles, builder); + } + } + } + return builder.build(); } return identity; diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java index b769b757..1f346428 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/ReportService.java @@ -557,8 +557,9 @@ public void submit(String id, JsonNode report) throws JsonProcessingException, I */ public ReportData saveAndSubmitNew(ReportData reportData) throws JsonProcessingException, IOException { String user = determineUser(reportData.report()); + String productId = extractProductId(reportData.report()); try { - return queueService.runIfHasCapacity(user, () -> { + return queueService.runIfHasCapacity(user, productId, () -> { try { ReportData saved = saveReport(reportData); submit(saved.reportRequestId().id(), saved.report()); @@ -576,18 +577,29 @@ public ReportData saveAndSubmitNew(ReportData reportData) throws JsonProcessingE } private String determineUser(JsonNode report) { - JsonNode metadata = report.get("metadata"); - if (metadata != null && metadata.has("product_id")) { - String productId = metadata.get("product_id").asText(); + String productId = extractProductId(report); + if (productId != null) { String productUser = productService.getUserName(productId); if (productUser != null && !productUser.isEmpty()) { return productUser; } } - + return userService.getUserName(); } + static String extractProductId(JsonNode report) { + if (report == null) { + return null; + } + JsonNode metadata = report.get("metadata"); + if (metadata == null || !metadata.has("product_id") || metadata.get("product_id").isNull()) { + return null; + } + String productId = metadata.get("product_id").asText(); + return productId != null && !productId.isBlank() ? productId : null; + } + /** * New scan id for {@code input.scan.id} when it must not be shared (e.g. batch SPDX components) or when trace context has no id. */ diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueService.java index 85e16cd1..a88145f9 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueService.java @@ -15,9 +15,7 @@ package com.redhat.ecosystemappeng.exploitiq.service; import java.time.Duration; -import java.time.Instant; import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -59,6 +57,7 @@ public class RequestQueueService { private static final Logger LOGGER = Logger.getLogger(RequestQueueService.class); private static final String DEFAULT_USER = "anonymous"; + private static final String PRODUCT_ID = "product_id"; @ConfigProperty(name = "exploit-iq.queue.max-active", defaultValue = "5") Integer maxActive; @@ -84,13 +83,20 @@ public class RequestQueueService { @Inject Tracer tracer; - private record PendingRequest(String id, String user) {} + /** {@code productId} is null for standalone (non-SBOM) reports. */ + private record PendingRequest(String id, String user, String productId) {} private Queue pending = new ConcurrentLinkedQueue<>(); private Map active = new ConcurrentHashMap<>(); private Map activeUserById = new ConcurrentHashMap<>(); private Map> activeByUser = new ConcurrentHashMap<>(); private Map> pendingByUser = new ConcurrentHashMap<>(); + /** Report id → product id for in-flight SBOM/product reports. */ + private Map productIdByReportId = new ConcurrentHashMap<>(); + /** Product id → in-flight report ids (active + pending). */ + private Map> inflightReportsByProduct = new ConcurrentHashMap<>(); + /** User → distinct in-flight product ids (each counts as one per-user slot). */ + private Map> productsByUser = new ConcurrentHashMap<>(); // Lock striping: each user gets its own lock so that admission/promotion for one user never // blocks another. Entries are intentionally never removed (safe lock-striping doesn't allow // pruning while another thread might still hold/await the same lock); the map is bounded by @@ -122,39 +128,33 @@ void checkQueue() { expired.forEach(this::removeActive); lastCheck = LocalDateTime.now(); moveToActive(); - LOGGER.debugf("queue sizes on checkQueue: pending: %d, active: %d, activeUserById: %d, activeByUser: %d, pendingByUser: %d", - pending.size(), active.size(), activeUserById.size(), activeByUser.size(), pendingByUser.size()); - if (activeByUser.size() != 0) { - LOGGER.debugf("activeByUser map contents:"); - activeByUser.forEach((user, requestIds) -> { - if (!requestIds.isEmpty()) { - LOGGER.debugf(" user: %s, active requests count: %d", user, requestIds.size()); - } - }); - } - if (pendingByUser.size() != 0) { - LOGGER.debugf("pendingByUser map contents:"); - pendingByUser.forEach((user, requestIds) -> { - if (!requestIds.isEmpty()) { - LOGGER.debugf(" user: %s, pending requests count: %d", user, requestIds.size()); - } - }); - } + LOGGER.debugf( + "queue sizes on checkQueue: pending: %d, active: %d, activeUserById: %d, activeByUser: %d, pendingByUser: %d, productsByUser: %d, inflightReportsByProduct: %d", + pending.size(), active.size(), activeUserById.size(), activeByUser.size(), pendingByUser.size(), + productsByUser.size(), inflightReportsByProduct.size()); + logPerUserMaps(); } @PostConstruct void loadExistingQueue() { // Load all queued reports - repository.list(Map.of("status", "queued"), Collections.emptyList(), new Pagination(0, maxSize)).results.forEach(r -> trackPending(r.id(), resolveUser(r))); + repository.list(Map.of("status", "queued"), Collections.emptyList(), new Pagination(0, maxSize)).results + .forEach(r -> trackPending(r.id(), resolveUser(r), extractProductId(r))); LOGGER.debugf("Loaded %d elements from existing pending queue", pending.size()); // Load all active sent reports (not expired yet), will be reloaded to active DS on application restarts, so there will be no orphan reports left in DB that will never become expired repository.list(Map.of("status", "sent"), Collections.emptyList(), new Pagination(0, maxSize)) .results - .forEach(r -> trackActive(r.id(), resolveUser(r), getSubmittedTS(r))); + .forEach(r -> trackActive(r.id(), resolveUser(r), getSubmittedTS(r), extractProductId(r))); LOGGER.debugf("Loaded %d elements from existing actives Map", active.size()); - LOGGER.debugf("queue sizes on loadExistingQueue: pending: %d, active: %d, activeUserById: %d, activeByUser: %d, pendingByUser: %d", - pending.size(), active.size(), activeUserById.size(), activeByUser.size(), pendingByUser.size()); + LOGGER.debugf( + "queue sizes on loadExistingQueue: pending: %d, active: %d, activeUserById: %d, activeByUser: %d, pendingByUser: %d, productsByUser: %d, inflightReportsByProduct: %d", + pending.size(), active.size(), activeUserById.size(), activeByUser.size(), pendingByUser.size(), + productsByUser.size(), inflightReportsByProduct.size()); + logPerUserMaps(); + } + + private void logPerUserMaps() { if (activeByUser.size() != 0) { LOGGER.debugf("activeByUser map contents:"); activeByUser.forEach((user, requestIds) -> { @@ -171,6 +171,14 @@ void loadExistingQueue() { } }); } + if (productsByUser.size() != 0) { + LOGGER.debugf("productsByUser map contents:"); + productsByUser.forEach((user, productIds) -> { + if (!productIds.isEmpty()) { + LOGGER.debugf(" user: %s, in-flight products count: %d", user, productIds.size()); + } + }); + } } private Object lockFor(String user) { @@ -239,16 +247,20 @@ private void submitOneReportFromQueue(PendingRequest next) { // otherwise a concurrent admit() call for this user could observe the pending count // already decremented but the active count not yet incremented, and admit one request // too many. No need to re-check maxActivePerUser here: moving a request from pending to - // active doesn't change this user's active+pending total, only which bucket it's in. + // active doesn't change this user's occupancy (standalone active+pending, or product slot). synchronized (lockFor(next.user())) { - untrackPending(next); - trackActive(next.id(), next.user()); + untrackPendingStandalone(next); + trackActive(next.id(), next.user(), LocalDateTime.now(), next.productId()); } sendAsync(next.id(), jsonReport); } catch (JsonProcessingException e) { LOGGER.error("Unable to submit request", e); repository.updateWithError(next.id(), "json-processing-error", e.getMessage()); } + } else { + synchronized (lockFor(next.user())) { + leavePending(next); + } } span.end(); } @@ -262,32 +274,24 @@ private void submitOneReportFromQueue(PendingRequest next) { */ public void admit(String id, JsonNode json, String user, Runnable onAdmitted) { var effectiveUser = resolveEffectiveUser(user); + var productId = ReportService.extractProductId(json); boolean sendNow; - // The compound check-then-act below only touches this user's own state (activeByUser/ - // pendingByUser), so a per-user lock is sufficient - concurrent admission for other users - // proceeds without contention. active.size()/pending.size() are read without a lock since - // they're global counters where a small race-induced overshoot is harmless. + // The compound check-then-act below only touches this user's own state, so a per-user lock + // is sufficient - concurrent admission for other users proceeds without contention. + // active.size()/pending.size() are read without a lock since they're global counters where + // a small race-induced overshoot is harmless. synchronized (lockFor(effectiveUser)) { - var userActiveCount = activeByUser.getOrDefault(effectiveUser, Collections.emptySet()).size(); - var userPendingCount = pendingByUser.getOrDefault(effectiveUser, Collections.emptySet()).size(); - // Cap active + pending combined, not just active: this guarantees that a user can never - // accumulate more than maxActivePerUser requests in flight in total, so promoting - // pending requests to active (see submitOneReportFromQueue()) can never push a user's - // active count past their limit either. - if (userActiveCount + userPendingCount >= maxActivePerUser) { - LOGGER.debugf("User %s exceeded per-user concurrent request limit of %d", effectiveUser, maxActivePerUser); - throw new UserQueueExceededException(maxActivePerUser); - } + assertUserHasCapacity(effectiveUser, productId); if (active.size() >= maxActive) { if (pending.size() >= maxSize) { throw new RequestQueueExceededException(maxSize); } // Reserve the per-user slot in the same critical section as the check. - trackPending(id, effectiveUser); + trackPending(id, effectiveUser, productId); sendNow = false; } else { // Reserve the per-user slot in the same critical section as the check. - trackActive(id, effectiveUser); + trackActive(id, effectiveUser, LocalDateTime.now(), productId); sendNow = true; } } @@ -300,9 +304,11 @@ public void admit(String id, JsonNode json, String user, Runnable onAdmitted) { if (sendNow) { removeActive(id); } else { - var request = new PendingRequest(id, effectiveUser); + var request = new PendingRequest(id, effectiveUser, productId); pending.remove(request); - untrackPending(request); + synchronized (lockFor(effectiveUser)) { + leavePending(request); + } } throw e; } @@ -330,53 +336,135 @@ public void queue(String id, JsonNode json, String user) { * {@link #admit(String, JsonNode, String, Runnable)} (which needs the id assigned during * persistence), so a small race is possible between the two; {@code admit()} enforces the * limits again at that point as the source of truth. + * + * @param productId optional SBOM product id; when non-null and already in flight for the user, + * the per-user check allows another component of the same product */ - public T runIfHasCapacity(String user, java.util.function.Supplier action) { + public T runIfHasCapacity(String user, String productId, java.util.function.Supplier action) { var effectiveUser = resolveEffectiveUser(user); + var effectiveProductId = blankToNull(productId); synchronized (lockFor(effectiveUser)) { if (active.size() >= maxActive && pending.size() >= maxSize) { throw new RequestQueueExceededException(maxSize); } - var userActiveCount = activeByUser.getOrDefault(effectiveUser, Collections.emptySet()).size(); - var userPendingCount = pendingByUser.getOrDefault(effectiveUser, Collections.emptySet()).size(); - if (userActiveCount + userPendingCount >= maxActivePerUser) { - LOGGER.debugf("User %s exceeded per-user concurrent request limit of %d", effectiveUser, maxActivePerUser); - throw new UserQueueExceededException(maxActivePerUser); - } + assertUserHasCapacity(effectiveUser, effectiveProductId); } return action.get(); } + private void assertUserHasCapacity(String user, String productId) { + // Same product already in flight: no additional per-user slot required. + if (productId != null && isProductAlreadyInFlight(user, productId)) { + return; + } + if (userOccupancy(user) >= maxActivePerUser) { + LOGGER.debugf("User %s exceeded per-user concurrent request limit of %d", user, maxActivePerUser); + throw new UserQueueExceededException(maxActivePerUser); + } + } + + private int userOccupancy(String user) { + int standalone = activeByUser.getOrDefault(user, Collections.emptySet()).size() + + pendingByUser.getOrDefault(user, Collections.emptySet()).size(); + int products = productsByUser.getOrDefault(user, Collections.emptySet()).size(); + return standalone + products; + } + + private boolean isProductAlreadyInFlight(String user, String productId) { + return productsByUser.getOrDefault(user, Collections.emptySet()).contains(productId); + } + private String resolveEffectiveUser(String user) { return Objects.nonNull(user) && !user.isBlank() ? user : DEFAULT_USER; } - private void trackActive(String id, String user) { - trackActive(id, user, LocalDateTime.now()); + private static String blankToNull(String value) { + return Objects.nonNull(value) && !value.isBlank() ? value : null; } - private void trackActive(String id, String user, LocalDateTime sentAt) { + private String extractProductId(Report report) { + if (report.metadata() == null) { + return null; + } + return blankToNull(report.metadata().get(PRODUCT_ID)); + } + + private void trackActive(String id, String user, LocalDateTime sentAt, String productId) { active.put(id, sentAt); activeUserById.put(id, user); - activeByUser.computeIfAbsent(user, k -> ConcurrentHashMap.newKeySet()).add(id); - LOGGER.debugf( - "[trackActive] active map: put id=%s -> sentAt=%s; activeUserById map: put id=%s -> user=%s; activeByUser map: added id=%s to user=%s set", - id, sentAt, id, user, id, user); + if (productId == null) { + activeByUser.computeIfAbsent(user, k -> ConcurrentHashMap.newKeySet()).add(id); + LOGGER.debugf( + "[trackActive] active map: put id=%s -> sentAt=%s; activeUserById map: put id=%s -> user=%s; activeByUser map: added id=%s to user=%s set", + id, sentAt, id, user, id, user); + } else { + registerProductReport(id, user, productId); + LOGGER.debugf( + "[trackActive] active map: put id=%s -> sentAt=%s; activeUserById map: put id=%s -> user=%s; product slot: productId=%s", + id, sentAt, id, user, productId); + } } - private void trackPending(String id, String user) { - pending.add(new PendingRequest(id, user)); - pendingByUser.computeIfAbsent(user, k -> ConcurrentHashMap.newKeySet()).add(id); - LOGGER.debugf("[trackPending] pendingByUser map: added id=%s to user=%s set", id, user); + private void trackPending(String id, String user, String productId) { + pending.add(new PendingRequest(id, user, productId)); + if (productId == null) { + pendingByUser.computeIfAbsent(user, k -> ConcurrentHashMap.newKeySet()).add(id); + LOGGER.debugf("[trackPending] pendingByUser map: added id=%s to user=%s set", id, user); + } else { + registerProductReport(id, user, productId); + LOGGER.debugf("[trackPending] product slot: added id=%s for user=%s productId=%s", id, user, productId); + } + } + + private void registerProductReport(String id, String user, String productId) { + productIdByReportId.put(id, productId); + inflightReportsByProduct.computeIfAbsent(productId, k -> ConcurrentHashMap.newKeySet()).add(id); + productsByUser.computeIfAbsent(user, k -> ConcurrentHashMap.newKeySet()).add(productId); } - private void untrackPending(PendingRequest request) { + /** Removes a standalone report from {@code pendingByUser} only (used on pending→active promotion). */ + private void untrackPendingStandalone(PendingRequest request) { + if (request.productId() != null) { + return; + } var ids = pendingByUser.get(request.user()); if (Objects.nonNull(ids)) { ids.remove(request.id()); if (ids.isEmpty()) { pendingByUser.remove(request.user(), ids); - LOGGER.debugf("[untrackPending] pendingByUser map: removed id=%s from user=%s set (set now empty, user entry removed)", request.id(), request.user()); + LOGGER.debugf("[untrackPending] pendingByUser map: removed id=%s from user=%s set (set now empty, user entry removed)", request.id(), request.user()); + } + } + } + + /** Leaves the pending queue entirely (rollback / missing report), releasing any product slot. */ + private void leavePending(PendingRequest request) { + untrackPendingStandalone(request); + if (request.productId() != null) { + releaseProductReport(request.id(), request.user()); + } + } + + private void releaseProductReport(String id, String user) { + var productId = productIdByReportId.remove(id); + if (productId == null) { + return; + } + var reports = inflightReportsByProduct.get(productId); + if (Objects.nonNull(reports)) { + reports.remove(id); + if (reports.isEmpty()) { + inflightReportsByProduct.remove(productId, reports); + var products = productsByUser.get(user); + if (Objects.nonNull(products)) { + products.remove(productId); + if (products.isEmpty()) { + productsByUser.remove(user, products); + } + } + LOGGER.debugf( + "[releaseProductReport] last in-flight report %s for product %s left queue; freed product slot for user %s", + id, productId, user); } } } @@ -396,7 +484,7 @@ private void sendAsync(String id, JsonNode report) { private void removeActive(String id) { active.remove(id); // activeUserById.remove(id) atomically resolves and clears the owning user; only the - // per-user activeByUser cleanup below needs to be synchronized on that user's lock. + // per-user cleanup below needs to be synchronized on that user's lock. var user = activeUserById.remove(id); if (Objects.nonNull(user)) { synchronized (lockFor(user)) { @@ -405,9 +493,10 @@ private void removeActive(String id) { ids.remove(id); if (ids.isEmpty()) { activeByUser.remove(user, ids); - LOGGER.debugf("[removeActive] activeByUser map: removed id=%s from user=%s set (set now empty, user entry removed)", id, user); + LOGGER.debugf("[removeActive] activeByUser map: removed id=%s from user=%s set (set now empty, user entry removed)", id, user); } } + releaseProductReport(id, user); } } } diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RpmReportService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RpmReportService.java index 650ed1d4..fdff5943 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RpmReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/RpmReportService.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -38,6 +39,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; /** * Builds, persists, and submits ExploitIQ report requests for the RPM package checker pipeline. @@ -54,6 +57,9 @@ public class RpmReportService { @Inject UserService userService; + @Inject + Validator validator; + private record ValidatedNewRpmReport(String name, String version, String release, String arch, String cveUppercase) { } @@ -81,12 +87,26 @@ private ValidatedNewRpmReport validateNewRpmReportRequest(NewRpmReportRequest re putIfBlank(errors, "arch", arch, "Architecture is required"); RpmArchitecture.putArchFieldErrorIfNotAllowed(errors, arch); CveIdRules.putOfficialCveFieldErrorIfInvalid(errors, request.cveId()); + putHibernateConstraintViolations(errors, request); if (!errors.isEmpty()) { throw new ValidationException(errors); } return new ValidatedNewRpmReport(name, version, release, arch, rawCve.toUpperCase()); } + /** + * Merges Hibernate Validator constraint violations (e.g. {@link com.redhat.ecosystemappeng.exploitiq.validation.NotCveIdAsRpmNvr}) + * into the aggregated field-error map without overwriting earlier messages for the same field. + */ + private void putHibernateConstraintViolations(Map errors, NewRpmReportRequest request) { + Set> violations = validator.validate(request); + for (ConstraintViolation violation : violations) { + String path = violation.getPropertyPath() == null ? "" : violation.getPropertyPath().toString(); + String field = path.isEmpty() ? "name" : path; + errors.putIfAbsent(field, violation.getMessage()); + } + } + private ReportData generateRpmPackageCheckerReport(ValidatedNewRpmReport v) throws JsonProcessingException { String scanId = reportService.createUniqueScanId(); Scan scan = new Scan(scanId, List.of(new VulnId(v.cveUppercase()))); diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportService.java index a7ef4492..747c2123 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportService.java @@ -53,6 +53,7 @@ public class SbomReportService { private ComponentProcessingService componentProcessingService; private CredentialProcessingService credentialProcessingService; private ObjectMapper objectMapper; + private RequestQueueService queueService; @Inject public void setCycloneDxParsingService(CycloneDxParsingService cycloneDxParsingService) { @@ -94,6 +95,11 @@ public void setObjectMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + @Inject + public void setRequestQueueService(RequestQueueService queueService) { + this.queueService = queueService; + } + /** * Generates a product ID from SBOM name and version. @@ -221,35 +227,55 @@ public String submitSpdx(InputStream fileInputStream, String cveId, String crede throw new ValidationException(errors); } LOGGER.info("Processing SPDX file upload for CVE: " + cveId); - - SpdxParsingService.ProductInfo productInfo = parsed.productInfo(); - Map metadata = new HashMap<>(); - // Add CPE to metadata if present - if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) { - metadata.put("cpe", productInfo.cpe()); - } - - if (Objects.nonNull(productInfo.spdxId())) { - metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId()); - } - int totalComponentCount = parsed.components().size() + parsed.unsupportedComponents().size(); - Product product = this.createProduct(cveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata); + final SpdxParsingService.ProductInfo productInfo = parsed.productInfo(); - for (SpdxParsingService.UnsupportedComponentInfo unsupported : parsed.unsupportedComponents()) { - String errorMessage = - "Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=..."; - String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : ""; - productRepository.addSubmissionFailure(product.id(), new FailedComponent( - unsupported.name(), unsupported.version(), imageForDisplay, errorMessage)); - } + // Generate productId before capacity check (needed for product slot optimization) + final String productId = generateProductId(productInfo.name(), productInfo.version()); + + // Get current user + final String user = userService.getUserName(); + + // Make variables final for lambda capture + final SpdxParsingService.ParsedSpdx finalParsed = parsed; + final String finalCveId = cveId; + final String finalCredentialId = credentialId; - // Start component processing (chunks run in parallel on executor) - processSpdxComponents(product.id(), parsed, cveId, credentialId); + // Check user capacity before creating product (matches RPM/CycloneDX pattern) + return queueService.runIfHasCapacity(user, productId, () -> { + try { + Map metadata = new HashMap<>(); + // Add CPE to metadata if present + if (productInfo.cpe() != null && !productInfo.cpe().trim().isEmpty()) { + metadata.put("cpe", productInfo.cpe()); + } - LOGGER.infof("Created product %s, started component processing", product.id()); - - return product.id(); + if (Objects.nonNull(productInfo.spdxId())) { + metadata.put(RepositoryConstants.SPDX_ID_METADATA_KEY, productInfo.spdxId()); + } + + int totalComponentCount = finalParsed.components().size() + finalParsed.unsupportedComponents().size(); + Product product = this.createProduct(productId, finalCveId, productInfo.name(), productInfo.version(), totalComponentCount, metadata); + + for (SpdxParsingService.UnsupportedComponentInfo unsupported : finalParsed.unsupportedComponents()) { + String errorMessage = + "Expects a container image purl with format pkg:oci/name@sha256:hash or pkg:oci/name@sha256%3Ahash?repository_url=...&tag=..."; + String imageForDisplay = unsupported.purl() != null ? unsupported.purl() : ""; + productRepository.addSubmissionFailure(product.id(), new FailedComponent( + unsupported.name(), unsupported.version(), imageForDisplay, errorMessage)); + } + + // Start component processing (chunks run in parallel on executor) + processSpdxComponents(product.id(), finalParsed, finalCveId, finalCredentialId); + + LOGGER.infof("Created product %s, started component processing", product.id()); + + return product.id(); + } catch (Exception e) { + LOGGER.errorf(e, "Failed to create product or start component processing for CVE %s", finalCveId); + throw e; + } + }); } private void processSpdxComponents(String productId, SpdxParsingService.ParsedSpdx parsed, String vulnerabilityId, String credentialId) { @@ -273,8 +299,7 @@ private void processSpdxComponents(String productId, SpdxParsingService.ParsedSp } } - private Product createProduct(String cveId, String sbomName, String sbomVersion, int componentCount, Map metadata) { - String productId = generateProductId(sbomName, sbomVersion); + private Product createProduct(String productId, String cveId, String sbomName, String sbomVersion, int componentCount, Map metadata) { Product product = newProductDocument(cveId, productId, sbomName, sbomVersion, componentCount, metadata); productRepository.save(product, userService.getUserName()); return product; diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/UserService.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/UserService.java index 344d6302..bf1b09a0 100644 --- a/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/UserService.java +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/service/UserService.java @@ -16,11 +16,13 @@ import io.quarkus.arc.properties.IfBuildProperty; import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import jakarta.json.Json; import jakarta.json.JsonObject; +import org.eclipse.microprofile.jwt.JsonWebToken; import java.util.Objects; @ApplicationScoped @@ -29,6 +31,9 @@ public class UserService { @Inject UserInfo userInfo; + @Inject + SecurityIdentity securityIdentity; + private static final String DEFAULT_USERNAME = "anonymous"; @IfBuildProperty(name = "quarkus.oidc.enabled", stringValue = "false") @@ -42,12 +47,56 @@ public UserInfo getAnonymousUserInfo() { } /** - * Resolves the best available username from UserInfo claims. + * Resolves the best available username from JWT token claims or UserInfo. * - * Checks explicitly for: email, upn, metadata.name, preferred_username, sub. - * Falls back to "anonymous" if UserInfo is missing. + * Priority: + * 1. JWT token claims (email, cognito:username, username, upn, preferred_username, sub) + * 2. UserInfo (if available) + * 3. Falls back to "anonymous" */ public String getUserName() { + // First try to get username from JWT token directly + if (securityIdentity != null && securityIdentity.getPrincipal() instanceof JsonWebToken jwt) { + + + // Try email claim (Cognito ID tokens, common in OIDC) + String name = jwt.getClaim("email"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + + // Try cognito:username (Cognito-specific) + name = jwt.getClaim("cognito:username"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + + // Try username claim + name = jwt.getClaim("username"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + + // Try upn (user principal name - common in enterprise) + name = jwt.getClaim("upn"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + + // Try preferred_username (standard OIDC claim) + name = jwt.getClaim("preferred_username"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + + // Try sub (subject - always present but may be UUID) + name = jwt.getClaim("sub"); + if (Objects.nonNull(name) && !name.isBlank()) { + return name; + } + } + + // Fallback to UserInfo if JWT extraction didn't work if (Objects.nonNull(userInfo)) { var name = userInfo.getString("email"); if (Objects.nonNull(name)) { diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvr.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvr.java new file mode 100644 index 00000000..163aeb20 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvr.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.validation; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * Rejects RPM name/version/release coordinates that reconstruct to an official CVE id + * (e.g. {@code CVE} / {@code 2024} / {@code 12345} from pasting a CVE into the Package N-V-R field). + */ +@Documented +@Constraint(validatedBy = NotCveIdAsRpmNvrValidator.class) +@Target(TYPE) +@Retention(RUNTIME) +public @interface NotCveIdAsRpmNvr { + + String message() default "A CVE ID was entered in the Package N-V-R field. Enter the package as name-version-release and put the CVE ID in the CVE ID field."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidator.java b/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidator.java new file mode 100644 index 00000000..eda924fc --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidator.java @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.validation; + +import java.util.Locale; + +import com.redhat.ecosystemappeng.exploitiq.model.NewRpmReportRequest; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Hibernate Validator implementation for {@link NotCveIdAsRpmNvr}. + */ +public class NotCveIdAsRpmNvrValidator implements ConstraintValidator { + + /** + * User-facing copy when a CVE id is pasted into Package N-V-R (aligned with Request Analysis UI). + */ + public static final String MESSAGE = + "A CVE ID was entered in the Package N-V-R field. Enter the package as name-version-release " + + "and put the CVE ID in the CVE ID field."; + + @Override + public boolean isValid(NewRpmReportRequest value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + String name = trimmedOrNull(value.name()); + String version = trimmedOrNull(value.version()); + String release = trimmedOrNull(value.release()); + if (name == null || version == null || release == null) { + return true; + } + String reconstructedNvr = name + "-" + version + "-" + release; + if (!isOfficialCveId(reconstructedNvr)) { + return true; + } + if (context != null) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(MESSAGE) + .addPropertyNode("name") + .addConstraintViolation(); + } + return false; + } + + /** {@code true} when {@code value} matches the official CVE pattern (case-insensitive). */ + public static boolean isOfficialCveId(String value) { + String trimmed = trimmedOrNull(value); + if (trimmed == null) { + return false; + } + return CveIdRules.OFFICIAL_CVE_PATTERN.matcher(trimmed.toUpperCase(Locale.ROOT)).matches(); + } + + private static String trimmedOrNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/src/main/resources/META-INF/resources/error/403.html b/src/main/resources/META-INF/resources/error/403.html index 400e92c6..41f0edea 100644 --- a/src/main/resources/META-INF/resources/error/403.html +++ b/src/main/resources/META-INF/resources/error/403.html @@ -21,10 +21,14 @@ diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f9616ed2..e16a4c7e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -59,11 +59,24 @@ quarkus.http.limits.max-form-attribute-size=10K # Use cases: # - OpenShift/Kubernetes with Keycloak (standalone or as Identity Broker) # - Direct GitHub/Google OAuth (without Keycloak) +# - AWS Cognito (browser login + agent M2M) # Activation: QUARKUS_PROFILE=external-idp # Required environment variables: # For Keycloak: QUARKUS_OIDC_AUTH_SERVER_URL, QUARKUS_OIDC_CREDENTIALS_SECRET # For Direct Google/GitHub: QUARKUS_OIDC_PROVIDER=google|github, # QUARKUS_OIDC_CLIENT_ID, QUARKUS_OIDC_CREDENTIALS_SECRET +# For AWS Cognito: +# QUARKUS_OIDC_AUTH_SERVER_URL=https://cognito-idp.{region}.amazonaws.com/{user-pool-id} +# QUARKUS_OIDC_CLIENT_ID={cognito-app-client-id} +# QUARKUS_OIDC_CREDENTIALS_SECRET={cognito-app-client-secret} +# Browser login roles: Cognito 'cognito:groups' claim is mapped automatically +# (see RoleMappingAugmentor); no extra config needed as long as group names +# match the configured application roles (exploit-iq-admin, etc.). +# Agent M2M authorization: Cognito client_credentials tokens have no group +# claim, so map the token's 'scope' claim to a role via (comma-separated +# "scope=role" pairs for multiple mappings): +# EXPLOITIQ_SECURITY_OIDC_SCOPE_ROLE_MAPPINGS=exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access +# (see exploitiq.security.oidc.scope-role-mappings below) # # Profile: dev # Use case: Local development with Keycloak DevServices @@ -129,7 +142,8 @@ quarkus.oidc.authentication.java-script-auto-redirect=false # ============================================================================== # Allow logout for all authenticated users (even without roles) -quarkus.http.auth.permission.logout.paths=/api/v1/user/logout +exploitiq.security.public-logout-paths=/api/v1/user/logout,/api/v1/user/logged-out +quarkus.http.auth.permission.logout.paths=${exploitiq.security.public-logout-paths} quarkus.http.auth.permission.logout.policy=permit @@ -146,6 +160,10 @@ quarkus.http.auth.permission.management.policy=permit # Outside exploit-iq.* AppConfig mapping so native builds do not bake unresolved ${NAMESPACE}. exploitiq.security.service-account-roles=system:serviceaccount:${NAMESPACE}:exploit-iq-sa,system:serviceaccount:${NAMESPACE}:pipeline,exploitiq-api-access +# AWS Cognito M2M (client_credentials) role mapping: comma-separated "scope=role" pairs. +# Unset by default (no effect on OpenShift/Keycloak); set per-deployment for Cognito M2M, e.g.: +# exploitiq.security.oidc.scope-role-mappings=exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access + # Global policy: Require authentication and at least one role quarkus.http.auth.policy.role-policy.roles-allowed=exploit-iq-view,exploit-iq-prodsec,exploit-iq-admin,${exploitiq.security.service-account-roles} quarkus.http.auth.permission.default.paths=/* diff --git a/src/main/webui/src/hooks/useAnalysisRequestForm.ts b/src/main/webui/src/hooks/useAnalysisRequestForm.ts index ed4eabc8..794df159 100644 --- a/src/main/webui/src/hooks/useAnalysisRequestForm.ts +++ b/src/main/webui/src/hooks/useAnalysisRequestForm.ts @@ -19,7 +19,9 @@ import { import { parseTrimmedRpmNvr, validateRpmPackageNvrBlur, + isCveIdAsPackageNvr, RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE, + RPM_PACKAGE_NVR_CVE_ID_ERROR_MESSAGE, DEFAULT_RPM_ARCH, isRpmArchChoice, type RpmArchChoice, @@ -187,6 +189,8 @@ function getClientValidationErrors(s: AnalysisRequestStoredValues): Partial, value: string) => { setValues((prev) => ({ ...prev, [field]: value })); + if (field === "rpmPackageNvr") { + setErrors((prev) => ({ + ...prev, + rpmPackageNvr: isCveIdAsPackageNvr(value) ? RPM_PACKAGE_NVR_CVE_ID_ERROR_MESSAGE : null, + })); + return; + } setErrors((prev) => (prev[field] ? { ...prev, [field]: null } : prev)); }, [] @@ -577,6 +588,13 @@ export function useAnalysisRequestForm({ setState({ isSubmitting: true }); try { const trimmedPkg = values.rpmPackageNvr.trim(); + if (isCveIdAsPackageNvr(trimmedPkg)) { + setErrors((prev) => ({ + ...prev, + rpmPackageNvr: RPM_PACKAGE_NVR_CVE_ID_ERROR_MESSAGE, + })); + return; + } const coords = parseTrimmedRpmNvr(trimmedPkg); if (!coords) { setErrors((prev) => ({ @@ -655,7 +673,9 @@ export function useAnalysisRequestForm({ state.isSubmitting || (values.mode !== "rpm" && values.isAuthenticationSecretChecked && - values.authenticationSecret.trim() === ""); + values.authenticationSecret.trim() === "") || + (values.mode === "rpm" && + (isCveIdAsPackageNvr(values.rpmPackageNvr) || errors.rpmPackageNvr !== null)); const { selectedFile: _selectedFile, ...exportedValues } = values; diff --git a/src/main/webui/src/utils/requestAnalysisRpm.ts b/src/main/webui/src/utils/requestAnalysisRpm.ts index 3a064f91..2965103f 100644 --- a/src/main/webui/src/utils/requestAnalysisRpm.ts +++ b/src/main/webui/src/utils/requestAnalysisRpm.ts @@ -2,11 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { NewRpmReportRequest } from "../generated-client/models/NewRpmReportRequest"; +import { CVE_ID_PATTERN } from "./requestAnalysisValidation"; /** Shown when the package string cannot be split into nonempty name, version, and release (RPM-style split from the right). */ export const RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE = "Enter the package as name-version-release (for example openssl-3.0.7-5.el9), with hyphens separating all three parts."; +/** + * Aligned with backend {@code NotCveIdAsRpmNvrValidator.MESSAGE} — CVE pasted into Package N-V-R. + */ +export const RPM_PACKAGE_NVR_CVE_ID_ERROR_MESSAGE = + "A CVE ID was entered in the Package N-V-R field. Enter the package as name-version-release and put the CVE ID in the CVE ID field."; + export type RpmArchChoice = NewRpmReportRequest["arch"]; export const DEFAULT_RPM_ARCH: RpmArchChoice = "x86_64"; @@ -39,6 +46,18 @@ function isValidRpmVersionOrRelease(value: string): boolean { return RPM_VERSION_RELEASE_PATTERN.test(value); } +/** + * {@code true} when the trimmed value matches an official CVE id (case-insensitive), + * i.e. a CVE was pasted into Package N-V-R instead of name-version-release. + */ +export function isCveIdAsPackageNvr(raw: string): boolean { + const t = raw.trim(); + if (t === "") { + return false; + } + return CVE_ID_PATTERN.test(t.toUpperCase()); +} + /** Parses a trimmed RPM N-V-R: release after last hyphen, version before that, name is the leading remainder (may contain hyphens). */ export function parseTrimmedRpmNvr( trimmed: string @@ -64,11 +83,18 @@ export function parseTrimmedRpmNvr( return { name, version, release }; } -/** Blur-only: empty yields no format error ("Required" is enforced on submit). */ +/** + * Blur/submit format check for Package N-V-R. + * Empty yields no format error ("Required" is enforced on submit). + * Rejects CVE ids pasted into this field (backend {@code @NotCveIdAsRpmNvr}). + */ export function validateRpmPackageNvrBlur(raw: string): string | null { const t = raw.trim(); if (t === "") { return null; } + if (isCveIdAsPackageNvr(t)) { + return RPM_PACKAGE_NVR_CVE_ID_ERROR_MESSAGE; + } return parseTrimmedRpmNvr(t) ? null : RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE; } diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/NewRpmReportRestTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/NewRpmReportRestTest.java index 77d32d01..7297d0fe 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/NewRpmReportRestTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/NewRpmReportRestTest.java @@ -149,6 +149,27 @@ void multipleFieldErrorsAggregated() { equalTo("Invalid CVE ID: bad. Must match the official CVE pattern CVE-YYYY-NNNN+")); } + @Test + void cveIdAsPackageNameVersionReleaseReturns400() { + Map body = Map.of( + "name", "CVE", + "version", "2016", + "release", "8687", + "arch", "x86_64", + "cveId", VALID_CVE); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.name", equalTo( + "A CVE ID was entered in the Package N-V-R field. Enter the package as name-version-release " + + "and put the CVE ID in the CVE ID field.")); + } + @Test void i686ArchitectureIsAccepted() { Map body = new HashMap<>(baseValidBody()); diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutCognitoTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutCognitoTest.java new file mode 100644 index 00000000..f82c6958 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutCognitoTest.java @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anyOf; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.restassured.RestAssured; + +import java.util.Map; + +/** + * Tests for {@link TokenResource} logout endpoint with AWS Cognito configuration. + * Verifies that Cognito logout redirects to the correct URL with required parameters. + */ +@QuarkusTest +@TestProfile(TokenResourceLogoutCognitoTest.CognitoProfile.class) +class TokenResourceLogoutCognitoTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithCognitoRedirectsToCognitoLogoutEndpoint() { + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .header("Location", containsString("https://test-domain.auth.us-east-1.amazoncognito.com/logout")) + .header("Location", containsString("client_id=test-client-id")) + .header("Location", containsString("logout_uri=")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + @Test + void logoutWithCognitoEncodesLogoutUriParameter() { + String location = RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .extract() + .header("Location"); + + // Verify logout_uri is URL-encoded (contains %3A for : and %2F for /) + assertThat(location, containsString("logout_uri=http%3A%2F%2F")); + } + + @Test + void logoutWithCognitoRedirectsToLoggedOutPage() { + String location = RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .extract() + .header("Location"); + + // Verify the logout_uri parameter points to /api/v1/user/logged-out + assertThat(location, anyOf( + containsString("user%2Flogged-out"), + containsString("user/logged-out"))); + } + + @Test + void logoutWithCognitoHandlesTrailingSlashInDomain() { + // This test uses a profile with trailing slash in cognito.domain + // The implementation should strip it + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .header("Location", containsString("amazoncognito.com/logout")) + .header("Location", containsString("/logout?")); // Verify no double slash before query params + } + + @Test + void loggedOutPageReturnsSuccessHtml() { + RestAssured.given() + .when() + .get("/api/v1/user/logged-out") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .body(containsString("ExploitIQ")) + .body(containsString("Login Again")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + public static class CognitoProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.auth-server-url", "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_TestPool", + "quarkus.oidc.client-id", "test-client-id", + "cognito.domain", "https://test-domain.auth.us-east-1.amazoncognito.com/" + ); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutErrorHandlingTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutErrorHandlingTest.java new file mode 100644 index 00000000..336903c0 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutErrorHandlingTest.java @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.restassured.RestAssured; + +import java.util.Map; + +/** + * Test suite for {@link TokenResource} logout endpoint error handling. + * Each nested test class has its own test profile to test different error scenarios. + * + * NOTE: The scenario "cognito.domain present but client-id missing" cannot be tested here + * because quarkus.oidc.client-id has a default value in application.properties and Quarkus + * requires it to be non-empty when OIDC is configured. The code handles this case with + * {@code if (cognitoDomain.isPresent() && clientId.isPresent())} guard. + */ +class TokenResourceLogoutErrorHandlingTest { + + /** + * Test profile for empty domain scenario + */ + @QuarkusTest + @TestProfile(EmptyDomainProfile.class) + static class EmptyDomainTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithWhitespaceOnlyDomainFallsBackToLocalLogout() { + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + } + + public static class EmptyDomainProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.client-id", "test-client-id", + "cognito.domain", " " // Whitespace only + ); + } + } + + /** + * Test profile for malformed URL scenario + */ + @QuarkusTest + @TestProfile(MalformedDomainProfile.class) + static class MalformedDomainTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithInvalidUrlFallsBackToLocalLogout() { + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + } + + public static class MalformedDomainProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.client-id", "test-client-id", + "cognito.domain", "not-a-valid-url" // Invalid URL + ); + } + } + + /** + * Test profile for domain with special characters + */ + @QuarkusTest + @TestProfile(SpecialCharsDomainProfile.class) + static class SpecialCharsDomainTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithSpecialCharactersInDomainHandledCorrectly() { + // This should either work or fall back gracefully + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + // Accept either redirect (303) or local logout (200) + .statusCode(org.hamcrest.Matchers.anyOf( + org.hamcrest.Matchers.equalTo(200), + org.hamcrest.Matchers.equalTo(303))) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + } + + public static class SpecialCharsDomainProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.client-id", "test-client-id", + "cognito.domain", "https://test-domain.auth.us-east-1.amazoncognito.com" + ); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutMissingConfigTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutMissingConfigTest.java new file mode 100644 index 00000000..86f23d0c --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutMissingConfigTest.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.restassured.RestAssured; + +import java.util.Map; + +/** + * Tests for {@link TokenResource} logout endpoint with missing or minimal OIDC configuration. + * Verifies fallback behavior when configuration is incomplete. + */ +@QuarkusTest +@TestProfile(TokenResourceLogoutMissingConfigTest.MinimalConfigProfile.class) +class TokenResourceLogoutMissingConfigTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithoutAnyOidcConfigReturnsLocalLogout() { + RestAssured.given() + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + @Test + void loggedOutPageWorksWithoutOidcConfig() { + RestAssured.given() + .when() + .get("/api/v1/user/logged-out") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + public static class MinimalConfigProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false" + // No auth-server-url, client-id, or cognito.domain + ); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutNonCognitoTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutNonCognitoTest.java new file mode 100644 index 00000000..a307f7f7 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutNonCognitoTest.java @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.restassured.RestAssured; + +import java.util.Map; + +/** + * Tests for {@link TokenResource} logout endpoint with non-Cognito OIDC provider. + * Verifies that non-Cognito logout performs local logout without redirect. + */ +@QuarkusTest +@TestProfile(TokenResourceLogoutNonCognitoTest.NonCognitoProfile.class) +class TokenResourceLogoutNonCognitoTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutWithoutCognitoReturnsLocalLogoutHtml() { + RestAssured.given() + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .body(containsString("ExploitIQ")) + .body(containsString("Login Again")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + @Test + void logoutWithoutCognitoDoesNotRedirect() { + RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(200); + } + + @Test + void loggedOutPageReturnsSuccessHtml() { + RestAssured.given() + .when() + .get("/api/v1/user/logged-out") + .then() + .statusCode(200) + .contentType("text/html") + .body(containsString("Successfully Logged Out")) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")); + } + + public static class NonCognitoProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.auth-server-url", "https://keycloak.example.com/realms/test", + "quarkus.oidc.client-id", "test-client-id" + // cognito.domain is NOT set + ); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutPathConstructionTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutPathConstructionTest.java new file mode 100644 index 00000000..247d7b91 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/rest/TokenResourceLogoutPathConstructionTest.java @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.exploitiq.rest; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.restassured.RestAssured; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * Tests for {@link TokenResource} logout URL path construction. + * Verifies that logout redirect URIs are correctly built regardless of baseUri format. + */ +@QuarkusTest +@TestProfile(TokenResourceLogoutPathConstructionTest.PathConstructionProfile.class) +class TokenResourceLogoutPathConstructionTest { + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void logoutRedirectUriIsCorrectlyConstructed() { + String location = RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .header("Clear-Site-Data", equalTo("\"cookies\", \"storage\"")) + .extract() + .header("Location"); + + // Verify the location contains the Cognito logout endpoint + assertTrue(location.contains("https://test-domain.auth.us-east-1.amazoncognito.com/logout"), + "Location should contain Cognito logout URL"); + + // Verify client_id parameter is present + assertTrue(location.contains("client_id=test-client-id"), + "Location should contain client_id parameter"); + + // Verify logout_uri parameter is present and encoded + assertTrue(location.contains("logout_uri="), + "Location should contain logout_uri parameter"); + + // Decode the logout_uri to verify it's correctly formed + int logoutUriStart = location.indexOf("logout_uri=") + "logout_uri=".length(); + String encodedUri = location.substring(logoutUriStart); + String decodedUri = URLDecoder.decode(encodedUri, StandardCharsets.UTF_8); + + // Should contain /api/v1/user/logged-out (not /api/v1user/logged-out or double slashes) + assertTrue(decodedUri.contains("/api/v1/user/logged-out"), + "Decoded logout_uri should be '/api/v1/user/logged-out' but was: " + decodedUri); + + // Should NOT contain double slashes (except in https://) + String pathPart = decodedUri.substring(decodedUri.indexOf("://") + 3); + assertTrue(!pathPart.contains("//"), + "Path should not contain double slashes: " + pathPart); + } + + @Test + void logoutRedirectUriHandlesTrailingSlashInBaseUri() { + // Even if baseUri has trailing slash, the constructed URI should be valid + String location = RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .extract() + .header("Location"); + + int logoutUriStart = location.indexOf("logout_uri=") + "logout_uri=".length(); + String encodedUri = location.substring(logoutUriStart); + String decodedUri = URLDecoder.decode(encodedUri, StandardCharsets.UTF_8); + + // Verify correct path format + assertTrue(decodedUri.matches("https?://[^/]+/api/v1/user/logged-out"), + "Logout URI should match expected format: " + decodedUri); + } + + @Test + void cognitoDomainTrailingSlashIsStripped() { + String location = RestAssured.given() + .redirects().follow(false) + .when() + .post("/api/v1/user/logout") + .then() + .statusCode(303) + .extract() + .header("Location"); + + // Should contain /logout? (not //logout?) + assertTrue(location.contains(".com/logout?"), + "Cognito domain trailing slash should be stripped: " + location); + + assertTrue(!location.contains(".com//logout"), + "Should not have double slash before /logout: " + location); + } + + public static class PathConstructionProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.oidc.enabled", "false", + "quarkus.oidc.client-id", "test-client-id", + "cognito.domain", "https://test-domain.auth.us-east-1.amazoncognito.com/" // With trailing slash + ); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMappingsUnconfiguredTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMappingsUnconfiguredTest.java new file mode 100644 index 00000000..e3483d45 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMappingsUnconfiguredTest.java @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.security; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.TestConfigProperty; + +/** + * Regression coverage for the real deployment/dev-mode default: when + * {@code exploitiq.security.oidc.scope-role-mappings} is entirely unset (as it + * is out of the box, unlike {@link RoleMappingAugmentorTest} which overrides + * it via {@code @TestConfigProperty}), the bean must still start up and + * augment identities normally rather than failing config validation. + * + *

This is a dedicated top-level test (rather than a case inside + * {@link RoleMappingAugmentorTest}) because {@code @QuarkusComponentTest} + * builds one component container per test class from that class's + * {@code @TestConfigProperty} overrides. + */ +@QuarkusComponentTest +@TestConfigProperty(key = "exploitiq.security.service-account-roles", value = "system:serviceaccount:exploit-iq:exploit-iq-sa,exploitiq-api-access") +class RoleMappingAugmentorScopeMappingsUnconfiguredTest { + + @Inject + RoleMappingAugmentor augmentor; + + @Test + void scopeClaimPresentButNoMappingsConfigured_doesNotFailStartupAndGrantsNoRole() { + JsonWebToken jwt = new RoleMappingAugmentorTest.FakeJsonWebToken( + Map.of("scope", "exploitiq-resource-server/exploitiq-api-access")); + SecurityIdentity identity = QuarkusSecurityIdentity.builder().setPrincipal(jwt).build(); + + SecurityIdentity result = augmentor.augment(identity, null).await().indefinitely(); + + assertTrue(result.getRoles().isEmpty()); + } + + @Test + void otherProviderPathsStillWorkWhenScopeMappingsUnconfigured() { + JsonWebToken jwt = new RoleMappingAugmentorTest.FakeJsonWebToken(Map.of("groups", List.of("exploit-iq-admin"))); + SecurityIdentity identity = QuarkusSecurityIdentity.builder().setPrincipal(jwt).build(); + + SecurityIdentity result = augmentor.augment(identity, null).await().indefinitely(); + + assertTrue(result.getRoles().contains("exploit-iq-admin")); + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMapsToUnknownRoleTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMapsToUnknownRoleTest.java new file mode 100644 index 00000000..9852b3c3 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorScopeMapsToUnknownRoleTest.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.TestConfigProperty; + +/** + * Scope maps to a role that is not in {@code roles-allowed}: must not be granted + * (and the augmentor warns — see {@link RoleMappingAugmentor}). + */ +@QuarkusComponentTest +@TestConfigProperty(key = "exploitiq.security.oidc.scope-role-mappings", value = "exploitiq-resource-server/exploitiq-api-access=not-a-configured-role") +@TestConfigProperty(key = "exploitiq.security.service-account-roles", value = "system:serviceaccount:exploit-iq:exploit-iq-sa,exploitiq-api-access") +class RoleMappingAugmentorScopeMapsToUnknownRoleTest { + + @Inject + RoleMappingAugmentor augmentor; + + @Test + void m2mScopeMappingToRoleOutsideTargetRoles_isNotGranted() { + JsonWebToken jwt = new RoleMappingAugmentorTest.FakeJsonWebToken( + Map.of("scope", "exploitiq-resource-server/exploitiq-api-access")); + SecurityIdentity identity = QuarkusSecurityIdentity.builder().setPrincipal(jwt).build(); + + SecurityIdentity result = augmentor.augment(identity, null).await().indefinitely(); + + assertFalse(result.getRoles().contains("not-a-configured-role")); + assertTrue(result.getRoles().isEmpty()); + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorTest.java new file mode 100644 index 00000000..2194161d --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/security/RoleMappingAugmentorTest.java @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; + +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.TestConfigProperty; + +/** + * Unit tests for {@link RoleMappingAugmentor}, covering AWS Cognito role + * mapping (browser login via 'cognito:groups' and M2M via the 'scope' claim) + * and non-regression of the existing OpenShift/Keycloak claim paths. + */ +@QuarkusComponentTest +@TestConfigProperty(key = "exploitiq.security.oidc.scope-role-mappings", value = "exploitiq-resource-server/exploitiq-api-access=exploitiq-api-access") +@TestConfigProperty(key = "exploitiq.security.service-account-roles", value = "system:serviceaccount:exploit-iq:exploit-iq-sa,exploitiq-api-access") +class RoleMappingAugmentorTest { + + @Inject + RoleMappingAugmentor augmentor; + + @Test + void cognitoGroupMatchingTargetRole_isGranted() { + SecurityIdentity result = augment(Map.of("cognito:groups", List.of("exploit-iq-admin"))); + assertTrue(result.getRoles().contains("exploit-iq-admin")); + } + + @Test + void cognitoGroupNotMatchingTargetRole_isNotGranted() { + SecurityIdentity result = augment(Map.of("cognito:groups", List.of("not-a-configured-role"))); + assertTrue(result.getRoles().isEmpty()); + } + + @Test + void missingCognitoGroupsClaim_doesNotAffectOpenShiftGroupsMapping() { + SecurityIdentity result = augment(Map.of("groups", List.of("exploit-iq-view"))); + assertTrue(result.getRoles().contains("exploit-iq-view")); + } + + @Test + void keycloakRealmRoles_stillMapped() { + JsonObject realmAccess = Json.createObjectBuilder() + .add("roles", Json.createArrayBuilder().add("exploit-iq-prodsec").build()) + .build(); + SecurityIdentity result = augment(Map.of("realm_access", realmAccess)); + assertTrue(result.getRoles().contains("exploit-iq-prodsec")); + } + + @Test + void keycloakClientRoles_stillMapped() { + JsonObject clientAccess = Json.createObjectBuilder() + .add("roles", Json.createArrayBuilder().add("exploit-iq-admin").build()) + .build(); + JsonObject resourceAccess = Json.createObjectBuilder() + .add("exploit-iq-client", clientAccess) + .build(); + SecurityIdentity result = augment(Map.of("resource_access", resourceAccess)); + assertTrue(result.getRoles().contains("exploit-iq-admin")); + } + + @Test + void kubernetesServiceAccount_stillMappedWhenNoGroupsClaim() { + JsonObject serviceAccount = Json.createObjectBuilder().add("name", "exploit-iq-sa").build(); + JsonObject kubernetesIo = Json.createObjectBuilder().add("serviceaccount", serviceAccount).build(); + SecurityIdentity result = augment(Map.of( + "kubernetes.io", kubernetesIo, + "sub", "system:serviceaccount:exploit-iq:exploit-iq-sa")); + assertTrue(result.getRoles().contains("system:serviceaccount:exploit-iq:exploit-iq-sa")); + } + + @Test + void m2mScopeMatchingConfiguredMapping_isGrantedMappedRole() { + SecurityIdentity result = augment(Map.of("scope", "exploitiq-resource-server/exploitiq-api-access")); + assertTrue(result.getRoles().contains("exploitiq-api-access")); + } + + @Test + void m2mScopeWithMultipleValuesMatchingConfiguredMapping_isGrantedMappedRole() { + SecurityIdentity result = augment(Map.of("scope", "openid exploitiq-resource-server/exploitiq-api-access")); + assertTrue(result.getRoles().contains("exploitiq-api-access")); + } + + @Test + void m2mScopeNotMatchingConfiguredMapping_isNotGranted() { + SecurityIdentity result = augment(Map.of("scope", "some-other-resource-server/some-other-scope")); + assertFalse(result.getRoles().contains("exploitiq-api-access")); + } + + private SecurityIdentity augment(Map claims) { + JsonWebToken jwt = new FakeJsonWebToken(claims); + SecurityIdentity identity = QuarkusSecurityIdentity.builder().setPrincipal(jwt).build(); + return augmentor.augment(identity, null).await().indefinitely(); + } + + /** Minimal {@link JsonWebToken} test double backed by a claim map. */ + static class FakeJsonWebToken implements JsonWebToken { + + private final Map claims; + + FakeJsonWebToken(Map claims) { + this.claims = claims; + } + + @Override + public String getName() { + return "test-principal"; + } + + @Override + public Set getClaimNames() { + return claims.keySet(); + } + + @Override + @SuppressWarnings("unchecked") + public T getClaim(String claimName) { + return (T) claims.get(claimName); + } + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/ReportServiceQueueAdmissionTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/ReportServiceQueueAdmissionTest.java index 0a79215a..e906c240 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/ReportServiceQueueAdmissionTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/ReportServiceQueueAdmissionTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; @@ -100,7 +101,7 @@ void saveAndSubmitNewQueueFullDoesNotPersist() throws Exception { ReportData unsaved = new ReportData(new ReportRequestId(null, "scan-x"), report); doThrow(new RequestQueueExceededException(2)) - .when(queueService).runIfHasCapacity(any(), any()); + .when(queueService).runIfHasCapacity(any(), nullable(String.class), any()); assertThrows(RequestQueueExceededException.class, () -> reportService.saveAndSubmitNew(unsaved)); diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueServiceTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueServiceTest.java index 89107272..49c3e2e2 100644 --- a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueServiceTest.java +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/RequestQueueServiceTest.java @@ -216,6 +216,101 @@ void moveToActive_neverExceedsPerUserLimitAfterPromotingFromPending() throws Exc assertThrows(UserQueueExceededException.class, () -> service.queue("alice-4", report, "alice")); } + @Test + void queue_sameProductIdCountsAsSingleUserSlot() throws Exception { + JsonNode productReport = sampleProductReport("product-a"); + + assertDoesNotThrow(() -> service.queue("p-a-1", productReport, "alice")); + assertDoesNotThrow(() -> service.queue("p-a-2", productReport, "alice")); + assertDoesNotThrow(() -> service.queue("p-a-3", productReport, "alice")); + + // One product slot used; alice can still admit one more distinct slot (standalone or product). + assertDoesNotThrow(() -> service.queue("standalone-1", sampleReport(), "alice")); + assertThrows(UserQueueExceededException.class, () -> service.queue("standalone-2", sampleReport(), "alice")); + } + + @Test + void queue_secondDistinctProductIdConsumesAnotherSlot() throws Exception { + JsonNode productA = sampleProductReport("product-a"); + JsonNode productB = sampleProductReport("product-b"); + + assertDoesNotThrow(() -> service.queue("p-a-1", productA, "alice")); + assertDoesNotThrow(() -> service.queue("p-a-2", productA, "alice")); + assertDoesNotThrow(() -> service.queue("p-b-1", productB, "alice")); + + assertThrows( + UserQueueExceededException.class, + () -> service.queue("p-c-1", sampleProductReport("product-c"), "alice")); + } + + @Test + void queue_mixesStandaloneAndProductSlotsTowardSameLimit() throws Exception { + assertDoesNotThrow(() -> service.queue("standalone-1", sampleReport(), "alice")); + assertDoesNotThrow(() -> service.queue("p-a-1", sampleProductReport("product-a"), "alice")); + assertDoesNotThrow(() -> service.queue("p-a-2", sampleProductReport("product-a"), "alice")); + + assertThrows(UserQueueExceededException.class, () -> service.queue("standalone-2", sampleReport(), "alice")); + assertThrows( + UserQueueExceededException.class, + () -> service.queue("p-b-1", sampleProductReport("product-b"), "alice")); + } + + @Test + void queue_freesProductSlotWhenLastInFlightReportIsReceived() throws Exception { + JsonNode productA = sampleProductReport("product-a"); + + service.queue("p-a-1", productA, "alice"); + service.queue("p-a-2", productA, "alice"); + service.queue("standalone-1", sampleReport(), "alice"); + assertThrows(UserQueueExceededException.class, () -> service.queue("standalone-2", sampleReport(), "alice")); + + service.received("p-a-1"); + // Product still in flight via p-a-2; still at limit. + assertThrows(UserQueueExceededException.class, () -> service.queue("standalone-2", sampleReport(), "alice")); + + service.received("p-a-2"); + // Product slot freed; alice can queue again. + assertDoesNotThrow(() -> service.queue("standalone-2", sampleReport(), "alice")); + } + + @Test + void queue_productReportsInPendingStillCountOnceTowardUserLimit() throws Exception { + JsonNode standalone = sampleReport(); + JsonNode productA = sampleProductReport("product-a"); + + int fillerUsers = MAX_ACTIVE / MAX_ACTIVE_PER_USER; + for (int u = 0; u < fillerUsers; u++) { + for (int i = 0; i < MAX_ACTIVE_PER_USER; i++) { + service.queue("filler-" + u + "-" + i, standalone, "filler-" + u); + } + } + + assertDoesNotThrow(() -> service.queue("alice-p-1", productA, "alice")); + assertDoesNotThrow(() -> service.queue("alice-p-2", productA, "alice")); + assertDoesNotThrow(() -> service.queue("alice-standalone", standalone, "alice")); + + assertThrows( + UserQueueExceededException.class, + () -> service.queue("alice-extra", sampleProductReport("product-b"), "alice")); + } + + @Test + void runIfHasCapacity_allowsSameProductWhenAlreadyInFlight() throws Exception { + JsonNode productA = sampleProductReport("product-a"); + service.queue("p-a-1", productA, "alice"); + service.queue("standalone-1", sampleReport(), "alice"); + + assertDoesNotThrow(() -> + service.runIfHasCapacity("alice", "product-a", () -> "ok")); + + assertThrows( + UserQueueExceededException.class, + () -> service.runIfHasCapacity("alice", "product-b", () -> "nope")); + assertThrows( + UserQueueExceededException.class, + () -> service.runIfHasCapacity("alice", null, () -> "nope")); + } + private JsonNode sampleReport() throws Exception { return objectMapper.readTree(""" { @@ -225,4 +320,17 @@ private JsonNode sampleReport() throws Exception { } """); } + + private JsonNode sampleProductReport(String productId) throws Exception { + return objectMapper.readTree(""" + { + "input": { + "scan": { "id": "scan-1" } + }, + "metadata": { + "product_id": "%s" + } + } + """.formatted(productId)); + } } diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportServiceQueueAdmissionTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportServiceQueueAdmissionTest.java new file mode 100644 index 00000000..da53834c --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/service/SbomReportServiceQueueAdmissionTest.java @@ -0,0 +1,203 @@ +package com.redhat.ecosystemappeng.exploitiq.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.redhat.ecosystemappeng.exploitiq.repository.ProductRepositoryService; +import io.quarkus.test.InjectMock; +import io.quarkus.test.component.QuarkusComponentTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests that SPDX upload checks queue capacity before creating product, + * matching the pattern used by RPM and CycloneDX flows. + */ +@QuarkusComponentTest +class SbomReportServiceQueueAdmissionTest { + + @Inject + SbomReportService sbomReportService; + + @InjectMock + RequestQueueService queueService; + @InjectMock + UserService userService; + @InjectMock + ProductRepositoryService productRepository; + @InjectMock + SpdxParsingService spdxParsingService; + @InjectMock + ComponentProcessingService componentProcessingService; + @InjectMock + CycloneDxParsingService cycloneDxParsingService; + @InjectMock + ReportService reportService; + @InjectMock + CredentialProcessingService credentialProcessingService; + @InjectMock + ObjectMapper objectMapper; + + /** + * Test happy path: when capacity is available, the lambda executes, + * product is created, and component processing starts. + */ + @Test + void submitSpdx_HappyPath_CreatesProductAndProcessesComponents() throws Exception { + String spdxJson = """ + { + "spdxVersion": "SPDX-2.3", + "name": "happy-product", + "versionInfo": "3.0.0", + "documentNamespace": "https://example.com/happy", + "packages": [] + } + """; + + InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); + + SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( + "happy-product", "3.0.0", null, null + ); + SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( + productInfo, + java.util.List.of(), + java.util.List.of() + ); + when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); + when(userService.getUserName()).thenReturn("charlie"); + + // Mock queueService to execute the lambda when capacity is available + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + java.util.function.Supplier action = invocation.getArgument(2); + return action.get(); // Execute the lambda + }).when(queueService).runIfHasCapacity(eq("charlie"), anyString(), any()); + + // Execute + String result = sbomReportService.submitSpdx(spdxStream, "CVE-2024-9999", null); + + // Verify: Product created + verify(productRepository).save(any(), eq("charlie")); + + // Verify: Component processing started + verify(componentProcessingService).processComponents(any(), anyString(), any(), eq("CVE-2024-9999"), eq(null)); + + // Verify: Returns product ID (non-null) + assertEquals(true, result != null && !result.isEmpty()); + } + + /** + * Test that SPDX upload throws UserQueueExceededException when user limit is exceeded, + * and does NOT create a product document. This matches the behavior of RPM analysis. + */ + @Test + void submitSpdx_UserQueueExceeded_DoesNotCreateProduct() throws Exception { + // Prepare minimal valid SPDX JSON + String spdxJson = """ + { + "spdxVersion": "SPDX-2.3", + "name": "test-product", + "documentNamespace": "https://example.com/test", + "packages": [ + { + "name": "test-component", + "versionInfo": "1.0.0", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:oci/test@sha256:abcd1234" + } + ] + } + ] + } + """; + + InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); + + // Mock parsing to return valid parsed data + SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( + "test-product", "1.0.0", null, null + ); + SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( + productInfo, + java.util.List.of(), // components + java.util.List.of() // unsupported components + ); + when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); + when(userService.getUserName()).thenReturn("alice"); + + // Mock queueService to throw UserQueueExceededException (user limit exceeded) + doThrow(new UserQueueExceededException(5)) + .when(queueService).runIfHasCapacity(eq("alice"), anyString(), any()); + + // Assert that exception is thrown + assertThrows(UserQueueExceededException.class, + () -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-1234", null)); + + // Verify: No product created (save never called on productRepository) + verify(productRepository, never()).save(any(), any()); + + // Verify: No component processing started + verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any()); + } + + /** + * Test that SPDX upload throws RequestQueueExceededException when global queue is full, + * and does NOT create a product document. + */ + @Test + void submitSpdx_GlobalQueueExceeded_DoesNotCreateProduct() throws Exception { + String spdxJson = """ + { + "spdxVersion": "SPDX-2.3", + "name": "test-product", + "versionInfo": "2.0.0", + "documentNamespace": "https://example.com/test2", + "packages": [] + } + """; + + InputStream spdxStream = new ByteArrayInputStream(spdxJson.getBytes(StandardCharsets.UTF_8)); + + SpdxParsingService.ProductInfo productInfo = new SpdxParsingService.ProductInfo( + "test-product", "2.0.0", null, null + ); + SpdxParsingService.ParsedSpdx parsedSpdx = new SpdxParsingService.ParsedSpdx( + productInfo, + java.util.List.of(), + java.util.List.of() + ); + when(spdxParsingService.parse(any())).thenReturn(parsedSpdx); + when(userService.getUserName()).thenReturn("bob"); + + // Mock queueService to throw RequestQueueExceededException (global queue full) + doThrow(new RequestQueueExceededException(500)) + .when(queueService).runIfHasCapacity(eq("bob"), anyString(), any()); + + // Assert that exception is thrown + assertThrows(RequestQueueExceededException.class, + () -> sbomReportService.submitSpdx(spdxStream, "CVE-2024-5678", null)); + + // Verify: No product created + verify(productRepository, never()).save(any(), any()); + + // Verify: No component processing started + verify(componentProcessingService, never()).processComponents(any(), any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidatorTest.java b/src/test/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidatorTest.java new file mode 100644 index 00000000..658c779e --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/exploitiq/validation/NotCveIdAsRpmNvrValidatorTest.java @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.exploitiq.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.redhat.ecosystemappeng.exploitiq.model.NewRpmReportRequest; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class NotCveIdAsRpmNvrValidatorTest { + + private static Validator validator; + + @BeforeAll + static void setUpValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void rejectsCoordinatesThatReconstructToOfficialCveId() { + NewRpmReportRequest request = new NewRpmReportRequest("CVE", "2016", "8687", "x86_64", "CVE-2016-8687"); + + Set> violations = validator.validate(request); + + assertEquals(1, violations.size()); + ConstraintViolation violation = violations.iterator().next(); + assertEquals("name", violation.getPropertyPath().toString()); + assertEquals(NotCveIdAsRpmNvrValidator.MESSAGE, violation.getMessage()); + } + + @Test + void rejectsLowercaseCvePastedAsNvr() { + NewRpmReportRequest request = new NewRpmReportRequest("cve", "2024", "12345", "x86_64", "CVE-2024-12345"); + + Set> violations = validator.validate(request); + + assertFalse(violations.isEmpty()); + } + + @Test + void acceptsNormalRpmCoordinates() { + NewRpmReportRequest request = + new NewRpmReportRequest("libarchive", "3.1.2", "14.el7_9.1", "x86_64", "CVE-2016-8687"); + + assertTrue(validator.validate(request).isEmpty()); + } + + @Test + void skipsWhenCoordinatesIncomplete() { + NewRpmReportRequest request = new NewRpmReportRequest("CVE", "2016", " ", "x86_64", "CVE-2016-8687"); + + assertTrue(validator.validate(request).isEmpty()); + } +}