Skip to content

feat(ratelimit): look up and clear sign-in lockouts from WP-CLI and the Users screen - #79

Open
bordoni wants to merge 3 commits into
feat/rate-limit-policy-filterfrom
feat/lockouts-admin-cli
Open

bordoni wants to merge 3 commits into
feat/rate-limit-policy-filterfrom
feat/lockouts-admin-cli

Conversation

@bordoni

@bordoni bordoni commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #76 (feat/rate-limit-policy-filter).

When a customer gets locked out by the email-code or password limits, support has no way to see it or clear it without running PHP on the server. TieredRateLimiter::reset() exists, but nothing called it.

Changes

  • New RateLimit\Lockouts covers the buckets keyed on an email address: password_email, magic_verify_email, magic_send_email, pw_reset_email, signup_email, otp_send_recipient and password_otp_recipient. Each one is keyed the way its endpoint keys it (the exact address or the mailbox), so a lookup finds the counters the customer's requests hit. Limits come from the filtered policy.
  • wp workos ratelimit check <email> lists those buckets, one row per window, with count, limit, time left and whether it's locked. --format=json|yaml|csv gives seconds.
  • wp workos ratelimit clear <email> [--bucket=<bucket>] clears one bucket or all of them. It resets every window, so the next request goes through.
  • WorkOS > Users gets a small "Sign-in lockouts" section below the user list. Enter an email, see the buckets with usage and lock time, and clear one or all. It reuses the list's table, buttons and notices, and only shows for users with edit_users.
  • It calls POST /workos/v1/admin/lockouts/check and /clear, both gated by edit_users. They're POST so the address stays out of URLs and access logs.
  • Every clear writes one rate_limit_cleared activity-log entry: the user who cleared it, the source (cli or admin) and each bucket with its subject hash. The hash matches that bucket's rate_limited entries from Rate limits: make them filterable and log when they trigger #60. The address itself isn't stored.
  • Limiters can now report a count without charging it, through a new optional CountsAttempts interface. Both shipped limiters implement it. The shared bucket constants on BaseEndpoint and Signup::RATE_LIMIT_CREATE_EMAIL are now public, so the lookup uses the same names and default as the endpoints.
  • Per-IP buckets and the user-ID buckets (admin password reset, change email) aren't covered. They aren't sign-in lockouts for an address.
  • docs/rate-limiting.md and AGENTS.md cover the command, the routes and the log entry.

Screenshots

WorkOS Users screen, a locked address looked up:

lockouts-after-cleared-desktop lockouts-after-cleared-mobile lockouts-after-locked-desktop lockouts-after-locked-mobile lockouts-before-desktop lockouts-before-mobile

Verified

  • New LockoutsTest: locking an address the way the endpoints do, then check shows it (exact and mailbox keying), then clear lets the next request through. Also covers clear all, every window, filtered limits, the log entry and bad input.
  • New LockoutsRestApiTest: signed-out, subscriber and editor callers get 401/403 on both routes and nothing is cleared. An admin can check and clear, and bad input gets a 400.
  • New RateLimitCommandTest: check and clear against WP-CLI test doubles, since WP-CLI isn't in the test environment.
  • RateLimiterTest: count() on both limiters.
  • Full WPUnit suite passes: 796 tests, up from 761 on main (771 on Rate limits: make them filterable and log when they trigger #60), plus the 15 constants tests.
  • phpcs, phpstan, tsc and bun run build pass.

Closes #61

…he Users screen

When a customer was locked out by the email-code or password limits,
support had no way to see it or clear it short of running PHP on the
server. TieredRateLimiter::reset() existed, but nothing called it.

- RateLimit\Lockouts covers the email-keyed buckets (password_email,
  magic_verify_email, magic_send_email, pw_reset_email, signup_email,
  otp_send_recipient, password_otp_recipient). Each is keyed the way its
  endpoint keys it, exact or by mailbox, and limits come from the
  filtered policy. check() gives per-window counts and the lock time;
  clear() resets every tier of one bucket or all of them.
- wp workos ratelimit check <email> lists them, one row per window with
  count, limit, time left and locked. wp workos ratelimit clear <email>
  [--bucket=<bucket>] clears one or all.
- WorkOS > Users gets a small "Sign-in lockouts" section below the user
  list: email in, buckets out, Clear per bucket and Clear all. It calls
  POST /workos/v1/admin/lockouts/{check,clear}, gated by edit_users. POST
  keeps the address out of URLs.
- Every clear writes one rate_limit_cleared activity-log entry with the
  user who cleared it, the source (cli or admin) and each bucket with its
  subject hash, which matches that bucket's rate_limited entries. The
  address isn't stored.
- Limiters can report a counter without charging it through a new
  optional CountsAttempts interface, implemented by both shipped
  limiters. The shared bucket constants on BaseEndpoint and
  Signup::RATE_LIMIT_CREATE_EMAIL are now public so Lockouts uses the
  same names and default.

Closes #61
@bordoni bordoni added this to the 1.0.11 milestone Oct 17, 2026
@bordoni bordoni added enhancement New feature or request area: admin WP-admin settings & screens labels Oct 17, 2026
@bordoni
bordoni added this pull request to stack #82 October 17, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: admin WP-admin settings & screens enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Look up and clear sign-in lockouts from the admin and WP-CLI

1 participant