strongroom-rails 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2313700e0514a62dbf3f58e5f541e0c4421f4c034a8220840cd9ca243d05b816
4
- data.tar.gz: 5530d843f7630a021488e36bcc230bdfd9d1137f3aec6c5a1dd8d233c3a9b09f
3
+ metadata.gz: 5561c0010955953673794759017eb0f3683d838d60dd70ed09ae88f185954717
4
+ data.tar.gz: 0d620abade30695c4779dc8e123c9444b4ff723904e3bc19bd903985f1f47d53
5
5
  SHA512:
6
- metadata.gz: 5b77a81722924260a13456a6416012c2670dafe0360cb8932f21c8fd3250301a50564fd7d86e7c26425e741b5fd781a08b694a812357afece92dec857570b881
7
- data.tar.gz: fce5256ee8f35e3f44e70165c0615a0d5c3a0ddc61b05d5b976cf7918368f425d0b0ef026d7009fb1c7a44a733bb0b36f8849b56eda5ee73b5cb32c2b25b4127
6
+ metadata.gz: aa14aa3c3c14336f886876097bb85afb3c56332583567eadbf88b3839abbf518e8af4841a80023d0085ad5e7df6b58a9dff0962c875b0a028751dd4ff6c42046
7
+ data.tar.gz: 333bce6b18f73ec77edbe98645668cd40bcbcaa3443eff5e501706c4e915aee4c18c1808298022222622bf4708cf80f21dd4f6e0923aaa0fc03aded6e51dac61
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.3.0...strongroom-rails-v0.4.0) (2026-10-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * add account suspension and session revocation, service imports with conditional password replacement and in-daemon pruning; recover only verified identities ([be52bd1](https://github.com/seuros/strongroom/commit/be52bd1654b9f104aa8e82eb2129d0751a62f3d1))
9
+
3
10
  ## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
4
11
 
5
12
 
data/README.md CHANGED
@@ -107,8 +107,10 @@ At most 16 are supported; names, tables and same-issuer audiences must be distin
107
107
  Pass actual model classes, never class names from parameters/configuration.
108
108
  `register` defaults false; `register: true` needs a client configured with
109
109
  `service_endpoint:` and `service_key:` and enables `register_account` and
110
- `issue_verification` on the account; `issue_recovery` and `events` need only
111
- the service key. The gem still installs no route.
110
+ `issue_verification` on the account; `issue_recovery`, `events`, `suspend`,
111
+ `reinstate` and `revoke_sessions` need only the service key, and
112
+ `import_account` and `replace_imported_password` a namespace with
113
+ `import = true`. The gem still installs no route.
112
114
 
113
115
  Controllers opt in explicitly and keep their own frontend response contract:
114
116
 
@@ -369,7 +371,8 @@ verifies the address.
369
371
  `Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
370
372
  `VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
371
373
  `PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
372
- `PasswordChange`); keep `page.next` and pass it as `after:`.
374
+ `PasswordChange`, `AccountSuspend`, `AccountReinstate`); keep `page.next`
375
+ and pass it as `after:`.
373
376
  `next` is `nil` for an empty page: ask again later with the cursor you have.
374
377
  `page.oldest_at` is the oldest event the kernel still holds; if your last
375
378
  event is older, retention pruned events you never read. `Strongroom::CursorAhead`
@@ -383,6 +386,39 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
383
386
  a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
384
387
  Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
385
388
 
389
+ ## Account control and imports
390
+
391
+ On the client of any keyed namespace:
392
+
393
+ - `suspend(user_id:)` suspends the user's account in the namespace's org: every
394
+ session it holds there ends, its verification and recovery links stop
395
+ working, and nothing authenticates until `reinstate(user_id:)`, which leaves
396
+ those sessions and links ended. Both return a `Strongroom::AccountChange`
397
+ (`state`, `changed`: false for a repeat, `revoked_sessions`).
398
+ - `revoke_sessions(user_id:)` ends every live session of the user in the org
399
+ ("sign out everywhere") and returns how many ended.
400
+ - All three raise `Strongroom::NotFound` for a user outside the namespace,
401
+ `Strongroom::AccountDisabled` (suspension and reinstatement) for a disabled
402
+ account, and `Strongroom::Busy` while one of the user's sessions is being
403
+ rotated or logged out: retry, nothing changed.
404
+
405
+ With `import = true` on the namespace:
406
+
407
+ - `import_account(source:, external_id:, identity:, password_hash:,
408
+ account_state:, grant_state:, identity_state:, credential_state:,
409
+ mfa_required:)` imports an account with a pre-hashed password, as
410
+ `strongroom-admin provision` does, and returns a `Strongroom::Import`
411
+ (`user_id`, `identity_id`, `credential_id`, `created`: false for a replay).
412
+ Every state is explicit. It raises `Strongroom::BindingConflict`,
413
+ `Strongroom::IdentityTaken` or `Strongroom::InvalidRequest`.
414
+ - `replace_imported_password(source:, external_id:, expected_password_hash:,
415
+ password_hash:)` replaces that account's password only while it still holds
416
+ `expected_password_hash`, and returns a `Strongroom::PasswordReplacement`
417
+ (`status` `"replaced"` or `"unchanged"`). Once the kernel owns the password
418
+ (a login upgraded the hash, a recovery or password change replaced it), it
419
+ raises `Strongroom::CredentialChanged` and changes nothing: stop sending
420
+ hashes for that account.
421
+
386
422
  ## Password change and recovery
387
423
 
388
424
  ### Changing a password
@@ -445,6 +481,8 @@ class RecoveryMailer < ApplicationMailer
445
481
  mail(to: params.fetch(:email), subject: "Choose a new password")
446
482
  rescue Strongroom::NotFound
447
483
  # No usable account holds the address: without `mail`, nothing is sent.
484
+ rescue Strongroom::IdentityUnverified
485
+ # Registered, never verified: cancel that registration instead (see below).
448
486
  end
449
487
  end
450
488
  ```
@@ -453,8 +491,9 @@ end
453
491
  `Strongroom::RecoveryToken` redacted everywhere and never a session credential;
454
492
  `expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
455
493
  address stop working. It raises `Strongroom::NotFound` when no usable account
456
- holds the address in the namespace: answer your users the same way either way,
457
- or the form tells anyone which addresses have accounts. If the token must
494
+ holds the address in the namespace, and `Strongroom::IdentityUnverified` when
495
+ the address was registered but never verified: answer your users the same way
496
+ in every case, or the form tells anyone which addresses have accounts. If the token must
458
497
  travel through a job after all, only in an argument your own
459
498
  `ActiveJob::Serializers::ObjectSerializer` encrypts.
460
499
 
@@ -471,16 +510,16 @@ const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
471
510
  // usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
472
511
  ```
473
512
 
474
- A completed recovery sets the new password, ends every session of the user in
475
- the org (in every app) and their other recovery links, and verifies an address
476
- that was never verified: following the link proved the mailbox. Send the
477
- person to your sign-in page afterwards. A second use of the link answers
513
+ A completed recovery sets the new password and ends every session of the user
514
+ in the org (in every app) and their other recovery links. Send the person to
515
+ your sign-in page afterwards. A second use of the link answers
478
516
  `invalid_token`. Links belong to the address across every app of the org.
479
517
 
480
- Do not offer recovery for an address whose registration is still unverified:
481
- the owner would inherit the account whoever registered it prepared (your
482
- records for that `external_id`), and `cancel_registration` refuses it once
483
- verified. Cancel such a registration and let the owner register afresh.
518
+ Only verified addresses recover: anybody can register an address, and its
519
+ owner would inherit the account whoever registered it prepared (your records
520
+ for that `external_id`). On `Strongroom::IdentityUnverified`, cancel that
521
+ registration and let the owner register afresh; do not send a verification
522
+ link instead, which would let the owner verify that account and recover it.
484
523
 
485
524
  ## Running the daemon
486
525
 
@@ -11,7 +11,7 @@ module Strongroom
11
11
  CURSOR = /\A[A-Za-z0-9_-]{34}\z/
12
12
  IDENTITY_STATES = %w[Unverified Verified Disabled].freeze
13
13
  EVENT_TYPES = %w[Provision Register RegistrationCancel VerificationIssue IdentityVerify PasswordHashUpgrade
14
- PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange].freeze
14
+ PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange AccountSuspend AccountReinstate].freeze
15
15
  LOGIN = {"session_id" => :uuid, "user_id" => :uuid, "expires_at" => :timestamp,
16
16
  "identity_verified" => :boolean}.freeze
17
17
  METADATA = LOGIN.merge("org_id" => :uuid, "app_id" => :uuid).freeze
@@ -181,8 +181,11 @@ module Strongroom
181
181
  # this namespace; earlier recovery links of that address stop working. The
182
182
  # browser completes it: POST {"token", "password"} to
183
183
  # `/accounts/<namespace>/recovery` on the kernel's public listener. Raises
184
- # `NotFound` when no usable account holds the address: answer your users
185
- # the same way either way.
184
+ # `NotFound` when no usable account holds the address, and
185
+ # `IdentityUnverified` when the address was registered but never verified:
186
+ # whoever registered it may not own it, so cancel that registration and let
187
+ # the owner register afresh rather than recover it. Answer your users the
188
+ # same way in every case.
186
189
  def issue_recovery(identity:)
187
190
  raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
188
191
  body = JSON.generate(identity_kind: "email", identity: identity)
@@ -195,6 +198,76 @@ module Strongroom
195
198
  raise InvalidRequest, "invalid recovery payload", cause: nil
196
199
  end
197
200
 
201
+ # Suspends the account of `user_id` in this namespace's org: every session
202
+ # it holds there ends, its verification and recovery links stop working,
203
+ # and it authenticates nowhere in the org until `reinstate`. Raises
204
+ # `NotFound`, `AccountDisabled`, or `Busy` while one of its sessions is
205
+ # being rotated or logged out (retry).
206
+ def suspend(user_id:)
207
+ account_change("suspensions", user_id, "Suspended")
208
+ end
209
+
210
+ # Lifts a suspension. The sessions and links it ended stay ended.
211
+ def reinstate(user_id:)
212
+ account_change("reinstatements", user_id, "Active")
213
+ end
214
+
215
+ # Ends every live session of `user_id` in this namespace's org, whatever
216
+ # its account's state, and returns how many ended. `Busy` as for `suspend`.
217
+ def revoke_sessions(user_id:)
218
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
219
+ response = service_request(:post, "revocations", body: JSON.generate(user_id: user_id), statuses: [200])
220
+ object(response, {"revoked_sessions" => :count})["revoked_sessions"]
221
+ end
222
+
223
+ # Imports one account with a pre-hashed password (bcrypt or Argon2id), keyed
224
+ # by your `source` and `external_id`, as `strongroom-admin provision` does;
225
+ # the namespace needs `import = true`. Every state is explicit: the kernel
226
+ # refuses MFA and unverified identities. Replaying the same import returns
227
+ # its receipt (`created: false`); a changed one raises `BindingConflict`,
228
+ # an address another account holds `IdentityTaken`.
229
+ def import_account(source:, external_id:, identity:, password_hash:, account_state:, grant_state:,
230
+ identity_state:, credential_state:, mfa_required:, identity_kind: "email")
231
+ fields = {source: source, external_id: external_id, identity_kind: identity_kind, identity: identity,
232
+ account_state: account_state, grant_state: grant_state, identity_state: identity_state,
233
+ credential_state: credential_state, password_hash: password_hash}
234
+ unless fields.values.all?(String) && [true, false].include?(mfa_required)
235
+ raise InvalidRequest, "import fields must be strings and mfa_required a boolean"
236
+ end
237
+ body = JSON.generate(fields.merge(mfa_required: mfa_required))
238
+ raise InvalidRequest, "import request too large" if body.bytesize > 16_384
239
+ response = service_request(:post, "imports", body: body, statuses: [200, 201])
240
+ values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "credential_id" => :uuid})
241
+ Import.new(user_id: values["user_id"], identity_id: values["identity_id"],
242
+ credential_id: values["credential_id"], created: response.status == 201)
243
+ rescue JSON::GeneratorError
244
+ raise InvalidRequest, "invalid import payload", cause: nil
245
+ end
246
+
247
+ # Replaces the password of the account an import bound to `source` and
248
+ # `external_id` with `password_hash`, but only while its active credential
249
+ # still holds `expected_password_hash`, the hash you imported or last
250
+ # replaced. Once the kernel owns the password (a login upgraded its hash, a
251
+ # recovery or password change replaced it) this raises `CredentialChanged`
252
+ # and changes nothing. Its sessions end with the old credential.
253
+ def replace_imported_password(source:, external_id:, expected_password_hash:, password_hash:)
254
+ fields = {source: source, external_id: external_id, expected_password_hash: expected_password_hash,
255
+ password_hash: password_hash}
256
+ raise InvalidRequest, "replacement fields must be strings" unless fields.values.all?(String)
257
+ body = JSON.generate(fields)
258
+ raise InvalidRequest, "replacement request too large" if body.bytesize > 16_384
259
+ response = service_request(:post, "imports/password", body: body, statuses: [200])
260
+ values = object(response, {"status" => :string, "user_id" => :uuid, "identity_id" => :uuid,
261
+ "credential_id" => :uuid, "revoked_credential_id" => :uuid_or_nil})
262
+ unless (values["status"] == "replaced") == !values["revoked_credential_id"].nil? &&
263
+ %w[replaced unchanged].include?(values["status"])
264
+ raise ProtocolError, "invalid service response"
265
+ end
266
+ PasswordReplacement.new(**values.transform_keys(&:to_sym))
267
+ rescue JSON::GeneratorError
268
+ raise InvalidRequest, "invalid replacement payload", cause: nil
269
+ end
270
+
198
271
  # One page of the namespace's event feed, in commit-safe order.
199
272
  def events(after: nil, limit: 100)
200
273
  unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
@@ -214,6 +287,16 @@ module Strongroom
214
287
 
215
288
  private
216
289
 
290
+ def account_change(suffix, user_id, state)
291
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
292
+ response = service_request(:post, suffix, body: JSON.generate(user_id: user_id), statuses: [200])
293
+ schema = {"state" => :string, "changed" => :boolean}
294
+ schema["revoked_sessions"] = :count if state == "Suspended"
295
+ values = object(response, schema)
296
+ raise ProtocolError, "invalid service response" unless values["state"] == state
297
+ AccountChange.new(state: state, changed: values["changed"], revoked_sessions: values.fetch("revoked_sessions", 0))
298
+ end
299
+
217
300
  def configure_service(endpoint, key, transport)
218
301
  if endpoint.nil? && key.nil? && transport.nil?
219
302
  @service_endpoint = @service_key = @service_transport = nil
@@ -340,7 +423,9 @@ module Strongroom
340
423
  when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
341
424
  when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
342
425
  when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
343
- when "credential_changed" then raise CredentialChanged, "session predates the identity's password"
426
+ when "credential_changed" then raise CredentialChanged, "the identity's password credential changed"
427
+ when "account_disabled" then raise AccountDisabled, "account is disabled"
428
+ when "no_active_credential" then raise NoActiveCredential, "account has no active password"
344
429
  else raise ProtocolError, "invalid service response"
345
430
  end
346
431
  end
@@ -398,6 +483,7 @@ module Strongroom
398
483
  when :uuid then canonical_uuid?(value)
399
484
  when :boolean then value == true || value == false
400
485
  when :uuid_or_nil then value.nil? || canonical_uuid?(value)
486
+ when :count then value.is_a?(Integer) && value >= 0
401
487
  else value.is_a?(String)
402
488
  end
403
489
  end
@@ -21,9 +21,17 @@ module Strongroom
21
21
  class IdentityUnverified < Conflict; end
22
22
 
23
23
  # A password change from a session issued with a password the identity no
24
- # longer logs in with: sign in again, then change it. Nothing changed.
24
+ # longer logs in with: sign in again, then change it. For an imported
25
+ # password replacement: the kernel's credential no longer holds the expected
26
+ # hash, so the kernel owns that password now. Nothing changed.
25
27
  class CredentialChanged < Conflict; end
26
28
 
29
+ # A suspension or reinstatement of an account that is disabled, which is final.
30
+ class AccountDisabled < Conflict; end
31
+
32
+ # An imported password replacement for an account without an active password.
33
+ class NoActiveCredential < Conflict; end
34
+
27
35
  # The feed cursor names a point the kernel's database has not reached (it was
28
36
  # restored, failed over or copied): drop the cursor and read from the start.
29
37
  class CursorAhead < Conflict; end
@@ -65,6 +65,21 @@ module Strongroom
65
65
  client.events(after: after, limit: limit)
66
66
  end
67
67
 
68
+ # Suspends the user's account in the namespace's org, ending its sessions
69
+ # and links; needs only the service key.
70
+ def suspend(user_id:)
71
+ client.suspend(user_id: user_id)
72
+ end
73
+
74
+ def reinstate(user_id:)
75
+ client.reinstate(user_id: user_id)
76
+ end
77
+
78
+ # Ends every live session of the user in the namespace's org.
79
+ def revoke_sessions(user_id:)
80
+ client.revoke_sessions(user_id: user_id)
81
+ end
82
+
68
83
  def resolve(session)
69
84
  subject = client.subject(session)
70
85
  record = @resolver.call(subject)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strongroom
4
4
  module Rails
5
- VERSION = "0.3.0" # x-release-please-version
5
+ VERSION = "0.4.0" # x-release-please-version
6
6
  end
7
7
  end
@@ -10,6 +10,20 @@ module Strongroom
10
10
  # changed nothing; `identity_state` is the identity's current state.
11
11
  Registration = Data.define(:user_id, :identity_id, :identity_state, :created)
12
12
 
13
+ # What a suspension or reinstatement did: the account's `state` now
14
+ # ("Suspended" or "Active"), whether this call `changed` it, and how many
15
+ # sessions a suspension ended (0 for a reinstatement or a repeat).
16
+ AccountChange = Data.define(:state, :changed, :revoked_sessions)
17
+
18
+ # An import's receipt. `created` is false for an exact replay, which changed
19
+ # nothing.
20
+ Import = Data.define(:user_id, :identity_id, :credential_id, :created)
21
+
22
+ # A replaced imported password. `status` is "replaced" (the credential
23
+ # `revoked_credential_id` was revoked for `credential_id`) or "unchanged"
24
+ # (the active credential held that hash already; `revoked_credential_id` nil).
25
+ PasswordReplacement = Data.define(:status, :user_id, :identity_id, :credential_id, :revoked_credential_id)
26
+
13
27
  # One account event from the namespace's feed. `meta` is a frozen Hash of
14
28
  # non-secret IDs (session, credential, token).
15
29
  Event = Data.define(:id, :type, :user_id, :identity_id, :at, :meta)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strongroom-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seuros