[pull] main from Stirling-Tools:main - #332
Merged
Merged
Conversation
# 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )