From e1b8282f33175c278519ae148d8a529751d36806 Mon Sep 17 00:00:00 2001 From: Andrii Trush Date: Wed, 18 Feb 2026 09:49:37 +0100 Subject: [PATCH 1/2] feat: add per-form permission granularity (PRO) Register a per-form permission `view decrypted {form} sensitive fields` using Statamic's native placeholder + replacements() mechanism. The global permission `view decrypted sensitive fields` acts as a wildcard (backward-compatible). Both DecryptingSubmissionRepository and DecryptingSubmissionQueryBuilder check global then per-form in the renamed isAuthorizedForForm(string $formHandle) method. Adds 2 feature tests covering grant and scope isolation. Updates all relevant docs. --- CHANGELOG.md | 1 + README.md | 9 ++- docs/OVERVIEW.md | 9 ++- docs/PLAN.md | 20 +++--- lang/en/messages.php | 3 + .../DecryptingSubmissionQueryBuilder.php | 14 ++-- .../DecryptingSubmissionRepository.php | 14 ++-- src/ServiceProvider.php | 9 +++ tests/Feature/SensitiveFieldsTest.php | 65 +++++++++++++++++++ 9 files changed, 123 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0081935..9f6321c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog is used as the base for GitHub Release notes. ## Unreleased - [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`) +- [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) ## 1.0.0 (2026-02-17) diff --git a/README.md b/README.md index 2de2e47..30f1fe3 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,14 @@ From this point on, new submissions will have those field values encrypted befor ### 2. [Pro] Assign the permission -Go to **CP → Users → Roles** and grant **"View Decrypted Sensitive Fields"** to roles that should see plain text. Super admins always see decrypted values regardless of role. +Go to **CP → Users → Roles** and grant a permission to roles that should see plain text. Super admins always see decrypted values regardless of role. -Users without the permission see `••••••` instead of the actual value. +Two permission levels are available: + +- **View Decrypted Sensitive Fields** (global) — grants access to decrypted values across **all** forms. Use this for administrator roles. +- **View Decrypted Sensitive Fields** per-form — grants access to decrypted values in **one specific form** only. Each form gets its own entry in the Roles editor. Use this to give role-specific access (e.g. HR reads the job-application form but not the contact form). + +Users without a matching permission see `••••••` instead of the actual value. ### 3. [Pro] Re-key after APP_KEY rotation diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 153a4f4..c999fa0 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -77,7 +77,12 @@ The addon supports Statamic Editions (`"editions": ["free", "pro"]` in `composer ### Permission -The addon registers a custom permission: **"View Decrypted Sensitive Fields"** under the Forms permission group. Super admins always have this permission implicitly. +The addon registers two custom permissions under the Forms permission group: + +- **`view decrypted sensitive fields`** — global wildcard; grants decrypted access to all forms. +- **`view decrypted {form-handle} sensitive fields`** — per-form; one entry is generated per registered form using Statamic's native `{placeholder}` + `replacements()` mechanism (same pattern as `view {collection} entries`). + +Super admins always have decrypted access implicitly, regardless of role assignments. ### Field Configuration @@ -94,7 +99,7 @@ vendor/bin/phpunit ### Test Coverage - **Unit tests** (`FieldEncryptorTest`, 7 tests): marker detection, encrypt/decrypt round-trip, double-encryption prevention, non-string skipping, decrypt failure handling, mask value. -- **Feature tests** (`SensitiveFieldsTest`, 10 tests): full write/read flow, free/pro mode, permission-based masking, query-builder decryption. +- **Feature tests** (`SensitiveFieldsTest`, 12 tests): full write/read flow, free/pro mode, permission-based masking, query-builder decryption, per-form permission scoping. - **PRO command tests** (`ProCommandsTest`, 6 tests): bulk encrypt/decrypt, dry-run, skip-already-encrypted. ### PRO Commands diff --git a/docs/PLAN.md b/docs/PLAN.md index a3c62d9..a0849a6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -23,7 +23,7 @@ Data flow: [Admin reads submission] → DecryptingSubmissionRepository::find() / whereForm() / all() → delegates to original repository - → checks user permission "view decrypted sensitive fields" + → checks user permission "view decrypted sensitive fields" (global) or "view decrypted {form} sensitive fields" (per-form) → if authorized: strips "enc:v1:" prefix, decrypts via Crypt::decryptString → if unauthorized: returns masked value "••••••" → if decrypt fails: logs warning, returns raw ciphertext @@ -125,7 +125,7 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads - No masking. No PRO commands. ### PRO -- Permission-based access control: only super admins and users with `view decrypted sensitive fields` permission see decrypted values. +- Permission-based access control: only super admins and users with `view decrypted sensitive fields` (global) or `view decrypted {form-handle} sensitive fields` (per-form) see decrypted values. - Unauthorized users see mask string (default `••••••`, configurable in addon settings). - Permission registered in CP only when PRO mode is active. - PRO Artisan commands available: `sensitive-fields:encrypt-existing`, `sensitive-fields:decrypt-existing`, `sensitive-fields:rekey`. @@ -181,7 +181,7 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads 6. mask returns configured value 7. decrypt returns non-encrypted as-is -### Feature (SensitiveFieldsTest, 10 tests) +### Feature (SensitiveFieldsTest, 12 tests) 1. Sensitive field stored encrypted 2. Non-sensitive field remains plain 3. Already-encrypted value not double-encrypted @@ -192,6 +192,8 @@ Edition is detected via the **Statamic Editions API**: `Addon::edition()` reads 8. Pro mode — user with permission reads plaintext 9. Query builder decrypts for super admin in free mode 10. Query builder masks for unauthorized user in pro mode +11. Pro mode — per-form permission grants access to that form +12. Pro mode — per-form permission is scoped to that form only ### Feature PRO (ProCommandsTest, 6 tests) 1. encrypt-existing encrypts plaintext sensitive fields @@ -230,14 +232,14 @@ Solves the documented APP_KEY rotation limitation. --- -### [PRO] Per-form permission granularity — Planned +### [PRO] Per-form permission granularity — Implemented -Currently the `view decrypted sensitive fields` permission is binary: a user either sees decrypted values in **all** forms or in none. Larger teams need per-form control (e.g. HR form vs. contact form handled by different roles). +Larger teams need per-form control (e.g. HR form vs. contact form handled by different roles). -Implementation sketch: -- Register dynamic permissions per form: `view decrypted {form-handle} sensitive fields`. -- Resolve the correct permission in `DecryptingSubmissionRepository` based on the submission's form handle. -- Statamic's permission system supports dynamic permission registration. +- Global permission `view decrypted sensitive fields` acts as a wildcard across all forms (backward-compatible). +- Per-form permission `view decrypted {form-handle} sensitive fields` grants access to a single form only. +- Dynamic per-form permissions registered via Statamic's native `{placeholder}` + `replacements()` mechanism in `ServiceProvider::registerPermission()`. +- Both `DecryptingSubmissionRepository` and `DecryptingSubmissionQueryBuilder` check global then per-form permission via `isAuthorizedForForm(string $formHandle)`. --- diff --git a/lang/en/messages.php b/lang/en/messages.php index f17edcc..873e101 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -9,6 +9,9 @@ 'permission_label' => 'View Decrypted Sensitive Fields', 'permission_description' => 'Allow viewing decrypted values of sensitive form fields', + 'permission_form_label' => 'View Decrypted Sensitive Fields', + 'permission_form_description' => 'Allow viewing decrypted values of sensitive fields in this form only', + 'settings_enabled_display' => 'Enabled', 'settings_enabled_instructions' => 'Enable or disable field encryption.', 'settings_mask_display' => 'Mask String', diff --git a/src/Repositories/DecryptingSubmissionQueryBuilder.php b/src/Repositories/DecryptingSubmissionQueryBuilder.php index 8107274..c2d4788 100644 --- a/src/Repositories/DecryptingSubmissionQueryBuilder.php +++ b/src/Repositories/DecryptingSubmissionQueryBuilder.php @@ -65,9 +65,9 @@ protected function decryptSubmission(Submission $submission): void } // FREE: all authenticated users can read decrypted values. - // PRO: only super admins and users with the dedicated permission can. + // PRO: only super admins and users with the global or per-form permission can. // Mirrors the same logic in DecryptingSubmissionRepository::decryptSubmission(). - $canDecrypt = $this->addon->edition() !== 'pro' || $this->isAuthorized(); + $canDecrypt = $this->addon->edition() !== 'pro' || $this->isAuthorizedForForm($submission->form()->handle()); foreach ($sensitiveHandles as $handle) { $value = $submission->get($handle); @@ -88,7 +88,7 @@ protected function decryptSubmission(Submission $submission): void } } - protected function isAuthorized(): bool + protected function isAuthorizedForForm(string $formHandle): bool { $user = Auth::user(); @@ -100,6 +100,12 @@ protected function isAuthorized(): bool return true; } - return $user->hasPermission('view decrypted sensitive fields'); + // Global permission acts as a wildcard across all forms. + if ($user->hasPermission('view decrypted sensitive fields')) { + return true; + } + + // Per-form permission grants access to this specific form only. + return $user->hasPermission("view decrypted {$formHandle} sensitive fields"); } } diff --git a/src/Repositories/DecryptingSubmissionRepository.php b/src/Repositories/DecryptingSubmissionRepository.php index 12805ee..28eadd7 100644 --- a/src/Repositories/DecryptingSubmissionRepository.php +++ b/src/Repositories/DecryptingSubmissionRepository.php @@ -89,8 +89,8 @@ protected function decryptSubmission(Submission $submission): void } // FREE: all authenticated users can read decrypted values. - // PRO: only super admins and users with the dedicated permission can. - $canDecrypt = $this->addon->edition() !== 'pro' || $this->isAuthorized(); + // PRO: only super admins and users with the global or per-form permission can. + $canDecrypt = $this->addon->edition() !== 'pro' || $this->isAuthorizedForForm($submission->form()->handle()); foreach ($sensitiveHandles as $handle) { $value = $submission->get($handle); @@ -111,7 +111,7 @@ protected function decryptSubmission(Submission $submission): void } } - protected function isAuthorized(): bool + protected function isAuthorizedForForm(string $formHandle): bool { $user = Auth::user(); @@ -124,6 +124,12 @@ protected function isAuthorized(): bool return true; } - return $user->hasPermission('view decrypted sensitive fields'); + // Global permission acts as a wildcard across all forms. + if ($user->hasPermission('view decrypted sensitive fields')) { + return true; + } + + // Per-form permission grants access to this specific form only. + return $user->hasPermission("view decrypted {$formHandle} sensitive fields"); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 5871c17..8b8dc21 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -12,6 +12,7 @@ use Isapp\SensitiveFormFields\Repositories\RawSubmissionRepository; use Isapp\SensitiveFormFields\Support\SensitiveFieldResolver; use Statamic\Contracts\Forms\SubmissionRepository; +use Statamic\Facades\Form; use Statamic\Facades\Permission; use Statamic\Fieldtypes\Text; use Statamic\Fieldtypes\Textarea; @@ -90,6 +91,14 @@ private function registerPermission(): void Permission::register('view decrypted sensitive fields') ->label(__('statamic-sensitive-form-fields::messages.permission_label')) ->description(__('statamic-sensitive-form-fields::messages.permission_description')); + + Permission::register('view decrypted {form} sensitive fields') + ->label(__('statamic-sensitive-form-fields::messages.permission_form_label')) + ->description(__('statamic-sensitive-form-fields::messages.permission_form_description')) + ->replacements('form', fn () => Form::all()->map(fn ($f) => [ + 'value' => $f->handle(), + 'label' => $f->title(), + ])); }); } diff --git a/tests/Feature/SensitiveFieldsTest.php b/tests/Feature/SensitiveFieldsTest.php index 0e0e460..d8cc218 100644 --- a/tests/Feature/SensitiveFieldsTest.php +++ b/tests/Feature/SensitiveFieldsTest.php @@ -51,6 +51,24 @@ protected function createContactForm(): void $form->save(); } + protected function createNewsletterForm(): void + { + $blueprint = Blueprint::makeFromFields([ + 'email' => [ + 'type' => 'text', + 'display' => 'Email', + 'sensitive' => true, + ], + ]); + + $blueprint->setHandle('newsletter'); + $blueprint->setNamespace('forms'); + $blueprint->save(); + + $form = Form::make('newsletter')->title('Newsletter'); + $form->save(); + } + protected function createSubmission(array $data = []): \Statamic\Contracts\Forms\Submission { $form = Form::find('contact'); @@ -210,4 +228,51 @@ public function test_query_builder_masks_for_unauthorized_user_in_pro_mode() $this->assertSame('••••••', $results->first()->get('email')); } + + // --- PRO mode: per-form permission --- + + public function test_pro_mode_per_form_permission_grants_access_to_that_form() + { + $this->enableProMode(); + $submission = $this->createSubmission(); + + $role = Role::make()->handle('contact-reader') + ->title('Contact Reader') + ->permissions(['view decrypted contact sensitive fields']); + $role->save(); + + $user = User::make()->id('form-reader')->email('formreader@test.com'); + $user->assignRole('contact-reader'); + $user->save(); + $this->actingAs($user); + + $found = FormSubmission::find($submission->id()); + + $this->assertSame('john@example.com', $found->get('email')); + $this->assertSame('Hello!', $found->get('message')); + } + + public function test_pro_mode_per_form_permission_is_scoped_to_that_form_only() + { + $this->enableProMode(); + $this->createNewsletterForm(); + + $role = Role::make()->handle('contact-only-reader') + ->title('Contact Only Reader') + ->permissions(['view decrypted contact sensitive fields']); + $role->save(); + + $user = User::make()->id('scoped-reader')->email('scopedreader@test.com'); + $user->assignRole('contact-only-reader'); + $user->save(); + $this->actingAs($user); + + $form = Form::find('newsletter'); + $newsletterSubmission = FormSubmission::make()->form($form)->data(['email' => 'sub@example.com']); + $newsletterSubmission->save(); + + $found = FormSubmission::find($newsletterSubmission->id()); + + $this->assertSame('••••••', $found->get('email')); + } } From 51afa7b7e51bc260873bf4df9aac4cabdb454c7e Mon Sep 17 00:00:00 2001 From: Andrii Trush Date: Wed, 18 Feb 2026 09:53:46 +0100 Subject: [PATCH 2/2] docs: do not include Test plan section in PR descriptions --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 5c610f0..209f4d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ These rules apply whenever a new Artisan command is written or documented. - Do not skip hooks with `--no-verify`. - Never mention AI agents or co-authorship in commit messages. - Do not add `Co-Authored-By` lines referencing AI. +- PR body: include only a `## Summary` section (bullet points). Do not add a `## Test plan` section. ## Testing Rules