```
Refactor scraperhelper internal telemetry to use mdatagen

The `scraperhelper` package currently creates its OpenTelemetry metrics for
scraped and errored metric points manually — by obtaining a meter directly,
building counters with hardcoded names and descriptions, and storing them as
individual fields on the `ObsReport` struct.

This should be migrated to the `mdatagen` code-generation approach used
elsewhere in the collector, so that the metrics are defined in a
`metadata.yaml` file and the corresponding `TelemetryBuilder` type is
generated.  After the change:

• The manual counter-fields (`scrapedMetricsPoints`, `erroredMetricsPoints`)
  are replaced with a single `*metadata.TelemetryBuilder` field on
  `ObsReport`.
• The `recordMetrics` method uses the builder’s typed counter methods
  instead of the raw `metric.Int64Counter.Add` calls.
• The metric **names**, **descriptions**, and **units** must stay identical
  to the current ones — any observable difference would break existing
  dashboards and alerts.
• The `//go:generate mdatagen metadata.yaml` directive is added to the
  package so that `go generate` will regenerate the metadata code when the
  YAML changes.
• The existing hand-written `package_test.go` (the `TestMain` that calls
  `goleak.VerifyTestMain`) is replaced by the standard
  mdatagen-generated package test file.

No externally visible change in emitted telemetry is expected; this is a
pure refactoring of how the metrics are registered and recorded.
```

[chore] use mdatagen scraperhelper (#10095)


**Title:** Simplify retry‑to‑queue feedback loop in exporterhelper

**Background**
The sender pipeline in `exporterhelper` currently wires a callback from the `retrySender` into the `queueSender`. When retries are exhausted (or interrupted by shutdown), the `retrySender` calls back into the `queueSender` to re‑enqueue the request. This coupling complicates the senders and will make it harder to introduce independent controls for re‑queueing in the future.

**Desired behavior**
Remove the callback mechanism and let the queue sender’s consumer function decide what to do based solely on the error returned by the next sender. The observable behaviour must stay exactly the same:

1. **When the retry sender is enabled** (the typical case): non‑permanent failures must still be re‑enqueued by the queue sender, just as they are today.
2. **When the retry sender is explicitly disabled** (`RetrySettings.Enabled == false`): the queue sender must **not** re‑enqueue on failure. Instead, it must log and drop the data (a temporary failure effectively becomes permanent when no retries are configured).

**Key constraints & edge cases**
- **Permanent errors** (as defined by `consumererror.IsPermanent`) must not trigger re‑queueing or any extra logging from the queue sender – the retry sender already logs them.
- **Re‑queueing disabled + transient failure**: the queue sender must emit an error log (including the number of dropped items) and drop the request.
- **Re‑queueing enabled but queue full/stopped**: the drop path must still log the failure and the dropped item count.
- The retry sender itself should no longer need to know about re‑queueing; it should simply return the last error it encountered, and the queue sender’s consumer acts on it.
- All existing logging, observability reporting, and drop‑counting behaviour must be preserved.

**Scope**
Implement this within `exporterhelper`, removing the `onTemporaryFailure` style callback entirely and updating the queue’s consumption function accordingly.

[chore] [exporterhelper] Remove retry sender -> queue sender callback (#8985)


When SCIM requests a user by userName filter (GET `/scim/v2/Users?filter=userName eq "..."`), deleted (soft-deleted) users are returned in the response. This misleads external identity providers (IdPs) into believing the user record is still active and available. IdPs may then attempt to update or synchronize the deleted record instead of creating a new one, causing synchronization failures and possible data inconsistencies.

Additionally, when an IdP sends a SCIM POST to create a user whose username matches a previously-deleted user in Passbolt, the system cannot properly reactivate that user. The lookup method `findExistingUserEntity` only considers active (non-deleted) users, so the creation either fails with a duplicate username error (because the username is still reserved by the deleted record) or creates a new user with a different identifier, losing the intended association.

**Expected behavior**
- SCIM GET endpoints (list/filter queries) must never return soft-deleted user resources. They should behave as if the deleted user does not exist.
- The internal lookup used during SCIM user creation should be able to optionally include soft-deleted users, so that the creation logic can decide to reactivate an existing record when appropriate (for example, when a user was manually deleted in Passbolt and later re-provisioned by the IdP without a prior SCIM entry).
- The username matching in lookups should use the table alias to avoid any ambiguity, ensuring stable queries.

**Edge cases and constraints**
- The change must not expose deleted users through any external SCIM interface (GET, queries, etc.).
- The optional inclusion of deleted users must only be used internally during the creation flow and never triggered by external parameters.
- The creation flow for a user that already exists in Passbolt (active, without prior SCIM entry) must still work: the existing user must be reused, a SCIM entry created, and optional profile updates applied.
- Any reactivation of a deleted user must restore the original account without granting access to any data that was owned by the user before deletion, as per existing Passbolt behavior.

**Impact**
Synchronization between Passbolt and external SCIM-compatible IdP (Azure, Okta, etc.) is unreliable when users are reinstated after deletion. Administrators may experience obscure errors and inconsistent user state without a clear path to recovery.

PB-43455_SCIM - Add some integration tests for SCIM user create


The TOTP secret used for provisioning is generated from 256 random bytes, which after Base32 encoding yields an excessively long string. This results in QR codes that are large, dense, and difficult for authenticator apps to scan, especially on smaller screens or poor cameras. There is no way to reduce that length without modifying the source code.

As an administrator, I need to be able to configure a shorter secret length through the existing environment-based configuration (`PASSBOLT_PLUGINS_MFA_TOTP_SECRET_LENGTH`). A reasonable default length should be 32 bytes (aligned with typical TOTP implementations and the underlying library default). A minimum length of 16 bytes must be enforced to avoid creating unacceptably weak secrets.

When the configured value is not present, non-numeric, empty, or an unsupported type (e.g., array, boolean, object), the system should silently fall back to the default 32 bytes. Values configured below the minimum (including zero) should be clamped up to 16.

This change should only affect the generation of new TOTP secrets during setup; existing secrets must remain unchanged. The QR code rendering must continue to work correctly with the new, shorter secrets.

PB-22019 - As AD, I can configure TOTP secret length


As a user, I want to delete a metadata session key so I can remove keys I no longer need.

### Acceptance Criteria

- **Endpoint availability**: A `DELETE` request to `/metadata/session-keys/{id}.json` where `{id}` is a UUID of a metadata session key.
- **Authentication required**: Requests without valid authentication tokens must be rejected with a `401` (or equivalent authentication error).
- **Request format**: The endpoint only responds to requests with the `.json` extension. Requests to `/metadata/session-keys/{id}` (without `.json`) must return a `404`.
- **Valid UUID constraint**: If `{id}` is not a UUID, the endpoint must return a `400 Bad Request` with a descriptive message (“The metadata session key identifier should be a UUID.”).
- **Ownership enforcement**: The key is deleted only if it belongs to the currently authenticated user. If the key does not exist or belongs to a different user, the endpoint must return a `404 Not Found` with a message indicating that the key does not exist or does not belong to the user.
- **Successful deletion**: When all conditions are met, the key is permanently removed from storage. The response must be a `200 OK` with a JSON body containing a success message (e.g., “The operation was successful.”).
- **Idempotency / double deletion**: Attempting to delete the same key twice or a non-existent key results in a `404` with the same ownership message, as the key is no longer found (and is not owned).

PB-35152 Add DELETE /metadata/session-keys/<uuid>.json endpoint


**Title:** PB-41382 – Prevent cross‑user personal tag contamination when adding by slug

**Background**
When a user adds a tag to a resource using an existing slug, the system looks up whether a tag with that slug already exists. If it does, it re‑uses it. This lookup currently ignores tag ownership, so a personal tag belonging to user A can be inadvertently linked to user B. As a result, a personal tag becomes visible/usable by another user, violating the personal‑tag isolation contract.

**Acceptance Criteria**

1. **Isolation when adding personal tags by slug**
   - **Given** user A has a personal tag “important” on a resource
   - **When** user B (who also has access to that resource) adds a tag with the same slug “important”
   - **Then** a **new, distinct** personal tag is created for user B and associated with the resource.
   - User A’s original personal tag remains unchanged and is never linked to user B’s addition.

2. **Shared tags are still reused**
   - **Given** a shared (non‑personal) tag “shared‑t” exists on a resource
   - **When** any authorised user adds a tag using the slug “shared‑t”
   - **Then** the existing shared tag is reused (no duplicate tag record is created).

3. **Personal tag reuse for the same owner**
   - **Given** user A already has a personal tag “my‑tag” on a resource
   - **When** user A adds a tag with slug “my‑tag” again
   - **Then** the existing personal tag is reused (current behaviour preserved).

4. **Encrypted‑ID path respects ownership**
   - **When** a user adds a tag by providing tag identifiers (e.g. v5 encrypted‑metadata IDs), the lookup must only return tags that are either shared or owned by that user.
   - Identifiers referencing another user’s personal tags must not be automatically linked (the system may reject the request or treat them as new, but never silently re‑associate them with another user).

5. **API response and resource view consistency**
   - After adding a tag, when any user fetches the resource, they must only see shared tags plus their own personal tags. No personal tags from other users should appear in their tag list.
   - The response from the “add tags” endpoint must accurately reflect the newly created (or reused) tag with the correct `id` and ownership.

6. **No breaking change for existing shared‑tag workflows**
   - All existing behaviour for shared tags (including group‑owned resources, shared tag encryption, etc.) must remain unchanged.

**Non‑Functional Constraints**

- The fix must apply to both v4 clear‑text slug additions and v5 metadata tag additions.
- Tag counts and resource‑tag associations in the database must remain consistent; no orphan records or unexpected cascades.
- The lookup must remain case‑sensitive for slugs, matching the current contract.

**Edge Cases**

- Two users adding the same slug **concurrently** on the same resource must not create duplicate tags for the same user (each user gets at most one personal tag with that slug, guaranteed by standard concurrency control).
- If a tag slug matches a personal tag owned by the same user on a **different** resource, that tag should be reused as per normal slug‑matching logic (the filter is scoped to the current user, not just current resource).

PB-41382 Fixes the adding of a new personal tag with existing slug for another user


The directory settings controller should stop leaking the LDAP password in plaintext responses and should preserve the stored password when a client omits it from an update request.

**Acceptance criteria / behavior:**

1. **Obfuscate the password in responses**
   In `DirectorySettingsController`, ensure the `password` field is obfuscated (e.g., replaced with a mask) in all JSON responses, so the raw LDAP password is never sent to the browser.

2. **Preserve existing password on partial update**
   When the client sends an update request that omits the `password` field, `LdapConfigurationForm::formatFormDataToOrgSettings()` must not clear the stored password. Instead, it must fall back to the current value from `DirectoryOrgSettings`. If the field is explicitly present (even if empty), that explicit value should be honoured.

**Non-obvious constraints / edge cases:**

- If no password has ever been saved, an absent password should result in an empty string (existing behaviour, unless explicitly set).
- The obfuscation must apply to the `view` action and any other action that returns settings data.
- The password fallback must work domain‑scoped; the default domain is `org_domain`.

PB-23599 - Add ObfuscateFieldsComponent to DirectorySettingsController


PingOne SSO provider settings must include the PingOne environment ID so that Passbolt can construct correct authentication and API URLs for a given PingOne environment. Currently, the `SsoSettingsPingOneDataForm` and `SsoSettingsPingOneDataDto` do not accept or store an `environment_id`, which means draft settings created via `POST /sso/settings.json` for the PingOne provider are incomplete and unusable for actual authentication flows.

**Requested changes:**

1. **Add `environment_id` as a required field in `SsoSettingsPingOneDataForm` validation.**
   - Must be present and non-empty.
   - Must be a valid UUID (the PingOne environment ID is a UUID).

2. **Add `environment_id` to `SsoSettingsPingOneDataDto`.**
   - Accept it from the input data array on construction (default to empty string if missing, consistent with other optional fields).
   - Include it in the `toArray()` output so it is persisted correctly with the draft settings.

3. **Disable the PingOne provider in `SsoIntegrationTestCase::$disabledSsoProviders`**, consistent with how the generic OAuth2 and ADFS providers are already disabled by default in integration tests.

**Acceptance criteria:**
- A `POST /sso/settings.json` with `"provider": "pingone"` and valid `data` (including `environment_id` as a UUID) succeeds and returns the stored `environment_id` in the response under `data`.
- Omitting `environment_id`, passing an empty string, or passing a non-UUID value causes a 400 validation error on `data.environment_id`.
- The new field is included alongside the existing PingOne-specific fields (`email_claim`) in the DTO and does not interfere with the inherited OAuth2 fields (`url`, `client_id`, `client_secret`, etc.) or with the PingOne defaults (`openid_configuration_path`, `scope`).

**Out of scope:**
- No changes to the ADFS or Azure provider forms/DTOs.
- No changes to the SSO login/authentication flows; this is only about accepting and storing the field in draft settings.

PB-50354 PINGSSO - WP2.1 Update Create Draft Settings endpoint to store PingOne provider settings


**Title:** `FoldersViewController::view()` does not render v5 folder format

**Background**
The `POST /folders.json` and `PUT /folders/<uuid>.json` endpoints have been updated to return v5 folder format: when a folder holds encrypted metadata (`metadata` + `metadata_key_id`), the response omits the plain-text `name` field and instead exposes `metadata`, `metadata_key_id`, and `metadata_key_type`.
The `GET /folders/<uuid>.json` view endpoint currently always returns `name`, breaking the v5 contract and potentially leaking plain-text folder names that should only be decrypted client-side.

**Expected behaviour**
`FoldersViewController::view()` must conditionally render the folder entity using the same logic as the create and update controllers. Specifically:

- For v4 folders (no `metadata`/`metadata_key_id`): response must include the `name` field as before; no `metadata`-related fields.
- For v5 folders (with `metadata` and `metadata_key_id`): response must omit `name` and include `metadata`, `metadata_key_id`, and `metadata_key_type`.
- All existing `contain` options (`children_folders`, `children_resources`, `permissions`, `permissions.user.profile`, etc.) must continue to work. Nested child entities must be rendered according to their own format (v4 children keep `name`, v5 children omit it and show metadata fields).
- The `personal` and `folder_parent_id` properties must remain present for both formats.
- The endpoint must not break existing v4 consumers – responses for v4-only folders must be identical to the current behaviour.

**Implementation notes**
A `MetadataFolderDto::fromArray($folder)` can determine whether the folder is v5, and `MetadataFoldersRenderService::renderFolder()` already encapsulates the format transformation. These are already used in `FoldersCreateController` and `FoldersUpdateController`. The work consists of integrating them into the `view()` action, right after the folder is fetched, in the same way the other controllers do.

**Acceptance criteria**
- Sending `GET /folders/<v5_folder_uuid>.json` returns `metadata`, `metadata_key_id`, `metadata_key_type` and no `name`.
- Sending `GET /folders/<v4_folder_uuid>.json` returns `name` and no metadata-related fields.
- Contained children (`?contain[children_folders]=1`, `?contain[children_resources]=1`) each respect their own v4/v5 format.
- Contained permissions with user profiles work as before.
- No changes to the error handling (404 when folder doesn't exist or isn’t visible to the user).

PB-35363 Update GET /folders/<uuid>.json to support v5 format


**Bug Report: Missing GPG message validation in session-based authentication stage 0**

**Summary**
The session-based GPG authentication flow (`GpgAuthenticator::_stage0`) does not validate that the incoming `server_verify_token` is a syntactically valid PGP message before attempting to decrypt it. This is inconsistent with the JWT-based flow (`GpgJwtAuthenticator::assertArmoredChallenge`), which already performs such validation. The omission can lead to unclear or misleading errors (e.g., internal exceptions from the OpenPGP backend) when a client sends a malformed or empty token.

**Affected Component**
`App\Authenticator\GpgAuthenticator` – the `_stage0` method, which processes the initial GPG verify-response handshake.

**Current Behavior**
- In `_stage0`, the `server_verify_token` field from the request (`$this->_data['server_verify_token']`) is passed directly to `$this->_gpg->decrypt(...)`.
- No prior check ensures the value is a non-empty string or a well-formed PGP message.
- If the client sends an empty value, a non-string, or arbitrary garbage, the decryption call may throw a low-level exception or produce a confusing server error, rather than a clear client-facing 400 response.

**Expected Behavior**
Before attempting decryption, `_stage0` should verify that `server_verify_token` meets the same basic validity criteria applied to the armored challenge in the JWT path:

1. The value must exist and be a string.
2. It must be recognized as a valid PGP message by the OpenPGP backend (`OpenPGPBackendInterface::isValidMessage()`).

If either condition fails, the method should throw a `BadRequestException` with a context-appropriate message, such as **“The server verify token is missing or invalid.”**

This validation should be performed at the very beginning of the handshake (stage 0), before any decryption or further processing, mirroring the logic that already exists in `GpgJwtAuthenticator::assertArmoredChallenge` (which checks `isValidMessage` for the armored challenge).

**Why This Matters**
- **User experience:** Instead of an opaque 500 error or an unhelpful internal exception, the API consistently returns a 400 Bad Request with a clear explanation.
- **Security posture:** Early, explicit rejection of malformed input prevents any potential leak of sensitive information that might arise from deep decryption failures.
- **Consistency:** Both the session-based and JWT-based GPG authentication flows should enforce the same input validation guarantees.

**Acceptance Criteria**
- `GpgAuthenticator::_stage0` validates `server_verify_token` using the same `isValidMessage` check as the JWT authenticator.
- When the token is missing, null, not a string, or not a valid PGP message, a `BadRequestException` is thrown with the message “The server verify token is missing or invalid.”
- The existing JWT-side validation (`GpgJwtAuthenticator::assertArmoredChallenge`) remains unchanged and continues to work as before, with its own specific error message.
- All other authentication logic proceeds only after the token passes the validity check.

PB-19200 - Assert that GPG message is valid


Add an email notification when an admin activates SSO settings. Model it after the existing deletion-notification flow (`SsoSettingsActiveDeletedEmailRedactor`).

**Behavior**
- Trigger: after the `SsoSettingsActivateService` successfully activates settings (`AFTER_ACTIVATE_SSO_SETTINGS_EVENT`).
- Recipients: all active admins **except** the operator who performed the activation.
- Email subject: `{operator first name} activated the SSO setting` (localized per recipient).
- Template: a new HTML email template at `Passbolt/Sso.AD/sso_settings_activated`.
- Content: avatar block with operator details and modification time, a line stating the SSO provider and operator, a note that all users may now use SSO, IP and user-agent block, and a button linking to the SSO administration page (`/app/administration/sso`).
- The event must carry `uac` (ExtendedUserAccessControl) and the `ssoSetting` entity; the redactor should validate both.

**Registration**
- Add the new redactor to `SsoSettingsRedactorPool::getSubscribedRedactors()`.
- Ensure the `SsoSettingsActivateService` passes `ssoSetting` in the event data.

Only admins receive the email; the operator is filtered out. The email collection must contain one email per eligible admin.

PB-21570 - Send email notification when SSO setting get activated


**Feature Request: Soft-delete filter and resource count for Resource Types API**

The `GET /resource-types.json` endpoint must support optional filtering by soft-deleted status and optional inclusion of a resource count, gated behind the `passbolt.v5.enabled` feature flag.

### Behavior when `passbolt.v5.enabled` is **true**

1. **Filter by deleted status**
   - Accept query parameter `filter[is-deleted]=1` to return only soft-deleted resource types (those with a non-null `deleted` timestamp).
   - Accept `filter[is-deleted]=0` to return only non-deleted resource types (those with a null `deleted` timestamp).
   - When the parameter is omitted, all resource types are returned, regardless of deletion state.
   - The value must be boolean-like (e.g., `1`, `0`, `true`, `false`). Any other value (e.g., `foo`) must result in a `400 Bad Request` with a descriptive error message indicating an invalid filter value for `is-deleted`.

2. **Include resource count**
   - Accept query parameter `contain[resources_count]=1` to add a `resources_count` field to each resource type object.
   - This field holds the total number of resources associated with that resource type (including soft-deleted resources, as applicable).
   - **Access restriction**: The count is only added when the authenticated user has an administrator role. Non-admin requests that supply the parameter must still succeed with the normal response, but the `resources_count` field must be **omitted** entirely (no error).
   - The parameter value must be boolean-like. Non‑boolean values (e.g., `foo`) must result in a `400 Bad Request` with an appropriate error message.

3. **No side effects**
   - All other response structures, HTTP success codes, and pagination (if any) remain unchanged.
   - The `deleted` column must already exist in the `resource_types` table (added via migration) and is the basis for the soft-delete filter.

### Behavior when `passbolt.v5.enabled` is **false**

- The endpoint must ignore the `filter[is-deleted]` and `contain[resources_count]` parameters entirely and behave exactly as it did before these capabilities were introduced.

PB-34609 Adds is-deleted filter and resources_count contain to ResourceTypesIndexController.php


We need a rate-limiting mechanism for MFA verification attempts. After a user logs in, they must complete a second factor. If they repeatedly submit incorrect MFA responses, the system should eventually block further MFA attempts until they re-authenticate.

**Acceptance Criteria:**

1. **Attempt counting** – Failed MFA verification attempts are counted per user. Only failures that occur *after* the user’s most recent successful login are considered. A new successful login resets the counter.
2. **Configurable threshold** – The maximum allowed failed attempts is controlled by the environment variable `PASSBOLT_SECURITY_MFA_MAX_ATTEMPTS`. The default value is `4`.
3. **Blocking** – When the number of failed attempts exceeds the configured threshold, the MFA verification endpoint must reject the request (i.e., treat the limit as exceeded).
4. **Unlimited mode** – Setting `PASSBOLT_SECURITY_MFA_MAX_ATTEMPTS` to `0` (or any value less than 1) disables the limit entirely; no matter how many failures occur, the user is never blocked.
5. **Persistence via action logs** – The count is derived from existing action logs; no new storage is introduced. Only failed TOTP verification actions (`TotpVerifyPost.post`) with a `status` of `1` that were created after the most recent successful login action (`AuthLogin.loginPost`) are counted.

PB-23591 - Add MfaRateLimiter service class


**Title:** Move comment deletion logic out of the controller and into a dedicated service to improve isolation, testability, and input validation

**Observed behavior:**
The `CommentsDeleteController::delete()` action currently contains all business logic for deleting a comment – ID validation, fetching the comment, performing the delete with ownership check, and error handling. This makes it hard to unit test the deletion rules independently of the HTTP layer. Additionally, there is no explicit validation of the user identifier passed to the process; if the session user ID is missing or empty, the deletion attempt could fail with a confusing internal error rather than a clear response.

**Expected behavior:**
The comment deletion logic should live in a separate service class (`CommentsDeleteService`) that:
- Accepts a comment ID (string) and a user ID (string|null).
- Validates that the comment ID is a valid UUID and that the user ID is not null or empty, throwing a `BadRequestException` with a clear message on invalid input.
- Fetches the comment and deletes it with the ownership constraint (`'Comments.user_id' => $userId`).
- Returns nothing on success and throws a `NotFoundException` if the comment is missing or the user is not the owner, or a `BadRequestException` for other deletion errors.
- Is fully unit-testable without requiring an HTTP context.

The controller should then be reduced to a thin layer that instantiates the service, calls `delete($id, $this->User->id())`, and sends the success response. All existing endpoint behavior (response codes, error messages, side effects) must remain unchanged.

**Impact:**
- Improves separation of concerns and maintainability.
- Enables thorough unit testing of deletion rules (owner check, input validation) outside of integration tests.
- Prevents potential undefined behavior when the user ID is not available, by explicitly rejecting such requests.
- Aligns with the project’s ongoing refactoring toward service-oriented architecture.

**Additional context:**
This change is part of a larger effort to extract business logic from controllers (see related work on other comment controllers). The existing integration tests in `CommentsDeleteControllerTest` should be adapted to use factories instead of fixtures, and new service-level tests should be added to cover input validation and ownership scenarios previously only tested through the controller.

PB-19235 - Refactor delete comments controllers into services


# PB-52095 – Infinite redirect loop on login after edition change

## Summary

After an edition change (CE → PRO or PRO → CE), users who attempt to log in via the web browser may become stuck in an endless redirect loop. The login page loads, credentials are submitted, and the application immediately redirects back to the login page—repeatedly.

## Steps to reproduce

1. Have a Passbolt instance with at least one active user session that predates an edition change.
2. Perform an edition change (e.g., CE → PRO or PRO → CE).
3. With the `passbolt.editionLastChangeDateTime` config value set by the edition manager, attempt to log in as a user whose previous session was created before that timestamp (or browse to any page requiring authentication).

## Expected behaviour

- The `LogoutUsersOnEditionChangeMiddleware` should force re-authentication for **existing** sessions that predate the change, and then allow the new login to proceed normally.
- The browser should land on the login page **once** (either via a `302` to `/auth/login` for HTML requests, or via a `401` JSON envelope for API clients), and after entering valid credentials the user should enter the application.
- The middleware must not interfere with the authentication handshake itself.

## Actual behaviour

When the user attempts to log in after the edition change, the middleware incorrectly triggers **during the login flow** and sends another `401` or `302` before the new session is fully established. This results in a second redirect to the login page. Because the condition fires on every request where the identity attribute is present but the session hasn't been persisted yet, the browser loops indefinitely.

In some configurations (especially subpath installs, e.g. `/passbolt/`) the redirect loop can also be worsened by the session cookie not being properly expired—the browser ignores the expiry if the cookie's `path`, `domain`, or `secure` attributes do not match the original session cookie.

## Context and constraints

- The middleware must only invalidate **requests that already carry a pre-existing session** (i.e., the session already contains the `Auth` key). Requests that are still in the middle of the GPG-based authentication handshake—where the `identity` attribute may be set but `persistIdentity()` has not yet run—must be passed through to the handler without modification.
- JWT/API token requests are stateless and must not be affected by this middleware (they are handled separately by the `LogoutAllUsersOnEditionChangeListener`).
- When a session **is** invalidated, the cookie expiry sent to the browser must match the original session cookie’s `name`, `path`, `domain`, `secure` flag, `httpOnly`, and `SameSite` attributes—as returned by `session_get_cookie_params()`. Otherwise the browser may ignore the `Set-Cookie` and continue sending the old session identifier, contributing to a different variant of the loop.
- The response shape must mirror the existing `AuthLogoutController`:
  - For JSON requests (`Accept: application/json` or `?format=json`): return `401` with the standard Passbolt error envelope (`header.status = "error"`, `header.message = "Your session expired due to an edition change. Please log in again."`, `body = null`).
  - For HTML requests: return `302` with `Location: /auth/login` (the GET login route).
  - The JSON branch must not produce a `Location` header or redirect.
- The existing operator escape hatch (`Configure::write('passbolt.plugins.edition.disableLogoutUsersOnEditionChangeMiddleware', true)`) must continue to work and completely bypass the middleware.

## Impact

Users are unable to log in after an edition change, effectively locking them out of the application. Affected versions: from 5.13.0 onward.

PB-52095 Fixes infinite redirect on login after edition update


**Request:** Prevent Host‑Header Fallback When `App.fullBaseUrl` Is Not Configured

**Context**
When `App.fullBaseUrl` is empty or missing, the application router uses the `Host` header from incoming requests to construct links and perform redirects. This creates a host‑header injection vector that an attacker can exploit to forge URLs and poison caches.

**Acceptance Criteria**

1. **New security toggle**
   A boolean configuration key `passbolt.security.preventHostHeaderFallback` is introduced (default: `false`). It can be set via the environment variable `PASSBOLT_SECURITY_PREVENT_HOST_HEADER_FALLBACK`.

2. **Strict mode – no fallback**
   When `passbolt.security.preventHostHeaderFallback` is `true` **and** `App.fullBaseUrl` evaluates to a non‑string or an empty string (e.g., `null`, `false`, `true`, `[]`, `''`), the application MUST reject any request that carries a `Host` header.
   - The response MUST be `400 Bad Request`.
   - The body MUST contain a clear message indicating that providing a `Host` header is not permitted when the base URL is unset.

3. **Safe‑by‑default**
   If the configuration key is `false` (the default), requests are processed as before – no rejection, even if `App.fullBaseUrl` is empty and a `Host` header is present.

4. **No interference when base URL is set**
   When `App.fullBaseUrl` is a non‑empty string (a valid URL), the middleware MUST NOT reject the request, regardless of the `preventHostHeaderFallback` setting.

5. **No interference when no Host header**
   If the incoming request does **not** contain a `Host` header, the middleware MUST allow it through under all configurations.

6. **Execution position**
   The check MUST run early in the middleware pipeline, before routing, to prevent any fallback logic from being triggered.

**Out of scope**
- Changing how `App.fullBaseUrl` is populated or validated.
- Preventing other forms of host‑header abuse when `App.fullBaseUrl` is already configured.

PB-38166 Fix passbolt app router should not fallback on Host header if fullbaseurl is not set


We need a single, well-defined representation of the application’s edition — Community Edition (`ce`) or Pro (`pro`) — that every other part of the code can depend on without comparing raw strings. Today the constants live in `EditionManager`, configuration and bootstrapping compare against the literal `'pro'`, and there is no central safeguard against invalid or trimmed values.

The desired behavior:

1. **Canonical constants**
   - There should be publicly available constants for `EDITION_CE = 'ce'` and `EDITION_PRO = 'pro'` that are the sole source of truth for valid edition literals.
   - Downstream code must use these constants, never compare against `'ce'` or `'pro'` directly.

2. **Immutable edition value**
   - An edition value object must be constructable from any string (or `null`).
   - The constructor trims whitespace and normalises case, then produces exactly `'ce'` or `'pro'`.
   - Any input that does not map to `'ce'` or `'pro'` (e.g., empty string, `'enterprise'`, `'PRO'` with extra spaces) is quietly normalised to `'ce'` (the safe default).
   - Once built, the edition cannot be changed.

3. **Inspection methods**
   - The object offers `isCe(): bool` and `isPro(): bool`.
   - `getEdition(): string` returns the normalised canonical literal.
   - A `toArray()` method returns `['edition' => <literal>]`.

4. **Integration with existing configuration and services**
   - `EditionManager` must reference the canonical constants instead of its own (so `EditionDto::EDITION_*` are the only places the strings `'ce'` and `'pro'` are defined).
   - The application bootstrap (`config/bootstrap.php`) and test bootstraps that set or read `Configure::read('passbolt.edition')` must compare against the new constants.
   - The test bootstrap that forces the edition to PRO for homogeneous CE testing must use the new constant.

Safety / correctness constraints:
- `Configure::read('passbolt.edition')` might be missing, empty, or contain unexpected whitespace; all such cases must result in the system behaving as CE.
- The normalisation must be tested independently of the rest of the system so that every consumer benefits from the same rules.

Delivery scope:
- Provide the edition DTO class as described.
- Update `EditionManager` and all usage sites that currently reference `EditionManager::EDITION_*` or literal `'pro'`/`'ce'` for edition comparison (especially in bootstrapping and test bootstrapping).
- No other behavioral changes are expected; the system must continue to boot, load plugins, and enforce feature-availability exactly as before, only with safer handling of the edition string.

PB-51951 MEP - WP 2.4 Create the EditionDto


When a user creates a resource via `POST /resources.json` and passes a `secrets` field whose first element is not an object/array (for example, `"secrets": ["data"]`), the server currently crashes with a 500 Internal Server Error instead of returning a proper validation error. The request handler assumes that if `secrets` is an array, its first element is also an array and attempts to assign user metadata into it, causing a fatal error.

The server must instead detect this malformed input and respond with a `400 Bad Request`, including a validation error that indicates the secret data is missing (e.g., `"secrets.0.data._required"`), consistent with how other missing/invalid secret fields are reported.

No other existing validation or authorization semantics should change. The fix must ensure that any `secrets` value that is not a properly structured array of secret objects triggers validation before the service attempts to inject user identifiers, avoiding any internal server error.
When a user creates a resource via `POST /resources.json` and passes a `secrets` field whose first element is not an object/array (for example, `"secrets": ["data"]`), the server currently crashes with a 500 Internal Server Error instead of returning a proper validation error. The request handler assumes that if `secrets` is an array, its first element is also an array and attempts to assign user metadata into it, causing a fatal error.

The server must instead detect this malformed input and respond with a `400 Bad Request`, including a validation error that indicates the secret data is missing (e.g., `"secrets.0.data._required"`), consistent with how other missing/invalid secret fields are reported.

No other existing validation or authorization semantics should change. The fix must ensure that any `secrets` value that is not a properly structured array of secret objects triggers validation before the service attempts to inject user identifiers, avoiding any internal server error.

PB-49323 As a user creating a resource, I should not get a 500 if the secret passed is not an array of secrets


```
Add POST /sso/pingone/login.json endpoint for starting PingOne SSO login.

- Accept JSON body with required `user_id` (UUID).
- Return JSON with a `url` field containing the PingOne authorization URL, and set a secure, HttpOnly `sso_state` cookie scoped to `/sso` (respecting `App.base` subdirectory if configured).
- The authorization URL must include: the PingOne environment’s authorization path, `client_id`, `environment_id`, scope (`openid email profile`), `redirect_uri` pointing to `/sso/pingone/redirect`, `state`, `nonce`, and `login_hint` set to the user’s username.
- Validate user exists, is active, and not deleted. If invalid/missing `user_id` or user is deleted/inactive, respond 400.
- Respond 403 if the requesting user is already authenticated.
- Respond 400 if no active PingOne SSO settings are configured.
```

PB-50387 PINGSSO - WP4.3 Create SsoPingOneStage1Controller – the controller that initiates the PingOne login flow


**Summary**
The self-registration settings API defines a route for `POST`/`PUT /self-registration/settings.json`, but the corresponding controller class (`SelfRegistrationSetSettingsController`) is missing. Any attempt to save self-registration configuration via the API fails with a fatal error, preventing administrators from enabling or modifying self-registration.

**Observed behavior**
- A request to `POST /self-registration/settings.json` returns an HTTP 500 error because the referenced controller does not exist (class not found).
- The `GET /self-registration/settings` endpoint works correctly and returns any previously stored settings.

**Expected behavior**
Administrators must be able to save self-registration settings through the API. The endpoint should:

1. **Restrict access to administrators** – reject requests from non-admin users with a `403 Forbidden` and appropriate message.
2. **Accept a JSON body** with the following structure:
   ```json
   {
     "provider": "<string>",
     "data": { ... }
   }
   ```
3. **Validate the payload** according to the selected provider:
   - `provider` is required and must be one of the supported identifiers (initially only `"email_domains"`).
   - `data` is required and must not be empty.
   - When `provider` is `"email_domains"`, the `data` object must contain an `allowed_domains` field with a non‑empty array of valid domain strings (e.g., `["example.com"]`).
   - If validation fails, return a `400 Bad Request` with a descriptive error message and details for each invalid field.
4. **Persist the validated settings** as an organization setting under the property key `selfRegistration` (the same key used by the GET endpoint).
5. **Return a success response** (HTTP 200) with a success message and the saved settings object.

**Impact**
- Administrators cannot configure self‑registration (e.g., email domain allowlists) through the API.
- UI tools that rely on this endpoint to toggle or update self‑registration are broken.

**Additional context**
- The routes file already maps `POST`/`PUT` to a `SelfRegistrationSetSettings` controller, but the controller and its supporting logic were not implemented.
- The implementation should reuse the existing form validation classes (`SelfRegistrationBaseSettingsForm` and its `email_domains` specialization) to avoid duplicating rules.
- The service should use the `OrganizationSettings` model’s `createOrUpdateSetting` method to store the configuration atomically.


Implement the `SsoRecoverStartService` so that its public method `generateAndGetRecoverUrl` produces the correct SSO recovery URL for a given user ID.

- For an **active** user, the returned URL must use the `/setup/recover/{userId}/{token}` path.
- For an **inactive** user, the URL must use `/setup/install/{userId}/{token}`.
- When the user does **not exist**, throw `Cake\Datasource\Exception\RecordNotFoundException`.
- When the user is **deleted**, throw `App\Error\Exception\ValidationException`.

The method must persist an authentication token of the correct type before building the URL. The logic that determines the token type (`recover` vs. `register`) must be private to the service.

PB-21580 - Add tests for SsoRecoverStartService


As an administrator, I need a way to verify that my PingOne SSO configuration is correct before activating it. To do this, the system should provide an endpoint that performs a dry-run login for PingOne. This endpoint must:

- Accept `POST` requests to `/sso/pingone/login/dry-run.json` (JSON only).
- Require that the route exists only when the PingOne provider is enabled (via the `passbolt.plugins.sso.providers.pingone` configuration flag).
- Require user authentication; return `401` if not logged in.
- Require the logged-in user to be an administrator; return `403` if not.
- Respect the global SSO endpoint security lock (if `SsoEndpointsSecurityMiddleware` is activated, return `403` with an appropriate message).
- Require a request body containing a valid `sso_settings_id` (UUID string). Missing, null, or non-UUID values should return `400`.
- Validate that the `sso_settings_id` corresponds to an existing SSO settings entry with provider `pingone` that is in `draft` status. If not found or not in `draft`, return `404`.

On success, the endpoint must:
- Construct a PingOne authorization URL (pointing to the PingOne authorize endpoint for the configured environment) containing:
  - `client_id` from the draft settings.
  - `response_type=code`.
  - `scope` including `openid`, `email`, and `profile`.
  - `redirect_uri` set to `<site>/sso/pingone/redirect`.
  - A randomly generated `state` and `nonce`.
  - `login_hint` set to the admin’s username (if available), to pre-fill the PingOne login screen.
- Set a secure cookie (via the existing cookie service) containing the generated `state` and metadata linking the dry-run to a settings-configuration flow.
- Return a JSON response with the constructed `url`.

No actual authentication with PingOne should occur at this stage; the endpoint only prepares the redirect URL for testing purposes.

PB-50385 PINGSSO - WP4.1 Create SsoPingOneStage1DryRunController – the controller that initiates the admin dry-run PingOne login


## Bug: Azure SSO prompts for password on every login despite `prompt` being set to `none`

**Problem**
When an administrator configures Azure SSO with `prompt` set to `none` (so that users who are already signed into Azure are not asked to re-enter their password), Passbolt still sends a `login_hint` query parameter in the authorization URL. In some Azure AD configurations, the presence of `login_hint` causes the identity provider to ignore the `prompt=none` directive and force a password prompt anyway. This defeats the purpose of the `prompt=none` setting and degrades the user experience—users are asked for their password every time they perform an SSO login, even when their Azure session is still valid.

**Expected behavior**
- The `login_hint` parameter should not be unconditionally appended to the Azure authorization URL.
- There should be a separate, explicit setting that controls whether `login_hint` is sent. When the setting is disabled, no `login_hint` query parameter should appear in the authorization URL at all.
- The new setting should default to the current behavior (sending `login_hint`) for backward compatibility, but administrators who want `prompt=none` to work correctly can turn it off.
- The setting should accept a boolean and be properly validated.

**Affected components**
- Azure SSO provider (`SsoAzureService` / `getAuthorizationUrl`)
- Azure SSO settings form, DTO, and persistence layer
- The `login_hint` value must be surfaced in the settings API responses so the UI can display and manage it.

**Constraints**
- Existing installations without the new field in stored data must keep the old behavior (i.e. `login_hint` defaults to `true`/enabled).
- The field must be optional in create/update payloads to avoid breaking API clients that don't send it.
- Casting must handle non-boolean inputs (e.g. string `"true"` / `"false"`) gracefully during massaging.

PB-40274 Fix azure SSO asking for password everytime even after setting prompt to false


**Bug Report: Group delete endpoints return incorrect resource fields for V4/V5 resources**

**Affected endpoints**
- `DELETE /groups/<uuid>.json` (error response when group is sole owner)
- `DELETE /groups/<uuid>/dry-run.json` (success response listing affected resources)

**Observed behavior**
When a group cannot be deleted because it is the sole owner of one or more resources, the 400 error response includes those resources – but the resources always contain a mix of V4 and V5 metadata fields, regardless of the resource’s actual version.

Concretely:
- V4 resources incorrectly expose V5-specific fields (e.g., `metadata_key_id`, `metadata_key_type`, `metadata`, etc.).
- V5 resources incorrectly expose V4-specific fields (e.g., `username`, `uri`, `description`, etc.).

The same field leakage happens in the success response of the dry-run endpoint, which returns the list of resources that would be affected by the deletion.

**Expected behavior**
Each resource in the response must only contain the fields belonging to its own version:
- For V4 resources: only V4 meta properties.
- For V5 resources: only V5 meta properties.

This is the behavior already enforced for the corresponding user deletion endpoints (`DELETE /users/<uuid>.json` and its dry-run), and it should be consistent across all endpoints that return resource lists.

**Impact**
API consumers (e.g., the browser extension) rely on the resource schema to parse and display data. Mixed fields can cause display errors, validation failures, or incorrect handling because the client expects a clean version-specific object.

**Acceptance / safety constraints**
- The version (V4/V5) of each resource must be detected and used to filter out the opposite version’s properties before including it in any response.
- The fix must cover both the error body (sole owner validation) and the dry-run success body.
- Group deletions that succeed (no sole-owner conflict) are not affected; only the cases where resources are returned back need correction.

**Additional context**
The `UsersDeleteController` already contains a `formatResources` method that performs this version-aware filtering. The group delete controller should apply the same logic to all response paths that return resource arrays.

PB-35163 Update DELETE /groups/<uuid>.json


**Title:** `action_logs_purge` command allows non-positive retention days, leading to unintended behavior

**Description:**
The `passbolt action_logs_purge` command accepts the `--retention-in-days` (`-r`) option without lower-bound validation. Supplying zero, a negative number, or a non-numeric string that casts to ≤0 does not produce an error. The command completes "successfully", but the resulting behavior is undefined — depending on the implementation, it may attempt to purge everything or produce unexpected database queries.

**Expected behavior:**
The command should reject any value ≤0 for the `--retention-in-days` option at the entry point, before any processing or database access occurs. It should output a clear, human-readable error message indicating that retention days must be greater than zero, and then exit with a non-zero exit code (failure).

**Affected component:**
`bin/cake passbolt action_logs_purge -r <value>`

**Steps to reproduce:**
```sh
bin/cake passbolt action_logs_purge -r -10   # or -r 0
```
Observe that the command exits with success and no error message.

**Proposed fix:**
Add a guard in the command's execution flow that checks the parsed `-r`/`--retention-in-days` integer value is at least 1. If not, log/print the error (e.g., "Retention in days option must be greater than zero.") and return a failure code immediately. The validation should happen before any dry-run or actual purge operations.

PB-34219 Add validation to retention days option in action_logs_purge command


**Feature Request: Reliable client IP detection when running behind a reverse proxy or load balancer**

### Problem
When a Passbolt instance is deployed behind a reverse proxy (e.g., Nginx, HAProxy, AWS ALB), the application sees the proxy’s IP as the client address instead of the true end‑user IP. This breaks all IP‑based security measures (e.g., IP whitelisting, rate limiting, audit logs) because `$_SERVER['REMOTE_ADDR']` contains the proxy’s IP.

Additionally, the browser extension cannot access the proxy‑forwarded headers (`X-Forwarded-For`, `X-Real-IP`, `Client-IP`) because they are not listed in the `Access-Control-Expose-Headers` response header. This prevents the extension from displaying the correct IP for debugging or client‑side checks.

A secondary issue exists: the GPG authentication middleware (and potentially other middleware) currently sets `Access-Control-Expose-Headers` by overwriting the entire header value, so any headers exposed by later middleware are lost.

### Expected behavior
1. **Server‑side IP resolution**
   When configured appropriately, Passbolt should trust the `X-Forwarded-For` / `X-Real-IP` / `Client-IP` headers from known proxies and use the actual end‑user IP internally (e.g., in `$request->clientIp()`).

2. **Browser‑exposed headers**
   When proxy support is enabled, the `Access-Control-Expose-Headers` response header must include `X-Forwarded-For`, `X-Real-IP`, and `Client-IP` so that the browser extension can read them. This must be added **without removing** other expose headers already set (e.g., the GPG authentication headers).

3. **Safe defaults**
   Proxy header trust must be **disabled by default** to prevent IP spoofing. Enabling it should require an explicit configuration change and an optional list of trusted proxy IPs to further restrict which headers are accepted.

### Configuration requirements
A new configuration section is needed under `passbolt.security.proxies`:

- `active` (boolean, default `false`) – when `true`, the `X-Forwarded-For`, `X-Real-IP`, and `Client-IP` headers are trusted and used for client IP determination.
- `trustedProxies` (array, default `[]`) – list of IP addresses of trusted proxies. If empty, all proxies are trusted (behaviour inherited from CakePHP’s `ServerRequest::setTrustedProxies()`). Users can override this in `config/passbolt.php`.

The settings should be controllable via environment variables `PASSBOLT_SECURITY_PROXIES_ACTIVE` and overridable in the configuration file.

### Technical constraints / implementation hints
- The middleware responsible for proxy handling must be inserted **after** the `CsrfProtectionMiddleware` in the middleware queue so that request tokens are already validated before IP mutation.
- The `GpgAuthHeadersMiddleware` currently uses `->withHeader('Access-Control-Expose-Headers', ...)` which **replaces** any previous value. It must be changed to `->withAddedHeader(...)` to preserve headers exposed by the new proxy middleware (and any future middleware).
- The `HTTP_HEADERS_WHITELIST` constant in `GpgAuthenticator` is currently a comma‑separated string. It should be converted to an array for easier combining with the proxy headers list.
- The proxy middleware should only set the response header when proxy support is active; otherwise the standard remote address is used and no extra expose headers are needed.

### Impact
Without this support, Passbolt instances behind common production architectures cannot correctly identify clients, rendering IP‑based security features useless and creating confusing audit trails. The lack of exposed proxy headers also limits client‑side diagnostics.

PB-21485 - Allow proxy headers to get client IP via proxies (v3.10)


We need a CLI command to disable multi-factor authentication for a given user, analogous to the existing web controller `DELETE /mfa/settings/{userId}.json`. The command should:

- Be named `mfa_user_settings_disable` and accept a required `--user-username` (short `-u`) argument.
- Only allow execution by the non-root process user (e.g., `www-data`); reject execution by root with an error.
- Find the user by username; if not found, exit with an error.
- Check whether MFA is currently **enabled** for the user (respecting org-level MFA settings and user-level account settings). If already disabled, exit with an error message to that effect (without altering settings).
- Disable the user’s MFA account settings and dispatch the `mfa.user_account.settings.delete` event so the existing email notification is sent.
- Re‑check that MFA is indeed disabled afterwards; if it remains enabled due to a failure, exit with an error.
- On success, output a confirmation and exit with success.

To avoid duplicating the MFA-disable logic currently embedded in `MfaUserSettingsDeleteController`, **extract that logic** (account settings deletion + event dispatch) into a shared service class. Both the controller and the new command must use this service.

Move the event name constant `MFA_USER_ACCOUNT_SETTINGS_DELETE_EVENT` from the controller into the new service, and update the email redactor `MfaUserSettingsResetEmailRedactor` to reference the new constant location.

The command must be registered via the plugin’s DI container and receive `ProcessUserService` as a dependency.

No changes to the controller’s HTTP behavior are expected; it should continue to return the same JSON responses.

PB-45567 Add MfaUserSettingsDisable command and move the logic shared with MfaUserSettingsDeleteController in a dedicated service class


We need to change the default behavior for email notifications in Passbolt. Specifically:

1. **Disable password/folder creation notifications by default**
   The `send_password_create` setting should default to `false`.
   Environment variable: `PASSBOLT_EMAIL_SEND_PASSWORD_CREATE`.

2. **Hide resource details in all notification emails by default**
   All `show_*` settings — `show_comment`, `show_description`, `show_secret`, `show_uri`, `show_username` — should default to `false`.
   Environment variables: `PASSBOLT_EMAIL_SHOW_COMMENT`, `PASSBOLT_EMAIL_SHOW_DESCRIPTION`, `PASSBOLT_EMAIL_SHOW_SECRET`, `PASSBOLT_EMAIL_SHOW_URI`, `PASSBOLT_EMAIL_SHOW_USERNAME`.

Both changes must be reflected in:
- `config/default.php` – the application’s default configuration
- `CoreNotificationSettingsDefinition::buildSchema()` – the schema definition that controls UI/API-advertised defaults

No other notification types are affected. Administrators can still re-enable these globally via environment variables or the admin settings interface.

Implementation notes:
- When `show_comment` is disabled, emails triggered by adding a comment must omit the comment body.
- When resource creation emails are disabled, no email is queued for that event.
- Existing behavior when these settings are explicitly enabled must remain unchanged.

PB-21906 Disable email notification on resource and folder creation and disable email content visibility by default


We want to simplify secret revision trimming and improve the endpoint response. Currently, when a new secret revision is created, the `SecretRevisionsTable::afterSave` hook soft-deletes both old `SecretRevision` rows **and** their secrets if they exceed a configured `maxRevisions` threshold. This leads to unintended loss of audit metadata, because the revision records themselves (who changed a resource, when, and what type of change) can be useful for logs and history even after the secret data is gone.

Likewise, the GET endpoint (`SecretRevisionsResourceGet`) returns all revisions, which can grow unbounded and cause large responses.

We want the following behavioral changes:

1. **Keep all SecretRevision rows forever** – only the associated `Secrets` should be pruned.
   The `maxRevisions` org setting (property `secretRevisions.maxRevisions`) represents the total number of revisions whose secrets we preserve: the current active revision plus up to `maxRevisions - 1` past (soft-deleted) revisions.
   When the threshold is exceeded, soft-delete the secrets of the oldest deleted revisions, but leave the `SecretRevision` records intact.

2. **The GET endpoint must respect the threshold.**
   Always return the active (non-deleted) revision first, followed by at most `maxRevisions - 1` most-recently deleted revisions (ordered by `deleted` descending).
   If the setting is absent or `maxRevisions` is 1, return only the active revision. This prevents the response from growing without bound.

3. **No changes to how the active revision is handled** – it must never be soft-deleted or excluded from responses regardless of the threshold.

4. **The pruning logic after save must continue to operate per resource** and only affect soft-deleted revisions; it must never touch secrets of the active revision or revisions from other resources.

Implement this in both `SecretRevisionsTable::afterSave` and `SecretRevisionsResourceGetController::get`. Keep the changes cohesive and straightforward.

PB-46400 Refactors the logic to trim secret relations for simplicity


**Title:** PB-23591 – Force logout when MFA TOTP failed attempts exceed limit

**Background**
Currently, repeated TOTP verification failures only return validation errors, but the user’s session (or JWT tokens) remains active. This allows an attacker to keep guessing the TOTP code indefinitely.

**Request**
Add forced logout logic to the TOTP verify POST controller so that when the number of consecutive failed TOTP attempts (including the current failing attempt) exceeds `passbolt.security.mfa.maxAttempts`, the user is immediately logged out.

**Acceptance Criteria / Behavior**
1. The check happens inside the `catch (CustomValidationException $exception)` block of the `post` action, **before** re-throwing the exception for JSON requests.
2. The failed attempt count must include the current request.
3. When the limit is exceeded, the user must be logged out. The precise logout steps depend on the request type and authentication method:
   - **Non-JSON (HTML) request:** redirect to the authentication logout endpoint (i.e., `$this->Authentication->logout()` redirection).
   - **JSON request with session authentication:** call `$this->Authentication->logout()` and throw a `BadRequestException` with a clear message (e.g., “You have been logged out due to too many failed attempts.”).
   - **JSON request with JWT authentication:**
     * Use `RefreshTokenLogoutService` to invalidate the refresh token.
     * Remove the `RefreshTokenAbstractService::REFRESH_TOKEN_COOKIE` from the response cookie collection.
     * Call `$this->Authentication->logout()`.
     * Throw the same `BadRequestException` as above.
4. No change is needed for non-TOTP MFA providers at this stage.

**Dependencies / Possible modifications**
- The `MfaRateLimiterService::isFailedAttemptsExceeded` signature or internal logic may need to accept an option to treat the current attempt as an additional failure, so that the check can be performed before the count is persisted but still count this attempt. The exact mechanism is open.
- The controller must know whether the request is using JWT authentication (e.g., by checking the request attribute `'authentication'` against `JwtAuthenticationService`).

**Config reference**
- `passbolt.security.mfa.maxAttempts` (default `5`) defines the attempt limit.

**Expected error response (JSON)**
HTTP 400 with body containing an appropriate “logged out” message.

PB-23591 - Logout if user exceeded failed attempts


We need to prevent the URL sanitizer from returning empty redirects and to consolidate MFA redirect-loop prevention into the sanitizer component with a configurable loop-stop path.

**User story:**
As a developer maintaining the MFA and redirect handling, I want `SanitizeUrlComponent` to always return a valid path (`/` when the redirect is empty) and to accept an optional loop-stop prefix, so that `MfaVerifyController` can reliably prevent redirects that would send the user back to any `/mfa/verify` page without duplicating loop detection logic.

**Detailed requirements:**

1. In `App\Controller\Component\SanitizeUrlComponent`:
   - `sanitize()`: when `$url` is empty or only whitespace, return `'/'` instead of an empty string.
   - `sanitizeRedirect()`: accept an optional `$redirectLoopStop` string argument.
     - When the argument is non-empty, treat it (pre‑matched against the sanitized redirect as a prefix) as the loop blocker; otherwise use the current request path (preserving current behaviour).
   - The existing prefix‑blacklist check already handles sub‑paths correctly — no change needed there.

2. In `Passbolt\MultiFactorAuthentication\Controller\MfaVerifyController`:
   - `beforeRender()`: call `$this->SanitizeUrl->sanitizeRedirect('/mfa/verify')`.
   - `_handleVerifySuccess()` (non‑JSON branch):
     - Replace the current manual check (`if (empty($redirect) || substr($redirect, 0, strlen($redirectLoop)) === $redirectLoop) { $redirect = '/'; }`) with a call to `$this->SanitizeUrl->sanitizeRedirect('/mfa/verify')`.
     - Use the result directly as the redirect target.

**Acceptance criteria / invariants:**
- An empty redirect query parameter now results in a redirect to `/`, not an empty string.
- If a redirect parameter resolves to a path that starts with the given loop‑stop prefix, the component returns `/`.
- All existing callers of `sanitizeRedirect()` without an argument continue to work as before (loop blocked against current request path).
- The MFA verify success flow no longer directs the user back to `/mfa/verify` or any sub‑path of it (e.g., `/mfa/verify/something`).


**Feature Request: Prevent session-authenticated users from accessing SCIM endpoints**

### Summary
SCIM endpoints must only accept authentication via the SCIM API key mechanism. Requests that carry a valid session cookie or a valid JWT token must be rejected or treated as unauthenticated, preventing any cross‑protocol authentication.

### Acceptance Criteria

1. **Block session‑authenticated requests**
   - When an incoming request targets any SCIM API path (e.g., `/scim/v2/*`), the system must check whether the request includes a valid session cookie that authenticates the user.
   - If a valid session is detected, the server must immediately respond with HTTP `403 Forbidden` and the error message `"Simultaneous SCIM and session authentication is not permitted."`.
   - This check must occur **before** the SCIM authentication processing, so that a session‑authenticated request can never reach the SCIM authenticator.

2. **Disable JWT authentication for SCIM endpoints**
   - The JWT authentication feature must be explicitly disabled when handling SCIM API requests.
   - A request that arrives with a valid JWT token (and no session) at a SCIM endpoint must be treated as *unauthenticated*, causing a redirect (e.g., to the login flow) rather than being processed with the JWT identity.

3. **Unchanged behavior for non‑SCIM requests**
   - Requests that do **not** target SCIM API paths must pass through the authentication pipeline without any of the above restrictions (session and JWT authentication remain unchanged).

4. **SCIM authentication service remains the sole authentication method**
   - For SCIM requests, the authentication service must be replaced with the dedicated SCIM authentication service, ensuring that only the SCIM API key (provided via headers or request configuration) can successfully authenticate the request.

### Out of scope
- Changes to the SCIM API key generation or configuration.
- Additional rate limiting or logging beyond what already exists.

### Background / Motivation
This prevents a scenario where a user who is already authenticated via the web interface (session cookie) inadvertently gains access to the SCIM API. The SCIM protocol must operate in isolation, requiring the dedicated SCIM credentials. Similarly, JWT authentication should not be accepted as a fallback for SCIM endpoints.

PB-44610 As a user authentifying with the SCIM protocol, I should not be allowed to be authentified via session


**PB-22926 – Missing `/sso/recover/error` route prevents display of user-friendly error during SSO recovery when self-registration is enabled**

When a user attempts SSO-based account recovery but their email does not match any existing user and self-registration is allowed, the identity provider redirects to `/sso/recover/error?email=...`. This route is not currently defined, resulting in a 404 page instead of a meaningful error.

We need to add the missing route and a controller that handles this redirect and produces a proper, styled error page consistent with other API feedback pages (e.g., loading the `api-feedback.js` and the `api_main.min.css` stylesheet).

**Requirements**
- New `GET` route `/sso/recover/error` inside the `SsoRecover` plugin.
- The endpoint **must**:
  - Be accessible only to unauthenticated users – return `403` if the user is already logged in.
  - Reject JSON requests with a `400` (this is an HTML-only flow).
  - Require a valid email via the `email` query parameter; invalid or missing email returns `400`.
  - Render a page that sets the title to “Single sign-on failed.” and displays the message “The user does not exist.” inside a hidden `div#api-error-details`.
  - Include the standard API feedback JavaScript and stylesheet to ensure the error is surfaced consistently.
- The controller should extend `AbstractSsoController` and properly allow unauthenticated access for the error action.

**Context**
- This fixes the broken redirect after a failed SSO recovery when self-registration is enabled.
- The template should be placed under `templates/SelfRegistration/handle_error.php` and use the `default` layout.
- No JSON representation is needed; JSON requests should be explicitly refused.

PB-22926 - Add /sso/recover/error missing route


PB-51650 - Introduce ScimSettingsDto for the ScimGetSettingsService::getSettings()

Update `ScimGetSettingsService::getSettings()` to return a `ScimSettingsDto` instead of a plain array.
- If no SCIM settings row exists, return `null` (no fallback to default settings).
- Include the computed `base_api_endpoint` in the DTO.
- In the controller, convert the DTO to an array before passing it to the success response (or send an empty object when null).

Fix a bug in `ScimBaseSettingsService::getRenderedValue()` where the `created` timestamp was incorrectly taken from `modified`; now it should use `created`.


**Feature request: Add `POST /metadata/session-keys.json` to store encrypted session keys**

### Problem
I’m working on the client-side metadata encryption flow. To securely share or persist the user’s session key, I need to upload an encrypted copy to the Passbolt server, but there’s no API endpoint for it. This blocks any workflow that relies on server-stored encrypted session keys (key rotation, recovery, sharing, etc.).

### Expected behavior
Provide a new authenticated endpoint:

- **URL**: `POST /metadata/session-keys.json`
- **Request body**: JSON with a single `data` string containing the encrypted key (e.g. `{"data":"-----BEGIN..."}`).

The server must:
- Reject unauthenticated requests with `401`.
- Require `Content-Type: application/json`; 404 or 400 if not JSON.
- Require a non‑empty body; return `400` if the body is missing or empty.
- Validate that `data` is a **string**; return `400` if it’s `null`, an array, an object, or any other non‑string type.
- Validate that the `data` string:
  - is a valid ASCII OpenPGP message,
  - is encrypted for the user’s current public key (i.e., passes `isValidEncryptedMetadataSessionKey`).
- On validation failure, return `400` with error paths such as `data.isValidOpenPGPMessage`, `data.ascii`, or `data.isValidEncryptedMetadataSessionKey`.
- On success, store the encrypted key for the authenticated user and return:
  - HTTP `200`
  - JSON body containing the created record fields: `id`, `user_id`, `data`, `created`, `modified`.
- Allow the same user to store **multiple** keys (no uniqueness constraint), so key rotations work without deleting old keys.

### Why this matters
This endpoint is a prerequisite for any client-side metadata key synchronization. Without it, users cannot persist encrypted session keys on the server, making features like encrypted metadata sharing impossible.

PB-35151 Add POST /metadata/session-keys.json endpoint


The healthcheck currently leaks unmanaged internals when the database connection is unavailable:

- `Healthchecks::application()` sets `schema` to `null` instead of `false` when it cannot reach the database.
- `DatabaseHealthchecks` methods throw uncaught `MissingConnectionException` in `tableCount()` and `defaultContent()`, and expose raw driver-level exception text in `canConnect()`.
- These cause the overall healthcheck output to display unhelpful error messages (or break) rather than cleanly report a single connection failure and mark dependent checks as failed.

**Required behavior**

When the database connection fails (or the server is unreachable), the healthcheck must:

1. Return `false` (not `null`) for the `application.schema` check.
2. Catch `MissingConnectionException` in **every** database check method so no uncaught exception escapes to the caller.
3. Set **all** database sub-checks (`connect`, `tablesCount`, `defaultContent`) to `false` when the connection cannot be established.
4. Present a human-readable connection error message in `database.info.connection`. This message should be either a generic “Database connection failed” text, or, if the driver provides a `reason` attribute, the reason from the underlying exception; it must not contain raw exception message formats or implementation details.
5. Continue to correctly set `supportedBackend` according to the configuration, even when the backend cannot be reached (the current behavior for unsupported backend must be preserved).

PB-19601 - Fix unmanaged DB error in healthcheck


Add required `email_claim` field to Azure SSO provider configuration.

The `POST /sso/settings.json` endpoint must accept an `email_claim` attribute inside the `data` object when `provider` is `azure`.
Accepted values: `"email"`, `"preferred_username"`, `"upn"`.
The field is mandatory for Azure – missing, empty, or unsupported values must be rejected with a validation error.

The value must be stored encrypted alongside the other Azure data fields and included in the response when retrieving SSO settings.

PB-24244: Add email_claim field in /sso/settings.json endpoint


**Summary**
API login (JWT authentication) does not update the “Last logged in” timestamp that is displayed for users. Only standard web authentication (AuthLogin) is considered when computing the last login time.

**Context**
The custom finder `findlastLoggedIn` in `UsersFindersTrait` queries the `action_logs` table to determine a user’s most recent successful login. It currently filters only on the action ID `AuthLogin.loginPost`. Logins performed via the API (JWT) create entries with the action ID `JwtLogin.loginPost`, which are completely ignored even if they occurred more recently.

**Expected behavior**
The “Last logged in” value should reflect the latest successful authentication across **all** supported login methods. Specifically, both `AuthLogin.loginPost` and `JwtLogin.loginPost` action IDs must be considered.

**Acceptance criteria**
- When a user logs in via JWT, the `findlastLoggedIn` finder returns the timestamp from the most recent successful `action_logs` entry with `action_id` equal to either `AuthLogin.loginPost` or `JwtLogin.loginPost`.
- The logic must continue to consider only successful logins (`status = 1`).
- The implementation should not require consumers of the finder to change their interface or pass additional parameters.

PB-35849 Fix API app does not update "Last logged in" time


**Feature: Support `aud` claim as an array in BaseIdToken**
The id_token validation in `BaseIdToken::assertTokenClaims` only handles a string `aud` claim. Per RFC 7519 §4.1.3, the `aud` claim may be a single string or an array of case‑sensitive strings. Update the validation so:

- If `aud` is a string, treat it as a single‑element array.
- If `aud` is an array, verify that the provider’s client ID is present (strict equality).
- If the claim is missing, not a string/array, or the client ID is not present in the array, throw a `BadRequestException` with the existing message `'The aud (client id) parameter is invalid.'`.

The existing checks for `iss` and `email` must remain unchanged and continue to work correctly.

PB-35957 Add support for Aud JWT claim to be an array and improve BaseIdToken test coverage


**Title:** Azure SSO with `prompt=login` does not reject authentications with a stale `auth_time` claim

**Observed Problem**
When the Azure SSO provider is configured with `prompt=login` (i.e. users are required to re-authenticate on every login), the application accepts ID tokens with an `auth_time` value that is older than the SSO state creation time. This means a caller can complete the SSO flow without having actually authenticated after the SSO state was generated, effectively bypassing the forced login prompt.

**Expected Behavior**
- If the SSO settings use a prompt **other than `none`** (specifically `prompt=login`), the system must verify that the `auth_time` claim from the token is **greater than or equal to the `SsoState` creation timestamp**.
- If `auth_time` is missing (Azure treats this claim as optional), the check should be skipped.
- If `auth_time` is present and older than the `SsoState` created time, the login must be rejected with the error: “You must authenticate with Azure again.”
- The `prompt=none` flow must remain unaffected – no `auth_time` validation is performed in that case because re-prompt is not required.

**Impact**
Without this validation, a stale ID token (with a valid nonce but an old `auth_time`) could be replayed to authenticate a user without the forced credential prompt. This weakens the security posture when administrators explicitly configure `prompt=login` to ensure fresh user authentication at every SSO login.

**Repository-specific Notes**
- The Azure resource owner already carries the `auth_time` claim internally; it needs a method to expose it (e.g. `getAuthTime()`).
- The SSO Azure service is where the post-authorization checks happen (likely in or around the `assertResourceOwnerAgainstSsoState` method). The new validation must be added there, layered on top of the existing nonce check.
- The SSO state entity’s `created` timestamp should be used as the baseline for comparison.


## Feature Request: POSIX-compatible exit mode for healthcheck command

### Context
The `passbolt healthcheck` command reports individual check outcomes (PASS, FAIL, WARN, HELP, INFO). Currently the command always exits with code 0, even when failures or warnings are present, and all output is written to the standard output stream. This makes it difficult to use the command as a gate in shell scripts or CI/CD pipelines where a non‑zero exit status is expected on problems.

### Desired behavior
Add a new `--posix` flag that switches the command into a scripting‑friendly mode. When the flag is **not** used, the command must behave exactly as before. When the flag is used, the following must hold:

#### Exit codes
- The command exits with **0** only when **no** check returns FAIL or WARN.
- The command exits with **1** when **at least one** check returns FAIL or WARN.

#### Output streams
- **stdout** receives only PASS, HELP, and INFO messages.
- **stderr** receives FAIL and WARN messages.

#### Interaction with existing options
The `--posix` flag must work in combination with all existing options (e.g., `--hide-pass`, `--hide-help`, `--hide-notice`, and any domain‑specific check selections). The flag is boolean (`--posix` to enable, omission to disable).

### Non‑obvious constraints
- The presence of multiple FAIL/WARN results still yields exit code 1 (not a count‑based code).
- Any internal command error (exception) that would already cause a non‑zero exit must still take precedence, but the POSIX mode should not interfere with the existing exception‑based exit logic.
- The flag does **not** affect the content of messages; it only changes their destination stream and the final exit code.

### Illustration (acceptance scenarios)

1. All checks pass:
   - `passbolt healthcheck --environment --posix` → exit 0, all messages on stdout.

2. Some checks fail/warn:
   - `passbolt healthcheck --environment --posix` where PHP version is below minimum → exit 1, PASS messages on stdout, FAIL and WARN messages on stderr.

3. Without `--posix`:
   - All messages on stdout, exit 0 regardless of warnings/errors (unless a fatal exception occurs).

PB-47196 Add POSIX mode to the healthcheck command


**Title:** Exclude tags from soft‑deleted resources from the tags index and optimize the permission filter

### Problem
When a resource is soft‑deleted, any tags that are only associated with that resource still appear in `GET /tags.json`. This leaks data about deleted items and violates the expected “active resource” scope. Additionally, the current permission filtering uses a nested sub‑query (`Resources.findIndex`) that can cause poor performance on large datasets.

### Acceptance Criteria

1. **Deleted‑resource exclusion**
   The tags index for a user must only return tags that are linked to at least one **non‑deleted** resource the user can access. Tags that are exclusively associated with soft‑deleted resources must be omitted from the response.

2. **Permission enforcement**
   Filtering must respect all resource‑level permissions, including those inherited from folder hierarchies. Personal tags must be returned only if they belong to the requesting user; shared tags (where `ResourcesTags.user_id IS NULL`) must be returned for any resource the user can see.

3. **Performance**
   The permission check must be performed with a single direct join to the resources table (using `ResourcesTable::filterResourcesByPermissions`), avoiding a correlated sub‑query. The query must not produce a Cartesian product and should scale linearly with the number of resources.

4. **Contain compatibility**
   The resources index endpoint (`/resources.json?contain[tag]=1`) must continue to correctly include tags when the Folders plugin is enabled.

5. **No regressions**
   Existing tag‑sharing scenarios remain untouched: e.g., a resource with a personal tag owned by another user must not leak that tag, and a resource with both personal and shared tags must correctly expose only the relevant ones.

PB-33688 [EE] Improves performance of tags index endpoint


Add an SSO healthcheck that warns when SSL host verification is disabled, so administrators can detect insecure configurations.

**What to check:** Read `passbolt.security.sso.sslVerify` (default `true`). Pass when `true`; fail when `false`.

**Where it appears:**
- Healthcheck domain: `sso`
- Legacy array key: `sslHostVerification`
- Severity: warning (not error), since SSL verification is important but connections may still work without it

**Messages:**
- Success: "SSL certification validation for SSO instance is enabled."
- Failure: "SSL certification validation for SSO instance is disabled."
- Help: explain the man-in-the-middle risk and note the env var `PASSBOLT_SECURITY_SSO_SSL_VERIFY` and config file equivalent

**CLI:** Support `--sso` as an option to run only SSO healthchecks, with the help description "Run SSO tests only."

Register the service in the SSO plugin container and wire it into `HealthcheckServiceCollector`, including the domain title mapping (`"SSO"`) and legacy domain key mapping (`"sso"`).

PB-29222 [Pro] As an administrator I want to know if SSL verification of SSO integration is not the default


We need a pluggable way to load edition-specific feature plugins so that different Passbolt editions can include different plugin sets without modifying the core Application class.

- Add a configuration key (e.g. `passbolt.featurePluginAdder`) that specifies the class responsible for loading feature plugins.
- Move the existing CE plugin loading logic out of `Application::addPassboltPlugins()` into a default implementation of that class. The logic must remain identical: early web installer check (only load `Passbolt/WebInstaller` and nothing else), debug/selenium plugins, the common fixed plugins, and feature-flagged plugins (`JwtAuthentication`, `Mobile`, `SelfRegistration`, `SmtpSettings`, `MultiFactorAuthentication`, `Log`, etc.) with their current default-enablement rules.
- In `Application::bootstrap()`, call the configured bootstrapper’s feature plugin method after core and vendor plugins are added. `Application` itself should stop using `FeaturePluginAwareTrait` and remove the `addPassboltPlugins()` method.
- Ensure that all existing plugin ordering and default-enabled behavior (e.g. MFA and Log enabled when not explicitly configured) are preserved in the default bootstrapper.
- The solution bootstrapper class must use the existing `FeaturePluginAwareTrait` and expose a clear method (`addFeaturePlugins`) to add all edition-specific plugins.

PB-22417 Introduces the base solution bootstrapper


PB-19212 - Do not change permissions in tests

The web installer’s `install()` step recursively adjusts file and directory permissions inside `CONFIG` (directories to `0550`, files to `0440`). This mutates the real project configuration when tests run, which is unacceptable.

Extract the permission-setting logic into its own service. The service must accept the target directory path as a dependency, and tests must be able to replace it with a version that operates on a temporary directory so the real `CONFIG` is never touched. Keep the production behavior identical.


Make `GroupsUsersDeleteService::deleteLostAccessAssociatedSecrets` soft-delete secrets instead of permanently removing them when a group membership removal causes a user to lose access to resources.

- Fetch only active (non-deleted) secrets that will be lost using `SecretsTable::find('notDeleted')`.
- Mark them as deleted via `SecretsTable::softDeleteMany()`.
- Already-deleted secrets must be left untouched; do not double-soft-delete them.
- Any downstream checks for secret existence must take into account that a secret row may still exist with a non-null `deleted` field after the operation.

PB-46155 - SH - WP2.2.6 Secrets deleted upgrade: GroupsUsersDeleteService::deleteLostAccessAssociatedSecrets


Update the `datacheck` command to correctly validate v5 resources during the “Data integrity for Resources” check. Currently, only v4 resources pass validation; v5 resources fail because the validation ruleset does not match their schema. The check must detect whether a resource is v4 or v5 (via `MetadataResourceDto`) and apply the appropriate validation ruleset (`'validate' => 'v5'` for v5, default for v4). Both valid v4 and valid v5 resources should be reported as success.

PB-41736 Adjust datacheck command to support v5 resources


---
**Title:** PB-47276 – Prevent email enumeration on `POST /users/recover.json` when self‑registration is enabled

**Background**
When self‑registration is active, the recover endpoint must not let an unauthenticated caller distinguish between an existing account and a non‑existent one for email addresses whose domain is **not** in the self‑registration allowlist. Today the controller either relies on the global `passbolt.security.preventEmailEnumeration` flag or simply re‑throws the `NotFoundException`, both of which can leak information.

**Desired behaviour**

1. **Self‑registration inactive (plugin disabled or not open):**
   - When `UserRecoverService::recover()` throws `NotFoundException`, always return a generic success response (**never** a 404).
   - The existing `passbolt.security.preventEmailEnumeration` configuration key is effectively ignored for this path.

2. **Self‑registration active and open:**
   - If the submitted email’s domain **is not** present in the self‑registration allowed domain list, return a generic success response as above (no user enumeration possible).
   - If the submitted email’s domain **is** allowed and no other validation blocks self‑registration (i.e. `SelfRegistrationDryRunServiceInterface::canGuestSelfRegister()` returns `true` for this email), re‑throw the original `NotFoundException` so the client receives a 404 and can direct the user to the self‑registration flow.
   - If `canGuestSelfRegister()` throws a `CustomValidationException`, only re‑throw it if the error is **not** solely about the domain not being allowed (`email.checkEmailDomainIsAllowed`). Other validation failures (e.g. malformed email) should still be surfaced to the caller as a 400.

**Scope**
- Modify `UsersRecoverController::recoverPost()` to accept an instance of `SelfRegistrationDryRunServiceInterface` and to delegate the exception‑handling decision to a private method.
- Do **not** change the service layer; the controller is the only place where the response shape is decided.
- The change must not affect the behaviour for logged‑in users (they are already blocked upstream) nor the successful recovery path for existing users.

**References**
- `App\Controller\Users\UsersRecoverController` (existing constants `RECOVER_SUCCESS_EVENT_NAME`, `PREVENT_EMAIL_ENUMERATION_CONFIG_KEY`).
- `Passbolt\SelfRegistration\Service\DryRun\SelfRegistrationDryRunServiceInterface::canGuestSelfRegister()`.
- `Passbolt\SelfRegistration\Service\DryRun\SelfRegistrationDryRunServiceInterface::isSelfRegistrationOpen()`.

PB-47276 As a non-logged in user I cannot enumerate user emails using the recover endpoint


As a logged-in user, the `GET /settings.json` response must include metadata plugin details when the passbolt v5 feature flag is enabled. The plugin’s `enabled` status and its `version` (as defined in the plugin’s `config.php`) should appear under `passbolt.plugins.metadata`.

**Visibility constraints**
- The metadata plugin information must **not** be exposed to unauthenticated users.
- When `passbolt.v5.enabled` is `false`, no metadata plugin information should appear in the response, even for authenticated users.
- If the plugin’s own configuration whitelist does not include the necessary keys, the information must remain excluded (the existing plugin whitelist mechanism already governs this).

**Expected behavior**
- Flag on + logged in → `passbolt.plugins.metadata` contains `{"enabled": true, "version": "<version>"}`.
- Flag on + not logged in → `passbolt.plugins.metadata` absent.
- Flag off + logged in → `passbolt.plugins.metadata` absent.

PB-34461 As a logged-in user the settings.json provides information on the metadata plugin


Implement PlanarFlow transform and fix IAF inverse caching.

- **PlanarFlow**: Add a new `TransformModule` subclass implementing the planar flow transformation `y = x + u_hat * tanh(w^T x + b)` from “Variational Inference with Normalizing Flows” (Rezende & Mohamed 2015).
  - Use learnable parameters `u` (vector) and a linear layer `lin` producing a scalar.
  - Enforce the invertibility condition `w^T u > -1` by parameterizing `u_hat` via softplus (see paper).
  - The transform has no closed-form inverse; cache `x` on the forward call and return it in `_inverse`, raising an error when the cache is empty.
  - `log_abs_det_jacobian(x, y)` must return `log|1 + u_hat^T * psi_z|` where `psi_z = (1 - tanh^2(lin(x))) * w`, with output shape equal to the batch shape (a scalar per batch element).
  - Ensure the module is hashable.
  - Register in `pyro.distributions` and add to the documentation.

- **IAF inverse caching**: `InverseAutoregressiveFlow._inverse` currently recomputes `log_scale` from the autoregressive network, breaking the intended caching and potentially producing incorrect gradients.
  - Apply `clamp_preserve_gradients` to `log_scale` during `_call` and store it in the intermediate cache alongside `y`.
  - In `_inverse`, retrieve the clamped `log_scale` from the cache instead of recomputing it.
  - `log_abs_det_jacobian` should pop the cached `log_scale` rather than calling the ARN again.

- **Test suite**: Generalize the flow tests so they can validate non-autoregressive flows like PlanarFlow.
  - In the Jacobian test, compute the full determinant numerically when the transform is not lower-triangular (i.e., does not have an `.arn` attribute); apply the permutation-based check only for autoregressive flows.
  - In the inverse test, correctly clear the intermediate caches (both `'log_scale'` and `'x'`) for flows that cache multiple intermediates.
  - Keep all existing IAF and PermuteTransform tests passing.


We need an autoregressive rational spline transform to complement the existing `SplineCoupling` and the simpler `AffineAutoregressive`. Users want to build normalizing flows where each dimension’s spline parameters are conditioned on previous dimensions via an `AutoRegressiveNN`, exactly as `AffineAutoregressive` does for affine transforms.

**Request:**
- Add a `SplineAutoregressive` transform module that uses an autoregressive neural network to produce elementwise rational spline parameters (widths, heights, derivatives, and optionally lambdas for linear-rational splines). The forward call should condition a `ConditionalSpline` on the input `x` and apply it elementwise. The inverse must honour the autoregressive structure, inverting one dimension at a time.
- Provide a convenience function `spline_autoregressive` analogous to `affine_autoregressive` that constructs an `AutoRegressiveNN` with appropriate `param_dims = [count_bins, count_bins, count_bins - 1, count_bins]` and wraps it in `SplineAutoregressive`.
- The transform should support `order='linear'` (and ideally `order='quadratic'` in the future) and expose the standard spline parameters: `count_bins` and `bound`.
- **Non-obvious constraint:** `ConditionalSpline.condition()` currently assumes a `DenseNN` output shape (flat over the input dimension). When driven by an `AutoRegressiveNN`, the output shapes differ. Update `ConditionalSpline` to accept both conventions, so that it works correctly as the inner conditional transform for both coupling and autoregressive contexts.
- Register the new class and helper in the `transforms` package and in the documentation.

**Acceptance criteria:**
- `SplineAutoregressive` is recognized as a bijective transform with `event_dim=1` and `autoregressive=True`.
- Basic shape, Jacobian, and inverse tests pass when driven by a several‑layer `AutoRegressiveNN`.
- The existing `SplineCoupling` and `ConditionalSpline` continue to work unchanged.
- The `spline_autoregressive` helper creates a working transform with default hidden dimensions `[input_dim * 10, input_dim * 10]`.


We need an operation that contracts a tree of tensors down to a single tensor in a specific `iarange` context, summing out specified enumeration dims. Currently `_contract_component` only reduces to the minimum shared ordinal; we need to be able to request a particular target ordinal.

Implement `contract_to_tensor(tensor_tree, sum_dims, target_ordinal)` that:

- Accepts an `OrderedDict` mapping ordinals to lists of tensors, a dict of per-tensor sum dims, and a target ordinal (frozenset of `CondIndepStackFrame`).
- Contracts all terms, broadcasting and summing iarange dimensions as needed so that the result lives exactly in the given target ordinal.
- Returns the sum of terms at that ordinal as a single `torch.Tensor`.
- Must be deterministic and leave the input tensor tree in a consistent (but consumed) state.

Extend `_contract_component` with an optional `target_ordinal=None` parameter; when `None`, behavior is unchanged (contract to intersection). When provided, it should greedily contract away iarange dims not in the target via `sum` and expand (broadcast) any missing iarange dims to the correct size using `expand`. Ensure the broadcast logic correctly handles shape padding and keeps enumeration dims intact.

Implement contract_to_tensor op for querying marginals (#1357)


**Feature Request: Add Hamiltonian Monte Carlo (HMC) Sampling Kernel**

As a Pyro user performing MCMC inference, I need an HMC kernel so that I can sample from continuous, differentiable models more efficiently than with random-walk proposals. The kernel must follow the existing `TraceKernel` interface and integrate with the `MCMC` runner class.

### Acceptance Criteria

1. **Kernel Class**
   - A class `HMC` exists in `pyro.infer.mcmc.hmc` that extends `TraceKernel`.
   - Constructor accepts `model` (callable), `step_size` (float, default 0.5), `num_steps` (int, default 3).

2. **Setup Validation**
   - `setup(*args, **kwargs)` must raise an error if the model contains any non-reparameterized stochastic nodes (sample sites where `node['fn'].reparameterized` is `False`).
   - Also raise an error if the trace log probability of the initial trace is NaN, Inf, or zero.
   - The setup should store the arguments and compute the prototype trace, which is used later for replaying values into the model.

3. **Potential and Kinetic Energy**
   - The potential energy is defined as `-log_pdf` of the trace produced by replaying a proposed position `z` into the model using `poutine.replay`.
   - The kinetic energy is a sum of `0.5 * r^2` over all momentum variables.
   - Total energy = kinetic + potential.

4. **Sampling Step**
   - `sample(trace)` extracts the current position `z` from the trace (stochastic node values).
   - It draws a new momentum `r` for each variable from an independent standard normal distribution (using `pyro.sample` to stay inside the probabilistic effect stack).
   - It calls `velocity_verlet` from `pyro.ops.integrator` with the current `z`, `r`, the potential energy function, step size, and number of steps to produce a proposal `(z_new, r_new)`.
   - It performs a Metropolis-Hastings correction: draw a uniform random number `rand`; if `log(rand) < -(E_new - E_current)`, accept the proposal (`z = z_new`), otherwise keep current `z`.
   - The kernel must track the number of accepted proposals for diagnostics.
   - Returns a trace reconstructed from the (possibly updated) `z` via `_get_trace`.

5. **Warm-up and Sampling in MCMC Runner**
   - The `MCMC._traces` loop must be adjusted so that the warm-up phase properly excludes samples from the output: `if self._t <= self.warmup_steps: continue` (currently the code incorrectly yields warmup traces).
   - The loop shall increment `self._t` *before* checking the logging and warmup condition, ensuring correct count and diagnostics breakpoints.

6. **Utility on Trace**
   - `Trace` (in `pyro.poutine.trace`) must provide an `iter_stochastic_nodes()` method that yields `(name, node)` tuples for all sample sites that are **not** observed.

7. **Diagnostics**
   - `diagnostics(time_step)` returns a string containing the current acceptance rate: `self._accept_cnt / time_step`.

8. **Cleanup**
   - `cleanup()` resets internal counters, momenta distributions, and stored arguments so a new run can be started.

9. **Compatibility**
   - The kernel must work with `MCMC` without any other changes to the interface (i.e., `MCMC` calls `kernel.initial_trace()`, `kernel.sample(trace)`, `kernel.cleanup()`, and `kernel.diagnostics(…)`).
   - The implementation must use Pyro’s existing primitives (`pyro.sample`, `poutine.trace`, `poutine.replay`) and the provided `velocity_verlet` integrator.

### Non-Functional Constraints

- The implementation must be placed in a new file `pyro/infer/mcmc/hmc.py`.
- The HMC module must be added to the documentation (`docs/source/pyro.infer.mcmc.txt`) under an "HMC" section.
- No changes to the public API of other existing classes except the necessary addition to `Trace` and the bugfix in `MCMC._traces`.