Skip to content

Give every PAM failure a stable, machine-readable code - #8235

Open
Hinton wants to merge 2 commits into
pam/uatfrom
pam/error-codes
Open

Give every PAM failure a stable, machine-readable code#8235
Hinton wants to merge 2 commits into
pam/uatfrom
pam/error-codes

Conversation

@Hinton

@Hinton Hinton commented Aug 20, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

Companion to the client-side ask in docs/pam-server-asks.md (clients pam/uat). Base branch is pam/uat, not main.

📔 Objective

PAM rejected with ErrorResponseModel — a human-readable message and no machine-readable discriminant. Every failure a client must act on differently was therefore identified by matching the server's English sentence. The web client had grown two such catalogs, eighteen sentences matched with String.includes(), written weeks apart by different people, both carrying the same note in their own words: "When the server grows a code, this catalog is the single place to retire."

Three of the eighteen are not failures at all. access_already_active, access_request_already_approved and access_request_already_pending mean the requester already has what they asked for; the UI reconciles — collapses the form, re-reads the access state, shows an informational toast. Reword one of those sentences today and a reconciliation silently becomes a red error toast, with no test failing on either side. Status codes don't disambiguate: they were all 400.

This gives every PAM failure a stable code, in RFC 7807 problem responses.

The shape

The same shape the Admin Console's invite-link confirm endpoint already ships (TypedResults.BitwardenValidationProblem), so clients learn one parser:

// 400 application/problem+json
{
  "type": "validation_error",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "reason": [{ "type": "reason_required", "detail": "A reason is required for items that need human approval." }]
  }
}
  • The code is errors.<property>[].type — stable, never localized, never reworded once shipped.
  • The property is the request field a form should mark invalid (reason, durationSeconds, collections, name, …), named exactly as the request model serializes it. A failure no single field caused — a state conflict, a request shaped for the wrong approval mode, a denial by the governing rule — is keyed by code.
  • detail is today's message, unchanged.
  • A state conflict is a 409 with the identical body ("type": "conflict_error"). The code is still what a client switches on; the status is a coarse hint for anything that reads no further. This is a status change for the three reconcile cases, which were 400.

How

PAM commands stop throwing for expected failures and return CommandResult<T> carrying an Error — reusing the Admin Console's v2 Error / IValidationError / CommandResult types rather than inventing a parallel set. One record per failure in Bit.Services.Pam.Errors, with the code and the property on it:

public record AccessAlreadyActive()
    : ConflictError("You already have active access to this item."), IValidationError
{
    public string PropertyName => PamErrorProperties.Code;
    public string Type => "access_already_active";
}

Handlers render them through the single PamErrorResult arm of their Results<…> return type. Declaring the union rather than returning bare IResult is what keeps the success schema in the generated OpenAPI, and so in the SDK's bindings.

The model-state filter converted too, with the failing DataAnnotations attribute as the code (required, range, string_length) — otherwise a client would still need a second parser for anything rejected before a handler ran, and [Required] Reason on the extension model would shadow extension_reason_required entirely.

Deliberately unchanged

  • 404 stays ErrorResponseModel. PAM's read paths still throw NotFoundException, so all its 404s remain identical however they were reached. A 404 needs no code — the status already tells it apart. Codes exist for the failures a status cannot separate.
  • The exception filter stays as the safety net for genuinely unexpected throws. Only failures PAM chooses to return became problem responses.

Scope

All ~35 expected failures across the PAM surface, not just the eighteen the clients match today: submit, activate, cancel, decide, extend, revoke, the access-rule writes, and the three rule-engine denial reasons (which the catalogs never covered, so they were indistinguishable prose). One error style across the whole surface means the leasing UI's next prose-matching catalog never gets written.

TypedResults.BitwardenValidationProblem gained an optional statusCode (defaults to 400) for the 409s — additive, every existing call site unchanged.

Tests

PamErrorResultTests asserts the bytes, not the result type — the code and its placement in the body are the promise. PamErrorCatalogTests reflects over the catalog: codes are snake_case, a code reused across two errors keys the same property, the set of deliberately-shared codes is written down, and the eighteen the clients match today are all present. Two integration tests prove the shape survives the real pipeline end-to-end.

427 unit + 31 integration tests pass.

Downstream

The SDK maps codes onto typed error variants (AccessRequestError::AlreadyPending, …) and both client catalogs are deleted rather than copied into the next client. Those are bitwarden/sdk-internal#1400 and bitwarden/clients#22544, also targeting pam/uat.

`TypedResults.BitwardenValidationProblem` hard-coded 400. A failure can be
carried by the same body without being bad input — a state conflict is a 409 —
and a caller that reads only the status should still learn that much before it
reads the codes. Additive: the parameter defaults to 400, so every existing call
site is unchanged.
@Hinton
Hinton requested a review from a team as a code owner August 20, 2026 09:00
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the conversion of the PAM surface from thrown exceptions to CommandResult<T> + coded RFC 7807 problem responses, spanning the error catalog (Bit.Services.Pam.Errors), PamErrorResult/PamResults, the rewritten PamValidationEndpointFilter, all ten commands and the rule write validator, plus the additive statusCode parameter on TypedResults.BitwardenValidationProblem. Verified status-code parity against the previous exception mapping (ExceptionHandlerEndpointFilter): every 400/409 is preserved except the three reconcile cases deliberately promoted to 409, and the 404 body (ErrorResponseModel with "Resource not found.") is byte-identical to what a thrown NotFoundException produced. PamErrorResult.ToResult mirrors the established BaseAdminConsoleController.MapError ordering, and no caller outside the endpoint handlers consumes the changed command interfaces, so no failure path is silently swallowed. Test coverage is strong — the catalog reflection tests, the wire-level PamErrorResultTests, and the ValidationErrors_AreAlsoOneOfTheStatusCarryingErrorKinds guard close the gaps that review alone would leave.

Code Review Details
  • 🎨 : <returns> cref names AccessRequestNotFound, but the command returns CipherNotFound
    • bitwarden_license/src/Services/Pam/OrganizationFeatures/Commands/Interfaces/ISubmitAccessRequestCommand.cs:13

Considered and dismissed:

  • PamValidationEndpointFilter now invokes IValidatableObject.Validate unconditionally (where Validator.TryValidateObject skipped it after property failures) and no longer honours type-level validation attributes. No PAM request model uses either today, so this is latent rather than a defect.
  • Deriving filter codes from attribute type names (CodeFor) couples a published code to a .NET class name — explicitly documented as the intended trade-off.
  • The _ => 500 arm echoing error.Message without logging matches BaseAdminConsoleController.MapError exactly; consistency with the established pattern wins.

/// </summary>
Task<AccessRequestResult> SubmitAsync(Guid userId, Guid cipherId, AccessRequestSubmission submission);
/// <returns>
/// The submitted request, or the failure that stopped it: <see cref="Errors.AccessRequestNotFound"/> when the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: The cref names AccessRequestNotFound, but SubmitAsync returns CipherNotFound for this case.

Details and fix

SubmitAccessRequestCommand.SubmitAsync returns new CipherNotFound() when _cipherRepository.GetByIdAsync(cipherId, userId) comes back null — and CipherNotFound is documented in AccessRequestErrors.cs as exactly this case ("The cipher does not exist, or the caller cannot see it").

Suggested change
/// The submitted request, or the failure that stopped it: <see cref="Errors.AccessRequestNotFound"/> when the
/// The submitted request, or the failure that stopped it: <see cref="Errors.CipherNotFound"/> when the

No behavioural difference (both are NotFoundError and render the same 404 envelope), but this <returns> block is the contract an SDK author reads to enumerate the error surface, so the wrong record name is worth correcting.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.58065% with 54 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (pam/uat@1f927dc). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...nse/src/Services/Pam/Errors/AccessRequestErrors.cs 75.49% 25 Missing ⚠️
...icense/src/Services/Pam/Errors/AccessRuleErrors.cs 75.60% 10 Missing ⚠️
...den_license/src/Services/Pam/Api/PamErrorResult.cs 84.61% 5 Missing and 1 partial ⚠️
...cense/src/Services/Pam/Errors/AccessLeaseErrors.cs 76.00% 6 Missing ⚠️
...ionFeatures/Commands/SubmitAccessRequestCommand.cs 77.77% 3 Missing and 1 partial ⚠️
...rvices/Pam/Api/Endpoints/AccessRequestEndpoints.cs 0.00% 1 Missing ⚠️
.../Endpoints/Handlers/CipherLeaseEndpointsHandler.cs 0.00% 1 Missing ⚠️
...e/src/Services/Pam/Api/Endpoints/LeaseEndpoints.cs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             pam/uat    #8235   +/-   ##
==========================================
  Coverage           ?   63.88%           
==========================================
  Files              ?     2431           
  Lines              ?   106107           
  Branches           ?     9571           
==========================================
  Hits               ?    67786           
  Misses             ?    36029           
  Partials           ?     2292           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

PAM rejected with `ErrorResponseModel`, which carries a human-readable message
and no discriminant. Every failure a client must act on differently was
therefore identified by matching the server's English sentence, and the web
client had grown two catalogs of eighteen sentences matched with
`String.includes()` to do it. Three of those are not failures at all —
already-active, already-approved and already-pending mean the requester has
what they asked for, and the UI reconciles rather than reporting an error — so
rewording one sentence would silently turn a reconciliation into a red toast,
with no test failing on either side.

PAM now answers with RFC 7807 problem responses in the shape the Admin Console's
invite-link confirm endpoint already ships: the stable code is
`errors.<property>[].type`, and the property is the request field a form should
mark invalid, or `code` when no single field is at fault. The message is
unchanged and travels as `detail`.

To get there, PAM commands stop throwing for expected failures and return
`CommandResult<T>` carrying an `Error` from `Bit.Services.Pam.Errors` — one
record per failure, each with the code and the property on it. Handlers render
them through the single `PamErrorResult` arm of their `Results<…>` return type,
which keeps the success schema in the generated OpenAPI. A coded failure is a
400 unless it is a `ConflictError`, which is a 409.

The model-state filter is deliberately left alone. A DataAnnotations rejection
means the request never bound cleanly, which leaves a client nothing to act on
differently, so those failures carry no code and keep the `ErrorResponseModel`
400 the controllers produced. The guards behind those attributes that do carry a
code — a blank name, a non-positive duration — are the commands defending their
own contract, not a second user-facing validation layer. Codes are for the
semantic failures: state a request conflicts with, a bound it exceeds, a rule
that denies it.

A `NotFoundError` deliberately keeps the `ErrorResponseModel` 404 that a thrown
`NotFoundException` produces — PAM's read paths still throw those, so all its
404s stay identical, and a 404 needs no code because the status already tells it
apart. Codes exist for the failures a status cannot separate.

The exception filter stays as the safety net for genuinely unexpected throws.

Contract: a code is never localized and never reworded once shipped; adding one
is additive, since a client treats an unknown code as a generic failure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant