fix(storage): don't query the encryption key registry when storage is off (unblocks backend:dev:saas) (#7265)

# Description of Changes

Fixes a startup failure introduced by #7155 and reported against `task
backend:dev:saas`.

**What goes wrong**

`StorageProviderConfig.storageEncryptionState(...)` is created on every
startup, in every profile. When `storage.encryption.enabled` is false —
the default, and what SaaS ships — the `||` short-circuit evaluates
`fileEncryptionKeyRepository.count()`, a live query against
`file_encryption_keys`:

```java
if (writeEnabled || fileEncryptionKeyRepository.count() > 0) {   // <- always runs when the flag is off
```

That table only exists if `ddl-auto=update` managed to create it. When
it cannot — permissions on a shared Supabase branch DB, concurrent DDL
from several developers, schema ordering — **ddl-auto logs and
continues**, so the situation used to be a warning nobody noticed. Now
it is a query that throws during bean creation and takes the whole
context down.

Two things make this sting in SaaS specifically: `storage.enabled` is
false there, so before this feature nothing ever touched the table; and
`hibernate.default_schema=stirling_pdf` means the table has to exist in
a schema the app may not be able to create in.

There is a second exposure on the request path:
`suppressDirectDownloads()` also counts (60s cached), so even a
surviving boot could 500 on downloads.

**Fix**

- The boot probe runs only when `storage.enabled` is true, so a
deployment that does not use storage never touches the table.
- Registry reads are wrapped. The boot probe degrades to "no keys"
rather than propagating; `suppressDirectDownloads()` **fails safe by
suppressing** rather than issuing a presigned URL it cannot vouch for.
Losing the direct-download fast path is recoverable; serving ciphertext
is not.

**Safety is unchanged, and that is the important part.** The decorator
is still installed unconditionally, so any blob carrying the `SPDFEAR1`
magic is still decrypted via lazy materialisation or fails loudly — the
eager probe only ever bought *earlier* master-key verification. A node
that can actually serve stored files has `storage.enabled` on by
definition, which is exactly the node the drifted-node protection is
for; that test now configures it that way, and a new test pins that the
decorator remains installed even with storage off.

**Tests** — storage-disabled never calls `count()`; an unreadable
registry still boots *and* still suppresses direct downloads; the
decorator stays installed with storage off; storage-enabled still
probes. Full proprietary suite green apart from the pre-existing
Windows-symlink `FolderIdentitiesTest` failure, which is environmental
and unrelated.

**Note on scope:** deliberately minimal so it can land quickly. The
Aikido `findAll()` code-quality finding lives in #7173 only
(`rotateMasterKey` does not exist on main), so it is fixed there rather
than here. #7173 will be rebased once this merges.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
ConnorYoh
2026-08-03 16:41:56 +00:00
committed by GitHub
parent c91d63f215
commit 50bc4a7866
3 changed files with 77 additions and 6 deletions
@@ -70,7 +70,10 @@ public class StorageProviderConfig {
createKeyService(
configuredFileEncryptionKey, clusterEnabled, requiresNew),
fileEncryptionKeyRepository);
if (writeEnabled || fileEncryptionKeyRepository.count() > 0) {
// The registry table may not exist when storage is unused, so only probe if it is on.
boolean probeForExistingKeys =
!writeEnabled && applicationProperties.getStorage().isEnabled();
if (writeEnabled || (probeForExistingKeys && state.encryptedContentMayExist())) {
state.initialiseEagerly();
log.info(
"Storage encryption at rest active (writes {})",
@@ -1,6 +1,7 @@
package stirling.software.proprietary.storage.crypto;
import java.time.Duration;
import java.util.Optional;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;
@@ -104,9 +105,28 @@ public class StorageEncryptionState {
&& now - keysExistCheckedAtNanos < KEYS_EXIST_CACHE_TTL.toNanos()) {
return keysExistCached;
}
keysExistCached = keyRepository.count() > 0;
keysExistCached = countKeys().orElse(true /* unreadable registry: fail safe */);
keysExistCheckedAtNanos = now;
keysExistEverChecked = true;
return keysExistCached;
}
/** True when key rows exist; empty when the registry could not be read. */
private Optional<Boolean> countKeys() {
try {
return Optional.of(keyRepository.count() > 0);
} catch (RuntimeException e) {
log.warn(
"Could not read the storage encryption key registry ({}). Treating direct"
+ " downloads as unsafe; encrypted content is still decrypted on"
+ " demand.",
e.getMessage());
return Optional.empty();
}
}
/** Whether key rows exist, for the decrypt-only startup path; false if unreadable. */
public boolean encryptedContentMayExist() {
return countKeys().orElse(false);
}
}
@@ -4,15 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Base64;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.dao.InvalidDataAccessResourceUsageException;
import org.springframework.transaction.PlatformTransactionManager;
import stirling.software.common.model.ApplicationProperties;
@@ -24,6 +28,7 @@ import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider;
import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo;
import stirling.software.proprietary.storage.crypto.StorageEncryptionState;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
/**
@@ -66,8 +71,8 @@ class StorageProviderConfigTest {
@Test
void decorator_flagOffButKeysExist_decryptOnlyModeStillMaterialises() throws Exception {
// Simulate the drifted-node case: another node already created keys.
StorageProviderConfig seedCfg = newConfig("local", License.SERVER, true);
// Drifted node: keys created elsewhere; storage on, as it must be to serve files.
StorageProviderConfig seedCfg = newConfig("local", License.SERVER, true, true);
StorageEncryptionState seedState = seedCfg.storageEncryptionState(MASTER, false, txManager);
Team team = new Team();
team.setId(1L);
@@ -75,7 +80,7 @@ class StorageProviderConfigTest {
owner.setTeam(team);
seedState.keyService().activeKekForOwner(owner);
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false);
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false, true);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThat(cfg.storageProvider(state, Optional.empty()))
@@ -104,6 +109,44 @@ class StorageProviderConfigTest {
.hasMessageContaining("32 bytes");
}
// ---- deployments that do not use storage (the SaaS shape) ---------------------------
@Test
void storageDisabled_neverQueriesTheKeyRegistry() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThat(state.isWriteEnabled()).isFalse();
verify(keyRepo.mock, never()).count();
}
@Test
void storageDisabled_decoratorStillInstalledSoCiphertextIsNeverServedRaw() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThat(cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(EncryptingStorageProvider.class);
}
@Test
void unreadableKeyRegistry_stillStartsAndFailsSafeOnDirectDownloads() {
FileEncryptionKeyRepository broken = mock(FileEncryptionKeyRepository.class);
when(broken.count())
.thenThrow(new InvalidDataAccessResourceUsageException("no such table"));
StorageEncryptionState state = new StorageEncryptionState(false, () -> null, broken);
assertThat(state.encryptedContentMayExist()).isFalse();
assertThat(state.suppressDirectDownloads()).isTrue();
}
@Test
void storageEnabledWithoutEncryption_probesRegistry() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false, true);
cfg.storageEncryptionState(MASTER, false, txManager);
verify(keyRepo.mock, atLeastOnce()).count();
}
// ---- backend licence gates (unchanged behaviour) ------------------------------------
@Test
@@ -161,9 +204,14 @@ class StorageProviderConfigTest {
private StorageProviderConfig newConfig(
String provider, License license, boolean encryptionEnabled) {
return newConfig(provider, license, encryptionEnabled, false);
}
private StorageProviderConfig newConfig(
String provider, License license, boolean encryptionEnabled, boolean storageEnabled) {
ApplicationProperties props = new ApplicationProperties();
props.getStorage().setProvider(provider);
props.getStorage().setEnabled(false); // local-fallback path skips dir creation
props.getStorage().setEnabled(storageEnabled);
props.getStorage().getEncryption().setEnabled(encryptionEnabled);
StoredFileBlobRepository repo = mock(StoredFileBlobRepository.class);
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);