Skip to content

[pull] main from Stirling-Tools:main - #332

Merged
pull[bot] merged 2 commits into
5474312:mainfrom
Stirling-Tools:main
Sep 3, 2026
Merged

[pull] main from Stirling-Tools:main#332
pull[bot] merged 2 commits into
5474312:mainfrom
Stirling-Tools:main

Conversation

@pull

@pull pull Bot commented Sep 3, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

# 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.

- [x] The Storage tab under Infrastructure is enabled again, with
encryption as its content
- [x] Shows write state from `writeEnabled`: new uploads are encrypted,
or they are not
- [x] Shows `encryptedFiles` and `plaintextFiles` counts, so partial
coverage is obvious
- [x] Distinguishes `writeEnabled: false, active: true` (decrypt-only,
the state after turning the
flag off) from `writeEnabled: false, active: false` (feature never used)
- [x] Empty state when no keys exist yet: explains that a key is created
on the first upload, not at
      startup
- [x] 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
- [x] 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.

- [x] Displays `masterKeyFingerprint` (SHA-256 prefix, 16 hex
characters) and `masterKeyVersion`
- [x] Copy-to-clipboard on the fingerprint
- [x] 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
- [x] 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.

- [x] On a non-Enterprise licence the panel carries a persistent notice:
encryption is active, audit
      events are not recorded, this requires Enterprise
- [x] The notice sits next to any claim about auditing, not in a
separate help page
- [x] On Enterprise the notice is absent
- [x] 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.

- [x] 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
- [x] Links to the backup section of the runbook
- [x] 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.

- [x] Each key row has a Revoke action, calling `POST
/keys/{keyId}/disable`
- [x] 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
- [x] The row shows DISABLED, plus `statusChangedBy` and
`statusChangedAt`
- [x] 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.

- [x] Enable action on DISABLED keys only, calling `POST
/keys/{keyId}/enable`
- [x] 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
- [x] When the result is RETIRED, the UI explains it: this key decrypts
its existing files again,
      and a newer key is wrapping new uploads
- [x] Enable is not offered on ACTIVE or RETIRED keys, matching the
API's 409
- [x] 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.

- [x] Start action calls `POST /migrate`, disabled when `plaintextFiles`
is zero
- [x] Progress from `GET /migrate/status`: state, total, processed,
skipped, failed, started time
- [x] All four terminal states render distinctly: IDLE, RUNNING,
COMPLETED, FAILED
- [x] 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
- [x] Skipped is explained on hover: a user replaced the file
mid-migration, so the job left their
      copy alone
- [x] 409 when a run is already going, or when encryption is off,
surfaces as a message rather than a
      failure
- [x] 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.

- [x] Shows the current `masterKeyVersion` and the number of key rows
below it
- [x] Re-wrap action calls `POST /master/rotate` and reports the
`rewrapped` count
- [x] 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
- [x] Warns while any row is behind: removing the previous key now would
make those files
      unreadable
- [x] 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

- [x] New API client module under `portal/api/`, following the
conventions in `sources.ts`
- [x] All copy is i18n keys in the editor `en-US` catalogue, which is
the source of truth
- [x] Storybook stories for every state named above, including the
empty, 403, 503 and FAILED states
- [x] a11y baseline recorded with the full task, so a partial scan does
not delete other entries
- [x] Light and dark themes both checked
- [x] 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:**

- **#7550** — the encrypted badge on documents (US-9). Needs a documents
endpoint to return
  `encryptionKeyId` before it can render anywhere
- **#7551** — the revoked-key message (US-10), the migration cancel
endpoint with page-size and
  pause knobs, and pagination on the `/status` key list

**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 | <img width="2560"
height="1800" alt="01-panel-encryption-off"
src="https://github.com/user-attachments/assets/36ae3c9a-2c54-48b1-8837-c7c68e157961"
/> |
| Encryption on, healthy, key table | <img width="2560" height="2036"
alt="02-panel-active"
src="https://github.com/user-attachments/assets/949d930c-7e13-409d-b960-49d44e49fc21"
/> |
| Pro licence audit notice | <img width="2560" height="2226"
alt="03-licence-notice"
src="https://github.com/user-attachments/assets/f5e1b39e-c221-4e42-87c1-0f45f8ecd2a3"
/> |
| Revoke confirmation | <img width="2560" height="2036"
alt="04-revoke-confirm"
src="https://github.com/user-attachments/assets/0c86a40c-3bf1-4a0d-aa19-c28691986e2f"
/> |
| Revoked key row | <img width="2560" height="1912" alt="05-key-revoked"
src="https://github.com/user-attachments/assets/2333bc0d-968f-4472-b253-7877fe1d41a2"
/> |
| Re-enabled as RETIRED, with explanation |<img width="2560"
height="1912" alt="06-key-retired"
src="https://github.com/user-attachments/assets/a4e84165-020b-4377-8506-49f4312783e4"
/> |
| Migration running | <img width="2560" height="2222"
alt="07-migration-running"
src="https://github.com/user-attachments/assets/6ca07208-ad58-441d-85ba-e5617f36bb29"
/> |
| Migration failed after the flag was turned off | <img width="2560"
height="2380" alt="08-migration-failed"
src="https://github.com/user-attachments/assets/35a54549-1b92-4c17-9fad-4790bda8c24b"
/> |
| Rotation with rows still on the old key | <img width="2560"
height="2232" alt="09-rotation-pending"
src="https://github.com/user-attachments/assets/991e1811-3103-45d1-8bd6-a83b6eb9291b"
/> |
| Storage disabled state | <img width="2560" height="1800"
alt="11-storage-disabled"
src="https://github.com/user-attachments/assets/c52c5087-2a4a-4189-a3c1-22e69016fc2e"
/> |
| Dark theme, active panel | <img width="2560" height="2036"
alt="12-panel-active-dark"
src="https://github.com/user-attachments/assets/7d6c3ff5-7824-4ce6-92c7-d8af82d52c10"
/> |

Extras captured while iterating, not required in the body: registry
unavailable
<img width="2560" height="1800" alt="13-registry-unavailable"
src="https://github.com/user-attachments/assets/4c8df8ce-5659-41f0-9484-010cc9f74c2d"
/>

(`13`), generated master key (`14`)
<img width="2560" height="2226" alt="14-generated-master-key"
src="https://github.com/user-attachments/assets/3ff765ed-a24f-48fa-b9b8-d92de3cc2af0"
/>
, dark migration (`15`)
<img width="2560" height="2222" alt="15-panel-active-dark-migration"
src="https://github.com/user-attachments/assets/7a2ae906-8c23-404b-b778-b40ea74c3058"
/>
.

## 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.
Adds the capacity step to self-hosted checkout. the field is ignored
until [#325
](Stirling-Tools/Stirling-PDF-SaaS#325) lands.
<img width="1262" height="487" alt="image"
src="https://github.com/user-attachments/assets/c22d0b15-103a-491a-bd98-df772e63c71b"
/>

## What it does

A capacity stage between billing period and payment, for the **Server
tier only**. Enterprise is priced per seat and Free has nothing to size,
so both go straight to payment exactly as before.

The stepper counts **servers**, because that is the unit we sell. Every
figure beside it is stated in **users**, because that is the unit an
admin measures. The line item does the translation, so a buyer picks "3
servers" and reads "300 users" without converting anything themselves.

`server_quantity` now rides `createCheckoutSession` through to the edge
function. Before this the base line item was always `quantity: 1`, and
the only way to buy more capacity was Stripe's own portal after the
fact.

## Two guards on the stepper

**It cannot go below current usage.** An installation running 240 users
cannot buy fewer than three servers. Reducing capacity is a renewal
conversation, not something checkout should do by stranding accounts
that already exist.

**At five servers, or a thousand users, it offers an enterprise quote**
beside the purchase. Deliberately an option and not a gate — self-serve
checkout still completes. `onContactSales` is optional, so the door only
appears where a caller wires it up.

## Review notes

- **`USERS_PER_SERVER = 100` is a frontend constant** in
`utils/capacity.ts`. The authoritative value lives on `pricing_policy`
and is resolved server-side at licence-issue time, but before a purchase
there is no licence to read it from and the packaging RPC is not
anon-callable. The comment says so. A follow-up could serve it from
`stripe-price-lookup`, which the plan page already calls; I kept it out
of scope so this PR stays inside one repo.
- The `SELF_SERVE_MAX_SERVERS` bound here is cosmetic. `create-checkout`
clamps server-side against the policy, so a crafted request cannot
exceed it regardless of what the stepper allows.
- No copy sweep needed — `plan.features.usersIncluded` already reads
"100 users included" on main.

## Testing

- 5 unit tests on the capacity arithmetic and the enterprise-door
threshold.
- 5 Storybook stories: single server, multiple servers monthly,
constrained by current users, below current usage (blocked), and the
enterprise door.
@pull pull Bot locked and limited conversation to collaborators Sep 3, 2026
@pull pull Bot added the ⤵️ pull label Sep 3, 2026
@pull
pull Bot merged commit d414763 into 5474312:main Sep 3, 2026
4 of 5 checks passed
@github-actions github-actions Bot added Documentation Improvements or additions to documentation Front End Issues or pull requests related to front-end development Java Pull requests that update Java code Translation Issues or pull requests related to translation Test Testing-related issues or pull requests Devtools Development tools labels Sep 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull 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 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