Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
20 changes: 11 additions & 9 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)`.

---

Expand Down
3 changes: 3 additions & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 10 additions & 4 deletions src/Repositories/DecryptingSubmissionQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -88,7 +88,7 @@ protected function decryptSubmission(Submission $submission): void
}
}

protected function isAuthorized(): bool
protected function isAuthorizedForForm(string $formHandle): bool
{
$user = Auth::user();

Expand All @@ -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");
}
}
14 changes: 10 additions & 4 deletions src/Repositories/DecryptingSubmissionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -111,7 +111,7 @@ protected function decryptSubmission(Submission $submission): void
}
}

protected function isAuthorized(): bool
protected function isAuthorizedForForm(string $formHandle): bool
{
$user = Auth::user();

Expand All @@ -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");
}
}
9 changes: 9 additions & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
]));
});
}

Expand Down
65 changes: 65 additions & 0 deletions tests/Feature/SensitiveFieldsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'));
}
}