Compare commits

...
Author SHA1 Message Date
renovate[bot] 3857f36dee chore(sdk): update dependency openapi-spec-validator to v0.9.0 2026-10-03 23:20:14 +00:00
Alejandro Bailo 383a9bf903 fix(ui): bump Next.js to 16.3.6 to patch the next/og RCE advisory (#12922) 2026-10-01 11:54:44 +02:00
Alejandro Bailo 4605d9a770 feat(ui): invite a teammate from the AWS connect step (#12917) 2026-10-01 09:50:10 +02:00
César Arroba f0da33f451 revert(api): release providers blocked by scans whose worker died (#12915) 2026-09-30 13:00:50 +02:00
Alejandro Bailo a44a725507 fix(ui): retry the first-run redirect until the add-provider wizard opens (#12914) 2026-09-30 12:34:52 +02:00
César Arroba b8ca30400b fix(api): stop sending personal data to Sentry (#12912) 2026-09-30 12:28:42 +02:00
César Arroba a006525e78 fix(api): release providers blocked by scans whose worker died (#12899) 2026-09-30 11:09:21 +02:00
Pedro Martín ed510e217d chore(deps): bump pyjwt to 2.14.0 for osv-scanner (#12911) 2026-09-30 10:21:11 +02:00
Alejandro Bailo 04511f339e test(ui): stabilize attack-paths refit integration test (#12896) 2026-09-29 18:42:19 +02:00
Pedro Martín f418b32c81 fix(oci): use home region for identity bootstrap (#12865) 2026-09-29 17:50:54 +02:00
Pedro Martín 65fb146e76 chore(trivy): suppress fast-uri CVE-2026-84292 (#12907) 2026-09-29 17:04:46 +02:00
Prowler Botandprowler-bot 5ea363d582 chore(release): Bump versions to v5.45.0 (#12905)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 16:31:16 +02:00
Prowler Botandprowler-bot 3ec379a75a chore(changelog): v5.44.0 (#12900)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-09-29 13:32:41 +02:00
Pedro Martín ea020ed46e chore(changelog): v5.44.0 highlights (#12897) 2026-09-29 13:32:13 +02:00
114 changed files with 2328 additions and 375 deletions
+1 -1
View File
@@ -174,7 +174,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.44.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
@@ -76,7 +76,7 @@ jobs:
### Changes
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
+5 -1
View File
@@ -68,7 +68,7 @@ vulnerabilities:
expired_at: 2026-11-30
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# SBOM and reports what it declares, which is not the same as what the image contains:
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
@@ -129,6 +129,10 @@ vulnerabilities:
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-84292
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
+27
View File
@@ -4,6 +4,33 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.45.0] (Prowler v5.44.0)
### 🚀 Added
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
### 🔄 Changed
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
### 🐞 Fixed
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
### 🔐 Security
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
---
## [1.44.0] (Prowler v5.43.0)
### 🐞 Fixed
@@ -1 +0,0 @@
API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row
@@ -1 +0,0 @@
Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded
@@ -1 +0,0 @@
Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup
@@ -1 +0,0 @@
Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider
@@ -1 +0,0 @@
Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans
@@ -0,0 +1 @@
OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1
@@ -1 +0,0 @@
Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j
+1
View File
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
@@ -1 +0,0 @@
Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls
@@ -1 +0,0 @@
Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region
@@ -1 +0,0 @@
`POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit
@@ -0,0 +1 @@
Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials
@@ -1 +0,0 @@
`DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role
@@ -1 +0,0 @@
Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes.
+5 -5
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.45.0"
version = "1.46.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -375,7 +375,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.14.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -476,8 +476,8 @@ constraint-dependencies = [
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.14.0
# patches GHSA-ffc3-869f-jxw9 (HMAC/PEM key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
@@ -500,5 +500,5 @@ override-dependencies = [
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
"pyjwt[crypto]==2.14.0"
]
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.45.0
version: 1.46.0
description: |-
Prowler API specification.
+15
View File
@@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn():
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
def test_initialize_sentry_sends_no_personal_data():
with (
patch.object(
sentry_settings.env,
"str",
return_value="https://fake-public-key@sentry.example.invalid/1",
),
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
):
sentry_settings.initialize_sentry()
assert mock_init.call_args.kwargs["send_default_pii"] is False
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
+21 -23
View File
@@ -215,36 +215,34 @@ class TestOracleCloudProviderSecret:
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
def test_keeps_region_as_home_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
data=self.valid_secret(region=" me-abudhabi-1 ")
)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["region"] == "me-abudhabi-1"
assert legacy_field not in serializer.validated_data
def test_rejects_unknown_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region="mars-north-1")
)
assert not serializer.is_valid()
assert "region" in serializer.errors
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
def test_drops_blank_or_non_string_region(self, legacy_value):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region=legacy_value)
)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_includes_legacy_region_field(self):
def test_oraclecloud_schema_region_is_not_deprecated(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
@@ -253,7 +251,7 @@ class TestProviderSecretFieldSchema:
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
class TestKubernetesProviderSecret:
+34 -8
View File
@@ -172,7 +172,7 @@ class TestInitializeProwlerProvider:
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_removes_region_string(
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
self, mock_return_prowler_provider
):
provider = MagicMock()
@@ -182,7 +182,7 @@ class TestInitializeProwlerProvider:
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
@@ -193,6 +193,7 @@ class TestInitializeProwlerProvider:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
home_region="me-abudhabi-1",
)
@patch("api.utils.return_prowler_provider")
@@ -254,11 +255,35 @@ class TestProwlerProviderConnectionTest:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
region=OraclecloudProvider._bootstrap_region,
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region="me-abudhabi-1",
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@@ -434,7 +459,7 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
self,
):
secret_dict = {
@@ -461,6 +486,7 @@ class TestGetProwlerProviderKwargs:
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
"home_region": "us-ashburn-1",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
+6 -6
View File
@@ -3363,7 +3363,7 @@ current-context: test-context
provider_secret = ProviderSecret.objects.get()
assert "region" not in provider_secret.secret
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_create_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3372,14 +3372,14 @@ current-context: test-context
authenticated_client,
oraclecloud_provider,
self._oraclecloud_secret(
key_content=" test-key-content ", region=" us-ashburn-1 "
key_content=" test-key-content ", region=" me-abudhabi-1 "
),
)
assert response.status_code == status.HTTP_201_CREATED
provider_secret = ProviderSecret.objects.get()
assert provider_secret.secret["key_content"] == "test-key-content"
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
self,
@@ -3412,7 +3412,7 @@ current-context: test-context
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
def test_provider_secrets_update_oraclecloud_stores_region(
self,
authenticated_client,
oraclecloud_provider,
@@ -3430,7 +3430,7 @@ current-context: test-context
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
"secret": self._oraclecloud_secret(region=" me-abudhabi-1 ")
},
}
}
@@ -3443,7 +3443,7 @@ current-context: test-context
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert "region" not in provider_secret.secret
assert provider_secret.secret["region"] == "me-abudhabi-1"
@pytest.mark.parametrize(
"attributes, error_code, error_pointer",
+14 -7
View File
@@ -302,17 +302,26 @@ def get_prowler_provider_kwargs(
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if home_region:
prowler_provider_kwargs["home_region"] = home_region
return prowler_provider_kwargs
def _oraclecloud_home_region(region) -> str | None:
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
if isinstance(region, str) and region.strip():
return region.strip()
return None
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
prowler_provider_kwargs.pop("region", None)
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if (
prowler_provider_kwargs.get("user")
@@ -323,11 +332,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
or prowler_provider_kwargs.get("key_file")
)
):
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
# Identity calls only succeed in a region the tenancy is subscribed to.
prowler_provider_kwargs["region"] = (
home_region or OraclecloudProvider._bootstrap_region
)
return prowler_provider_kwargs
@@ -301,8 +301,7 @@ from rest_framework_json_api import serializers
},
"region": {
"type": "string",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
},
},
"required": ["user", "fingerprint", "tenancy"],
+13 -4
View File
@@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.oraclecloud.config import OCI_REGIONS
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
@@ -1917,9 +1918,16 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class LegacyOCIRegionField(serializers.Field):
class OCIHomeRegionField(serializers.Field):
"""Optional OCI home region; blank or non-string legacy values are dropped."""
def to_internal_value(self, data):
return data
if not isinstance(data, str) or not data.strip():
return None
region = data.strip()
if region not in OCI_REGIONS:
raise serializers.ValidationError(f"Invalid OCI region: {region}")
return region
def to_representation(self, value):
return value
@@ -1932,10 +1940,11 @@ class OracleCloudProviderSecret(serializers.Serializer):
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = LegacyOCIRegionField(required=False, allow_null=True)
region = OCIHomeRegionField(required=False, allow_null=True)
def validate(self, attrs):
attrs.pop("region", None)
if not attrs.get("region"):
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
+3 -3
View File
@@ -193,10 +193,10 @@ def initialize_sentry():
sentry_sdk.init(
dsn=sentry_dsn,
# Add data like request headers and IP for users,
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
before_send=before_send,
send_default_pii=True,
# No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details.
send_default_pii=False,
max_request_body_size="never",
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
_experiments={
# Set continuous_profiling_auto_start to True
Generated
+6 -6
View File
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -387,7 +387,7 @@ overrides = [
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" },
]
[[package]]
@@ -4938,7 +4938,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.45.0"
version = "1.46.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5332,11 +5332,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
+65
View File
@@ -4,6 +4,71 @@ description: "New features and improvements in each Prowler release"
rss: true
---
<Update label="v5.44.0" description="September 29, 2026">
### 🔁 Findings — Re-check a Resource with a Partial Scan
<Note>
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
</Note>
A resource that has just been fixed can be confirmed from the Findings page without waiting for the next full scan. **Re-check resource** is available in the actions menu of every resource row and in the resource detail drawer, and a hint icon next to **Last seen** opens it directly. It launches a partial scan that runs again only the checks that last reported on that resource; its findings update when the scan completes, and every other resource keeps the results of the latest full scan. Roles need the Manage Scans permission, and a re-check is refused while the provider has a scan running or queued.
Re-checked resources that now pass drop out of the finding groups list and its drill-down instead of opening a detail panel that still reports `FAIL`. Partial scans do not change overviews or compliance until the next full scan and produce no report files, so the Scans table marks them as **Partial**, offers no report download for them, and the per-scan Compliance selector leaves them out.
Partial scans can also be launched outside the Findings page:
- **API:** `POST /api/v1/scans` accepts up to 10 resources in `resource_uids`, and scans expose `is_partial` with a `filter[is_partial]` filter.
- **MCP Server:** `prowler_trigger_scan` takes a `resource_uids` argument, and `prowler_list_scans` and `prowler_get_scan` return `is_partial`, with an `is_partial` filter on `prowler_list_scans`.
- **Lighthouse AI:** can launch a partial scan to re-check specific resources, such as confirming a remediation.
### ☁️ AWS — Connect an Account in One Step
The Add Provider wizard connects an AWS account in a single step. The account ID is read from the role ARN (or typed when using static access keys), the role is assumed with Prowler's own credentials, and the account, its credentials and the connection test are handled by one submit. A confirmed connection goes straight to the launch step. A refused connection stays on the form with the reason the API returned, so the fields can be fixed and retried without registering the account twice.
New tenants without providers now land on this wizard on their first sign-in instead of a welcome modal, and the sidebar action reads **Add Provider** until the first provider is connected.
Read more in the [Getting Started with AWS documentation](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws).
### 🔌 Connection Tests No Longer Give Up Early
The provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable. The UI now waits for the full time limit of the backend task, and if that is still exhausted it shows the provider's current connection state instead of a failure.
On the SDK side, STS calls after a role assumption reuse the region that answered, so an unreachable partition region is waited on once instead of twice. The new `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable sets the Boto3 retries for deployments that build the AWS provider without CLI flags, next to the existing timeout variables; `0` disables retries.
Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration#retries-configuration).
### 🗄️ Self-Hosted — S3-Compatible Storage and Air-Gapped Deployments
- `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL` points scan output uploads and downloads at S3-compatible object storage such as MinIO. Previously the only way to reach it was exporting process-wide AWS environment variables, which also hijacked unrelated AWS API calls such as role assumption for AWS providers.
- Report downloads from a bucket with default SSE-KMS encryption no longer fail with `InvalidArgument`: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, download URLs are signed with Signature Version 4 for that region.
- Icons ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly without internet access.
- Celery worker fatal errors are logged instead of silenced, and every long-running service in `docker-compose.yml` restarts automatically after an unexpected crash.
### 📊 Consistent Latest Scan Across Endpoints
Every endpoint now resolves a provider's latest completed scan the same way, so overlapping scans no longer make findings, compliance and mute rules read from different scans. Providers whose latest completed scan has no `completed_at` timestamp are no longer missing from those endpoints, and resources no longer keep a stale failed findings count when a scoped or imported scan completes after a full scan.
### 🛠️ Prowler App Fixes
- API key authentication no longer locks the key row on every request, so a heavily used key no longer serializes all its requests; `last_used_at` is updated at most once per minute.
- `POST /api/v1/scans` returns the new scan ID in `task_args` again.
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j.
- A periodic sweep drops orphaned Attack Paths temporary Neo4j databases left behind when a worker or Neo4j crashes mid-scan.
- **Prowler Cloud:** imported findings no longer stay stuck in `pending` when the ingestion worker picks up the job before it is committed, and a failed enqueue marks the ingestion as failed.
- **Prowler Cloud:** the Lighthouse AI connection check reports a network failure as one, naming the endpoint it could not reach, instead of hitting a time limit that looked the same as a bad key.
- **Prowler Cloud:** the finding groups endpoints no longer query Manual Pass triages once per finding, and skip that overlay for tenants with no active Manual Pass.
- The Findings page renders a skeleton at once and streams the table before the filters, and the **Finding Group** options load when the dropdown opens.
- Mute rule creation errors show the API error message instead of the raw response body.
- The sidebar no longer throws a hydration error on full page loads for users who last used the chat mode.
### 🔐 Security Updates
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the role's visibility.
- The UI E2E workflow receives its AWS credentials through environment variables instead of template expansion.
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.44.0) for the complete list of changes.
</Update>
<Update label="v5.43.0" description="September 21, 2026">
### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.43.0"
PROWLER_API_VERSION="5.43.0"
PROWLER_UI_VERSION="5.44.0"
PROWLER_API_VERSION="5.44.0"
```
<Note>
@@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server.
1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID.
2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**.
3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint.
4. Note the **Region** identifier to scan (for example, `us-ashburn-1`).
4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works.
### Step 2: Access Prowler Cloud
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app).
@@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide:
- **User OCID** for the API key owner
- **Fingerprint** of the API key
- **Region** (for example, `us-ashburn-1`)
- **Home Region**: select it from the list (for example, `me-abudhabi-1`)
- **Private Key Content** (paste the full PEM value)
- **Passphrase (Optional)** if the private key is encrypted
Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission.
<Note>
The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated.
</Note>
Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region.
![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png)
---
@@ -334,6 +340,11 @@ prowler oci \
#### Region Issues
**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`**
- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`)
- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region
- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region
**Error: "Invalid region"**
- Check available regions: `prowler oci --list-regions`
- Verify your tenancy is subscribed to the region
@@ -93,6 +93,10 @@ After adding your cloud account credentials, click the `Check connection` button
For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan.
</Note>
<Note>
To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account.
</Note>
## Step 6: Scan Started
After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress:
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
+1
View File
@@ -81,5 +81,6 @@ constraint-dependencies = [
"cryptography==50.0.0",
"joserfc==1.6.8",
"mcp==1.28.1",
"pyjwt==2.14.0",
"python-multipart==0.0.30"
]
+6 -5
View File
@@ -13,6 +13,7 @@ constraints = [
{ name = "cryptography", specifier = "==50.0.0" },
{ name = "joserfc", specifier = "==1.6.8" },
{ name = "mcp", specifier = "==1.28.1" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-multipart", specifier = "==0.0.30" },
]
@@ -977,11 +978,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.13.0"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
@@ -1292,8 +1293,8 @@ name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
{ name = "cryptography", marker = "sys_platform != 'win32'" },
{ name = "jeepney", marker = "sys_platform != 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
+16
View File
@@ -4,6 +4,22 @@ All notable changes to the **Prowler SDK** are documented in this file.
<!-- changelog: release notes start -->
## [5.44.0] (Prowler v5.44.0)
### 🚀 Added
- `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870)
### 🐞 Fixed
- STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870)
### 🔐 Security
- Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion [(#12864)](https://github.com/prowler-cloud/prowler/pull/12864)
---
## [5.43.0] (Prowler v5.43.0)
### 🚀 Added
@@ -1 +0,0 @@
`PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags
@@ -1 +0,0 @@
STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region
@@ -0,0 +1 @@
OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect
@@ -0,0 +1 @@
`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9
@@ -1 +0,0 @@
Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion
+1 -1
View File
@@ -52,7 +52,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.44.0"
prowler_version = "5.45.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -89,6 +89,7 @@ class OraclecloudProvider(Provider):
key_content: str = None,
tenancy: str = None,
pass_phrase: str = None,
home_region: str = None,
):
"""
Initializes the OCI provider.
@@ -110,6 +111,7 @@ class OraclecloudProvider(Provider):
- key_content: Content of the private key (base64 encoded).
- tenancy: The OCID of the tenancy.
- pass_phrase: The passphrase for the private key, if encrypted.
- home_region: Region used to bootstrap identity calls with API key credentials; it does not filter the audited regions.
Raises:
- OCISetUpSessionError: If an error occurs during the setup process.
@@ -140,7 +142,7 @@ class OraclecloudProvider(Provider):
)
has_direct_credentials = user and fingerprint and tenancy
bootstrap_region = single_region or (
self._bootstrap_region if has_direct_credentials else None
(home_region or self._bootstrap_region) if has_direct_credentials else None
)
# Setup OCI Session
+3 -3
View File
@@ -14,7 +14,7 @@ dev = [
"mock==5.2.0",
"moto[all]==5.1.11",
"openapi-schema-validator==0.6.3",
"openapi-spec-validator==0.7.1",
"openapi-spec-validator==0.9.0",
"prek==0.3.9",
"pylint==3.3.4",
"pytest==9.0.3",
@@ -144,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.44.0"
version = "5.45.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -349,7 +349,7 @@ constraint-dependencies = [
"pydash==8.0.6",
"pyflakes==3.2.0",
"pygments==2.20.0",
"pyjwt==2.13.0",
"pyjwt==2.14.0",
"pylint==3.3.4",
"pynacl==1.6.2",
"pyopenssl==26.4.0",
@@ -543,6 +543,58 @@ class TestOraclecloudProviderInit:
assert mock_get_regions_to_audit.call_args_list[0].args == (None,)
assert provider.regions == all_subscribed_regions
def test_init_with_home_region_bootstraps_there_without_scan_filter(self):
mock_session = OCISession(
config={"region": "me-abudhabi-1"}, signer=None, profile=None
)
mock_identity = OCIIdentityInfo(
tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample",
tenancy_name="test-tenancy",
user_id="ocid1.user.oc1..aaaaaaaexample",
region="me-abudhabi-1",
profile=None,
audited_regions=set(),
audited_compartments=[],
)
all_subscribed_regions = [
OCIRegion(key="me-abudhabi-1", name="me-abudhabi-1", is_home_region=True),
OCIRegion(key="me-dubai-1", name="me-dubai-1", is_home_region=False),
]
with (
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session",
return_value=mock_session,
) as mock_setup_session,
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity",
return_value=mock_identity,
),
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit",
return_value=all_subscribed_regions,
) as mock_get_regions_to_audit,
patch(
"prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit",
return_value=["ocid1.compartment.oc1..aaaaaaaexample"],
),
patch("prowler.providers.common.provider.Provider.set_global_provider"),
):
provider = OraclecloudProvider(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
home_region="me-abudhabi-1",
config_content={"dummy": True},
mutelist_content={"Accounts": {}},
)
assert mock_setup_session.call_args.kwargs["region"] == "me-abudhabi-1"
assert mock_get_regions_to_audit.call_args_list[0].args == (None,)
assert provider.regions == all_subscribed_regions
assert provider.home_region == "me-abudhabi-1"
def test_init_with_config_file_auth_without_region_uses_session_config_region_for_identity(
self,
):
@@ -0,0 +1,20 @@
import re
from pathlib import Path
from prowler.providers.oraclecloud.config import OCI_REGIONS
UI_REGIONS_FILE = (
Path(__file__).resolve().parents[3]
/ "ui"
/ "lib"
/ "provider-credentials"
/ "oci-regions.ts"
)
def test_ui_home_region_list_matches_sdk_regions():
ui_regions = set(
re.findall(r'"([a-z]{2,3}-[a-z-]+-\d+)"', UI_REGIONS_FILE.read_text())
)
assert ui_regions == set(OCI_REGIONS)
+21
View File
@@ -4,6 +4,27 @@ All notable changes to the **Prowler UI** are documented in this file.
<!-- changelog: release notes start -->
## [1.44.0] (Prowler v5.44.0)
### 🚀 Added
- Sidebar action reads Add Provider while the tenant has no providers [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
### 🔄 Changed
- AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
- New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852)
- Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens [(#12891)](https://github.com/prowler-cloud/prowler/pull/12891)
### 🐞 Fixed
- Mute rule creation errors show the API error message instead of the raw JSON:API response body [(#12853)](https://github.com/prowler-cloud/prowler/pull/12853)
- Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted [(#12869)](https://github.com/prowler-cloud/prowler/pull/12869)
- Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode [(#12873)](https://github.com/prowler-cloud/prowler/pull/12873)
- Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments [(#12892)](https://github.com/prowler-cloud/prowler/pull/12892)
---
## [1.43.0] (Prowler v5.43.0)
### 🚀 Added
@@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest";
import { BrowserHarness } from "./browser-harness";
const QUIET_MS = 50;
/** Exposes the protected waiting helpers; no fixture or DOM is involved. */
class WaitingHarness extends BrowserHarness<null> {
constructor() {
@@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness<null> {
probeOrNull<T>(fn: () => T | null | undefined | false): Promise<T | null> {
return this.waitForOrNull(fn, 200, "probe");
}
probeStable<T>(read: () => T): Promise<T> {
return this.waitForStable(read, QUIET_MS, 1000, "probe");
}
}
describe("BrowserHarness waiting helpers", () => {
@@ -58,4 +64,20 @@ describe("BrowserHarness waiting helpers", () => {
}),
).resolves.toBe("ready");
});
it("resolves with a value only once it has held for the quiet window", async () => {
const harness = new WaitingHarness();
let reads = 0;
let settledAt = 0;
// Changes on each of the first reads, then holds at 4.
const settled = await harness.probeStable(() => {
reads += 1;
if (reads === 4) settledAt = performance.now();
return Math.min(reads, 4);
});
expect(settled).toBe(4);
expect(performance.now() - settledAt).toBeGreaterThanOrEqual(QUIET_MS);
});
});
+25
View File
@@ -211,6 +211,31 @@ export abstract class BrowserHarness<TFixture> {
}
}
/** Wait until `read` returns the same value for `quietMs`, and return it. */
protected async waitForStable<T>(
read: () => T,
quietMs: number,
timeoutMs = 5000,
label?: string,
): Promise<T> {
let value = read();
let since = performance.now();
const settled = await this.waitFor(
() => {
const next = read();
if (!Object.is(next, value)) {
value = next;
since = performance.now();
return null;
}
return performance.now() - since >= quietMs ? { value } : null;
},
timeoutMs,
label ?? `a value stable for ${quietMs}ms`,
);
return settled.value;
}
protected async waitForText(
pattern: RegExp,
timeoutMs = 5000,
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { toSentInvitation } from "./invitation.adapter";
const created = {
data: {
id: "inv-1",
type: "invitations",
attributes: {
email: "teammate@company.com",
token: "abc123DEF45678",
state: "pending",
expires_at: "2026-10-07T10:00:00Z",
},
},
};
describe("toSentInvitation", () => {
it("reads the id, email and token of a created invitation", () => {
expect(toSentInvitation(created)).toEqual({
id: "inv-1",
email: "teammate@company.com",
token: "abc123DEF45678",
});
});
it("returns null when the action resolved without a value", () => {
// A 5xx makes `sendInvite` resolve undefined.
expect(toSentInvitation(undefined)).toBeNull();
});
it("returns null on a rejection, with or without an errors array", () => {
expect(
toSentInvitation({ errors: [{ detail: "Invalid email" }] }),
).toBeNull();
expect(toSentInvitation({ error: "Something went wrong" })).toBeNull();
});
it("returns null when the record is missing any of the fields the link needs", () => {
expect(
toSentInvitation({
data: { id: "inv-1", attributes: { email: "a@b.com" } },
}),
).toBeNull();
expect(
toSentInvitation({
data: { id: "inv-1", attributes: { token: "abc123DEF45678" } },
}),
).toBeNull();
});
});
@@ -0,0 +1,25 @@
import type { SentInvitation } from "@/types/onboarding-invite";
const readString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
/**
* The created record out of `sendInvite`'s JSON:API response. Null for every
* failure shape: `undefined` (a 5xx makes the action resolve without a value),
* `{ errors }`, a bare `{ error }`, or a record missing what the link needs.
*/
export function toSentInvitation(response: unknown): SentInvitation | null {
if (!response || typeof response !== "object") return null;
const { data } = response as { data?: unknown };
if (!data || typeof data !== "object") return null;
const { id, attributes } = data as { id?: unknown; attributes?: unknown };
const fields =
attributes && typeof attributes === "object"
? (attributes as Record<string, unknown>)
: {};
const invitationId = readString(id);
const email = readString(fields.email);
const token = readString(fields.token);
if (!invitationId || !email || !token) return null;
return { id: invitationId, email, token };
}
@@ -5,11 +5,9 @@ import type { InvitationRoleOption } from "@/types/onboarding-invite";
const ROLES_PAGE_SIZE = 50;
// Roles the onboarding invite step can offer; empty when the read fails so
// the step can fall back to skipping rather than blocking the checkpoint.
export const getOnboardingInviteRoles = async (): Promise<
InvitationRoleOption[]
> => {
// Roles an invitation can grant; empty when the read fails so a caller can
// fall back (skip, disable) rather than block.
export const getInvitationRoles = async (): Promise<InvitationRoleOption[]> => {
const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE });
const roles: unknown = rolesData?.data;
if (!Array.isArray(roles)) return [];
@@ -18,6 +18,8 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
private static readonly VIEWPORT_SEL = ".react-flow__viewport";
private static readonly MINIMAP_SEL = ".react-flow__minimap";
private static readonly BACKGROUND_SEL = ".react-flow__background";
// Matches the graph's auto-fit duration; a pause this long means no fit is mid-flight.
private static readonly FIT_ANIMATION_MS = 300;
private static isFindingElement(el: Element): boolean {
return (
@@ -255,17 +257,31 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
/** Wait until the React Flow viewport transform changes from `previous`. */
async waitForViewportChange(
previous: string,
timeoutMs = 2000,
timeoutMs?: number,
): Promise<void> {
await this.waitFor(() => this.viewportTransform !== previous, timeoutMs);
await this.waitFor(
() => this.viewportTransform !== previous,
timeoutMs,
"the viewport transform to change",
);
}
/** Wait until the viewport stops moving and return its settled transform. */
async waitForViewportSettled(): Promise<string> {
return this.waitForStable(
() => this.viewportTransform,
AttackPathPageHarness.FIT_ANIMATION_MS,
undefined,
"the viewport to settle",
);
}
/** Wait until every requested node is fully contained in the graph canvas. */
async waitForNodesInViewport(
nodeIds: string[],
timeoutMs = 2000,
timeoutMs?: number,
): Promise<void> {
await this.waitFor(() => {
const allInViewport = () => {
const canvas = this.q(AttackPathPageHarness.FLOW_SEL);
if (!canvas) return false;
@@ -282,7 +298,12 @@ export class AttackPathPageHarness extends BrowserHarness<PageFixture> {
nodeRect.bottom <= canvasRect.bottom
);
});
}, timeoutMs);
};
await this.waitFor(
allInViewport,
timeoutMs,
`nodes ${nodeIds.join(", ")} to be in the viewport`,
);
}
/** Wait until exactly `count` edges are highlighted. */
@@ -407,19 +407,24 @@ describe("exploring the graph", () => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForGraphStable(3);
const initialViewport = graph.viewportTransform;
// Settle before each capture so the next change can only come from the
// action under test, not the tail of the previous fit animation.
const initialViewport = await graph.waitForViewportSettled();
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForViewportChange(initialViewport);
const contextualViewport = graph.viewportTransform;
const contextualViewport = await graph.waitForViewportSettled();
const visibleNodeIds = graph.renderedNodeIds;
await graph.fit();
await graph.waitForViewportChange(contextualViewport);
// The fit must end with the whole visible graph on screen, not just move
await graph.waitForViewportSettled();
await graph.waitForNodesInViewport(visibleNodeIds);
});
test("clicking an expanded resource re-fits the remaining visible graph", async ({
mountWith,
@@ -0,0 +1 @@
Option to invite a teammate from the AWS connect step when the user cannot access the account credentials
@@ -1 +0,0 @@
AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account
@@ -1 +0,0 @@
Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments
@@ -1 +0,0 @@
Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens
@@ -1 +0,0 @@
New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal
@@ -1 +0,0 @@
Mute rule creation errors show the API error message instead of the raw JSON:API response body
@@ -0,0 +1 @@
`next` to 16.3.6, patching a remote code execution in `next/og` `ImageResponse` (GHSA-vcvr-r3jv-pc5j)
@@ -0,0 +1 @@
Required home region selector in the OCI credentials form, so tenancies not subscribed to us-ashburn-1 can connect
@@ -1 +0,0 @@
Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted
@@ -1 +0,0 @@
Sidebar action reads Add Provider while the tenant has no providers
@@ -1 +0,0 @@
Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode
@@ -0,0 +1 @@
First-login redirect to the add-provider wizard is retried on the next load when the navigation was cut short, on Cloud and self-hosted alike, instead of being written off after a single attempt
@@ -4,6 +4,7 @@ import Link from "next/link";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { DateWithTime } from "@/components/shadcn/entities";
import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link";
import { AddIcon } from "../icons";
import { Button, Card, CardContent, CardHeader } from "../shadcn";
@@ -54,7 +55,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
? window.location.origin
: "http://localhost:3000";
const invitationLink = `${baseUrl}/invitation/accept?invitation_token=${attributes.token}`;
const invitationLink = buildInvitationAcceptLink(attributes.token, baseUrl);
return (
<div className="flex flex-col gap-x-4 gap-y-8">
@@ -66,7 +66,13 @@ const fillAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
describe("SendInvitationForm", () => {
beforeEach(() => {
pushMock.mockReset();
sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } });
// The API answers with the created record, token included.
sendInviteMock.mockReset().mockResolvedValue({
data: {
id: "inv-1",
attributes: { email: "teammate@company.com", token: "abc123DEF45678" },
},
});
});
it("navigates to the invitation details by default", async () => {
@@ -1,13 +1,10 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { SaveIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import * as z from "zod";
import { Controller } from "react-hook-form";
import { sendInvite } from "@/actions/invitations/invitation";
import { Button, useToast } from "@/components/shadcn";
import { Button } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import {
@@ -17,15 +14,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { ApiError } from "@/types";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
const sendInvitationFormSchema = z.object({
email: z.email({ error: "Please enter a valid email" }),
roleId: z.string().min(1, "Role is required"),
});
export type FormValues = z.infer<typeof sendInvitationFormSchema>;
import { useSendInvitation } from "./use-send-invitation";
interface SendInvitationFormProps {
roles: InvitationRoleOption[];
@@ -45,90 +36,23 @@ export const SendInvitationForm = ({
source,
onSuccess,
}: SendInvitationFormProps) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<FormValues>({
resolver: zodResolver(sendInvitationFormSchema),
defaultValues: {
email: "",
roleId: isSelectorDisabled ? defaultRole : "",
const { form, onSubmit, isSubmitting } = useSendInvitation({
source,
defaultRoleId: isSelectorDisabled ? defaultRole : "",
onSuccess: (invitation) => {
if (onSuccess) {
onSuccess(invitation.id);
return;
}
router.push(`/invitations/check-details/?id=${invitation.id}`);
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
const formData = new FormData();
formData.append("email", values.email);
formData.append("role", values.roleId);
if (source) formData.append("source", source);
try {
const data = await sendInvite(formData);
if (data?.errors && data.errors.length > 0) {
data.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
const pointer = error.source?.pointer;
switch (pointer) {
case "/data/attributes/email":
form.setError("email", {
type: "server",
message: errorMessage,
});
break;
case "/data/relationships/roles":
form.setError("roleId", {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
} else {
const invitationId = data?.data?.id;
if (!invitationId) {
// A transport failure returns nothing and a rejection can come
// back as a bare `error` without an `errors` array; neither
// created an invitation, so neither is a success.
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description:
typeof data?.error === "string"
? data.error
: "The invitation could not be sent. Please try again.",
});
return;
}
if (onSuccess) {
onSuccess(invitationId);
return;
}
router.push(`/invitations/check-details/?id=${invitationId}`);
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "An unexpected error occurred. Please try again.",
});
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
{/* Email Field */}
<CustomInput
control={form.control}
@@ -182,9 +106,9 @@ export const SendInvitationForm = ({
className="w-1/2"
variant="default"
size="lg"
disabled={isLoading}
disabled={isSubmitting}
>
{isLoading ? (
{isSubmitting ? (
<>Loading</>
) : (
<>
@@ -0,0 +1,106 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, useFormState, type UseFormProps } from "react-hook-form";
import * as z from "zod";
import { sendInvite } from "@/actions/invitations/invitation";
import { toSentInvitation } from "@/actions/invitations/invitation.adapter";
import { useToast } from "@/components/shadcn";
import { ApiError } from "@/types";
import type { SentInvitation } from "@/types/onboarding-invite";
export const sendInvitationFormSchema = z.object({
email: z.email({ error: "Please enter a valid email" }),
roleId: z.string().min(1, "Role is required"),
});
export type SendInvitationFormValues = z.infer<typeof sendInvitationFormSchema>;
const EMAIL_ERROR_POINTER = "/data/attributes/email";
const ROLES_ERROR_POINTER = "/data/relationships/roles";
interface UseSendInvitationOptions {
// Where the invitation is sent from, forwarded to the API as `?source=`
// so the origin can be told apart (e.g. the onboarding invite step).
source?: string;
defaultRoleId?: string;
// `onChange` lets a caller gate its submit button on `isValid`.
mode?: UseFormProps<SendInvitationFormValues>["mode"];
onSuccess: (invitation: SentInvitation) => void;
}
/** Owns an invitation form: schema, submit, API error mapping and toasts. */
export function useSendInvitation({
source,
defaultRoleId = "",
mode = "onSubmit",
onSuccess,
}: UseSendInvitationOptions) {
const { toast } = useToast();
const form = useForm<SendInvitationFormValues>({
resolver: zodResolver(sendInvitationFormSchema),
mode,
defaultValues: { email: "", roleId: defaultRoleId },
});
// A hook, not `form.formState` read inline: the React Compiler keys its memo
// on the stable `form` object and would freeze a proxy read.
const { isSubmitting, isValid } = useFormState({ control: form.control });
const onSubmit = form.handleSubmit(async (values) => {
const formData = new FormData();
formData.append("email", values.email);
formData.append("role", values.roleId);
if (source) formData.append("source", source);
try {
const data = await sendInvite(formData);
if (data?.errors && data.errors.length > 0) {
data.errors.forEach((error: ApiError) => {
const message = error.detail;
switch (error.source?.pointer) {
case EMAIL_ERROR_POINTER:
form.setError("email", { type: "server", message });
break;
case ROLES_ERROR_POINTER:
form.setError("roleId", { type: "server", message });
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: message,
});
}
});
return;
}
const invitation = toSentInvitation(data);
if (!invitation) {
// A transport failure returns nothing and a rejection can come back
// as a bare `error` without an `errors` array; neither created an
// invitation, so neither is a success.
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description:
typeof data?.error === "string"
? data.error
: "The invitation could not be sent. Please try again.",
});
return;
}
onSuccess(invitation);
} catch {
toast({
variant: "destructive",
title: "Error",
description: "An unexpected error occurred. Please try again.",
});
}
});
return { form, onSubmit, isSubmitting, isValid };
}
@@ -1,7 +1,15 @@
import { render, waitFor } from "@testing-library/react";
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isFirstRunHandled } from "@/lib/onboarding/first-run-marker";
import {
FIRST_RUN_MAX_ATTEMPTS,
isFirstRunHandled,
} from "@/lib/onboarding/first-run-marker";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
@@ -111,7 +119,7 @@ describe("OnboardingGate", () => {
expect(armMock).toHaveBeenCalledOnce();
});
it("happens only once per tenant on this browser", async () => {
it("tries again on the next load when the wizard never opened (the navigation was cut short)", async () => {
// Given
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
@@ -124,28 +132,83 @@ describe("OnboardingGate", () => {
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(
CLOUD_FIRST_RUN_HREF,
),
);
expect(isFirstRunHandled(TENANT_A)).toBe(false);
});
it("honours a browser-wide marker written before markers were tenant-scoped", () => {
// Given: e2e storage state and pre-existing browsers set the bare key.
window.localStorage.setItem("prowler.onboarding.first-run", "true");
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled(TENANT_A)).toBe(true);
});
it("still runs for a different empty tenant on the same browser", async () => {
it("is resolved once the add-provider wizard opens, so later loads leave the user alone", async () => {
// Given
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
act(() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source: WIZARD_OPEN_SOURCE.FIRST_RUN,
});
});
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it("gives up after a few attempts that never reached the wizard, so no browser is trapped", async () => {
// Given: three loads whose navigation never completed.
for (let attempt = 0; attempt < FIRST_RUN_MAX_ATTEMPTS; attempt++) {
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
}
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
it.each(["true", "1legacy", "-1"])(
"honours a browser-wide marker holding %s, written before markers counted attempts",
(value) => {
// Given: e2e storage state and pre-existing browsers set the bare key.
window.localStorage.setItem("prowler.onboarding.first-run", value);
// When
render(<OnboardingGate hasProviders={false} tenantId={TENANT_A} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled(TENANT_A)).toBe(true);
},
);
it("still runs for a different empty tenant on the same browser", async () => {
// Given: tenant A went through its first run on this browser.
const { unmount } = render(
<OnboardingGate hasProviders={false} tenantId={TENANT_A} />,
);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
act(() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.WIZARD_OPENED,
source: WIZARD_OPEN_SOURCE.FIRST_RUN,
});
});
unmount();
replaceMock.mockClear();
@@ -154,7 +217,8 @@ describe("OnboardingGate", () => {
// Then
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
expect(isFirstRunHandled(TENANT_B)).toBe(true);
expect(isFirstRunHandled(TENANT_A)).toBe(true);
expect(isFirstRunHandled(TENANT_B)).toBe(false);
});
});
@@ -172,6 +236,24 @@ describe("OnboardingGate", () => {
);
expect(armMock).not.toHaveBeenCalled();
});
it("tries again on the next load when the wizard never opened, with no tenant id available", async () => {
// Given: self-hosted layouts mount the gate without a tenant id.
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const { unmount } = render(<OnboardingGate hasProviders={false} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} />);
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF),
);
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the user cannot add providers", () => {
@@ -19,8 +19,8 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/actions/onboarding/invite", () => ({
getOnboardingInviteRoles: getRolesMock,
vi.mock("@/actions/invitations/roles", () => ({
getInvitationRoles: getRolesMock,
}));
vi.mock("@/actions/invitations/invitation", () => ({
@@ -85,7 +85,13 @@ describe("OnboardingInviteStep", () => {
outcomes.length = 0;
window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
getRolesMock.mockReset().mockResolvedValue(ROLES);
sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } });
// The API answers with the created record, token included.
sendInviteMock.mockReset().mockResolvedValue({
data: {
id: "inv-1",
attributes: { email: "teammate@company.com", token: "abc123DEF45678" },
},
});
toastMock.mockReset();
});
+26 -7
View File
@@ -12,8 +12,14 @@ import {
import {
isFirstRunHandled,
markFirstRunHandled,
recordFirstRunAttempt,
} from "@/lib/onboarding/first-run-marker";
import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events";
import {
PROVIDER_FUNNEL_EVENT,
PROVIDER_FUNNEL_STEP,
type ProviderFunnelDetail,
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { buildAddProviderHref } from "@/lib/providers-navigation";
import { isCloud } from "@/lib/shared/env";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
@@ -28,7 +34,8 @@ interface OnboardingGateProps {
}
// New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to
// the add-provider wizard, once per tenant and browser. Renders nothing.
// the add-provider wizard, retried per load until the wizard opens once for that
// tenant on this browser (bounded attempts). Renders nothing.
export function OnboardingGate({
hasProviders,
tenantId = null,
@@ -77,17 +84,29 @@ function FirstRunRedirect({ flow, tenantId }: FirstRunRedirectProps) {
return;
}
markFirstRunHandled(tenantId);
// The wizard opening resolves the first run, whether this redirect got there
// or the user opened it on their own. Until then each load retries, bounded
// by the attempt count, so a navigation cut short is not the end of it.
const resolveOnWizardOpened = (event: Event) => {
const { detail } = event as CustomEvent<ProviderFunnelDetail>;
if (detail?.step === PROVIDER_FUNNEL_STEP.WIZARD_OPENED) {
markFirstRunHandled(tenantId);
}
};
window.addEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened);
recordFirstRunAttempt(tenantId);
const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN);
if (!isCloud()) {
router.replace(addProviderHref);
return;
} else {
// Tours and the post-connect checkpoint are Cloud-only.
useOnboardingCheckpointStore.getState().arm();
router.replace(`${addProviderHref}&onboarding=${flow.id}`);
}
// Tours and the post-connect checkpoint are Cloud-only.
useOnboardingCheckpointStore.getState().arm();
router.replace(`${addProviderHref}&onboarding=${flow.id}`);
return () =>
window.removeEventListener(PROVIDER_FUNNEL_EVENT, resolveOnWizardOpened);
});
return null;
@@ -4,6 +4,7 @@ import { SendInvitationForm } from "@/components/invitations/workflow/forms/send
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
import { orderRolesAdminFirst } from "@/lib/invitations/order-roles";
import {
INVITATION_SOURCE,
type InvitationRoleOption,
@@ -16,19 +17,6 @@ interface OnboardingInviteDialogProps {
onSkip: () => void;
}
const DEFAULT_ROLE_NAME = "admin";
// Roles are listed with the admin one first so it is the natural pick for a
// first teammate; the form itself keeps the selection required.
const orderRoles = (roles: InvitationRoleOption[]) =>
[...roles].sort((a, b) =>
a.name.toLowerCase() === DEFAULT_ROLE_NAME
? -1
: b.name.toLowerCase() === DEFAULT_ROLE_NAME
? 1
: 0,
);
// "Invite your team", offered once right after the first provider is
// connected: permissions on the cloud were just granted and the value of
// sharing the first scan is fresh. Reuses the members-page form, tagged as an
@@ -55,7 +43,9 @@ export function OnboardingInviteDialog({
<div className="flex flex-col gap-4">
{hasRoles ? (
<SendInvitationForm
roles={orderRoles(roles)}
// Admin first: the natural pick for a first teammate; the form
// itself keeps the selection required.
roles={orderRolesAdminFirst(roles)}
isSelectorDisabled={false}
source={INVITATION_SOURCE.ONBOARDING}
onSuccess={onSent}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { getOnboardingInviteRoles } from "@/actions/onboarding/invite";
import { useInvitationRoles } from "@/hooks/use-invitation-roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
dispatchOnboardingInviteStep,
ONBOARDING_STEP_OUTCOME,
} from "@/lib/onboarding/onboarding-events";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
import { OnboardingInviteDialog } from "./onboarding-invite-dialog";
@@ -16,38 +13,17 @@ interface OnboardingInviteStepProps {
onDone: () => void;
}
// Roles that have not arrived by then count as unavailable, so a request
// that never answers cannot hold the checkpoint behind an empty step.
const ROLES_TIMEOUT_MS = 5_000;
// Mounted only while the step is showing: loads the roles once, announces
// the impression once, and resolves through a sent invitation or a skip.
export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) {
// `null` until the roles settle: the invitation form takes its default
// role from the list at mount, so the dialog renders once the list is known.
const [roles, setRoles] = useState<InvitationRoleOption[] | null>(null);
// Without roles the dialog offers only the skip, so the checkpoint is never
// blocked: not by a failed read, not by one that never answers.
const roles = useInvitationRoles();
useMountEffect(() => {
dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN });
let active = true;
let timer: ReturnType<typeof setTimeout> | undefined;
// First answer wins: a late response or a timer after it is ignored.
const settle = (loaded: InvitationRoleOption[]) => {
if (!active) return;
active = false;
clearTimeout(timer);
setRoles(loaded);
};
// Without roles the dialog offers only the skip, so the checkpoint is
// never blocked: not by a failed read, not by one that never answers.
timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS);
getOnboardingInviteRoles()
.then(settle)
.catch(() => settle([]));
return () => {
active = false;
clearTimeout(timer);
};
});
if (roles === null) return null;
@@ -36,12 +36,23 @@ const {
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: { tenantId: "tenant-abc" },
status: "authenticated",
}),
const { getInvitationRoles, sendInvite, session } = vi.hoisted(() => ({
getInvitationRoles: vi.fn(),
sendInvite: vi.fn(),
// Mutable: only the permissions differ between cases.
session: {
data: { tenantId: "tenant-abc" } as {
tenantId: string;
user?: { permissions: Record<string, boolean> };
},
},
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({ data: session.data, status: "authenticated" }),
}));
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
vi.mock("@/actions/invitations/invitation", () => ({ sendInvite }));
vi.mock("@/actions/providers/providers", () => ({
addCredentialsProvider,
addProvider,
@@ -165,6 +176,7 @@ describe("provider wizard account creation", () => {
afterEach(() => {
vi.unstubAllEnvs();
session.data = { tenantId: "tenant-abc" };
});
it("shows progress, blocks repeat clicks, and advances after creation", async () => {
@@ -459,6 +471,61 @@ describe("provider wizard account creation", () => {
expect(endActiveTour).toHaveBeenCalled();
});
it("closes the wizard once a teammate has been invited to connect the account instead", async () => {
// Given: a user who can invite but cannot reach the account.
session.data = {
tenantId: "tenant-abc",
user: { permissions: { manage_account: true } },
};
getInvitationRoles.mockResolvedValue([
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
]);
sendInvite.mockResolvedValue({
data: {
id: "inv-1",
attributes: {
email: "teammate@company.com",
token: "abc123DEF45678",
},
},
});
const funnelSignals: ProviderFunnelDetail[] = [];
const recordFunnelSignal: EventListener = (event) => {
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
};
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
const onOpenChange = vi.fn();
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
await screen.findByRole("option", { name: "Acme Cloud Registry" });
await user.click(
screen.getByRole("option", { name: /Amazon Web Services/ }),
);
// When
await user.click(
await screen.findByRole("radio", { name: /invite a teammate/i }),
);
await user.type(
await screen.findByRole("textbox", { name: /Teammate email/ }),
"teammate@company.com",
);
const send = screen.getByRole("button", { name: "Send invitation" });
await waitFor(() => expect(send).toBeEnabled());
await user.click(send);
await user.click(await screen.findByRole("button", { name: "Done" }));
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
// Then: no account was created, so the wizard closes instead of launching.
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(screen.queryByText("Launch scan")).not.toBeInTheDocument();
expect(funnelSignals.at(-1)).toEqual({
step: "wizard_closed",
lastStep: "connect",
providerCreated: false,
});
});
it("goes back to the provider list", async () => {
// Given
const user = await pickAws();
@@ -185,6 +185,7 @@ export function ProviderWizardModal({
handleTestSuccess();
endActiveTour();
}}
onClose={handleClose}
onSelectOrganizations={openOrganizationsFlow}
onFooterChange={setFooterConfig}
onProviderTypeChange={(providerType) => {
@@ -23,6 +23,10 @@ const {
updateCredentialsProvider,
testProviderConnection,
openCloudUpgradeMock,
endActiveTour,
getInvitationRoles,
sendInvite,
session,
} = vi.hoisted(() => ({
addProvider: vi.fn(),
addCredentialsProvider: vi.fn(),
@@ -30,14 +34,24 @@ const {
updateCredentialsProvider: vi.fn(),
testProviderConnection: vi.fn(),
openCloudUpgradeMock: vi.fn(),
endActiveTour: vi.fn(),
getInvitationRoles: vi.fn(),
sendInvite: vi.fn(),
// Mutable: only the permissions differ between suites.
session: {
data: { tenantId: "tenant-abc" } as {
tenantId: string;
user?: { permissions: Record<string, boolean> };
},
},
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: { tenantId: "tenant-abc" },
status: "authenticated",
}),
useSession: () => ({ data: session.data, status: "authenticated" }),
}));
vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour }));
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
vi.mock("@/actions/invitations/invitation", () => ({ sendInvite }));
vi.mock("@/actions/providers/providers", () => ({
addProvider,
addCredentialsProvider,
@@ -130,6 +144,7 @@ describe("AwsConnectStep", () => {
afterEach(() => {
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
vi.unstubAllEnvs();
session.data = { tenantId: "tenant-abc" };
});
describe("in Prowler Cloud", () => {
@@ -754,4 +769,99 @@ describe("AwsConnectStep", () => {
expect(secret).not.toHaveProperty("aws_access_key_id");
});
});
describe("inviting a teammate who can connect the account", () => {
const inviteRadio = () =>
screen.queryByRole("radio", { name: /invite a teammate/i });
beforeEach(() => {
session.data = {
tenantId: "tenant-abc",
user: { permissions: { manage_account: true } },
};
getInvitationRoles.mockResolvedValue([
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
]);
});
it("is not offered to a user who cannot invite", () => {
// Given: no `manage_account`, so the API would refuse the invitation.
session.data = { tenantId: "tenant-abc" };
// When
renderStep();
// Then
expect(inviteRadio()).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: /IAM Role/ })).toBeChecked();
});
it("swaps the AWS form for the invitation and signals the choice once", async () => {
// Given
const { user } = renderStep();
// When
await user.click(inviteRadio()!);
await user.click(inviteRadio()!);
// Then: the teammate form takes the step and the footer, the tour steps aside.
expect(inviteRadio()).toBeChecked();
expect(
screen.queryByRole("textbox", { name: /Role ARN/ }),
).not.toBeInTheDocument();
expect(
await screen.findByRole("textbox", { name: /Teammate email/ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send invitation" }),
).toBeInTheDocument();
expect(endActiveTour).toHaveBeenCalled();
expect(
funnelSignals.filter((signal) => signal.step === "method_selected"),
).toEqual([
{
step: "method_selected",
providerType: "aws",
method: "invite_teammate",
},
]);
});
it("comes back to the IAM Role form with what was typed", async () => {
// Given
const { user } = renderStep();
await user.type(
screen.getByRole("textbox", { name: /Role ARN/ }),
ROLE_ARN,
);
await user.click(inviteRadio()!);
await screen.findByRole("textbox", { name: /Teammate email/ });
// When
await user.click(screen.getByRole("radio", { name: /IAM Role/ }));
// Then
expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue(
ROLE_ARN,
);
expect(inviteRadio()).not.toBeChecked();
});
it("restores the invitation panel when the step is reopened", async () => {
// Given: the user left for the organizations tab and came back.
const { user, unmount } = renderStep();
await user.click(inviteRadio()!);
await screen.findByRole("textbox", { name: /Teammate email/ });
unmount();
// When
renderStep();
// Then
expect(inviteRadio()).toBeChecked();
expect(
await screen.findByRole("textbox", { name: /Teammate email/ }),
).toBeInTheDocument();
});
});
});
@@ -7,6 +7,7 @@ import {
KeyRound,
Loader2,
ShieldCheck,
UserPlus,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useRef, useState } from "react";
@@ -35,6 +36,7 @@ import {
CollapsibleTrigger,
} from "@/components/shadcn/collapsible";
import { Form } from "@/components/shadcn/form";
import { useAuth } from "@/hooks/use-auth";
import { useFormServerErrors } from "@/hooks/use-form-server-errors";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
@@ -43,14 +45,22 @@ import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-cr
import {
ACCOUNT_SUBMIT_OUTCOME,
dispatchProviderFunnel,
PROVIDER_FUNNEL_METHOD,
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { testProviderConnection } from "@/lib/provider-helpers";
import { endActiveTour } from "@/lib/tours/use-driver-tour";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import type { AWSCredentials, AWSCredentialsRole } from "@/types";
import type { AwsConnectDraft } from "@/types/provider-wizard";
import {
AWS_CONNECT_PANEL,
type AwsConnectDraft,
type AwsConnectPanel,
} from "@/types/provider-wizard";
import { CONNECTION_CHECK_STATUS } from "@/types/providers";
import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel";
import {
awsKeysConnectSchema,
type AwsKeysConnectValues,
@@ -84,6 +94,11 @@ const initialMethod = (): AwsAccessMethod =>
? AWS_ACCESS_METHOD.CREDENTIALS
: AWS_ACCESS_METHOD.ROLE;
const initialPanel = (): AwsConnectPanel =>
readDraft()?.panel === AWS_CONNECT_PANEL.INVITE
? AWS_CONNECT_PANEL.INVITE
: AWS_CONNECT_PANEL.ACCESS;
function useDraftValues<T extends FieldValues>(
form: UseFormReturn<T>,
key: keyof Pick<AwsConnectDraft, "roleValues" | "keysValues">,
@@ -112,14 +127,41 @@ export function AwsConnectStep({
}: AwsConnectStepProps) {
// Local state needed: the access method only matters until the account is connected.
const [method, setMethod] = useState<AwsAccessMethod>(initialMethod);
// Local state needed: whether the step shows the access forms or hands the
// account over to a teammate. Separate from the method, which is the `via`
// an account gets connected with.
const [panel, setPanel] = useState<AwsConnectPanel>(initialPanel);
// Local state needed: the active form reports it so the method cannot change mid-submit.
const [isBusy, setIsBusy] = useState(false);
const { permissions } = useAuth();
// Inviting takes `manage_account`, which the API also asks of the roles list.
const canInvite = permissions.manage_account === true;
const isRole = method === AWS_ACCESS_METHOD.ROLE;
const isInvite = canInvite && panel === AWS_CONNECT_PANEL.INVITE;
const isRole = !isInvite && method === AWS_ACCESS_METHOD.ROLE;
const isKeys = !isInvite && method === AWS_ACCESS_METHOD.CREDENTIALS;
const chooseMethod = (next: AwsAccessMethod) => {
setPanel(AWS_CONNECT_PANEL.ACCESS);
setMethod(next);
useProviderWizardStore.getState().setAwsConnectDraft({ method: next });
useProviderWizardStore
.getState()
.setAwsConnectDraft({ method: next, panel: AWS_CONNECT_PANEL.ACCESS });
};
const chooseInvite = () => {
if (isInvite) return;
setPanel(AWS_CONNECT_PANEL.INVITE);
useProviderWizardStore
.getState()
.setAwsConnectDraft({ panel: AWS_CONNECT_PANEL.INVITE });
// Delegating diverges from the path the tour guides toward. No-op off-onboarding.
endActiveTour();
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED,
providerType: "aws",
method: PROVIDER_FUNNEL_METHOD.INVITE_TEAMMATE,
});
};
return (
@@ -133,11 +175,11 @@ export function AwsConnectStep({
<div
role="radiogroup"
aria-label="AWS access method"
aria-label="AWS connection option"
className="flex flex-col gap-3"
>
<p className="text-text-neutral-secondary text-sm">
Choose how Prowler should access your account.
Choose how to connect this account.
</p>
<RadioCard
icon={ShieldCheck}
@@ -153,20 +195,40 @@ export function AwsConnectStep({
<RadioCard
icon={KeyRound}
title="Static access keys"
selected={!isRole}
selected={isKeys}
disabled={isBusy}
onClick={() => chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)}
/>
{canInvite && (
<RadioCard
icon={UserPlus}
title="I don't have access, invite a teammate"
selected={isInvite}
disabled={isBusy}
onClick={chooseInvite}
/>
)}
</div>
{isRole ? (
{isInvite && (
<InviteTeammatePanel
providerType="aws"
formId={formId}
onUiStateChange={onUiStateChange}
onBusyChange={setIsBusy}
/>
)}
{isRole && (
<AwsRoleConnectForm
formId={formId}
onConnected={onConnected}
onBusyChange={setIsBusy}
onUiStateChange={onUiStateChange}
/>
) : (
)}
{isKeys && (
<AwsKeysConnectForm
formId={formId}
onConnected={onConnected}
@@ -6,6 +6,15 @@ export const AWS_ACCESS_METHOD = {
export type AwsAccessMethod =
(typeof AWS_ACCESS_METHOD)[keyof typeof AWS_ACCESS_METHOD];
/** What the footer's main action does: submit the step's form, or close the wizard. */
export const AWS_CONNECT_ACTION_KIND = {
SUBMIT: "submit",
CLOSE: "close",
} as const;
export type AwsConnectActionKind =
(typeof AWS_CONNECT_ACTION_KIND)[keyof typeof AWS_CONNECT_ACTION_KIND];
/** What the step publishes so the wizard can draw its footer. */
export interface AwsConnectUiState {
showBack: boolean;
@@ -13,4 +22,6 @@ export interface AwsConnectUiState {
actionLabel: string;
actionDisabled: boolean;
isLoading: boolean;
/** Absent means submit. */
actionKind?: AwsConnectActionKind;
}
@@ -14,6 +14,7 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
import { AwsConnectStep } from "./aws/aws-connect-step";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "./aws/types";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
@@ -23,6 +24,8 @@ interface ConnectStepProps {
onNext: () => void;
/** AWS registers, stores and tests the account in this step, so it skips ahead. */
onCredentialsSaved: () => void;
/** AWS offers it as the footer action once a teammate has been invited instead. */
onClose: () => void;
onSelectOrganizations: (orgType: OrgFlowType) => void;
onFooterChange: (config: WizardFooterConfig) => void;
onProviderTypeChange: (providerType: ProviderType | null) => void;
@@ -33,6 +36,7 @@ interface ConnectStepProps {
export function ConnectStep({
onNext,
onCredentialsSaved,
onClose,
onSelectOrganizations,
onFooterChange,
onProviderTypeChange,
@@ -41,9 +45,13 @@ export function ConnectStep({
const { setProvider, setVia, setSecretId, setMode } =
useProviderWizardStore();
const backHandlerRef = useRef<(() => void) | null>(null);
// The modal hands over a fresh `onClose` every render; the footer effect
// keeps one closure and reads the latest through the ref, as LaunchStep does.
const closeHandlerRef = useRef(onClose);
closeHandlerRef.current = onClose;
// Local state needed: AWS swaps the generic account form for its one-step form.
const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws");
const [uiState, setUiState] = useState({
const [uiState, setUiState] = useState<AwsConnectUiState>({
showBack: false,
showAction: false,
actionLabel: "Next",
@@ -74,6 +82,8 @@ export function ConnectStep({
if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) {
endActiveTour();
}
// Nothing left to submit once a teammate has been invited: the action closes.
const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE;
onFooterChange({
showBack: uiState.showBack,
backLabel: "Back",
@@ -86,8 +96,11 @@ export function ConnectStep({
actionLabel: uiState.actionLabel,
actionLoading: uiState.isLoading,
actionDisabled: uiState.actionDisabled || uiState.isLoading,
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: formId,
actionType: closes
? WIZARD_FOOTER_ACTION_TYPE.BUTTON
: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
actionFormId: closes ? undefined : formId,
onAction: closes ? () => closeHandlerRef.current() : undefined,
});
}, [isAwsFlow, onFooterChange, uiState]);
@@ -0,0 +1,124 @@
"use client";
import { useEffect } from "react";
import { Controller } from "react-hook-form";
import { useSendInvitation } from "@/components/invitations/workflow/forms/use-send-invitation";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { Form } from "@/components/shadcn/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { isAdminRole } from "@/lib/invitations/order-roles";
import {
INVITATION_SOURCE,
type InvitationRoleOption,
type SentInvitation,
} from "@/types/onboarding-invite";
import { getProviderDisplayName, type ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
interface InviteTeammateFormProps {
roles: InvitationRoleOption[];
providerType: ProviderType;
formId: string;
onSent: (invitation: SentInvitation) => void;
onUiStateChange: (state: AwsConnectUiState) => void;
onBusyChange: (isBusy: boolean) => void;
}
/** Email and role for the teammate; the wizard footer submits it by `formId`. */
export function InviteTeammateForm({
roles,
providerType,
formId,
onSent,
onUiStateChange,
onBusyChange,
}: InviteTeammateFormProps) {
const { form, onSubmit, isSubmitting, isValid } = useSendInvitation({
source: INVITATION_SOURCE.PROVIDER_CONNECT,
// Admin can finish the setup; the user may still pick another role.
defaultRoleId: roles.find(isAdminRole)?.id ?? "",
mode: "onChange",
onSuccess: onSent,
});
// Same contract the AWS forms use: the wizard footer lives outside the step.
// Both callbacks must be stable setters, or this effect would loop.
useEffect(() => {
onBusyChange(isSubmitting);
onUiStateChange({
showBack: true,
showAction: true,
actionLabel: isSubmitting ? "Sending invitation..." : "Send invitation",
actionDisabled: !isValid || isSubmitting,
isLoading: isSubmitting,
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
});
}, [isSubmitting, isValid, onBusyChange, onUiStateChange]);
return (
<Form {...form}>
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-4">
<p className="text-text-neutral-secondary text-sm">
Invite someone from your team who can access the{" "}
{getProviderDisplayName(providerType)} account. They will join this
Prowler tenant and can connect it themselves.
</p>
<WizardInputField
control={form.control}
name="email"
type="email"
label="Teammate email"
labelPlacement="inside"
placeholder="name@company.com"
variant="bordered"
isRequired
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<Controller
name="roleId"
control={form.control}
render={({ field, fieldState }) => (
<div className="flex flex-col gap-1.5">
<Select
value={field.value || undefined}
onValueChange={field.onChange}
disabled={isSubmitting}
>
<SelectTrigger aria-label="Select a role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-text-neutral-tertiary text-xs">
Pick a role that can manage providers, such as admin.
</p>
{fieldState.error && (
<p className="text-text-error text-sm">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</form>
</Form>
);
}
@@ -0,0 +1,294 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getProviderDisplayName } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
import { InviteTeammatePanel } from "./invite-teammate-panel";
const { getInvitationRoles, sendInvite, toastMock } = vi.hoisted(() => ({
getInvitationRoles: vi.fn(),
sendInvite: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
vi.mock("@/actions/invitations/invitation", () => ({ sendInvite }));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/shadcn")>()),
useToast: () => ({ toast: toastMock }),
}));
// Radix Select does not open in jsdom; a native select keeps the test on the
// form's behaviour rather than the dropdown's.
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({
value,
onValueChange,
disabled,
children,
}: {
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<select
aria-label="Select a role"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
<option value="">Select a role</option>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <option value={value}>{children}</option>,
}));
const FORM_ID = "invite-teammate-test-form";
const ROLES = [
{ id: "11111111-1111-4111-8111-111111111111", name: "member" },
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
];
const SENT = {
data: {
id: "inv-1",
attributes: { email: "teammate@company.com", token: "abc123DEF45678" },
},
};
// Stands in for the wizard footer: the panel only publishes its UI state.
function Harness({
onUiState,
onBusyChange,
}: {
onUiState: (state: AwsConnectUiState) => void;
onBusyChange: (isBusy: boolean) => void;
}) {
const [uiState, setUiState] = useState<AwsConnectUiState | null>(null);
// Stable like the wizard's own setter: the panel keys an effect on it.
const handleUiState = useRef((state: AwsConnectUiState) => {
setUiState(state);
onUiState(state);
}).current;
return (
<>
<InviteTeammatePanel
providerType="aws"
formId={FORM_ID}
onUiStateChange={handleUiState}
onBusyChange={onBusyChange}
/>
{uiState?.showAction && (
<button
type="submit"
form={FORM_ID}
disabled={uiState.actionDisabled || uiState.isLoading}
>
{uiState.actionLabel}
</button>
)}
</>
);
}
function renderPanel() {
const onUiState = vi.fn();
const onBusyChange = vi.fn();
render(<Harness onUiState={onUiState} onBusyChange={onBusyChange} />);
return { onUiState, onBusyChange, user: userEvent.setup() };
}
const lastUiState = (onUiState: ReturnType<typeof vi.fn>) =>
onUiState.mock.calls.at(-1)?.[0] as AwsConnectUiState;
async function fillAndSend(user: ReturnType<typeof userEvent.setup>) {
await user.type(
await screen.findByRole("textbox", { name: /Teammate email/ }),
"teammate@company.com",
);
const send = screen.getByRole("button", { name: "Send invitation" });
await waitFor(() => expect(send).toBeEnabled());
await user.click(send);
}
describe("InviteTeammatePanel", () => {
beforeEach(() => {
vi.clearAllMocks();
getInvitationRoles.mockResolvedValue(ROLES);
sendInvite.mockResolvedValue(SENT);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("holds the footer on a disabled Send invitation while the roles load", () => {
// Given: roles that have not answered yet.
getInvitationRoles.mockReturnValue(new Promise(() => {}));
// When
const { onUiState } = renderPanel();
// Then
expect(lastUiState(onUiState)).toMatchObject({
showAction: true,
actionLabel: "Send invitation",
actionDisabled: true,
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
});
expect(screen.getByRole("status")).toHaveTextContent(/Loading roles/);
});
it("offers the roles admin first, preselected, and gates Send on a valid email", async () => {
// When
const { onUiState } = renderPanel();
// Then
const select = await screen.findByRole("combobox", {
name: "Select a role",
});
expect(select).toHaveValue(ROLES[1].id);
expect(
screen.getAllByRole("option").map((option) => option.textContent),
).toEqual(["Select a role", "admin", "member"]);
expect(lastUiState(onUiState)).toMatchObject({
actionLabel: "Send invitation",
actionDisabled: true,
});
});
it("sends the invitation tagged as coming from the provider connection and shows the link", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const { onUiState, onBusyChange, user } = renderPanel();
// When
await fillAndSend(user);
// Then: the API got the form, the user gets the link to share.
const formData = sendInvite.mock.calls[0]?.[0] as FormData;
expect(formData.get("email")).toBe("teammate@company.com");
expect(formData.get("role")).toBe(ROLES[1].id);
expect(formData.get("source")).toBe("provider_connect");
expect(
await screen.findByText("Invitation sent to teammate@company.com"),
).toBeInTheDocument();
expect(
screen.getByText(
`${window.location.origin}/invitation/accept?invitation_token=abc123DEF45678`,
),
).toBeInTheDocument();
expect(
screen.getByText(/Prowler does not send emails/),
).toBeInTheDocument();
expect(
screen.getByText(
new RegExp(`connect the ${getProviderDisplayName("aws")} account`),
),
).toBeInTheDocument();
// The footer closes the wizard from here, and the step is no longer busy.
expect(lastUiState(onUiState)).toMatchObject({
actionLabel: "Done",
actionDisabled: false,
actionKind: AWS_CONNECT_ACTION_KIND.CLOSE,
});
expect(onBusyChange).toHaveBeenLastCalledWith(false);
expect(onBusyChange).toHaveBeenCalledWith(true);
});
it("tells a Cloud user the invitation was emailed too", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
const { user } = renderPanel();
// When
await fillAndSend(user);
// Then
expect(
await screen.findByText(/We have emailed them the invitation/),
).toBeInTheDocument();
expect(
screen.queryByText(/Prowler does not send emails/),
).not.toBeInTheDocument();
});
it("keeps the form up with the field error when the API rejects the email", async () => {
// Given
sendInvite.mockResolvedValue({
errors: [
{
detail: "This email has already been invited.",
source: { pointer: "/data/attributes/email" },
},
],
});
const { onUiState, user } = renderPanel();
// When
await fillAndSend(user);
// Then
expect(
await screen.findByText("This email has already been invited."),
).toBeInTheDocument();
expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument();
expect(lastUiState(onUiState)).toMatchObject({
actionLabel: "Send invitation",
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
});
});
it("stays on the form with a toast when the action resolves without an invitation", async () => {
// Given: a 5xx makes the action resolve undefined.
sendInvite.mockResolvedValue(undefined);
const { user } = renderPanel();
// When
await fillAndSend(user);
// Then
await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ variant: "destructive" }),
),
);
expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /Teammate email/ }),
).toBeInTheDocument();
});
it("explains and hides the action when the roles cannot be loaded", async () => {
// Given
getInvitationRoles.mockRejectedValue(new Error("roles unavailable"));
// When
const { onUiState } = renderPanel();
// Then
expect(
await screen.findByText(/Roles could not be loaded right now/),
).toBeInTheDocument();
expect(lastUiState(onUiState)).toMatchObject({ showAction: false });
expect(
screen.queryByRole("button", { name: "Send invitation" }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,121 @@
"use client";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useInvitationRoles } from "@/hooks/use-invitation-roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { orderRolesAdminFirst } from "@/lib/invitations/order-roles";
import type { SentInvitation } from "@/types/onboarding-invite";
import type { ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
import { InviteTeammateForm } from "./invite-teammate-form";
import { InviteTeammateSent } from "./invite-teammate-sent";
const SEND_LABEL = "Send invitation";
interface InviteTeammatePanelProps {
providerType: ProviderType;
formId: string;
onUiStateChange: (state: AwsConnectUiState) => void;
onBusyChange: (isBusy: boolean) => void;
}
/**
* The connect step's way out for a user who cannot connect the account: invite
* a teammate who can. Loads the roles, sends the invitation through the wizard
* footer and then shows the link to share. Provider-agnostic; AWS mounts it.
*/
export function InviteTeammatePanel({
providerType,
formId,
onUiStateChange,
onBusyChange,
}: InviteTeammatePanelProps) {
const roles = useInvitationRoles();
// Local state needed: the sent record belongs to this panel alone, never to
// the wizard draft or the store.
const [sent, setSent] = useState<SentInvitation | null>(null);
if (sent) {
return (
<InviteTeammateSent
invitation={sent}
providerType={providerType}
onUiStateChange={onUiStateChange}
/>
);
}
if (roles === null) {
return <RolesLoading onUiStateChange={onUiStateChange} />;
}
if (roles.length === 0) {
return <RolesUnavailable onUiStateChange={onUiStateChange} />;
}
return (
<InviteTeammateForm
roles={orderRolesAdminFirst(roles)}
providerType={providerType}
formId={formId}
onSent={(invitation) => {
// The form unmounts mid-submit; release the step before it can.
onBusyChange(false);
setSent(invitation);
}}
onUiStateChange={onUiStateChange}
onBusyChange={onBusyChange}
/>
);
}
interface StaticStateProps {
onUiStateChange: (state: AwsConnectUiState) => void;
}
function RolesLoading({ onUiStateChange }: StaticStateProps) {
useMountEffect(() => {
onUiStateChange({
showBack: true,
showAction: true,
actionLabel: SEND_LABEL,
actionDisabled: true,
isLoading: false,
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
});
});
return (
<p
role="status"
className="text-text-neutral-secondary flex items-center gap-2 text-sm"
>
<Loader2 aria-hidden className="size-4 animate-spin" />
Loading roles...
</p>
);
}
function RolesUnavailable({ onUiStateChange }: StaticStateProps) {
useMountEffect(() => {
onUiStateChange({
showBack: true,
showAction: false,
actionLabel: SEND_LABEL,
actionDisabled: true,
isLoading: false,
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
});
});
return (
<p className="text-text-neutral-secondary text-sm">
Roles could not be loaded right now. You can invite your team later from
the Invitations page.
</p>
);
}
@@ -0,0 +1,68 @@
"use client";
import { CircleCheck } from "lucide-react";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link";
import { isCloud } from "@/lib/shared/env";
import type { SentInvitation } from "@/types/onboarding-invite";
import { getProviderDisplayName, type ProviderType } from "@/types/providers";
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types";
interface InviteTeammateSentProps {
invitation: SentInvitation;
providerType: ProviderType;
onUiStateChange: (state: AwsConnectUiState) => void;
}
/** The link to share once the invitation exists; the footer's "Done" closes the wizard. */
export function InviteTeammateSent({
invitation,
providerType,
onUiStateChange,
}: InviteTeammateSentProps) {
useMountEffect(() => {
onUiStateChange({
showBack: true,
showAction: true,
actionLabel: "Done",
actionDisabled: false,
isLoading: false,
actionKind: AWS_CONNECT_ACTION_KIND.CLOSE,
});
});
// Mounted after a click, so the window is there; the guard keeps SSR safe.
const origin = typeof window === "undefined" ? "" : window.location.origin;
const link = buildInvitationAcceptLink(invitation.token, origin);
return (
<section role="status" className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<CircleCheck
aria-hidden
className="text-text-success-primary size-5 shrink-0"
/>
<div className="flex min-w-0 flex-col gap-1">
<h4 className="text-sm font-semibold break-words">
Invitation sent to {invitation.email}
</h4>
<p className="text-text-neutral-secondary text-sm">
{isCloud()
? "We have emailed them the invitation. You can also share this link with them:"
: "Prowler does not send emails. Share this link with them:"}
</p>
</div>
</div>
<CodeSnippet value={link} className="max-w-full" />
<p className="text-text-neutral-secondary text-sm">
The link expires in 7 days. Once they accept, they can connect the{" "}
{getProviderDisplayName(providerType)} account from the Providers page.
</p>
</section>
);
}
@@ -4,6 +4,9 @@ import {
WizardInputField,
WizardTextareaField,
} from "@/components/providers/workflow/forms/fields";
import { Combobox } from "@/components/shadcn/combobox";
import { FormControl, FormField, FormMessage } from "@/components/shadcn/form";
import { OCI_REGION_GROUPS } from "@/lib/provider-credentials/oci-regions";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { OCICredentials } from "@/types";
@@ -48,6 +51,34 @@ export const OracleCloudCredentialsForm = ({
variant="bordered"
isRequired
/>
<FormField
control={control}
name={ProviderCredentialFields.OCI_REGION}
render={({ field }) => (
<div className="flex flex-col gap-1.5">
<span className="text-text-neutral-tertiary text-xs font-light tracking-tight">
Home Region<span className="text-text-error-primary">*</span>
</span>
<FormControl>
<Combobox
aria-label="Home Region"
value={field.value ?? ""}
onValueChange={field.onChange}
groups={OCI_REGION_GROUPS}
placeholder="Select your tenancy home region"
searchPlaceholder="Search region..."
emptyMessage="No region found."
contentClassName="z-[60] sm:w-(--radix-popover-trigger-width) sm:max-w-none"
/>
</FormControl>
<span className="text-text-neutral-tertiary text-xs">
Shown in the OCI Console under Tenancy Details. Used only to
validate the credentials: all subscribed regions are scanned.
</span>
<FormMessage className="text-text-error-primary max-w-full text-xs" />
</div>
)}
/>
<WizardTextareaField
control={control}
name={ProviderCredentialFields.OCI_KEY_CONTENT}
+3 -3
View File
@@ -498,10 +498,10 @@
{
"section": "dependencies",
"name": "next",
"from": "16.2.11",
"to": "16.3.3",
"from": "16.3.3",
"to": "16.3.6",
"strategy": "installed",
"generatedAt": "2026-09-09T12:23:05.642Z"
"generatedAt": "2026-10-01T09:28:54.112Z"
},
{
"section": "dependencies",
+1
View File
@@ -181,6 +181,7 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.OCI_FINGERPRINT]: "",
[ProviderCredentialFields.OCI_KEY_CONTENT]: "",
[ProviderCredentialFields.OCI_TENANCY]: providerUid || "",
[ProviderCredentialFields.OCI_REGION]: "",
[ProviderCredentialFields.OCI_PASS_PHRASE]: "",
};
case "mongodbatlas":
+66
View File
@@ -0,0 +1,66 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useInvitationRoles } from "./use-invitation-roles";
const { getInvitationRoles } = vi.hoisted(() => ({
getInvitationRoles: vi.fn(),
}));
vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles }));
const ROLES = [
{ id: "11111111-1111-4111-8111-111111111111", name: "member" },
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
];
describe("useInvitationRoles", () => {
beforeEach(() => {
getInvitationRoles.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("is unsettled until the roles arrive, then exposes them as loaded", async () => {
getInvitationRoles.mockResolvedValue(ROLES);
const { result } = renderHook(() => useInvitationRoles());
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).toEqual(ROLES));
});
it("settles empty when the read fails", async () => {
getInvitationRoles.mockRejectedValue(new Error("roles unavailable"));
const { result } = renderHook(() => useInvitationRoles());
await waitFor(() => expect(result.current).toEqual([]));
});
it("settles empty when the read never answers, and ignores a late answer", async () => {
vi.useFakeTimers();
let resolveLate: (roles: typeof ROLES) => void = () => {};
getInvitationRoles.mockReturnValue(
new Promise<typeof ROLES>((resolve) => {
resolveLate = resolve;
}),
);
const { result } = renderHook(() => useInvitationRoles());
expect(result.current).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(result.current).toEqual([]);
await act(async () => {
resolveLate(ROLES);
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current).toEqual([]);
});
});
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { getInvitationRoles } from "@/actions/invitations/roles";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
// Roles that have not arrived by then count as unavailable, so a request
// that never answers cannot hold a form behind an empty list.
const ROLES_TIMEOUT_MS = 5_000;
/** Roles an invitation can grant: `null` until the read settles, `[]` when it failed or timed out. */
export function useInvitationRoles(): InvitationRoleOption[] | null {
const [roles, setRoles] = useState<InvitationRoleOption[] | null>(null);
useMountEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout> | undefined;
// First answer wins: a late response or a timer after it is ignored.
const settle = (loaded: InvitationRoleOption[]) => {
if (!active) return;
active = false;
clearTimeout(timer);
setRoles(loaded);
};
timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS);
getInvitationRoles()
.then(settle)
.catch(() => settle([]));
return () => {
active = false;
clearTimeout(timer);
};
});
return roles;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { buildInvitationAcceptLink } from "./accept-link";
describe("buildInvitationAcceptLink", () => {
it("points the invitee at the accept page of the given origin", () => {
expect(
buildInvitationAcceptLink("abc123DEF45678", "https://app.example.com"),
).toBe(
"https://app.example.com/invitation/accept?invitation_token=abc123DEF45678",
);
});
it("keeps the token safe inside the query string", () => {
expect(buildInvitationAcceptLink("a b&c", "https://app.example.com")).toBe(
"https://app.example.com/invitation/accept?invitation_token=a%20b%26c",
);
});
});
+9
View File
@@ -0,0 +1,9 @@
const INVITATION_ACCEPT_PATH = "/invitation/accept";
/** Link an invitee opens to join the tenant; the API only hands back the token. */
export function buildInvitationAcceptLink(
token: string,
origin: string,
): string {
return `${origin}${INVITATION_ACCEPT_PATH}?invitation_token=${encodeURIComponent(token)}`;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { orderRolesAdminFirst } from "./order-roles";
describe("orderRolesAdminFirst", () => {
it("moves the admin role to the front and keeps the rest in order", () => {
const roles = [
{ id: "1", name: "member" },
{ id: "2", name: "Admin" },
{ id: "3", name: "auditor" },
];
expect(orderRolesAdminFirst(roles).map((role) => role.name)).toEqual([
"Admin",
"member",
"auditor",
]);
});
it("leaves the list untouched when there is no admin role", () => {
const roles = [
{ id: "1", name: "member" },
{ id: "2", name: "auditor" },
];
expect(orderRolesAdminFirst(roles)).toEqual(roles);
expect(orderRolesAdminFirst(roles)).not.toBe(roles);
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { InvitationRoleOption } from "@/types/onboarding-invite";
const ADMIN_ROLE_NAME = "admin";
export const isAdminRole = (role: InvitationRoleOption) =>
role.name.toLowerCase() === ADMIN_ROLE_NAME;
/** Admin first: the natural pick for a teammate who has to finish the setup. */
export function orderRolesAdminFirst(
roles: InvitationRoleOption[],
): InvitationRoleOption[] {
return [...roles].sort(
(a, b) => Number(isAdminRole(b)) - Number(isAdminRole(a)),
);
}
+41 -5
View File
@@ -3,11 +3,20 @@
// written there; without this marker an empty tenant would be redirected on
// every page load.
//
// The first run is resolved when the add-provider wizard actually opens, not
// when the redirect is issued: a navigation cut short (a second login, a tab
// closed mid-flight) must be retried on the next load. Each redirect counts as
// an attempt; after a few attempts that never reached the wizard the marker
// resolves anyway, so a browser can never be trapped in the redirect.
//
// Scoped per tenant, like the other onboarding markers: going through the first
// run in one tenant must not silence it for another one on the same browser.
// The bare key is a browser-wide opt-out: written before markers were scoped,
// by e2e storage state, or when no usable tenant id exists.
const FIRST_RUN_MARKER_KEY = "prowler.onboarding.first-run";
const HANDLED_VALUE = "true";
export const FIRST_RUN_MAX_ATTEMPTS = 3;
// Tenant ids are UUIDs; anything else is refused rather than concatenated
// into a storage key.
@@ -21,23 +30,50 @@ export function firstRunMarkerKey(tenantId?: string | null): string {
return `${FIRST_RUN_MARKER_KEY}.${tenantId.toLowerCase()}`;
}
// A stored value is either an attempt count (digits only) or `HANDLED_VALUE`;
// anything else (a legacy or hand-written marker) is read as resolved.
const ATTEMPT_COUNT_PATTERN = /^\d+$/;
function readAttempts(value: string | null): number | null {
if (value === null) return 0;
return ATTEMPT_COUNT_PATTERN.test(value) ? Number(value) : null;
}
export function isFirstRunHandled(tenantId?: string | null): boolean {
if (typeof window === "undefined") return true;
try {
return (
window.localStorage.getItem(FIRST_RUN_MARKER_KEY) !== null ||
window.localStorage.getItem(firstRunMarkerKey(tenantId)) !== null
// The bare key opts the whole browser out, unless it merely holds the
// attempt count of a deployment that mounts the gate without a tenant id.
const bareAttempts = readAttempts(
window.localStorage.getItem(FIRST_RUN_MARKER_KEY),
);
if (bareAttempts === null) return true;
const attempts = readAttempts(
window.localStorage.getItem(firstRunMarkerKey(tenantId)),
);
return attempts === null || attempts >= FIRST_RUN_MAX_ATTEMPTS;
} catch {
// Unreadable storage must not redirect forever: treat as handled.
return true;
}
}
export function markFirstRunHandled(tenantId?: string | null): void {
export function recordFirstRunAttempt(tenantId?: string | null): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(firstRunMarkerKey(tenantId), "true");
const key = firstRunMarkerKey(tenantId);
const attempts = readAttempts(window.localStorage.getItem(key));
if (attempts === null) return;
window.localStorage.setItem(key, String(attempts + 1));
} catch {
// Non-fatal: a repeated redirect beats a thrown render.
}
}
export function markFirstRunHandled(tenantId?: string | null): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(firstRunMarkerKey(tenantId), HANDLED_VALUE);
} catch {
// Non-fatal: a repeated redirect beats a thrown render.
}
@@ -389,6 +389,10 @@ export const buildOracleCloudSecret = (
[ProviderCredentialFields.OCI_TENANCY]:
providerUid ||
getFormValue(formData, ProviderCredentialFields.OCI_TENANCY),
[ProviderCredentialFields.OCI_REGION]: getFormValue(
formData,
ProviderCredentialFields.OCI_REGION,
),
[ProviderCredentialFields.OCI_PASS_PHRASE]: getFormValue(
formData,
ProviderCredentialFields.OCI_PASS_PHRASE,
@@ -0,0 +1,72 @@
import type { ComboboxGroup } from "@/components/shadcn/combobox";
// Keep in sync with OCI_REGIONS in prowler/providers/oraclecloud/config.py
const OCI_COMMERCIAL_REGIONS = [
"af-casablanca-1",
"af-johannesburg-1",
"ap-batam-1",
"ap-chuncheon-1",
"ap-hyderabad-1",
"ap-kulai-2",
"ap-melbourne-1",
"ap-mumbai-1",
"ap-osaka-1",
"ap-seoul-1",
"ap-singapore-1",
"ap-singapore-2",
"ap-sydney-1",
"ap-tokyo-1",
"ca-montreal-1",
"ca-toronto-1",
"eu-amsterdam-1",
"eu-frankfurt-1",
"eu-madrid-1",
"eu-madrid-3",
"eu-marseille-1",
"eu-milan-1",
"eu-paris-1",
"eu-stockholm-1",
"eu-turin-1",
"eu-zurich-1",
"il-jerusalem-1",
"me-abudhabi-1",
"me-dubai-1",
"me-jeddah-1",
"me-riyadh-1",
"mx-monterrey-1",
"mx-queretaro-1",
"sa-bogota-1",
"sa-santiago-1",
"sa-saopaulo-1",
"sa-valparaiso-1",
"sa-vinhedo-1",
"uk-cardiff-1",
"uk-london-1",
"us-ashburn-1",
"us-chicago-1",
"us-phoenix-1",
"us-sanjose-1",
];
const OCI_GOVERNMENT_REGIONS = [
{ value: "us-langley-1", label: "us-langley-1 (US Gov West)" },
{ value: "us-luke-1", label: "us-luke-1 (US Gov East)" },
{ value: "us-gov-ashburn-1", label: "us-gov-ashburn-1 (US DoD East)" },
{ value: "us-gov-chicago-1", label: "us-gov-chicago-1 (US DoD North)" },
{ value: "us-gov-phoenix-1", label: "us-gov-phoenix-1 (US DoD West)" },
];
export const OCI_REGION_GROUPS: ComboboxGroup[] = [
{
heading: "Commercial",
options: OCI_COMMERCIAL_REGIONS.map((region) => ({
value: region,
label: region,
})),
},
{ heading: "Government", options: OCI_GOVERNMENT_REGIONS },
];
export const OCI_REGION_VALUES = OCI_REGION_GROUPS.flatMap((group) =>
group.options.map((option) => option.value),
);
@@ -41,6 +41,8 @@ export type WizardOpenSource =
export const PROVIDER_FUNNEL_METHOD = {
SINGLE: "single",
ORGANIZATION: "organization",
// The user cannot connect the account and hands it to a teammate instead.
INVITE_TEAMMATE: "invite_teammate",
} as const;
export type ProviderFunnelMethod =
+1 -1
View File
@@ -98,7 +98,7 @@
"marked": "15.0.12",
"modern-screenshot": "4.7.0",
"nanoid": "5.1.16",
"next": "16.3.3",
"next": "16.3.6",
"next-auth": "5.0.0-beta.32",
"next-themes": "0.2.1",
"posthog-js": "1.407.2",

Some files were not shown because too many files have changed in this diff Show More