Skip to content

Commit 566cd2f

Browse files
committed
feat: show CP toast on decrypt failure with per-form deduplication
1 parent 412d40b commit 566cd2f

8 files changed

Lines changed: 58 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This changelog is used as the base for GitHub Release notes.
88

99
- [new] PRO: `sensitive-fields:rekey` — re-encrypts sensitive field values from an old `APP_KEY` to the current one (supports `--old-key`, `--form`, `--dry-run`)
1010
- [new] PRO: per-form permission granularity — `view decrypted {form-handle} sensitive fields` grants access to a single form; the global `view decrypted sensitive fields` acts as a wildcard (backward-compatible)
11+
- [new] CP error toast on decrypt failure — shown once per form per hour when a sensitive field cannot be decrypted (e.g. after an APP_KEY rotation); suppressed in CLI context
1112

1213
## 1.0.0 (2026-02-17)
1314

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Go to **CP → Tools → Addons → Sensitive Form Fields → Settings**:
112112
- **Pro, authorized** — decrypts and returns plain text
113113
- **Pro, unauthorized** — returns the configured mask string
114114
3. Values already prefixed with `enc:v1:` are never double-encrypted.
115-
4. If decryption fails (e.g. after `APP_KEY` rotation), the raw ciphertext is returned and a warning is logged.
115+
4. If decryption fails (e.g. after `APP_KEY` rotation), the raw ciphertext is returned, a warning is logged, and an error toast is shown in the CP (once per form per hour to avoid notification spam).
116116

117117
---
118118

docs/OVERVIEW.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ tests/
6060
- Checks the current user's permission (`view decrypted sensitive fields`).
6161
- **Authorized**: strips `enc:v1:` prefix and decrypts the value.
6262
- **Unauthorized**: replaces the value with the mask string (default `••••••`).
63-
4. If decryption fails (e.g. key rotation), returns raw ciphertext and logs a warning.
63+
4. If decryption fails (e.g. key rotation), returns raw ciphertext, logs a warning, and dispatches a CP error toast to the current user (HTTP context only; deduplicated to once per form per hour via `Cache::add`).
6464

6565
### Addon Settings
6666

@@ -98,8 +98,9 @@ vendor/bin/phpunit
9898

9999
### Test Coverage
100100

101-
- **Unit tests** (`FieldEncryptorTest`, 7 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value.
101+
- **Unit tests** (`FieldEncryptorTest`, 9 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value, CP toast console guard.
102102
- **Feature tests** (`SensitiveFieldsTest`, 12 tests): full write/read flow, free/pro mode, permission-based masking, query-builder decryption, per-form permission scoping.
103+
- **Unit tests** (`FieldEncryptorTest`, 9 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value, CP toast console guard.
103104
- **PRO command tests** (`ProCommandsTest`, 6 tests): bulk encrypt/decrypt, dry-run, skip-already-encrypted.
104105

105106
### PRO Commands

docs/PLAN.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,16 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads
172172

173173
## Tests
174174

175-
### Unit (FieldEncryptorTest, 7 tests)
175+
### Unit (FieldEncryptorTest, 9 tests)
176176
1. Encrypts value with marker prefix
177177
2. Decrypts back to plaintext
178178
3. No double encryption
179179
4. Failed decrypt returns raw + logs warning
180180
5. isEncrypted detects prefix
181181
6. mask returns configured value
182182
7. decrypt returns non-encrypted as-is
183+
8. Failed decrypt does not dispatch toast in console context
184+
9. Failed decrypt without context does not dispatch toast in console context
183185

184186
### Feature (SensitiveFieldsTest, 12 tests)
185187
1. Sensitive field stored encrypted
@@ -243,11 +245,11 @@ Larger teams need per-form control (e.g. HR form vs. contact form handled by dif
243245

244246
---
245247

246-
### [FREE/PRO] CP notification on decrypt failure — Planned
248+
### [FREE/PRO] CP notification on decrypt failure — Implemented
247249

248-
Currently, decryption failures (e.g. after an unrecovered APP_KEY rotation) are only logged via `Log::warning`. A Statamic CP notification dispatched to super admins would make data corruption visible without requiring log monitoring.
250+
Decryption failures are now surfaced in the CP as an error toast in addition to the existing `Log::warning`.
249251

250-
Implementation sketch:
251-
- Hook into the existing `catch (\Throwable)` path in `FieldEncryptor::decrypt()`.
252-
- Dispatch a Statamic `Notification` to super admins (or use a Statamic flash/CP alert).
253-
- Add a rate-limit or deduplication guard to avoid notification spam.
252+
- `FieldEncryptor::decrypt()` accepts an optional `string $context` parameter (form handle) for deduplication.
253+
- In HTTP context, `Cache::add('sffields.decrypt_failure_notified.{context}', true, 3600)` is used as an atomic set-if-not-exists guard — at most one toast per form per hour.
254+
- `Toast::error()` is skipped entirely when `app()->runningInConsole()` is true (commands, queue workers).
255+
- `DecryptingSubmissionRepository` passes the form handle as `$context` when calling `decrypt()`.

lang/en/messages.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
'permission_form_label' => 'View Decrypted Sensitive Fields',
1313
'permission_form_description' => 'Allow viewing decrypted values of sensitive fields in this form only',
1414

15+
'decrypt_failure_toast' => 'One or more sensitive field values could not be decrypted. Your APP_KEY may have changed. Use sensitive-fields:rekey (Pro) to recover.',
16+
1517
'settings_enabled_display' => 'Enabled',
1618
'settings_enabled_instructions' => 'Enable or disable field encryption.',
1719
'settings_mask_display' => 'Mask String',

src/Encryption/FieldEncryptor.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@
44

55
namespace Isapp\SensitiveFormFields\Encryption;
66

7+
use Illuminate\Support\Facades\Cache;
78
use Illuminate\Support\Facades\Crypt;
89
use Illuminate\Support\Facades\Log;
910
use Statamic\Addons\Addon;
11+
use Statamic\Facades\CP\Toast;
1012

1113
class FieldEncryptor
1214
{
1315
// Versioned prefix prepended to every ciphertext.
1416
// Allows detecting already-encrypted values and future format migrations.
1517
protected const PREFIX = 'enc:v1:';
1618

19+
// Suppress repeated CP toasts for the same form within this window (seconds).
20+
protected const NOTIFY_TTL = 3600;
21+
1722
public function __construct(
1823
protected Addon $addon,
1924
) {}
@@ -31,7 +36,12 @@ public function encrypt(string $value): string
3136
// Returns the value unchanged (with a log warning) on decryption failure,
3237
// e.g. after an APP_KEY rotation. Callers should treat the raw ciphertext
3338
// as an opaque fallback rather than a fatal error.
34-
public function decrypt(string $value): string
39+
//
40+
// $context is an optional form handle used to deduplicate CP toasts so that
41+
// bulk failures (e.g. many submissions after an APP_KEY rotation) produce at
42+
// most one toast per form per hour. Pass the form handle from repository callers;
43+
// omit from CLI callers (toast is suppressed in console context anyway).
44+
public function decrypt(string $value, string $context = ''): string
3545
{
3646
if (! $this->isEncrypted($value)) {
3747
return $value;
@@ -42,6 +52,15 @@ public function decrypt(string $value): string
4252
} catch (\Throwable $e) {
4353
Log::warning('Failed to decrypt sensitive field value: ' . $e->getMessage());
4454

55+
// Only dispatch CP toasts during HTTP (CP) requests.
56+
// Commands and queue workers run in console context — skip entirely.
57+
if (! app()->runningInConsole()) {
58+
$cacheKey = 'sffields.decrypt_failure_notified.' . ($context ?: 'unknown');
59+
if (Cache::add($cacheKey, true, self::NOTIFY_TTL)) {
60+
Toast::error(__('statamic-sensitive-form-fields::messages.decrypt_failure_toast'));
61+
}
62+
}
63+
4564
return $value;
4665
}
4766
}

src/Repositories/DecryptingSubmissionRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ protected function decryptSubmission(Submission $submission): void
104104
}
105105

106106
if ($canDecrypt) {
107-
$submission->set($handle, $this->encryptor->decrypt($value));
107+
$submission->set($handle, $this->encryptor->decrypt($value, $submission->form()->handle()));
108108
} else {
109109
$submission->set($handle, $this->encryptor->mask());
110110
}

tests/Unit/FieldEncryptorTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,25 @@ public function test_decrypt_returns_non_encrypted_value_as_is()
6666
{
6767
$this->assertSame('plaintext', $this->encryptor->decrypt('plaintext'));
6868
}
69+
70+
public function test_failed_decrypt_does_not_dispatch_toast_in_console_context()
71+
{
72+
// The test suite runs in console context (app()->runningInConsole() === true),
73+
// so the Toast must never be called — verified by spying on the facade.
74+
\Statamic\Facades\CP\Toast::spy();
75+
76+
$this->encryptor->decrypt('enc:v1:corrupted-ciphertext', 'contact');
77+
78+
\Statamic\Facades\CP\Toast::shouldNotHaveReceived('error');
79+
}
80+
81+
public function test_failed_decrypt_without_context_does_not_dispatch_toast_in_console_context()
82+
{
83+
\Statamic\Facades\CP\Toast::spy();
84+
85+
// Backward-compatible: context parameter is optional.
86+
$this->encryptor->decrypt('enc:v1:corrupted-ciphertext');
87+
88+
\Statamic\Facades\CP\Toast::shouldNotHaveReceived('error');
89+
}
6990
}

0 commit comments

Comments
 (0)