Skip to content

Encryption at rest: operator surface (P3A) - #7501

Open
ConnorYoh wants to merge 13 commits into
mainfrom
claude/encryption-ui-p3a
Open

Encryption at rest: operator surface (P3A)#7501
ConnorYoh wants to merge 13 commits into
mainfrom
claude/encryption-ui-p3a

Conversation

@ConnorYoh

@ConnorYoh ConnorYoh commented Aug 14, 2026

Copy link
Copy Markdown
Member

Encryption at rest: operator surface (P3A)

Encryption at rest has shipped twice and has never been visible in the product. P1 (#7155) built the
crypto, P2 (#7173) built the admin API. Today an operator enables it by editing YAML, revokes a key
with curl, and has no screen anywhere telling them the feature exists or whether their files are
actually encrypted.

This PR puts the P2 API behind a UI. No crypto changes, no schema changes.

Scope

In: the Storage tab re-enabled with encryption as its content, key actions, migration control,
rotation status, the encrypted badge on documents, and honest licence messaging.

Note on placement: #7497 removed the Infrastructure tabs backed by mock-only
/v1/infrastructure/* data, including the old Storage tab, leaving storage as a disabled
placeholder. This PR re-enables it, because it now reads the real
/api/v1/admin/storage-encryption surface, which is the criterion #7497 used. The mock usage,
quota, provider and retention UI that used to live there is not restored.

Out: per-source encryption (P3B, needs a schema change), KMS and BYOK backends (P4), FIPS builds
(P4).

User stories

Knowing where you stand

US-1. See whether encryption is actually on

As an administrator, I want one screen that tells me whether stored files are being encrypted, so
that I can answer "are we encrypted at rest?" without reading config files or logs.

  • The Storage tab under Infrastructure is enabled again, with encryption as its content
  • Shows write state from writeEnabled: new uploads are encrypted, or they are not
  • Shows encryptedFiles and plaintextFiles counts, so partial coverage is obvious
  • Distinguishes writeEnabled: false, active: true (decrypt-only, the state after turning the
    flag off) from writeEnabled: false, active: false (feature never used)
  • Empty state when no keys exist yet: explains that a key is created on the first upload, not at
    startup
  • When storage is disabled the API returns 403 "Storage is disabled" before touching the
    database. The panel shows that as an explanatory state, not an error toast
  • When the key registry cannot be read the API returns 503. The panel says the registry is
    unavailable and suggests checking the database, rather than showing zero keys

US-2. Verify my key backup matches the running system

As an administrator, I want to compare the live master key against my backup without exposing key
material, so that I can prove my disaster recovery actually works before I need it.

  • Displays masterKeyFingerprint (SHA-256 prefix, 16 hex characters) and masterKeyVersion
  • Copy-to-clipboard on the fingerprint
  • Explains in one line what the fingerprint is for: it matches the fingerprint logged at startup,
    so an operator can check their archived key is the one in use
  • Never renders anything that could be key material, and the field is absent when the key
    machinery has not been materialised

US-3. Be told when my licence gives me no audit trail

As a compliance reviewer on a Pro licence, I want the product to tell me that encryption events are
not being recorded, so that I do not report an audit trail to my auditor that does not exist.

  • On a non-Enterprise licence the panel carries a persistent notice: encryption is active, audit
    events are not recorded, this requires Enterprise
  • The notice sits next to any claim about auditing, not in a separate help page
  • On Enterprise the notice is absent
  • Wording matches the startup warning and devGuide/STORAGE_ENCRYPTION_AT_REST.md, so the log,
    the docs and the UI agree

Note: encryption is gated at Pro but AuditService only records on Enterprise. Today this is
visible only in a startup log line. If we would rather move audit down to Pro, that is a licence
decision and this story changes to "remove the notice".

US-4. Be told my master key was generated for me

As an administrator of a single-node install, I want to know that the system created an encryption
key on my behalf, so that I do not discover an unbacked-up secret after losing it.

  • When the key came from the auto-generated file-encryption.key file rather than explicit
    config, the panel says so and states the consequence: lose this file and encrypted files
    cannot be recovered
  • Links to the backup section of the runbook
  • Requires a small /status addition to report key provenance (config, environment, or generated
    file). Included in this PR

Acting on it

US-5. Revoke a scope's stored content

As an administrator responding to an incident, I want to revoke access to one team's stored files,
so that their content cannot be read while I investigate.

  • Each key row has a Revoke action, calling POST /keys/{keyId}/disable
  • The confirmation states what revocation actually does, in plain words:
    • reads of existing files under this key start failing with 403
    • the team can still upload new files, which get a fresh key
    • it is reversible, and no key material is destroyed
    • on a cluster, other nodes take up to 60 seconds to catch up
  • The row shows DISABLED, plus statusChangedBy and statusChangedAt
  • The 60 second cluster note is only shown when clustering is on

Note: the "still uploads" and "60 seconds" points are not padding. Both are real behaviour that
surprised us during review, and an admin who believes revoke means "frozen instantly" will be wrong.

US-6. Restore access, and understand what came back

As an administrator, I want to undo a revocation and see exactly what state the key returned to, so
that I am not misled into thinking a key is wrapping new files when it is not.

  • Enable action on DISABLED keys only, calling POST /keys/{keyId}/enable
  • The response status is what the row shows: ACTIVE if the scope had no other active key,
    RETIRED if one was minted while it was revoked
  • When the result is RETIRED, the UI explains it: this key decrypts its existing files again,
    and a newer key is wrapping new uploads
  • Enable is not offered on ACTIVE or RETIRED keys, matching the API's 409
  • A key that no longer exists returns 404 and surfaces as a clear message, not a crash

US-7. Encrypt the files I already had

As an administrator who has just enabled encryption, I want to encrypt the existing plaintext
backlog and watch it happen, so that "encrypted at rest" is true of my whole estate rather than only
new uploads.

  • Start action calls POST /migrate, disabled when plaintextFiles is zero
  • Progress from GET /migrate/status: state, total, processed, skipped, failed, started time
  • All four terminal states render distinctly: IDLE, RUNNING, COMPLETED, FAILED
  • FAILED is a real, explained state, not a stalled spinner. The run now ends FAILED when the
    write flag is turned off mid-run, and the message says so and what to do
  • Skipped is explained on hover: a user replaced the file mid-migration, so the job left their
    copy alone
  • 409 when a run is already going, or when encryption is off, surfaces as a message rather than a
    failure
  • Progress is in-memory, so a backend restart resets it to IDLE. The UI says that rather than
    appearing to lose the run
  • Adds a cancel endpoint and page-size and pause knobs to the API, since exposing a long job in a
    UI without a stop button is not defensible

US-8. Finish a key rotation without sealing my files

As an administrator rotating the master key, I want to see how many key rows are still on the old
key, so that I do not remove the outgoing key while rows still depend on it.

  • Shows the current masterKeyVersion and the number of key rows below it
  • Re-wrap action calls POST /master/rotate and reports the rewrapped count
  • States plainly that key material never travels over HTTP: the new key is a config and restart
    operation, and this button only performs the re-wrap step
  • Warns while any row is behind: removing the previous key now would make those files
    unreadable
  • Links to the rotation runbook

Everyone else

US-9. See which of my files are encrypted — moved to #7550

The badge component was built here but never wired to a surface, and no documents endpoint returns
encryptionKeyId, so it could not render in the product. It moved to its own draft PR rather than
shipping as unreachable code behind a screenshot.

US-10. Understand why a file will not open

As a user whose team's key has been revoked, I want a clear message rather than a generic error, so
that I raise the right request with the right person.

  • A 403 from a revoked key renders as "access to this file has been revoked" wording, not a
    generic failure toast
  • Distinguished from a permissions 403
  • Applies to both My Files and workflow document reads, which P2 made consistent

Definition of done

  • New API client module under portal/api/, following the conventions in sources.ts
  • All copy is i18n keys in the editor en-US catalogue, which is the source of truth
  • Storybook stories for every state named above, including the empty, 403, 503 and FAILED states
  • a11y baseline recorded with the full task, so a partial scan does not delete other entries
  • Light and dark themes both checked
  • No raw <button> elements, per the lint rule
  • /status paginates its key list, since this UI is the consumer that makes an unbounded list
    matter

Not covered in this PR

The five unticked boxes above, all tracked elsewhere. Everything else was checked against the code,
not assumed.

Split into follow-up PRs:

Also tracked: #7549, to publish the operator docs. The two runbook links in this PR point at
devGuide/STORAGE_ENCRYPTION_AT_REST.md until those pages exist; both are constants in
storageEncryption.ts, so swapping them is a one-file change.

Backend changes riding along

Two fields added to /status. Nothing else in the backend changed:

Change Why
masterKeySource (config, environment, generated) US-4 cannot be built without it. The value already existed inside FileEncryptionMasterKey.resolveKey and was simply never returned
provider (local, database, s3) The panel names the storage backend, and warns that object-store downloads stream through the app once anything is encrypted
pendingRotationRows The rotation warning needs a whole-table count, which the returned key list cannot give

Two items from the original plan are still not done and are called out below: key-list pagination,
and the migration cancel endpoint and knobs.

Screenshots

Replace each placeholder with the matching image.

Encryption off, nothing encrypted yet 01-panel-encryption-off
Encryption on, healthy, key table 02-panel-active
Pro licence audit notice 03-licence-notice
Revoke confirmation 04-revoke-confirm
Revoked key row 05-key-revoked
Re-enabled as RETIRED, with explanation 06-key-retired
Migration running 07-migration-running
Migration failed after the flag was turned off 08-migration-failed
Rotation with rows still on the old key 09-rotation-pending
Storage disabled state 11-storage-disabled
Dark theme, active panel 12-panel-active-dark

Extras captured while iterating, not required in the body: registry unavailable
13-registry-unavailable

(13), generated master key (14)
14-generated-master-key
, dark migration (15)
15-panel-active-dark-migration
.

Test plan

What is actually covered:

  • Storybook covers every state named above, driven by fixtures rather than a live backend:
    encryption off, active, Pro notice, revoke dialog, revoked row, restore-as-RETIRED, migration
    running, migration FAILED, rotation pending, storage disabled (403), registry unreadable (503),
    and generated master key
  • Infrastructure.test.tsx covers the tab wiring: Storage is enabled, reachable by click and by
    ?tab=storage, and disabled tabs stay inert
  • FileEncryptionMasterKeySourceTest covers key provenance, including that the wire names match the
    values the UI switches on
  • The shared Tooltip has a play-function story asserting it opens on keyboard focus and wires
    aria-describedby
  • a11y scanned clean across both themes, light and dark, and the shared baseline is unchanged
  • Typecheck across all nine build variants, oxlint, stylelint and a production build

Not covered, and worth a reviewer's attention:

  • No component tests for the encryption UI itself. The migration state machine and the
    enable-returns-RETIRED path are exercised by stories, which render them but assert almost nothing.
    This is the biggest remaining gap in the PR
  • No manual pass against a live backend with encryption on, a seeded plaintext backlog and a
    revoked key. Every state in this PR was driven by fixtures

Notes for reviewers

The revoke confirmation copy is deliberately blunt about two things that are easy to get wrong:
revoking does not stop the team storing new files, and on a cluster it takes up to a minute. Both
were found during the P2 review. If the copy reads as over-explaining, that is the intent.

@ConnorYoh
ConnorYoh requested a review from a team as a code owner August 14, 2026 16:33
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines ignoring generated files. enhancement New feature or request labels Aug 14, 2026
@github-actions github-actions Bot added Front End Issues or pull requests related to front-end development Translation Issues or pull requests related to translation and removed enhancement New feature or request labels Aug 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🌐 TOML Translation Verification Summary

🔄 Reference Branch: pr-branch

📃 File Check: en-US/translation.toml

  1. Test Status:Passed
  2. Test Status:Passed
  3. Test Status:Passed

✅ Overall Check Status: Success

Thanks @ConnorYoh for your help in keeping the translations up to date.

@ConnorYoh
ConnorYoh requested review from a team and Ludy87 as code owners August 17, 2026 15:05
@github-actions github-actions Bot added the Java Pull requests that update Java code label Aug 17, 2026
Puts the P2 admin API behind a UI. Storyboard state: components and
Storybook states are real, wired to MSW fixtures rather than a live
backend, so the look and the copy can be reviewed before the API is
plumbed in.

Covers the P3A user stories: whether encryption is on and how much is
covered, the master-key fingerprint for verifying a backup, the scope key
table with the kill switch, the encrypt-existing migration including its
FAILED state, rotation status, the encrypted badge, and the Pro-tier
notice that audit events need Enterprise.

The revoke dialog states the two consequences that surprised us during
the P2 review: revoking does not stop the scope uploading, and on a
cluster other nodes take up to 60 seconds.

Copy keyed by backend enum values (key status, write state, migration
state, refusal reason) is assembled at runtime, so those families are
registered in the translation audit's ignore list.

Not yet wired: key provenance and cluster-enabled both need small backend
additions, and the badge is not yet mounted in the documents list.
Reviewing the storyboard shots surfaced problems the code did not.

The never-used state rendered five cards of machinery that does not exist
yet: a master key with no fingerprint, a rotation card reading "Unknown",
and an empty key table complete with column headers. It now collapses to
coverage plus the backlog, which is the whole story at that point.

The plaintext backlog printed 1840 rather than 1,840: the plural key
interpolated the raw count. Count still drives plural selection, a
formatted value is what renders.

Coverage had a bar and no number, in the default blue at every value. It
now shows the percentage and turns green only at full coverage, matching
how the sibling storage meter uses colour.

Also: the unavailable states (storage disabled, registry unreadable) were
a bare centred message with no heading, orphaned in the middle of the
Storage tab, so they keep the section header and sit in a card; the
fingerprint sat half a card away from its own label; the two split cards
had ragged heights; banners butted against the content below them; a
stopped migration drew a healthy blue progress bar; the in-memory
progress caveat showed even with no run to lose; and Start sat disabled
next to a backlog the copy said could be encrypted now, with no reason
given.
…lesheet

The panel borrowed three portal-infra__ text classes, and one of them
(portal-infra__muted) had already been deleted by #7497's trim, so the key
table was one merge away from losing its styling silently. Those three now
live in the portal-enc__ namespace.

The stories keep importing Infrastructure.css, because SectionHeader has no
stylesheet of its own and the real view supplies it: without the import the
stories render at a different scale to the app, which makes the captured
screenshots misleading rather than merely unstyled.
CI caught two things.

Infrastructure.test.tsx pins which tabs are inert, and storage is no longer
one of them. The disabled-deep-link case moves to models, and storage gains
its own click and deep-link cases. EncryptionPanel is mocked there like the
other tabs, since that test is about tab wiring, not panel internals.

The a11y scan flagged duplicate banner landmarks: SectionHeader renders a
<header>, which is a banner unless a sectioning element scopes it, and the
panel placed several of them in plain divs and cards. The old StorageTab
wrapped every one in a <section>, which is why it never tripped this. Each
header is now scoped the same way.

The revoke story's play function asserted toBeVisible on the dialog copy;
the a11y harness does not compute layout, so it failed there while rendering
correctly in a browser. It asserts presence instead.
Coverage counts now refresh when a migration reaches a terminal state; the
card previously kept its pre-run numbers until a reload, and Start stayed
enabled against a backlog that no longer existed.

Coverage colour keys off plaintextFiles rather than the exact fraction, so
4,127 of 4,128 no longer renders "100% encrypted" in amber beside a bar that
is not green. The rounded percentage is computed once and shared.

Coverage copy now states what the feature covers. Only FileStorageService and
WorkflowSessionService create StoredFile rows, and nothing under policy/network
writes through StorageProvider, so files a pipeline reads in place (including
SFTP, FTP and SMB sources) are not encrypted at rest and were being counted as
if they did not exist.

The rotation backlog is now counted in the database and returned by /status.
Filtering the key array was correct only while that array is the whole table,
and this PR proposes paginating it; under-reporting 0 pending rows is exactly
the signal an operator uses to decide the outgoing master key is safe to
delete.

/status also reports the storage provider, and the panel names it and warns
that object-store downloads stream through the server while anything is
encrypted, which is otherwise an invisible consequence of switching this on.

Copying the fingerprint now confirms, and falls back to selecting the text
when navigator.clipboard is absent, as it is on the plain-http origins many
self-hosted installs are reached on.

Also: the in-memory-progress caveat is shown at IDLE when a backlog remains,
since that is the state a run lost to a restart leaves behind; the 403 reason
match no longer depends on one exact English sentence; EncryptedFileBadge
drops featureInUse, which no caller could supply and which a plaintext file
already implies; and the revoke dialog passes the scope label straight to the
modal instead of through a key whose entire value was an interpolation.
CI's proprietary typecheck reported no exported member Table or TableColumn
on @app/ui, while Button, Card, Modal and StatusBadge from the same barrel
resolved fine, and the vite build in the same run transformed every module
without complaint. It does not reproduce locally on any of the nine
typecheck variants.

The three portal components that already pull Table from the barrel
(DocumentExtractions, ReviewQueueTable, PolicyCatalogueTable) all do it as
one combined import; this file was alone in splitting the value import and
two type imports across three statements from the same module. Matching the
form that demonstrably passes CI.
Root cause of the CI typecheck failure: #7312 renamed core/ui/Table to
DataTable with a different API, after this branch was cut. CI builds the
merge with main, so it saw the rename while the branch tip did not, which is
why only Table and TableColumn were missing from @app/ui while every other
name in the same import resolved, and why no local typecheck could reproduce
it until the rebase.

Rebased onto main and moved to the column vocabulary DataTable requires:
entity for scope plus key version, badge for status, muted for the last
change, and actions for revoke/restore, which also gets the destructive tone
the raw button never had. The previous commit's guess at the import form was
wrong and is superseded.
The /status key list is not paged: it is findAll(). Both comments claimed
otherwise, which would mislead anyone deciding whether the count is redundant.
theme-lint reads #bac in #backing-up-the-master-key as a hex colour. Building
the fragment from an anchor argument avoids the false positive without an
exemption comment claiming a URL is a colour.
Revoke opened a dialog explaining itself while rotation and migration fired
straight off the button, which had the friction backwards: revoke is reversible,
those two are not, and migration cannot be stopped once started.

Both now confirm. The migration dialog says there is no stop control, since that
is the part an operator cannot discover until it is too late.

Also unstacks two Javadoc blocks in the status response: the blob-backend
comment had been orphaned above masterKeySource, leaving provider undocumented.
# Conflicts:
#	frontend/editor/src/core/i18n/translationAudit.ts
#	frontend/editor/src/portal/views/Infrastructure.test.tsx
#	frontend/editor/src/portal/views/Infrastructure.tsx
@github-actions github-actions Bot removed the has conflicts Pull request has merge conflicts with the base branch label Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🚀 V2 Auto-Deployment Complete!

🔗 Direct Test URL (non-SSL) http://54.175.155.236:7501

🧩 Admin portal included - try it at http://54.175.155.236:7501/portal.

📚 Storybook: http://54.175.155.236:27501/

2 stories changed

Stories

  • core/ui/Tooltip.stories.tsx
  • portal/components/infrastructure/EncryptionPanel.stories.tsx

This deployment will be automatically cleaned up when the PR is closed.

🔄 Auto-deployed for approved V2 contributors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Devtools Development tools Documentation Improvements or additions to documentation Front End Issues or pull requests related to front-end development Java Pull requests that update Java code size:XXL This PR changes 1000+ lines ignoring generated files. Test Testing-related issues or pull requests Translation Issues or pull requests related to translation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant