DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Node.js Healthtech Credentials: Project Scopes That Survive Staff Rotation

A healthtech access review is useless if nobody can tell what one leaked credential can reach. Short answer: group API credentials by deployed project and environment, assign each credential to a team role rather than a developer, and make rotation evidence part of the same inventory that reviewers sign.

The important unit is the blast radius. A monorepo is a source-control shape, not a security boundary. If three services happen to share a repository, that doesn't justify letting the appointment worker read the claims export or letting a local test token reach production.

This choice adds some plumbing. Good. Credential plumbing should be boring, explicit, and easy to delete when a project disappears.

How should a Node.js monorepo group API credentials by project for ownership rotation?

Start with the runtime project, not the person who created the key. For this example, imagine one repository containing appointment-reminders, eligibility-sync, and claims-export. Each deploys independently. Each gets a distinct credential per external integration and per environment. Human maintainers can change; the project identity stays put.

That gives an inventory row a useful shape: project, environment, external system, credential reference, owner group, approver group, and rotation metadata. The reference points to a secret manager entry. It is not the secret. A reviewer can now answer a concrete question: "If this reference is exposed, which workload and which external account must we contain?"

Don't stop at directory ownership. A CODEOWNERS entry can route a pull request, but it doesn't prove who may retrieve a production secret, who approved that access, or whether a departed maintainer still owns the rotation process. Keep repository review, secret access, and runtime authorization as separate controls, then connect them with stable project and team identifiers.

The grouping rule I use as a design test is deliberately plain:

Boundary New credential? Reason
Different deployed project Yes A compromise should not cross workload boundaries
Production versus staging Yes Test activity should not carry production authority
Different external integration Yes Revocation should affect one dependency
New developer on the same owner team No People receive role-based access to the reference
Package that never deploys by itself Usually no A library has no independent runtime identity

“Usually” matters. I'm not sure there is a universal rule for a package that runs both inside an API and as an ad hoc operations tool; deployment topology and permissions resolve that ambiguity. If the two execution paths need different authority, split their credentials even if the code lives in one package.

This is the core correction: developer ownership selects the responsible group, while project scope defines the credential.

Build the smallest credential boundary

The first implementation does not need a framework or an SDK. It needs one typed registry, one secret-provider interface, and a rule that application code asks for a project binding rather than reading arbitrary environment variables. That keeps config surface area small while leaving the storage mechanism replaceable.

Here is a compact TypeScript version. The example values are identifiers only; none is a usable secret.

type Environment = "staging" | "production";

type CredentialBinding = {
  project: "appointment-reminders" | "eligibility-sync" | "claims-export";
  environment: Environment;
  integration: string;
  secretRef: string;
  ownerGroup: string;
  approverGroup: string;
  rotationDays: number;
};

const bindings = [
  {
    project: "appointment-reminders",
    environment: "production",
    integration: "messaging",
    secretRef: "secrets/appointment-reminders/production/messaging",
    ownerGroup: "care-communications-oncall",
    approverGroup: "security-operations",
    rotationDays: 90,
  },
  {
    project: "eligibility-sync",
    environment: "production",
    integration: "payer-gateway",
    secretRef: "secrets/eligibility-sync/production/payer-gateway",
    ownerGroup: "coverage-platform-oncall",
    approverGroup: "security-operations",
    rotationDays: 60,
  },
] as const satisfies readonly CredentialBinding[];

interface SecretProvider {
  read(reference: string): Promise<string>;
}

export async function credentialFor(
  provider: SecretProvider,
  project: CredentialBinding["project"],
  environment: Environment,
  integration: string,
): Promise<string> {
  const match = bindings.find(
    (item) =>
      item.project === project &&
      item.environment === environment &&
      item.integration === integration,
  );

  if (!match) {
    throw new Error(
      `No credential binding for ${project}/${environment}/${integration}`,
    );
  }

  return provider.read(match.secretRef);
}
Enter fullscreen mode Exit fullscreen mode

There is no global API_KEY. That's intentional.

In CI, inject only the secret-provider identity that one deployment needs. Do not export every project key at the monorepo root and expect package filters to become an authorization system. A build command can be misconfigured; the credential broker or secret manager should still deny references outside that workload's policy.

The same registry can produce the access-review document. Keep raw values out of it. The reviewer needs scope, accountable groups, dates, and status—not authentication material.

type RotationEvidence = {
  secretRef: string;
  activeVersion: string;
  rotatedAt: string;
  nextReviewAt: string;
};

type ReviewRow = CredentialBinding & {
  activeVersion: string;
  rotatedAt: string;
  nextReviewAt: string;
};

export function buildAccessReview(
  evidence: readonly RotationEvidence[],
): ReviewRow[] {
  return bindings.map((binding) => {
    const record = evidence.find(
      (candidate) => candidate.secretRef === binding.secretRef,
    );

    if (!record) {
      throw new Error(`Missing rotation evidence for ${binding.secretRef}`);
    }

    return { ...binding, ...record };
  });
}
Enter fullscreen mode Exit fullscreen mode

Failing on missing evidence is better than printing an attractive but incomplete report. The build should also reject duplicate tuples of project, environment, and integration. Those two checks turn the registry into a reviewable contract instead of another config file everyone quietly ignores.

Make rotation an ownership transfer, not a calendar event

A rotation process has two jobs: replace credential material and prove that responsibility did not vanish when people moved teams. A schedule handles only the first half.

Model rotation as a short state transition. Create the replacement under the same stable project binding, allow the workload to read the intended version, deploy it, verify authenticated requests without logging authorization headers, revoke the prior version, and record the new version identifier and timestamps. If an external service permits only one active credential, the deployment plan needs a coordinated maintenance window; pretending every provider supports overlap creates brittle automation.

Ownership rotation is different. Change the owner group and approver group through review, verify that each group still has active members through the organization's identity process, and leave the project binding intact. No credential should be named after alex, sam, or whoever happened to click “create” two years ago. Names age badly.

For the signer, generate one row per binding and ask for explicit decisions: retain, rotate, reduce scope, or revoke. Include evidence that can be checked without exposing the value: secret reference, version identifier, last rotation time, next review time, workload identity, owner group, approver group, and the external permission set. The exact permission vocabulary depends on the external API, so preserve its native scopes rather than translating everything into a vague read or write label.

Audit events should answer who requested access, which stable identity retrieved which reference, when the policy changed, and who approved that change. They should never contain the credential. Also redact authorization headers and query parameters at the logging boundary; relying on every caller to remember redaction is config bloat disguised as flexibility.

Test the ugly paths. Remove a maintainer from the owner group and confirm the runtime still works while that person loses retrieval access. Disable the staging binding and confirm production remains untouched. Rotate one integration and confirm sibling projects do not redeploy. Then attempt an out-of-scope secret reference from each CI identity and require a denial. These tests measure the blast radius directly.

What I would change when the monorepo scales

Once the registry grows, I would generate it from a schema-validated manifest and make policy checks mandatory in pull requests. The generated review artifact should be diffable, because a reviewer can reason about “claims-export gained scope X” much faster than a fresh spreadsheet with 400 rows.

I would also separate control-plane access from runtime access. Developers may need metadata and audit visibility without permission to retrieve production values. Deployment identities need narrow read access without permission to edit owner groups. Rotation automation needs permission to create and retire versions, but it should not inherit broad application permissions.

Don't centralize all reads behind one all-powerful broker credential unless the broker enforces project scope with independently authenticated workload identities. Otherwise the architecture has merely moved the shared key one hop away and made its blast radius harder to see.

The catch is operational cost. Per-project, per-environment, per-integration credentials create more inventory rows and more rotation events. This pattern is not suitable when the upstream system offers only one account-wide credential and no narrower authorization; isolate that integration behind a dedicated service, restrict which projects can call it, and treat the upstream account as the blast-radius boundary. For a tiny, single-deployment repository, a separate key for every internal package is ceremony with no isolation benefit. Stick with one deployment-scoped binding until packages become independent workloads or acquire different permissions.

The final acceptance test is blunt: an access reviewer should be able to sign one row while rejecting another, and an operator should be able to revoke the rejected row without taking unrelated healthtech workflows down. If the inventory or deployment design cannot do that, the keys are still grouped for convenience rather than containment.

References

Top comments (0)