Skip to content

feat(auth): allow login via reverse-proxy email header#1685

Open
aroberts wants to merge 18 commits into
we-promise:mainfrom
aroberts:ar-header-login
Open

feat(auth): allow login via reverse-proxy email header#1685
aroberts wants to merge 18 commits into
we-promise:mainfrom
aroberts:ar-header-login

Conversation

@aroberts
Copy link
Copy Markdown

@aroberts aroberts commented May 6, 2026

Summary

Adds an opt-in path that lets self-hosters put Sure behind a reverse proxy handling auth (Authelia, oauth2-proxy, Traefik forward-auth) and trust a header from it as the user's identity. Unknown emails JIT-create a User and Family, like OidcAccountsController#create_user does for first OIDC sign-ins. Off by default. Set REMOTE_USER_HEADER_EMAIL to enable:

REMOTE_USER_HEADER_EMAIL="Remote-Email"
# Optional source-IP allowlist (CIDRs ok)
REMOTE_USER_TRUSTED_PROXIES="10.0.0.5,172.18.0.0/16"

Docs: docs/hosting/docker.md.

Hardening

Threat model assumes Sure isn't reachable past the proxy; the header itself is unauthenticated. JIT users have no password_digest so they can't fall back to local login or password reset. First user becomes super_admin via User.role_for_new_family_creator (matches OIDC). A cookie session for user A is dropped when a header asserts user B. Concurrent JIT inserts for the same email rescue RecordNotUnique from the unique index. JIT creation and every header-driven login write SsoAuditLog entries tagged provider: "remote_user_header". Header values are validated against the email regex before lookup, so a misconfigured proxy gets ignored, not 500'd.

Summary by CodeRabbit

  • New Features

    • Passwordless SSO via trusted reverse-proxy headers with just-in-time account creation and session reconciliation
    • Public API endpoints to list and show family balances (paginated, filterable) and corresponding OpenAPI schema
    • NDJSON export now includes family balance history
  • Bug Fixes

    • Require amount on valuation confirm actions; treat blank monetized values as nil
  • Documentation

    • Reverse-proxy authentication guidance and security warning
  • Tests

    • New integration and request tests covering header SSO, balances API, exports, and validations

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional reverse-proxy header SSO (trusted-header JIT user creation, session handling, proxy allowlist) and a new API surface for balance history (index/show) with controller, views, OpenAPI schemas, routes, pagination, filters, NDJSON export of balances, and many tests. Also includes small model/controller validation tweaks and related docs/initializers.

Changes

Remote Header-Based SSO

Layer / File(s) Summary
Configuration
config/application.rb, config/initializers/remote_user_header.rb
Introduce config.remote_user_header_email from ENV and parse config.remote_user_trusted_proxies; initializer warns when header is enabled without a proxy allowlist.
Public Constant / Entry
app/controllers/concerns/authentication.rb
Add REMOTE_HEADER_SSO_PROVIDER constant.
Core Authentication Flow
app/controllers/concerns/authentication.rb
Rework authenticate_user! to prefer cookie session but invalidate it if it disagrees with trusted remote header email, attempt session creation from trusted header, and fall back to registration/login.
Helpers / User Creation / Audit
app/controllers/concerns/authentication.rb
Add cookie_session_disagrees_with_header?, create_session_by_remote_header, trusted_remote_user_email, remote_user_proxy_trusted?, and find_or_create_remote_header_user implementing header extraction/validation, proxy allowlist enforcement, JIT user creation (skip password validation, family creation, admin role), and SsoAuditLog events.
Integration Tests
test/integration/remote_user_header_authentication_test.rb
New tests for self-hosted opt-in, JIT creation and family/role semantics, SsoAuditLog entries, cookie-session invalidation, proxy allowlist behavior, and malformed-header safety.
Docs
docs/hosting/docker.md
Add “Reverse-proxy authentication” guidance describing REMOTE_USER_HEADER_EMAIL, REMOTE_USER_TRUSTED_PROXIES, and security caveats.

API V1 Balances (Controller, Views, Routes, OpenAPI, Specs)

Layer / File(s) Summary
Routes
config/routes.rb
Add resources :balances, only: [:index, :show] under api/v1.
Controller Surface
app/controllers/api/v1/base_controller.rb, app/controllers/api/v1/balances_controller.rb
Add InvalidFilterError and parse_date_param; add Api::V1::BalancesController with index (filters, ordering, Pagy pagination, InvalidFilterError handling) and show (UUID validation, scoping, authorization).
Views / Serialization
app/views/api/v1/balances/*.jbuilder
Add _balance.json.jbuilder, index.json.jbuilder, and show.json.jbuilder serializing balance fields, money + minor-unit cent fields, nested account, and timestamps.
OpenAPI / Swagger
docs/api/openapi.yaml, spec/swagger_helper.rb
Add Balance, BalanceAccount, and BalanceCollection schemas and register GET /api/v1/balances and GET /api/v1/balances/{id} endpoints.
Request / Controller Tests
spec/requests/api/v1/balances_spec.rb, test/controllers/api/v1/balances_controller_test.rb
Add RSpec request spec and Minitest controller tests covering listing, filtering (account_id, currency, date range), pagination, JSON shape, auth (API key read scope), invalid filters (422), and not-found cases (404).

Data Export: Balance NDJSON

Layer / File(s) Summary
Exporter Implementation
app/models/family/data_exporter.rb
Extend generate_ndjson to include Balance records for the family (join via accounts), emitting chronological NDJSON entries with comprehensive balance fields.
Exporter Tests
test/models/family/data_exporter_test.rb
Add tests asserting exported Balance NDJSON entries exist and are chronologically ordered.

Validation, Small Behavior & Tests

Layer / File(s) Summary
Controller Validation
app/controllers/valuations_controller.rb, config/locales/views/valuations/en.yml
Add guard clauses in confirm_create and confirm_update to reject blank entry_params[:amount], render with unprocessable_entity, and add valuations.errors.amount_required i18n string.
Monetizable Concern
app/models/concerns/monetizable.rb
Treat blank monetary values as nil by changing guard to value.blank?.
User SSO Predicate
app/models/user.rb
Simplify User#sso_only? to return true when password_digest is nil (remove OIDC identity existence requirement).
Password Reset Tests
test/controllers/password_resets_controller_test.rb
Add tests ensuring password-reset flows do not email or allow resets for password-less JIT users and preserve redirects to avoid enumeration.
Valuations Tests
test/controllers/valuations_controller_test.rb
Add tests asserting 422 and localized error when amount is blank for confirm create/update flows.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Proxy as Reverse Proxy
    participant Rails as Rails App
    participant Auth as Authentication Concern
    participant User as User Model
    participant Audit as SsoAuditLog

    Client->>Proxy: HTTP request
    Proxy->>Rails: request + Remote-Email header
    Rails->>Auth: authenticate_user!()
    Auth->>Auth: read config.remote_user_header_email & trusted_proxies
    Auth->>Auth: verify proxy IP trusted
    Auth->>Auth: extract & validate header email
    Auth->>User: find_or_create_remote_header_user(email)
    User-->>Auth: return user (found or created)
    Auth->>Auth: create or reuse session (invalidate cookie if mismatch)
    Auth->>Audit: log creation/login with REMOTE_HEADER_SSO_PROVIDER
    Auth-->>Rails: set Current.session
    Rails-->>Client: respond (authenticated)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • we-promise/sure#1641: Overlapping additions for Api::V1::BalancesController, views, routes, data exporter changes, OpenAPI schemas, and tests.
  • we-promise/sure#569: Related to SSO/JIT user provisioning, skip_password_validation behavior, and password-reset protections.
  • we-promise/sure#1640: Overlaps Api::V1::BaseController changes (InvalidFilterError and date parsing helper).

Suggested reviewers

  • GoByeBye
  • jjmata

Poem

🐰 I found a header by the fence and nudged it with a hop,

it told the Rails a secret and the cookies went to stop.
A family formed, the audit hummed, a session came to be,
the proxy tipped its hat — passwordless, but safely keyed.
Hooray for tiny hops that set the login free.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(auth): allow login via reverse-proxy email header' accurately and concisely describes the main change: adding authentication support for reverse-proxy-based login via email headers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aroberts aroberts force-pushed the ar-header-login branch from 1df8768 to 0ea63b4 Compare May 6, 2026 04:27
@aroberts aroberts marked this pull request as ready for review May 6, 2026 04:32
@superagent-security superagent-security Bot added contributor:flagged Contributor flagged for review by trust analysis. pr:verified PR passed security analysis. labels May 6, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ea63b400a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +98 to +100
user.email = user_email
user.skip_password_validation = true
user.family = Family.new
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist remote-header users as SSO-only accounts

This JIT path creates users with password_digest unset, but it does not attach any marker that makes them sso_only?; User#sso_only? currently requires an OIDC identity, so these accounts are treated as resettable local accounts in PasswordResetsController. In practice, a header-provisioned user can request a password reset email and set a local password, which breaks the intended "proxy-only" authentication model for this feature.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Independently confirmed against head 40286d8. User#sso_only? requires oidc_identities.exists?, which is false for JIT remote-header users (they have no OIDC identity attached). Both the email-send gate (unless user.sso_only? in create) and the write gate (if @user.sso_only? in update) evaluate to the wrong branch for these users — password reset emails are sent and the password update is allowed through. The PR description's claim that these users "can't fall back to local login or password reset" is not implemented. Needs a fix before merge — see the review comment above for specifics.


Generated by Claude Code

Comment thread config/application.rb Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/controllers/concerns/authentication.rb`:
- Around line 81-88: The method remote_user_proxy_trusted? is using
request.remote_ip (end-user IP) and treating trusted.blank? as "allow all";
change it to use the immediate peer address request.env['REMOTE_ADDR'] for
comparison, and change the allow-all logic to only return true when
Rails.application.config.remote_user_trusted_proxies is nil (not when it's an
empty array), e.g. check for trusted.nil? instead of trusted.blank?; keep the
IPAddr parsing and trusted.any? { |range| range.include?(IPAddr.new(peer_ip)) }
flow and preserve the IPAddr::Error rescue to return false.

In `@docs/hosting/docker.md`:
- Around line 326-338: The fenced code blocks showing environment variables
REMOTE_USER_HEADER_EMAIL and REMOTE_USER_TRUSTED_PROXIES lack a language
identifier and trigger MD040; update the two blocks that contain
REMOTE_USER_HEADER_EMAIL="Remote-Email" and
REMOTE_USER_TRUSTED_PROXIES="10.0.0.5,172.18.0.0/16" to use a language tag
(e.g., txt) immediately after the opening backticks so the blocks read ```txt
... ``` to satisfy markdownlint.

In `@test/integration/remote_user_header_authentication_test.rb`:
- Around line 7-9: The tests rely on global REMOTE_USER_TRUSTED_PROXIES
state—update the setup block that already stubs
Rails.application.config.remote_user_header_email (and references HEADER_NAME)
to also stub Rails.application.config.remote_user_trusted_proxies to a
deterministic value (e.g. nil or an empty array) so tests assume “no allowlist
configured”; locate the setup method in
remote_user_header_authentication_test.rb and add a stub for
remote_user_trusted_proxies to ensure the test environment is consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7f3015c5-45d7-4ae4-819a-cae2b65e6216

📥 Commits

Reviewing files that changed from the base of the PR and between ec4559b and 0ea63b400ac42b8152622b31a8a6112b1d92b5cc.

📒 Files selected for processing (4)
  • app/controllers/concerns/authentication.rb
  • config/application.rb
  • docs/hosting/docker.md
  • test/integration/remote_user_header_authentication_test.rb

Comment thread app/controllers/concerns/authentication.rb
Comment thread docs/hosting/docker.md
Comment thread test/integration/remote_user_header_authentication_test.rb
Copy link
Copy Markdown

Code review: the implementation is carefully constructed — opt-in via env var, email regex guard, JIT mirrors OIDC, session invalidation when header disagrees, SsoAuditLog, no password_digest on JIT users. The integration tests cover the important paths.

Two concerns before merging:

Default-open when no IP allowlist is set
remote_user_proxy_trusted? returns true when REMOTE_USER_TRUSTED_PROXIES is nil. So a self-hoster who sets only REMOTE_USER_HEADER_EMAIL (the minimum advertised config) will honor the header from any source IP. The docs note says "Only use this if Sure is not accessible directly" — but that's advisory, and misconfigured deployments where Sure is accidentally reachable will silently accept forged headers.

Consider defaulting to fail-closed: require at least one trusted CIDR, or emit a startup warning (e.g., Rails.logger.warn) when the header feature is enabled but no IP allowlist is configured. The OIDC path doesn't have an equivalent footgun, so this feature needs a stronger guardrail.

contributor:flagged label
I see this is still flagged — flagging for the core team to confirm whether the self-hosted-only scope is acceptable before this lands on main.


Generated by Claude Code

@jjmata jjmata removed the contributor:flagged Contributor flagged for review by trust analysis. label May 6, 2026
@jjmata
Copy link
Copy Markdown
Collaborator

jjmata commented May 6, 2026

Thanks for the contribution, @aroberts! Can you address/respond to the open PR comments? 🙏

@jjmata
Copy link
Copy Markdown
Collaborator

jjmata commented May 6, 2026

Can you review/suggest ways to minimize the threat surface if we take this, @GoByeBye? Put it behind a feature flag? 🤔

@jjmata jjmata requested a review from GoByeBye May 6, 2026 06:10
@jjmata jjmata added this to the v0.7.1 milestone May 6, 2026
@we-promise we-promise deleted a comment from sure-admin May 6, 2026
@we-promise we-promise deleted a comment from sure-admin May 6, 2026
@jjmata jjmata force-pushed the ar-header-login branch from 03b32f1 to 40286d8 Compare May 6, 2026 06:53
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label May 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/controllers/concerns/authentication.rb`:
- Around line 81-89: The remote_user_proxy_trusted? method currently treats a
nil REMOTE_USER_TRUSTED_PROXIES as trusted, which is unsafe; add a startup check
instead of changing runtime behavior: in an initializer (e.g.,
config/initializers/remote_user_header.rb) add a
Rails.application.config.after_initialize hook that logs a one-time WARN if
Rails.application.config.remote_user_header_email.present? &&
Rails.application.config.remote_user_trusted_proxies.nil?, warning that the
header will be trusted from any source IP and advising to set
REMOTE_USER_TRUSTED_PROXIES or restrict exposure; alternatively, if you prefer
fail-closed, change remote_user_proxy_trusted? to treat nil the same as empty
(return false) and update tests/docs that assume nil default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d9ca3c63-e2ab-44f1-8a11-c32c9865ff81

📥 Commits

Reviewing files that changed from the base of the PR and between 03b32f1c51a7d584eda9a5ef10f8e5bf97b6e096 and 40286d8.

📒 Files selected for processing (4)
  • app/controllers/concerns/authentication.rb
  • config/application.rb
  • docs/hosting/docker.md
  • test/integration/remote_user_header_authentication_test.rb

Comment thread app/controllers/concerns/authentication.rb
@jjmata
Copy link
Copy Markdown
Collaborator

jjmata commented May 6, 2026

(deleted some "agentic eager beaver" comments from @sure-admin, sorry about that ... trying to fine-tune things!)

@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 6, 2026

Thanks for the contribution, @aroberts! Can you address/respond to the open PR comments? 🙏

hey, I believe I addressed them in 40286d8 - I did leave the "open door" of not requiring the trusted proxies env var to be set, but if you prefer, I can make that required as well.

@jjmata
Copy link
Copy Markdown
Collaborator

jjmata commented May 6, 2026

@coderabbitai re-review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@jjmata Sure! I'll kick off a fresh review of PR #1685, including the latest changes from commit 40286d8 that address the previous concerns.

[full_review]

@jjmata jjmata removed the contributor:flagged Contributor flagged for review by trust analysis. label May 6, 2026
Copy link
Copy Markdown

@sure-design sure-design left a comment

Choose a reason for hiding this comment

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

Independent re-review against the current head (40286d8). Most prior comments are well-addressed — the REMOTE_ADDR fix, the nil?-vs-blank? fix, the markdown lint, and the test setup stub are all confirmed correct. One blocking P1 remains, plus two smaller items.


❌ Blocking — P1: JIT remote-header users can bypass proxy via password reset

The PR description says "JIT users have no password_digest so they can't fall back to local login or password reset." This is not enforced by the code.

User#sso_only? (unchanged by this PR) is:

def sso_only?
  password_digest.nil? && oidc_identities.exists?
end

JIT remote-header users have password_digest = nil and no OIDC identities, so sso_only? returns false for them.

PasswordResetsController#create sends the reset email unless user.sso_only? — that guard is false for these users → the email is sent.

PasswordResetsController#update blocks the write if @user.sso_only? — also false → the password update succeeds → the user now has a password_digest and can log in locally without the proxy entirely.

The fix: make sso_only? cover any user with password_digest.nil?, regardless of OIDC identity. The simplest approach is to broaden the condition:

def sso_only?
  password_digest.nil?
end

Or add a separate predicate (remote_header_provisioned?) and update both password-reset checks. Either way, a test should cover this path.


⚠️ Non-blocking — No startup warning when allowlist is unconfigured

When REMOTE_USER_HEADER_EMAIL is set but REMOTE_USER_TRUSTED_PROXIES is not, the header is trusted from any source IP. The docs mention this, but an operator who misses that line gets silent default-open behavior. A one-shot Rails.logger.warn in an after_initialize block would catch real deployment mistakes at zero cost:

# config/initializers/remote_user_header.rb
Rails.application.config.after_initialize do
  if Rails.application.config.remote_user_header_email.present? &&
     Rails.application.config.remote_user_trusted_proxies.nil?
    Rails.logger.warn(
      "[remote_user_header] REMOTE_USER_HEADER_EMAIL is set but " \
      "REMOTE_USER_TRUSTED_PROXIES is unset. The header will be trusted " \
      "from ANY source IP. Set REMOTE_USER_TRUSTED_PROXIES or ensure " \
      "Sure is not reachable except via your authenticating reverse proxy."
    )
  end
end

ℹ️ Minor — No self_hosted? guard

The OIDC path and the first-login path both check app_mode.self_hosted?. This feature has no such guard, so a managed deployment that accidentally sets REMOTE_USER_HEADER_EMAIL enables header auth (including JIT super_admin creation). Lowest priority, but worth making consistent.


Generated by Claude Code

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 7, 2026
@aroberts aroberts force-pushed the ar-header-login branch from 0a436d1 to 9608857 Compare May 7, 2026 02:29
@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 7, 2026

addressed feedback; rebased back onto to main. I kept the sso_only? question isolated in case you want to go a different way with it ( 31db5a1a ) but for now I just removed the OIDC account check to avoid making a db column for this. If you prefer, though, I can mirror the OIDC structure for header-auth users.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
test/controllers/valuations_controller_test.rb (1)

54-79: 💤 Low value

LGTM — tests are minimal, correct, and follow project conventions.

Both tests use Minitest + fixtures, post to the right named route helpers, and assert both the HTTP status and the presence of the i18n error string in the body. Coverage is proportional to the change.

One small heads-up: assert_match I18n.t("valuations.errors.amount_required"), response.body passes vacuously if the i18n key is absent (both controller and test would produce the same "translation missing" sentinel). Since the key is confirmed to be present in en.yml, this is low risk, but asserting the literal English string instead of the i18n lookup would make the test self-contained.

🔍 Optional: assert literal string to avoid vacuous pass on missing key
-    assert_match I18n.t("valuations.errors.amount_required"), response.body
+    assert_match "Amount is required", response.body

Apply the same change to the confirm_update test. This makes the assertion independent of the i18n infrastructure and catches key-missing regressions explicitly. Only do this if the team prefers literal assertions in integration tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/controllers/valuations_controller_test.rb` around lines 54 - 79, Replace
the i18n lookup in the two tests so the assertion checks the literal English
error message instead of I18n.t(...) to avoid a vacuous pass when the
translation key is missing: in the "confirm_create with blank amount returns
unprocessable entity" and "confirm_update with blank amount returns
unprocessable entity" tests (the ones posting to confirm_create_valuations_url
and confirm_update_valuation_url), change the assert_match that currently uses
I18n.t("valuations.errors.amount_required") to assert against the literal string
shown in en.yml (e.g. "Amount is required" or the exact English message used) so
the response body assertion fails if the translation key is removed or changed.
test/controllers/password_resets_controller_test.rb (1)

94-124: 💤 Low value

Extract the repeated inline user creation into a private helper.

Both new tests repeat the same User.create! / Family.create! block verbatim. A private helper keeps the intent visible and avoids drift if the JIT creation contract changes.

♻️ Suggested refactor
+  private
+
+    def create_jit_user(email: "headerjit@test.example")
+      User.create!(
+        email: email,
+        family: Family.create!,
+        role: :admin,
+        skip_password_validation: true
+      )
+    end
+
   test "create does not send email for password-less user without OIDC identity" do
-    jit_user = User.create!(
-      email: "headerjit@test.example",
-      family: Family.create!,
-      role: :admin,
-      skip_password_validation: true
-    )
+    jit_user = create_jit_user
     ...
   end

   test "update blocks password setting for password-less user without OIDC identity" do
-    jit_user = User.create!(
-      email: "headerjit@test.example",
-      family: Family.create!,
-      role: :admin,
-      skip_password_validation: true
-    )
+    jit_user = create_jit_user
     ...
   end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/controllers/password_resets_controller_test.rb` around lines 94 - 124,
Extract the repeated User.create! / Family.create! block used in the tests
"create does not send email for password-less user without OIDC identity" and
"update blocks password setting for password-less user without OIDC identity"
into a private helper method (e.g., build_jit_user or create_jit_user) inside
the test class; have both tests call that helper to return the jit_user so they
reuse the same attributes (email, associated Family.create!, role: :admin,
skip_password_validation: true) and update any references (token generation or
post/patch calls) to use the helper result.
test/controllers/api/v1/balances_controller_test.rb (1)

116-124: ⚡ Quick win

account_id filter test only asserts inclusion, not exclusion

The test confirms @balance appears in the filtered results but doesn't assert that balances belonging to other accounts are absent. A second account with its own balance created in setup (or inline here) and an assert_not_includes would make the filter's isolation guarantee explicit.

✅ Suggested assertion addition
  test "filters balances by account_id" do
+   other_account_balance = `@family.accounts.create`!(
+     name: "Other Account",
+     accountable: Depository.new,
+     balance: 500,
+     currency: "USD"
+   ).balances.create!(date: Date.parse("2024-01-15"), balance: 500, currency: "USD")
+
    get api_v1_balances_url,
        params: { account_id: `@account.id` },
        headers: api_headers(`@api_key`)

    assert_response :success
    response_data = JSON.parse(response.body)
    balance_ids = response_data["balances"].map { |balance| balance["id"] }
    assert_includes balance_ids, `@balance.id`
+   assert_not_includes balance_ids, other_account_balance.id
  end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/controllers/api/v1/balances_controller_test.rb` around lines 116 - 124,
The test "filters balances by account_id" currently only asserts that `@balance`
is present; add a second account and its balance (either in the test or in
setup) and after parsing response_data assert that the other balance's id is not
present using assert_not_includes on response_data["balances"].map { |balance|
balance["id"] }, referencing the existing api_v1_balances_url request and
api_headers(`@api_key`) to ensure the filter excludes balances from other
accounts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/family/data_exporter.rb`:
- Around line 157-188: The Balance export loop in generate_ndjson currently uses
Balance.joins(...).chronological.each which materializes the entire result set;
change it to a batched iteration to avoid high memory use by either (A)
replacing .each with in_batches(of: 1000).order('date').each_record (or using
in_batches.with_record_fetching preserving the chronological scope) so ordering
by date is preserved while loading batches, or (B) if ordering by date is not
required, drop .chronological and use find_each(batch_size: 1000) on Balance to
stream records; update the Balance iteration in generate_ndjson accordingly
(referencing Balance, chronological, generate_ndjson, find_each, in_batches).

In `@test/controllers/password_resets_controller_test.rb`:
- Around line 109-124: The test "update blocks password setting for
password-less user without OIDC identity" currently only asserts redirect; add
an assertion that flash[:alert] contains the expected error message to mirror
the sso_only test coverage. After calling patch password_reset_path(token:
token) and asserting assert_redirected_to new_session_path, assert that
flash[:alert] (or flash[:error] if your app uses that key) equals or matches the
same message used in the controller for this branch so the error message path is
verified; reference the test name, jit_user, token, and the patch to
password_reset_path to locate where to add the assertion.

---

Nitpick comments:
In `@test/controllers/api/v1/balances_controller_test.rb`:
- Around line 116-124: The test "filters balances by account_id" currently only
asserts that `@balance` is present; add a second account and its balance (either
in the test or in setup) and after parsing response_data assert that the other
balance's id is not present using assert_not_includes on
response_data["balances"].map { |balance| balance["id"] }, referencing the
existing api_v1_balances_url request and api_headers(`@api_key`) to ensure the
filter excludes balances from other accounts.

In `@test/controllers/password_resets_controller_test.rb`:
- Around line 94-124: Extract the repeated User.create! / Family.create! block
used in the tests "create does not send email for password-less user without
OIDC identity" and "update blocks password setting for password-less user
without OIDC identity" into a private helper method (e.g., build_jit_user or
create_jit_user) inside the test class; have both tests call that helper to
return the jit_user so they reuse the same attributes (email, associated
Family.create!, role: :admin, skip_password_validation: true) and update any
references (token generation or post/patch calls) to use the helper result.

In `@test/controllers/valuations_controller_test.rb`:
- Around line 54-79: Replace the i18n lookup in the two tests so the assertion
checks the literal English error message instead of I18n.t(...) to avoid a
vacuous pass when the translation key is missing: in the "confirm_create with
blank amount returns unprocessable entity" and "confirm_update with blank amount
returns unprocessable entity" tests (the ones posting to
confirm_create_valuations_url and confirm_update_valuation_url), change the
assert_match that currently uses I18n.t("valuations.errors.amount_required") to
assert against the literal string shown in en.yml (e.g. "Amount is required" or
the exact English message used) so the response body assertion fails if the
translation key is removed or changed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 475b4aa7-5ee5-40a5-905c-99cfd05e9271

📥 Commits

Reviewing files that changed from the base of the PR and between 40286d8 and 96088575e6bb80cef4e03b9694c84a5d02dac27b.

📒 Files selected for processing (23)
  • app/controllers/api/v1/balances_controller.rb
  • app/controllers/api/v1/base_controller.rb
  • app/controllers/concerns/authentication.rb
  • app/controllers/valuations_controller.rb
  • app/models/concerns/monetizable.rb
  • app/models/family/data_exporter.rb
  • app/models/user.rb
  • app/views/api/v1/balances/_balance.json.jbuilder
  • app/views/api/v1/balances/index.json.jbuilder
  • app/views/api/v1/balances/show.json.jbuilder
  • config/application.rb
  • config/initializers/remote_user_header.rb
  • config/locales/views/valuations/en.yml
  • config/routes.rb
  • docs/api/openapi.yaml
  • docs/hosting/docker.md
  • spec/requests/api/v1/balances_spec.rb
  • spec/swagger_helper.rb
  • test/controllers/api/v1/balances_controller_test.rb
  • test/controllers/password_resets_controller_test.rb
  • test/controllers/valuations_controller_test.rb
  • test/integration/remote_user_header_authentication_test.rb
  • test/models/family/data_exporter_test.rb
✅ Files skipped from review due to trivial changes (2)
  • app/views/api/v1/balances/show.json.jbuilder
  • config/locales/views/valuations/en.yml
🚧 Files skipped from review as they are similar to previous changes (3)
  • config/application.rb
  • app/controllers/concerns/authentication.rb
  • test/integration/remote_user_header_authentication_test.rb

Comment on lines +157 to +188
Balance.joins(:account)
.where(accounts: { family_id: @family.id })
.chronological
.each do |balance|
lines << {
type: "Balance",
data: {
id: balance.id,
account_id: balance.account_id,
date: balance.date,
balance: balance.balance,
currency: balance.currency,
cash_balance: balance.cash_balance,
start_cash_balance: balance.start_cash_balance,
start_non_cash_balance: balance.start_non_cash_balance,
cash_inflows: balance.cash_inflows,
cash_outflows: balance.cash_outflows,
non_cash_inflows: balance.non_cash_inflows,
non_cash_outflows: balance.non_cash_outflows,
net_market_flows: balance.net_market_flows,
cash_adjustments: balance.cash_adjustments,
non_cash_adjustments: balance.non_cash_adjustments,
flows_factor: balance.flows_factor,
start_balance: balance.start_balance,
end_cash_balance: balance.end_cash_balance,
end_non_cash_balance: balance.end_non_cash_balance,
end_balance: balance.end_balance,
created_at: balance.created_at,
updated_at: balance.updated_at
}
}.to_json
end
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use batched loading for Balance records; .each loads the full result set into memory.

Every other section in generate_ndjson calls find_each to process records in 1,000-row DB batches, capping instantiated AR objects in memory. This block uses plain .each, which materializes the entire Balance result set at once. Balance tables grow at one row per account per day; a family with 15 accounts and 3 years of history already has ~16,000 rows — and the export contains the full family.

The chronological scope's ORDER BY date is incompatible with find_each's id-cursor batching, so a naive swap loses ordering. Two workable alternatives:

  1. in_batches with preserved ordering (Rails 7+ supports this):
⚡ Proposed fix using `in_batches`
-      Balance.joins(:account)
-        .where(accounts: { family_id: `@family.id` })
-        .chronological
-        .each do |balance|
+      Balance.joins(:account)
+        .where(accounts: { family_id: `@family.id` })
+        .chronological
+        .in_batches(of: 1000) do |batch|
+        batch.each do |balance|
          lines << {
            type: "Balance",
            data: {
              id: balance.id,
              # ...
            }
          }.to_json
+        end
        end
  1. Drop the chronological scope and use find_each (id order), which is acceptable for a backup export where exact date ordering is not a semantic requirement. The test at line 406 would need to be adjusted accordingly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/family/data_exporter.rb` around lines 157 - 188, The Balance
export loop in generate_ndjson currently uses
Balance.joins(...).chronological.each which materializes the entire result set;
change it to a batched iteration to avoid high memory use by either (A)
replacing .each with in_batches(of: 1000).order('date').each_record (or using
in_batches.with_record_fetching preserving the chronological scope) so ordering
by date is preserved while loading batches, or (B) if ordering by date is not
required, drop .chronological and use find_each(batch_size: 1000) on Balance to
stream records; update the Balance iteration in generate_ndjson accordingly
(referencing Balance, chronological, generate_ndjson, find_each, in_batches).

Comment thread test/controllers/password_resets_controller_test.rb
@GoByeBye
Copy link
Copy Markdown
Collaborator

GoByeBye commented May 7, 2026

#1685 (review)
Hah I was going to leave a comment on this, this morning after looking at this last night


My only real concern is that when REMOTE_USER_TRUSTED_PROXIES is unset it means "honor the header from any source IP", and the response was a Rails.logger.warn rather than a default.

I'd reckon (a sysadmin tailing application logs only when something visibly breaks, a Docker setup where the warning scrolls past during boot under a hundred other Sidekiq/Rails lines, a managed-by-Watchtower restart at 3am with nobody watching, a Loki/Promtail pipeline that filters level=warn because Rails is noisy at warn): in each, the warning fires into the void, login keeps working through the proxy, and the deployment looks healthy right up until the perimeter leaks once.

[Gitea](https://docs.gitea.com/administration/config-cheat-sheet) ship REVERSE_PROXY_TRUSTED_PROXIES=127.0.0.0/8,::1/128 as the default for exactly this reason: you want the failure to be "login is broken, please configure the allowlist" at first test, not "login works fine, until it doesn't" months later. A warning that the user has to notice is structurally weaker than a default that forces them to act.

With the current setup, a self-hoster who skips the optional var ships default-open and a logged warning. With a loopback default, that same self-hoster ships default-closed, the proxy (almost never on loopback) gets ignored, and they hit a tractable "login isn't going through" on day one instead of a silent compromise on day ninety. Same change as before:

config.remote_user_trusted_proxies = (ENV["REMOTE_USER_TRUSTED_PROXIES"].presence || "127.0.0.0/8,::1/128")
  .split(",")
  .map(&:strip)
  .reject(&:empty?)
  .filter_map { |s| IPAddr.new(s) rescue nil }

(then drop return true if trusted.nil? and the after_initialize warning becomes redundant, plus flip the doc framing to "loopback by default, set this to widen").

Outside of that LGTM

@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 7, 2026

sure, I can do that

Copy link
Copy Markdown

Two things worth addressing before this lands:

Merge conflicts — the branch is currently dirty against main. Needs a rebase before it can be merged.

PR scope — the diff bundles several unrelated changes alongside the SSO feature:

  • A new Balances API endpoint (Api::V1::BalancesController, OpenAPI schema, rswag spec, Minitest coverage)
  • Balance history added to the NDJSON export
  • Valuation blank-amount bug fix (ValuationsController, locale key)
  • Monetizable concern nil?blank? change

The SSO auth feature is substantial enough on its own (22 files, security-sensitive). The unrelated additions make the diff harder to reason about safely and would complicate any targeted rollback. Would recommend splitting out at minimum the Balances API and the valuation fixes as separate PRs.

The SSO implementation itself looks solid — fail-closed IP default, email regex guard, JIT mirrors OIDC path, audit logging, session invalidation on identity mismatch. The sso_only? simplification (password_digest.nil? without the OIDC check) is the one remaining open design question per the thread above.


Generated by Claude Code

aroberts added 16 commits May 7, 2026 08:57
Soft `save` swallowed validation errors and let `create_session_for`
proceed with an unpersisted User, producing FK errors on session create
or orphaned rows.
Two concurrent requests with the same header email both miss the
`find_by`, both attempt insert; the deterministic-encrypted email index
makes one win. Previously the loser bubbled `RecordNotUnique` to a 500.
Rescue and re-fetch the row the winner created.
…nt user

If a browser carries a session cookie for user A but the upstream proxy
sets the remote-user header to user B (e.g. shared kiosk, proxy-side
identity rotation), the cookie short-circuit auth'd as A and ignored B.
When a header is present, treat it as authoritative: invalidate any
cookie session that doesn't match and fall through to the header path.
The first JIT user on a fresh instance should become super_admin to
match the OIDC JIT path; subsequent users get :admin. Previously every
remote-header user was hardcoded to :admin, leaving the instance
without a super_admin if the first sign-in came through this path.
Setting a random password meant a remote-header user could later
acquire local-login access via password reset, defeating the
"proxy is the only way in" guarantee. Match the OIDC JIT pattern:
skip password validation, leave password_digest nil.
OIDC JIT writes to sso_audit_logs; remote-header auth was silent.
Log a jit_account_created entry on first sign-in and a login entry on
each subsequent header-driven session, both tagged with the
remote_user_header provider so they're filterable from the OIDC traffic.
A misconfigured proxy can send arbitrary garbage in the configured
header. Pre-validate against the standard email regexp (and also guard
against an unset config header name) so we fail closed silently
instead of throwing on User#save validations and 500ing the request.
Also normalize via strip+downcase before lookup so the cookie/header
mismatch comparison stays consistent.
A header-only auth path is only as trustworthy as the network in front
of it. Add an opt-in source-IP allowlist that the request must pass
before the header is honored:

- REMOTE_USER_TRUSTED_PROXIES: comma-separated IPs/CIDRs; request must
  originate from one of them.

Defaults to off so existing single-tenant homelab setups behave
unchanged. Doc updated.
Eight tests covering the security and correctness invariants of the
remote-header path. Each defends a property whose regression wouldn't be
caught by reading the diff:

- feature is opt-in: header ignored when REMOTE_USER_HEADER_EMAIL unset
- JIT user has password_digest = nil and a created family
- JIT delegates role to User.role_for_new_family_creator
- SsoAuditLog: jit_account_created once, login on every header session
- cookie session for user A is destroyed when header asserts user B
- IP allowlist denies non-allowlisted source
- IP allowlist permits allowlisted CIDR (incl. 127.0.0.0/8 happy path)
- malformed/empty header value fails closed without raising
The allowlist gate now compares against REMOTE_ADDR (the immediate peer)
instead of request.remote_ip; the latter resolves through XFF and could
let an attacker spoof past the gate if they reach Sure outside the
proxy. An empty or unparseable trusted-proxies list now fails closed
(deny-all) instead of silently disabling the gate; only an unset env
var disables. Doc updated to spell out the three states. Added a
language tag to the env-var fenced blocks for markdownlint, and stubbed
remote_user_trusted_proxies in the integration test setup so cases that
don't configure it have a deterministic baseline.
Header-JIT users have password_digest = nil but no OIDC identity, so
the original sso_only? definition (password_digest.nil? && oidc_
identities.exists?) returned false for them. PasswordResetsController
gates on sso_only?, so these users could request a password reset, set
a password, and from then on bypass the reverse proxy entirely.

Broaden the predicate to password_digest.nil?. The two existing
production callers (PasswordResetsController#create and #update) want
"this user can't use a local password" semantics; neither of them
relies on the OIDC clause specifically. Existing user-model tests
still pass under the broader definition.

Adds two controller tests for the regression: a password-less user
with no OIDC identity can't trigger a reset email, and even with a
forged token can't write a new password_digest.
…st unconfigured

Two minor follow-ups from review:

Header auth is now gated on Rails.application.config.app_mode.self_hosted?
in trusted_remote_user_email, matching the OIDC and first-login paths.
A managed deployment that accidentally sets REMOTE_USER_HEADER_EMAIL no
longer enables JIT super_admin creation. Doc updated.

Adds config/initializers/remote_user_header.rb that emits a one-time
Rails.logger.warn at startup when REMOTE_USER_HEADER_EMAIL is set but
REMOTE_USER_TRUSTED_PROXIES is unset. Catches the operator-error case
where someone enables header auth but forgets the source-IP allowlist
and silently default-opens.

Adds an integration test asserting the feature is inert in managed
mode even with all config set.
CodeRabbit re-review caught that the new password-reset update test
verified the redirect but not the flash[:alert] body, so a wrong-or-
empty message would have slipped through. Add substring assertions
on the alert text rather than pinning the full literal sentence,
so wording tweaks don't break the test.

Also extract the duplicated User.create!/Family.create! block into
a private create_jit_user helper, per the same review's nit.
Per review feedback: a Rails.logger.warn for an unconfigured allowlist
is structurally weak. It's silenced by warn-level log filters, scrolls
past during boot under Sidekiq/Rails noise, and only gets noticed when
the perimeter has already leaked. Real-world ops setups (log-tail-on-
incident, unattended Watchtower restarts) ship default-open + a logged
warning that's functionally equivalent to silently default-open.

Borrow the pattern Gitea uses for its REVERSE_PROXY_TRUSTED_PROXIES:
default to loopback (127.0.0.0/8 + ::1/128). A self-hoster who skips
the env var ships fail-closed; their proxy on a Docker bridge or
separate host gets ignored, login breaks at first test, they get an
actionable signal on day one instead of a silent compromise on day
ninety.

Also drops the now-redundant after_initialize warning in
config/initializers/remote_user_header.rb (the warning's only purpose
was warning operators about default-open; with default-closed there's
nothing to warn about) and the dead trusted.nil? short-circuit in
remote_user_proxy_trusted? (the config is now always populated).

Doc and tests updated. Test coverage expands to (a) the new default
behavior (loopback peer is honored), and (b) explicit override (a
non-loopback peer paired with a non-loopback CIDR override is honored,
where it would be denied under the default).
@aroberts aroberts force-pushed the ar-header-login branch from f4b476c to 3778578 Compare May 7, 2026 13:08
@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 7, 2026

rebased onto the latest main, and removed a bunch of unrelated commits out that somehow made their way into this PR. I still have them on disk if anyone needs them

Adds a second perimeter on top of the IP allowlist for setups where
peer-IP isn't sufficient — a multi-tenant Docker bridge where many
containers share an IP range, or any topology where the immediate
peer isn't fully trusted.

When REMOTE_USER_SHARED_SECRET is set, the proxy must echo the same
value in REMOTE_USER_SHARED_SECRET_HEADER (default X-Remote-User-Secret)
on every request. Compared in constant time. Mismatches and missing
headers fall through to the normal login redirect — same behavior as
a non-allowlisted source IP.

Defaults to unset, so existing deployments behave unchanged. The IP
allowlist remains the load-bearing gate; the shared secret is opt-in
defense-in-depth.

Tests cover the three branches: secret configured + missing header,
secret configured + mismatch, secret configured + match.
Copy link
Copy Markdown

P1 security issue: JIT remote-header users can bypass the reverse proxy via password reset. User#sso_only? checks password_digest.nil? && oidc_identities.exists? — but JIT users have no OIDC identities, so sso_only? returns false. This means PasswordResetsController will send reset emails and accept password updates for these accounts, allowing anyone to set a local password and log in directly, bypassing the proxy entirely.

Fix: broaden sso_only? to password_digest.nil? (or password_digest.nil? || remote_header_user?) rather than relying on OIDC identity presence.

This should be addressed before merge regardless of the separate security-scan CI failure.


Generated by Claude Code

@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 9, 2026

This was fixed in commit 13445501 (2026-05-06), which broadened sso_only? to password_digest.nil? in response to the original P1 review. The OIDC clause this comment quotes isn't on HEAD (e8d008be).

# app/models/user.rb (current)
def sso_only?
  password_digest.nil?
end

Coverage at test/controllers/password_resets_controller_test.rb (the "create does not send email for password-less user without OIDC identity" and "update blocks password setting for password-less user without OIDC identity" tests) verifies both paths.

Looks like an automation re-post of the original review.

@aroberts
Copy link
Copy Markdown
Author

aroberts commented May 9, 2026

Separately, the pipelock scan seems to be 2 false positives as well. Please let me know if there's anything you'd like changed regarding the pipelock report.

@jjmata jjmata self-requested a review May 9, 2026 10:02
Copy link
Copy Markdown
Collaborator

@jjmata jjmata left a comment

Choose a reason for hiding this comment

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

Want to double-check on the no password recovery before we merge.

Comment thread app/models/user.rb
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Asked about this in our Discord here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Development

Successfully merging this pull request may close these issues.

4 participants