Skip to content

feat(reporting): TAM-6862: auto-rotate the reporting role secret#10148

Open
dannash100 wants to merge 1 commit into
feat/TAM-6862/tamanu-manages-reporting-rolesfrom
feat/TAM-6862/reporting-secret-rotation
Open

feat(reporting): TAM-6862: auto-rotate the reporting role secret#10148
dannash100 wants to merge 1 commit into
feat/TAM-6862/tamanu-manages-reporting-rolesfrom
feat/TAM-6862/reporting-secret-rotation

Conversation

@dannash100

@dannash100 dannash100 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Changes

Stacked on the reporting-roles PR (#10082). Auto-rotates the per-server reporting/raw role secret: getReportingSecret regenerates it once it passes db.reportingSecretRotationDays (default 90; 0 disables), recording the time in a local system fact. Coordinated by an advisory lock so concurrently-starting central processes converge on one secret; new passwords take effect as each process restarts (existing pooled connections keep working). Mid-run rotation is deliberately avoided — passwords derive from a secret each process caches for its pool, so live rotation would break other processes' new connections until restart.

Auto-Deploy

  • Deploy
Options
  • Artillery load test
  • Seed from closest snapshot
  • Generate fake data
  • More data (20Gi)
  • No facility servers (central-only)
  • No sync (facility tasks scaled to zero)
  • AMD64 architecture (default is arm64)
  • Skip mobile build
  • Always build mobile
  • Stay up for 8 hours
  • Stay up for 24 hours
  • Stay up (no TTL)
  • Build images only (don't deploy)
  • Pause this deploy

Tests

  • Run E2E tests
  • Run DAST scan

Review Hero

  • Run Review Hero
  • Auto-fix review suggestions Wait for Review Hero to finish, resolve any comments you disagree with or want to fix manually, then check this to auto-fix the rest.
  • Auto-fix CI failures Check this to auto-fix lint errors, test failures, and other CI issues.
  • Auto-merge upstream Check this to merge the base branch into this PR, with AI conflict resolution if needed.
  • Save suppressions Check this to capture 👎 reactions on Review Hero comments as suppression rules in .github/review-hero/suppressions.yml. Also runs automatically at the end of any auto-fix run.

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

The reporting/raw role passwords derive from a per-server secret that was
generated once and never changed. Rotate it automatically: getReportingSecret
regenerates the secret once it passes db.reportingSecretRotationDays (default
90), recording the rotation time in a local system fact. Coordinated by an
advisory lock so concurrently-starting central processes converge on one secret;
the new passwords take effect as each process restarts (existing pooled
connections keep working). 0 disables age-based rotation.
@dannash100 dannash100 requested a review from a team as a code owner June 26, 2026 02:16

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 9c94dbf. Configure here.

const existing = await models.LocalSystemSecret.get(FACT_REPORTING_ROLE_SECRET);
const rotatedAt = await models.LocalSystemFact.get(FACT_REPORTING_SECRET_ROTATED_AT);
const rotationDays = config.db?.reportingSecretRotationDays ?? 0;
if (existing && !isReportingSecretStale(rotatedAt, rotationDays)) return existing;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing rotation timestamp disables forever

Medium Severity

After upgrade, servers that already have a reportingRoleSecret but no reportingSecretRotatedAt fact never age-rotate: isReportingSecretStale treats a missing timestamp as not stale, and the early return keeps the old secret without backfilling the fact, so the default 90-day policy never applies on existing deployments.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9c94dbf. Configure here.

const existing = await models.LocalSystemSecret.get(FACT_REPORTING_ROLE_SECRET);
const rotatedAt = await models.LocalSystemFact.get(FACT_REPORTING_SECRET_ROTATED_AT);
const rotationDays = config.db?.reportingSecretRotationDays ?? 0;
if (existing && !isReportingSecretStale(rotatedAt, rotationDays)) return existing;

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

After upgrading from a version that had no FACT_REPORTING_SECRET_ROTATED_AT fact, rotatedAt is null. isReportingSecretStale(null, 90) returns false, so the early-return fires and FACT_REPORTING_SECRET_ROTATED_AT is never written. Every subsequent startup follows the same path — the rotation timestamp is never seeded and auto-rotation silently never activates for any deployment that already had a secret before this feature was deployed.

Fix: when existing is truthy but rotatedAt is null, write the current time as the baseline timestamp before the staleness check:

if (existing) {
  if (!rotatedAt) {
    await models.LocalSystemFact.set(FACT_REPORTING_SECRET_ROTATED_AT, getCurrentDateTimeString());
    rotatedAt = /* the value you just wrote */;
  }
  if (!isReportingSecretStale(rotatedAt, rotationDays)) return existing;
}

Alternatively, treat null rotatedAt as stale (force a one-time rotation on first startup after upgrade) by removing the !rotatedAt guard from isReportingSecretStale.

// keep their cached secret until they restart.
const getReportingSecret = async ({ models, sequelize }) =>
sequelize.transaction(async () => {
await sequelize.query(`SELECT pg_advisory_xact_lock(hashtext('tamanu:reporting-secret'));`);

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

pg_advisory_xact_lock(hashtext(...)) uses a 32-bit hash space (~4 billion values). If any other advisory lock in the system happens to hash to the same int4 value as 'tamanu:reporting-secret', the two will accidentally serialise against each other. Because advisory locks are cluster-global and other packages/extensions may use them, prefer a hardcoded int8 literal (e.g. SELECT pg_advisory_xact_lock(7829301042::bigint)) to guarantee uniqueness with no collision risk. Same concern applies to the existing 'tamanu:reporting-roles' lock on line 67.

@review-hero

review-hero Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary
9 agents reviewed this PR | 1 critical | 1 suggestion | 0 nitpicks | Filtering: consensus 3 voters, 3 below threshold

Below consensus threshold (3 unique issues not confirmed by majority)
Location Agent Severity Comment
packages/database/__tests__/services/reportingSecret.test.js:20 Bugs & Correctness nitpick The test only covers daysAgo(120) >= 90 but not the exact boundary (daysAgo(90) exactly at threshold) or the day-before case (daysAgo(89)). The current >= check is correct but adding a boun...
packages/database/src/services/reporting.js:22 Bugs & Correctness suggestion new Date(rotatedAt) receives an ISO 9075 string (e.g. '2025-01-01 12:00:00', no timezone suffix) from getCurrentDateTimeString(). ECMAScript only guarantees parsing of ISO 8601 with a T sep...
packages/database/src/services/reporting.js:42 Security suggestion getReportingSecret wraps in sequelize.transaction(), but the project uses Sequelize CLS so any outer transaction in the call stack will be joined instead of a new one being started (the inner call ...
Local fix prompt (copy to your coding agent)

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


packages/database/src/services/reporting.js:49: After upgrading from a version that had no FACT_REPORTING_SECRET_ROTATED_AT fact, rotatedAt is null. isReportingSecretStale(null, 90) returns false, so the early-return fires and FACT_REPORTING_SECRET_ROTATED_AT is never written. Every subsequent startup follows the same path — the rotation timestamp is never seeded and auto-rotation silently never activates for any deployment that already had a secret before this feature was deployed.

Fix: when existing is truthy but rotatedAt is null, write the current time as the baseline timestamp before the staleness check:

js if (existing) { if (!rotatedAt) { await models.LocalSystemFact.set(FACT_REPORTING_SECRET_ROTATED_AT, getCurrentDateTimeString()); rotatedAt = /* the value you just wrote */; } if (!isReportingSecretStale(rotatedAt, rotationDays)) return existing; }
Alternatively, treat null rotatedAt as stale (force a one-time rotation on first startup after upgrade) by removing the !rotatedAt guard from isReportingSecretStale.


packages/database/src/services/reporting.js:44: pg_advisory_xact_lock(hashtext(...)) uses a 32-bit hash space (~4 billion values). If any other advisory lock in the system happens to hash to the same int4 value as 'tamanu:reporting-secret', the two will accidentally serialise against each other. Because advisory locks are cluster-global and other packages/extensions may use them, prefer a hardcoded int8 literal (e.g. SELECT pg_advisory_xact_lock(7829301042::bigint)) to guarantee uniqueness with no collision risk. Same concern applies to the existing 'tamanu:reporting-roles' lock on line 67.

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.

1 participant