Skip to content

feat(settings): KAM-439: secret secrets#9109

Closed
passcod wants to merge 20 commits intomainfrom
feat/secret-secrets
Closed

feat(settings): KAM-439: secret secrets#9109
passcod wants to merge 20 commits intomainfrom
feat/secret-secrets

Conversation

@passcod
Copy link
Copy Markdown
Member

@passcod passcod commented Feb 4, 2026

Changes

Adds the ability to store secrets in config files, in local system facts, and in settings. Secrets are encrypted using the Web Crypto API, with a symmetric key. For settings, there's an additional layer so that all servers can share a key (as the secrets are encrypted once in the DB).

In config, it looks like this:

{
  sync: {
    email: "admin@tamanu.io",
    password: "S1:xK3mN9pQrStUvW:a7Bz4cDe8fGh2iJk6lMn0oPq5rSt9uVw3xYz1AbC4dEf==",
  },
  crypto: {
    keyFile: "/secrets/configKey",
    settingsPsk: "S1:f4N7yyOcqyhf==:aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6cD7e4sT5uZ8==",
  },
}

In settings and local system facts, it looks similar:

INSERT INTO settings (key, value) VALUES
('superDuperSecret', 'S1:kwzzFf7NTtuh=:NL4CfIj3aHh+AgmwXBAffMThsvFK5trjcQtFwqCpPpR
CZiHKBljEo46KbXgusFEZQ==');

The unique thing with settings is the settingsPsk config secret. That's a shared key (PSK = Pre-Shared Key) common between all servers that can read secrets from settings. It's encrypted with the per-server key when it's stored in config. This key is required to be 32 bytes, encoded in hex, so it's sufficiently strong.

To start with, the per-server key will be just a file stored separately from the config, but later we can improve that security if we need, for example by using a TPM on metal or in AWS on Nitro EC2 instances.

The format of encrypted secrets is S1 (just a version and a visual identifier that it's a secret), the IV, and the ciphertext. The algorithm used is AES-GCM.

In the admin panel UI, secret values can't be read, only written.

As an example of a migration to secrets, we have the DHIS2 credentials, which are currently in config in plain text. This PR includes a change that will first look in settings, then for an encrypted value in config, and fall back to the plain value in config.

image

Deploys

  • Deploy to Tamanu Internal

Tests

  • Run E2E Tests

Remember to...

  • ...write or update tests
  • ...add UI screenshots and testing notes to the Linear issue
  • ...add any manual upgrade steps to the Linear issue
  • ...update the config reference, settings reference, or any relevant runbook(s)
  • ...call out additions or changes to config files for the deployment team to take note of

🦸 Review Hero

  • Run Review Hero
  • Auto-fix review suggestions
  • Auto-fix CI failures

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @passcod, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the application's security by introducing a comprehensive framework for managing encrypted secrets. It provides developers with tools and mechanisms to store sensitive information, such as API keys and passwords, in both configuration files and the database in an encrypted format, thereby mitigating risks associated with plaintext storage. The implementation ensures that secrets are handled securely throughout their lifecycle, from generation to retrieval, using industry-standard cryptographic practices.

Highlights

  • Secure Secret Storage: Implements a system to store sensitive data (secrets) securely within both application configuration files and database settings.
  • Dual Encryption Mechanism: Utilizes a per-server key for encrypting secrets directly in config files and a Pre-Shared Key (PSK) for encrypting secrets stored in the database settings, with the PSK itself being encrypted by the per-server key.
  • Web Crypto API Integration: Leverages the Web Crypto API with AES-GCM for robust symmetric encryption, ensuring data confidentiality and integrity.
  • CLI Tools for Secret Management: Introduces a new configSecret command-line utility to facilitate the generation of encryption key files and the encryption of values for easy integration into configuration.
  • Database Integration: Extends the Setting model with getSecret and setSecret methods, enabling direct handling of encrypted secrets within the database layer.
  • Dynamic Settings Reader Patch: Implements a runtime patch for ReadSettings to add a getSecret method, allowing applications to seamlessly retrieve and decrypt secrets from settings.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/central-server/app/database/index.js
    • Integrated patchReadSettings to enable secret retrieval from settings.
  • packages/central-server/app/subCommands/configSecret.js
    • Added a new CLI subcommand for managing configuration secrets, including key file initialization and value encryption.
  • packages/central-server/app/subCommands/index.js
    • Exported the new configSecretCommand.
  • packages/database/src/index.ts
    • Minor syntax update (comma to semicolon in interface).
  • packages/database/src/models/Setting.ts
    • Introduced getSecret and setSecret static methods for encrypted setting management.
    • Updated map callback syntax for consistency.
  • packages/facility-server/app/database/initDatabase.js
    • Integrated patchReadSettings for secret retrieval.
  • packages/facility-server/app/index.js
    • Incorporated the configSecretCommand into the main application's CLI.
  • packages/facility-server/app/subCommands/configSecret.js
    • Added a new CLI subcommand for managing configuration secrets (mirroring central-server).
  • packages/facility-server/app/subCommands/index.js
    • Exported the new configSecretCommand.
  • packages/settings/src/reader/ReadSettings.ts
    • Added a getSecret method placeholder, designed to be patched at runtime for secret decryption.
  • packages/shared/tests/utils/crypto.test.js
    • Added comprehensive unit tests for the new cryptographic utilities, covering encryption, decryption, key generation, and error handling.
  • packages/shared/src/utils/crypto.js
    • Implemented core cryptographic functions for key generation, AES-GCM encryption/decryption, key file management, and config secret retrieval.
  • packages/shared/src/utils/index.js
    • Exported the newly added crypto and patchReadSettings utilities.
Activity
  • No human activity (comments, reviews, etc.) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust feature for handling encrypted secrets within configuration files and settings, utilizing the Web Crypto API with AES-GCM. The implementation includes CLI tools for key management and is accompanied by a comprehensive test suite for the cryptographic functions. While the core functionality is well-implemented, the review has identified a few significant issues that need attention. There is a critical bug in the Setting.ts model that prevents per-facility secrets from functioning as intended. Additionally, there's notable code duplication in the CLI command logic and the use of monkey-patching, which poses a maintainability risk. Addressing these points will greatly improve the quality and robustness of this new feature.

Comment thread packages/database/src/models/Setting.ts Outdated
Comment thread packages/database/src/models/Setting.ts Outdated
Comment thread packages/facility-server/app/subCommands/configSecret.js
Comment thread packages/shared/src/utils/patchReadSettings.js
Comment thread packages/database/src/models/Setting.ts Outdated
Comment thread packages/shared/src/utils/crypto.js Outdated
@passcod
Copy link
Copy Markdown
Member Author

passcod commented Feb 4, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust mechanism for encrypting and storing secrets in configuration files and database settings using the Web Crypto API. The implementation is well-structured, with new crypto utilities, CLI commands for secret management, and comprehensive tests. The approach of using a server-specific key for config secrets and a pre-shared key for settings secrets is sound. My review focuses on a few areas for improvement, including fixing a bug that prevents encrypting empty strings, reducing code duplication, and improving type safety for better long-term maintainability.

Comment thread packages/shared/src/utils/crypto.js
Comment thread packages/database/src/models/LocalSystemFact.ts
Comment thread packages/database/src/models/Setting.ts
Comment thread packages/central-server/app/subCommands/configSecret.js
Comment thread packages/shared/src/utils/crypto.js Outdated
@passcod
Copy link
Copy Markdown
Member Author

passcod commented Feb 4, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant and well-implemented feature for handling encrypted secrets in both configuration and application settings. The use of the Web Crypto API with AES-GCM is a solid choice for security. The changes are extensive, touching everything from database models and API routes to CLI commands and the frontend UI. The code is generally of high quality, but I've identified a few areas for improvement, mainly concerning code duplication and a potential issue in the handling of the pre-shared key for settings encryption. My comments highlight these points with suggestions for refactoring.

Comment thread packages/facility-server/app/subCommands/configSecret.js
Comment thread packages/shared/src/utils/patchReadSettings.js Outdated
Comment thread packages/central-server/app/admin/adminRoutes.js Outdated
Comment thread packages/database/src/models/LocalSystemFact.ts
Comment thread packages/shared/src/utils/crypto.js Outdated
@passcod
Copy link
Copy Markdown
Member Author

passcod commented Feb 4, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust system for handling encrypted secrets in both configuration files and database settings, using the Web Crypto API with AES-GCM. The changes are extensive, touching on backend routes, database models, CLI commands, and frontend UI components to support this new functionality. Overall, the implementation is solid, with good security practices and comprehensive testing. I've identified a critical issue that could lead to data loss under specific circumstances, along with several medium-severity suggestions to improve code clarity, performance, and maintainability by reducing redundancy and refactoring complex logic. Addressing these points will further strengthen this already well-designed feature.

Comment thread packages/central-server/app/admin/adminRoutes.js Outdated
Comment thread packages/central-server/app/admin/adminRoutes.js Outdated
Comment thread packages/central-server/app/subCommands/configSecret.js
Comment thread packages/central-server/app/tasks/DHIS2IntegrationProcessor.js
Comment thread packages/database/src/models/Setting.ts
Comment thread packages/settings/src/schema/utils.ts Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 4, 2026

Android builds 📱

@passcod
Copy link
Copy Markdown
Member Author

passcod commented Feb 4, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive and well-implemented feature for managing encrypted secrets in both configuration files and database settings. It leverages the Web Crypto API for strong encryption and includes backend logic, CLI tooling, and a secure UI in the admin panel. The fallback mechanism for credentials is a thoughtful addition for smooth migration. The code is of high quality with thorough test coverage. I have a few suggestions to further improve maintainability by reducing code duplication and enhancing type safety, but overall this is an excellent contribution.

Comment thread packages/central-server/app/tasks/DHIS2IntegrationProcessor.js
Comment thread packages/database/src/models/Setting.ts
Comment thread packages/central-server/app/subCommands/configSecret.js
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 4, 2026

🍹 up on tamanu-on-k8s/bes/tamanu-on-k8s/feat-secret-secrets

Pulumi report
  Updating (feat-secret-secrets)

View Live: https://app.pulumi.com/bes/tamanu-on-k8s/feat-secret-secrets/updates/6

Downloading plugin random-4.19.0: starting
Downloading plugin random-4.19.0: done
Installing plugin random-4.19.0: starting
Installing plugin random-4.19.0: done

@ Updating....
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running 
@ Updating.....
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read kubernetes:core/v1:Namespace tamanu-feat-secret-secrets
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read pulumi:pulumi:StackReference bes/k8s-core/tamanu-internal-main
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Using tailscale proxy https://k8s-operator-tamanu-internal-main.tail53aef.ts.net
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read pulumi:pulumi:StackReference bes/k8s-core/tamanu-internal-main
@ Updating....
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read pulumi:pulumi:StackReference bes/core/tamanu-internal
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read pulumi:pulumi:StackReference bes/core/tamanu-internal
@ Updating......
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read kubernetes:core/v1:Namespace tamanu-feat-secret-secrets
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Waiting for central-db...
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Waiting for facility-1-db...
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Waiting for facility-2-db...
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read kubernetes:core/v1:ConfigMap actual-provisioning
@ Updating....
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running read kubernetes:core/v1:ConfigMap actual-provisioning
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Secret facility-2-db-superuser not found or not ready: Error: HTTP-Code: 404
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Message: Unknown API Status Code!
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"facility-2-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"facility-2-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Headers: {"audit-id":"bd82c4ff-c873-44de-81ae-7ae6c87d9f73","cache-control":"no-cache, private","connection":"close","content-length":"220","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Secret facility-1-db-superuser not found or not ready: Error: HTTP-Code: 404
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Message: Unknown API Status Code!
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"facility-1-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"facility-1-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Headers: {"audit-id":"6c0e967a-a835-401c-863b-f8501d3f08ca","cache-control":"no-cache, private","connection":"close","content-length":"220","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Secret central-db-superuser not found or not ready: Error: HTTP-Code: 404
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Message: Unknown API Status Code!
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"central-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"central-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets running Headers: {"audit-id":"a545669f-5429-4daf-b318-a5a458c2d340","cache-control":"no-cache, private","connection":"close","content-length":"214","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}
@ Updating....
  pulumi:pulumi:Stack tamanu-on-k8s-feat-secret-secrets  16 messages
Diagnostics:
pulumi:pulumi:Stack (tamanu-on-k8s-feat-secret-secrets):
  Waiting for central-db...
  Waiting for facility-1-db...
  Waiting for facility-2-db...

  Using tailscale proxy https://k8s-operator-tamanu-internal-main.tail53aef.ts.net

  Secret facility-2-db-superuser not found or not ready: Error: HTTP-Code: 404
  Message: Unknown API Status Code!
  Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"facility-2-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"facility-2-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  Headers: {"audit-id":"bd82c4ff-c873-44de-81ae-7ae6c87d9f73","cache-control":"no-cache, private","connection":"close","content-length":"220","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}
  Secret facility-1-db-superuser not found or not ready: Error: HTTP-Code: 404
  Message: Unknown API Status Code!
  Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"facility-1-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"facility-1-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  Headers: {"audit-id":"6c0e967a-a835-401c-863b-f8501d3f08ca","cache-control":"no-cache, private","connection":"close","content-length":"220","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}
  Secret central-db-superuser not found or not ready: Error: HTTP-Code: 404
  Message: Unknown API Status Code!
  Body: "{\"kind\":\"Status\",\"apiVersion\":\"v1\",\"metadata\":{},\"status\":\"Failure\",\"message\":\"secrets \\\"central-db-superuser\\\" not found\",\"reason\":\"NotFound\",\"details\":{\"name\":\"central-db-superuser\",\"kind\":\"secrets\"},\"code\":404}
"
  Headers: {"audit-id":"a545669f-5429-4daf-b318-a5a458c2d340","cache-control":"no-cache, private","connection":"close","content-length":"214","content-type":"application/json","date":"Wed, 11 Mar 2026 04:41:23 GMT","x-kubernetes-pf-flowschema-uid":"3fb296fc-e46b-45d1-9306-057e37ddd229","x-kubernetes-pf-prioritylevel-uid":"feccf24d-a074-4fa8-aa6f-db82477fc2f5"}

  [Pulumi Neo] Would you like help with these diagnostics?
  https://app.pulumi.com/bes/tamanu-on-k8s/feat-secret-secrets/updates/6?explainFailure

Outputs:
  urls: {
      Central      : "https://central.feat-secret-secrets.cd.tamanu.app"
      Facility- 1  : "https://facility-1.feat-secret-secrets.cd.tamanu.app"
      Facility- 2  : "https://facility-2.feat-secret-secrets.cd.tamanu.app"
      PatientPortal: "https://portal.feat-secret-secrets.cd.tamanu.app"
  }

Resources:
  67 unchanged

Duration: 9s

  

@passcod
Copy link
Copy Markdown
Member Author

passcod commented Feb 5, 2026

Note that the deploy is up and shows the UI, but Kubernetes-side is not yet configured to have a config secret and so on, so it won't "actually work"

Copy link
Copy Markdown
Collaborator

@rohan-bes rohan-bes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great @passcod! Awesome change.

Mostly questions/suggestions, just requesting changes around:

  1. Only making the password secret in DHIS2 integration settings/config
  2. The masking front-end logic, and whether we display the placeholder if a secret setting is empty

Also wasn't entirely sure why we need the patchReadSettings logic, but do feel its a a bit unexpected and might catch us out.

Comment on lines +87 to +97
try {
username = await this.context.settings.getSecret('integrations.dhis2.username');
} catch {
// Settings secret not available, try config secret
try {
username = await getConfigSecret('integrations.dhis2.username');
} catch {
// Config secret not available, fall back to plain config
username = config.integrations?.dhis2?.username || null;
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this specific instance might recommend just the password being encrypted. Nice to have the username in plaintext so we know it's set to the right user.

Comment on lines +164 to +175
it('should mask empty strings as valid secrets', () => {
const settings = {
emptySecret: '',
normalSetting: 'visible',
};
const secretPaths = ['emptySecret'];

const masked = maskSecrets(settings, secretPaths);

expect(masked.emptySecret).toBe(SECRET_PLACEHOLDER);
expect(masked.normalSetting).toBe('visible');
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this what we want? I might expect that we'd just display an empty string if the original secret setting is also empty.

Or at least, I'm wondering what happens when a user clears a secret setting? I think I'd expect that the field is cleared (it doesn't display the placeholder). Tho I'm not sure if we set the setting value to '' if the user clears the value...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty secrets are still encrypted so we reveal nothing to an unauthorised observer. I'm not sure if there's some way to clear the secret in the UI, though, will look.

description: 'Username for DHIS2 API authentication',
type: yup.string(),
defaultValue: '',
secret: true,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned, I think username can be plaintext 👍

Comment on lines +88 to +89
// Placeholder used by the server to indicate a secret exists but value is hidden
const SECRET_PLACEHOLDER = '••••••••';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth moving this to constants?

Comment on lines +115 to +119
const isUnchangedFromDefault = useMemo(() => {
if (isSecret) {
// For secrets, we can't compare to default since value is hidden
return !secretEdited;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could argue that secrets should never have a default value? Struggling to think of a use case where we want to hide a value but are happy with a global default

edge="end"
size="small"
>
{showSecretValue ? <VisibilityOff /> : <Visibility />}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I'm mmissing something here, but aren't we masking the secrets on the server-side of the api call? So even if we displayed the value of the secret it would just be the placeholder?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm okay with not being able to view the secret setting values. Feels like it'd be hard to allow users to view in plaintext in a way that's still secure.

Comment on lines +70 to +76
/**
* Gets a decrypted secret from the settings.
* This method must be patched at runtime before use.
*/
async getSecret(_name: string): Promise<string> {
throw new Error('BUG: ReadSettings.getSecret must be patched before use');
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to do this? What happens if the getSecret implementation from patchReadSettings is just here instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings package can't depend on shared. It's ugly, an alternative would be to make a new package but would still need a way to inject/patch some context for the PSK

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I get ya, that makes sense.

In that case, thoughts on injecting the getConfigSecret and decryptSecret functions in to the ReadSettings when initializing in the ApplicationContext for facility-server and central-server? Perhaps even wrap in a utility function called getSettingSecret a bit like what you've done for getConfigSecret?

That way we don't need any ReadSettings logic in the shared package, and it's a bit more clear what's going on.

Comment on lines +102 to +106
try {
password = await getConfigSecret('integrations.dhis2.password');
} catch {
password = config.integrations?.dhis2?.password || null;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on making this fallback logic generic in the getConfigSecret function? I could see it being convenient if it turns out that some deployments use secrets and others don't.

Just a suggestion tho

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed mind, I think if it's in the utility it will entrench the ability to have plain text secrets in the config. Perhaps the solution is rather to just require that the secrets are encrypted and eat the manual upgrade cost once.

Comment thread packages/database/src/models/LocalSystemFact.ts
import { addHooks } from './hooks';
import { closeAllDatabases, openDatabase } from '@tamanu/database/services/database';
import { patchReadSettings } from '@tamanu/shared/utils/patchReadSettings';
import { log } from '@tamanu/shared/services/logging';
import { closeAllDatabases, openDatabase } from '@tamanu/database/services/database';
import { patchReadSettings } from '@tamanu/shared/utils/patchReadSettings';
import { log } from '@tamanu/shared/services/logging';
import { REPORT_DB_SCHEMAS } from '@tamanu/constants';

import { closeAllDatabases, openDatabase } from '@tamanu/database/services/database';
import { fakeUUID } from '@tamanu/utils/generateId';
import { log } from '@tamanu/shared/services/logging';
import { fakeUUID } from '@tamanu/utils/generateId';
import { log } from '@tamanu/shared/services/logging';
import { patchReadSettings } from '@tamanu/shared/utils/patchReadSettings';
import { REPORT_DB_SCHEMAS } from '@tamanu/constants';
function flattenSettingsToEntries(obj, parentPath = '') {
const entries = [];

for (const [key, value] of Object.entries(obj)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

flattenSettingsToEntries is a non-trivial recursive algorithm that has been placed inline in a route file. It operates purely on a settings object and has no dependency on HTTP context. It belongs in @tamanu/settings or at least in a shared util, both to keep the route file focused on HTTP concerns and to make the function independently testable.

}

// Only save non-secret settings if there are any remaining leaf values
const remainingEntries = flattenSettingsToEntries(settings);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bugs & Correctness] critical

flattenSettingsToEntries(settings) is called unconditionally at line 165, but the guard at line 142 only runs when settings is a non-null object. If the caller sends no body, or settings is null/undefined/a primitive, Object.entries(settings) inside flattenSettingsToEntries will throw TypeError: Cannot convert undefined or null to object. The previous code passed settings directly to Setting.set which presumably handles such cases. Fix: wrap the call in the same guard, or add a standalone check: if (!settings || typeof settings !== 'object') { res.json({ code: 200 }); return; } before line 164.

return pick(logEntry.get({ plain: true }), LOG_FIELDS);
}

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

getDHIS2Credentials() fetches username and password independently through three nested try/catch fallback layers each. This means username could resolve from one source (e.g. DB secret) while password resolves from a different one (e.g. plain config), producing a mixed-source credential pair that is almost certainly wrong and very hard to debug. The fallback strategy should be applied once at the credential-pair level, not per-field — fetch both from the highest-priority source that has them, then fall back together.

* 3. Fall back to plain config value
*/
async getDHIS2Credentials() {
let username = null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BES Requirements] suggestion

Two let variables reassigned through nested try/catch conditionals is an explicit antipattern in coding-rules.md ('let is a smell'). The identical fallback pattern for username and password should be extracted into a helper, e.g. async getCredentialWithFallback(settingPath) that tries settings secret → config secret → plain config in sequence. This would eliminate the duplication and the mutable lets.


[Bugs & Correctness] suggestion

getDHIS2Credentials swallows all exceptions from getSecret and getConfigSecret with empty catch blocks, including unexpected errors like corrupted key files, DB failures, or decryption errors on a tampered ciphertext. This means a deployment with a broken key file will silently fall back to plain-config credentials (or null) with no log or alert, making the failure invisible. The catch should at minimum distinguish 'not configured' from unexpected errors — e.g. check err.message or a specific error type — and log unexpected ones before falling through.

// Try settings secret first
try {
username = await this.context.settings.getSecret('integrations.dhis2.username');
} catch {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] suggestion

The catch block for getSecret silently swallows ALL errors — including crypto failures like a missing key file, wrong PSK, or corrupted encrypted value. The fallback chain means that if the encryption infrastructure breaks (e.g. key file is deleted or PSK is wrong), the integration silently falls back to plaintext config values without any log warning. An operator who configured encrypted secrets would have no way to know their secrets are no longer being used. The outer catch should at minimum log a warning before falling back, and ideally should distinguish 'secret not configured' (expected, fall back) from 'decryption failed' (unexpected, warn loudly).

Comment thread packages/facility-server/app/subCommands/configSecret.js
@@ -0,0 +1,19 @@
import { ReadSettings } from '@tamanu/settings';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design & Architecture] suggestion

Monkey-patching ReadSettings.prototype.getSecret at startup is a fragile pattern. The @tamanu/settings package now contains a stub that says 'must be patched before use', creating an implicit runtime contract with no compile-time enforcement — any code path that calls getSecret before patchReadSettings() is invoked will fail silently until hit in production. A cleaner design would be to accept the decrypt function as a constructor parameter or via a factory (e.g. new ReadSettings({ getSecret: ... })), keeping the dependency explicit and testable without side effects.

*/
export function patchReadSettings() {
ReadSettings.prototype.getSecret = async function (name) {
const encryptedValue = await this.get(name);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] suggestion

getSecret retrieves the value and checks it's a non-empty string, but never calls isEncryptedSecret() before attempting to decrypt. If a setting path was previously stored as plaintext (before being marked secret: true in the schema), decryptSecret will throw 'Invalid encrypted secret format' — a confusing error that doesn't indicate the root cause. Adding an isEncryptedSecret guard would give a clearer error ('setting is not encrypted; re-save it via the admin UI to encrypt it') and prevents any confusion with legitimate non-secret string values at that path.


[Bugs & Correctness] suggestion

getConfigSecret('crypto.settingsPsk') reads and decrypts the PSK from disk on every call to getSecret. Because getSecret is called per-credential per integration run, this causes repeated key-file reads under normal operation. The PSK should be cached (e.g. a module-level promise) after the first successful read so subsequent calls don't incur repeated I/O.

['body']: SETTING_TYPES.LONG_TEXT,
};

// Placeholder used by the server to indicate a secret exists but value is hidden
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BES Requirements] suggestion

SECRET_PLACEHOLDER is redefined locally as '••••••••' instead of importing it from @tamanu/settings where it is now exported. The frontend logic value === SECRET_PLACEHOLDER and isSecretPlaceholder comparisons will silently break if the server-side constant ever changes, causing secret fields to appear as user-edited when they're not, and potentially sending placeholder values to the server on save. Import the shared constant instead.


[Security] suggestion

SECRET_PLACEHOLDER ('••••••••') is hardcoded locally in the frontend rather than imported from @tamanu/settings where the canonical value is defined. If the value in @tamanu/settings is ever changed, the backend's placeholder check in the PUT /settings handler (entry.value !== SECRET_PLACEHOLDER) won't match what the frontend sends, causing the unchanged placeholder to be encrypted and stored as a new secret value — silently corrupting any secret the admin 'saves' without changing. Import the constant from the shared package.


[Design & Architecture] nitpick

SECRET_PLACEHOLDER = '••••••••' is re-defined locally here even though it is already exported from @tamanu/settings. Import it directly so the two values cannot drift apart: import { SECRET_PLACEHOLDER } from '@tamanu/settings';.

@review-hero
Copy link
Copy Markdown

review-hero Bot commented Mar 11, 2026

🦸 Review Hero Summary
4 agents reviewed this PR | 1 critical | 12 suggestions | 2 nitpicks

Nitpicks

File Line Agent Comment
packages/central-server/app/tasks/DHIS2IntegrationProcessor.js 95 BES Requirements Project preference is ?? over || (important-project-rules.md). config.integrations?.dhis2?.username || null will coerce an empty string to null, silently swallowing a misconfigured credential rather than surfacing the misconfiguration. Use ?? null (same on line 105 for password).
Local fix prompt (copy to your coding agent)

Fix these issues identified on the pull request. One commit per issue fixed.


packages/central-server/app/admin/adminRoutes.js:119: flattenSettingsToEntries is a non-trivial recursive algorithm that has been placed inline in a route file. It operates purely on a settings object and has no dependency on HTTP context. It belongs in @tamanu/settings or at least in a shared util, both to keep the route file focused on HTTP concerns and to make the function independently testable.


packages/central-server/app/admin/adminRoutes.js:165: flattenSettingsToEntries(settings) is called unconditionally at line 165, but the guard at line 142 only runs when settings is a non-null object. If the caller sends no body, or settings is null/undefined/a primitive, Object.entries(settings) inside flattenSettingsToEntries will throw TypeError: Cannot convert undefined or null to object. The previous code passed settings directly to Setting.set which presumably handles such cases. Fix: wrap the call in the same guard, or add a standalone check: if (!settings || typeof settings !== 'object') { res.json({ code: 200 }); return; } before line 164.


packages/central-server/app/tasks/DHIS2IntegrationProcessor.js:76: getDHIS2Credentials() fetches username and password independently through three nested try/catch fallback layers each. This means username could resolve from one source (e.g. DB secret) while password resolves from a different one (e.g. plain config), producing a mixed-source credential pair that is almost certainly wrong and very hard to debug. The fallback strategy should be applied once at the credential-pair level, not per-field — fetch both from the highest-priority source that has them, then fall back together.


packages/central-server/app/tasks/DHIS2IntegrationProcessor.js:83: Two let variables reassigned through nested try/catch conditionals is an explicit antipattern in coding-rules.md ('let is a smell'). The identical fallback pattern for username and password should be extracted into a helper, e.g. async getCredentialWithFallback(settingPath) that tries settings secret → config secret → plain config in sequence. This would eliminate the duplication and the mutable lets.

getDHIS2Credentials swallows all exceptions from getSecret and getConfigSecret with empty catch blocks, including unexpected errors like corrupted key files, DB failures, or decryption errors on a tampered ciphertext. This means a deployment with a broken key file will silently fall back to plain-config credentials (or null) with no log or alert, making the failure invisible. The catch should at minimum distinguish 'not configured' from unexpected errors — e.g. check err.message or a specific error type — and log unexpected ones before falling through.


packages/central-server/app/tasks/DHIS2IntegrationProcessor.js:89: The catch block for getSecret silently swallows ALL errors — including crypto failures like a missing key file, wrong PSK, or corrupted encrypted value. The fallback chain means that if the encryption infrastructure breaks (e.g. key file is deleted or PSK is wrong), the integration silently falls back to plaintext config values without any log warning. An operator who configured encrypted secrets would have no way to know their secrets are no longer being used. The outer catch should at minimum log a warning before falling back, and ideally should distinguish 'secret not configured' (expected, fall back) from 'decryption failed' (unexpected, warn loudly).


packages/central-server/app/tasks/DHIS2IntegrationProcessor.js:95: Project preference is ?? over || (important-project-rules.md). config.integrations?.dhis2?.username || null will coerce an empty string to null, silently swallowing a misconfigured credential rather than surfacing the misconfiguration. Use ?? null (same on line 105 for password).


packages/facility-server/app/subCommands/configSecret.js:1: This file is byte-for-byte identical to packages/central-server/app/subCommands/configSecret.js. Duplicating CLI command definitions across packages is a maintenance hazard — both files need to be kept in sync. Since configSecretInitAction and configSecretEncryptAction already live in @tamanu/shared, the command wiring should live there too (or in a shared commands module), and both packages should import it.

This file is byte-for-byte identical to packages/central-server/app/subCommands/configSecret.js. The implementation should live once in @tamanu/shared (or a new subCommands/configSecret.js exported from there) and both servers should import it. As written, any future change to the CLI behaviour must be made in two places.


packages/shared/src/utils/patchReadSettings.js:1: Monkey-patching ReadSettings.prototype.getSecret at startup is a fragile pattern. The @tamanu/settings package now contains a stub that says 'must be patched before use', creating an implicit runtime contract with no compile-time enforcement — any code path that calls getSecret before patchReadSettings() is invoked will fail silently until hit in production. A cleaner design would be to accept the decrypt function as a constructor parameter or via a factory (e.g. new ReadSettings({ getSecret: ... })), keeping the dependency explicit and testable without side effects.


packages/shared/src/utils/patchReadSettings.js:10: getSecret retrieves the value and checks it's a non-empty string, but never calls isEncryptedSecret() before attempting to decrypt. If a setting path was previously stored as plaintext (before being marked secret: true in the schema), decryptSecret will throw 'Invalid encrypted secret format' — a confusing error that doesn't indicate the root cause. Adding an isEncryptedSecret guard would give a clearer error ('setting is not encrypted; re-save it via the admin UI to encrypt it') and prevents any confusion with legitimate non-secret string values at that path.

getConfigSecret('crypto.settingsPsk') reads and decrypts the PSK from disk on every call to getSecret. Because getSecret is called per-credential per integration run, this causes repeated key-file reads under normal operation. The PSK should be cached (e.g. a module-level promise) after the first successful read so subsequent calls don't incur repeated I/O.


packages/web/app/views/administration/settings/components/SettingInput.jsx:88: SECRET_PLACEHOLDER is redefined locally as '••••••••' instead of importing it from @tamanu/settings where it is now exported. The frontend logic value === SECRET_PLACEHOLDER and isSecretPlaceholder comparisons will silently break if the server-side constant ever changes, causing secret fields to appear as user-edited when they're not, and potentially sending placeholder values to the server on save. Import the shared constant instead.

SECRET_PLACEHOLDER ('••••••••') is hardcoded locally in the frontend rather than imported from @tamanu/settings where the canonical value is defined. If the value in @tamanu/settings is ever changed, the backend's placeholder check in the PUT /settings handler (entry.value !== SECRET_PLACEHOLDER) won't match what the frontend sends, causing the unchanged placeholder to be encrypted and stored as a new secret value — silently corrupting any secret the admin 'saves' without changing. Import the constant from the shared package.

SECRET_PLACEHOLDER = '••••••••' is re-defined locally here even though it is already exported from @tamanu/settings. Import it directly so the two values cannot drift apart: import { SECRET_PLACEHOLDER } from '@tamanu/settings';.

@passcod
Copy link
Copy Markdown
Member Author

passcod commented Mar 11, 2026

Closing for now, need to rethink the architecture a bit. The concept is sound but the implementation is brittle.

@passcod passcod closed this Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants