Skip to content

fix: fail the admin boot when /api/config does not answer - #921

Merged
danieltruong merged 1 commit into
developfrom
fix/config-fetch-hardening
Aug 14, 2026
Merged

fix: fail the admin boot when /api/config does not answer#921
danieltruong merged 1 commit into
developfrom
fix/config-fetch-hardening

Conversation

@danieltruong

Copy link
Copy Markdown
Collaborator

What

Three changes that share ConfigService, main.ts and the Dockerfile:

  1. A failed /api/config fetch now fails the boot instead of being swallowed.
  2. /admin/env.js stops being served with a one-year immutable cache.
  3. The build fails if the configEndpoint rewrite did not take.

Paired with bcgov/eagle-api#839, which stops a config failure being cached for a minute.

1. Hard-fail the fetch

fetchRemoteConfig() caught its own failure, logged, and carried on with env.js. main.ts already awaits configService.init() strictly before keycloakService.init(), so a throw means Keycloak is never constructed and nobody is sent to an identity provider. The ordering was right; only the swallow was wrong.

What the swallow costs, per environment:

  • prodenv.js is the eagle-admin-env-js ConfigMap, mounted subPath over the image's copy, and it holds only configEndpoint and KEYCLOAK_CLIENT_ID. Verified live. So there is no fallback at all: Keycloak gets constructed with url: undefined and the console breaks silently.
  • dev / test — the image's env.js supplies https://dev.loginproxy.gov.bc.ca/auth. Worse than nothing: staff authenticate against the dev identity provider, the sysadmin check at keycloak.service.ts:130-134 is decided by dev's role assignments against production data, and BANNER_COLOUR/ENVIRONMENT still read "dev" so the console looks like dev while writing to prod. One logger.error in a browser console is the only trace.

A failed boot is the cheaper outcome, so main.ts gains a .catch that logs and replaces app-root with one role="alert" sentence. Two details there:

  • The message is cause-agnostic ("The EPIC admin console could not start"). keycloak.service.ts:98 calls reject() with no argument, so a loginproxy outage would otherwise print undefined under a message blaming config.
  • It replaces app-root rather than appending. index.html ships a placeholder spinner inside it and nothing else removes it, so appending would leave the page saying "loading" and "could not start" at once.

The 200 is guarded too

Transport-level handling is not enough. KEYCLOAK_URL and KEYCLOAK_REALM both default to null in eagle-api's Config model, the controller hydrates rather than .lean()s, and its payload filter skips undefined but not null — so a partially seeded document is a valid 200 whose nulls merge over env.js and construct Keycloak with url: null. Same failure, no error status to catch it by. Hence the explicit check before the merge.

KEYCLOAK_CLIENT_ID preservation stays. It is not redundant: the live dev, test and prod payloads all serve eagle-api-console, and that line is the only thing stopping admin authenticating as the API's client.

Why admin and not eagle-public

eagle-public deliberately keeps its swallow. It is unauthenticated — no Keycloak, no withCredentials in the tree — and every env.js default it falls back to is still correct (API_PATH='/api', ANALYTICS_API_URL='/analytics', SEARCH_API_PATH='' falling back to eagle-api). Only ENVIRONMENT goes stale, which is cosmetic. Hard-failing it would turn a survivable eagle-api blip into an outage of projects.eao.gov.bc.ca and buy nothing.

The asymmetry is the point, not an inconsistency.

2. /admin/env.js was cached for a year

Verified live: curl -sI …/admin/env.jsmax-age=31536000, public, immutable. The no-cache rule was location = /env.js nested inside location / { alias /tmp/app/dist/; }, but requests arrive as /admin/env.js, so the exact match never fired and the sibling ~* \.(js|css|…)$ block won. Returning staff kept a year-old file — which, after this PR, would be a year-old configEndpoint.

Now location ~ ^/(admin/)?env\.js$. One caveat worth knowing at review time: nginx picks the first matching regex in file order, whereas an exact match is order-independent. This block must stay declared above the static-asset regex; moving it below silently restores the one-year cache. Noted in the wiki.

Tested against a real nginx:1.27-alpine using this Dockerfile's own config:

Path Before After
/admin/env.js max-age=31536000, immutable no-cache, no-store, must-revalidate
/admin/styles.css 1y immutable 1y immutable, unchanged
/admin/index.html, /admin/ 200 200, unchanged

3. Build guard

sed exits 0 when it matches nothing, so renaming configEndpoint would ship an image running production on baked-in dev values with a green build. The build now greps the built copy, which additionally catches env.js dropping out of the angular.json assets list — that ships no env.js at all, and in admin means KEYCLOAK_ENABLED undefined, falsy, every user bounced to /login.

The sed is also anchored on the full window.__env. assignment so it and the guard reference an identical string: rename the key and both fail together.

Verified by building the image (passes) and by building a throwaway copy with the key renamed (fails, with the message).

Backwards compatibility

Safe to deploy alone, ahead of the cutover, in any environment.

The new throw is dormant until cutover: /api/config is currently answered by nginx from a file on local disk, so it effectively cannot fail while the pod is up. When it does become live, it needs KEYCLOAK_URL and KEYCLOAK_REALM — confirmed present in dev and test rproxy-config, confirmed by live curl in prod, and emitted unconditionally by eao-nginx/helm/rproxy/templates/configmap.yaml:18-19 so a values omission cannot drop them.

Tests

New src/app/services/config.service.spec.ts, 6 cases: non-2xx rejects; network error rejects; a 200 missing KEYCLOAK_URL rejects and no merge happens; a 200 with KEYCLOAK_URL: null rejects; configEndpoint: false does not fetch and resolves (this is what keeps local dev working); and a server-sent eagle-api-console does not overwrite eagle-admin-console.

Lint clean, 49 passing.

The fetch swallowed its failure and carried on with env.js. In prod that file
is a ConfigMap holding only configEndpoint and KEYCLOAK_CLIENT_ID, so a
swallowed failure builds Keycloak with no URL at all; in dev and test the
image's env.js supplies the dev loginproxy, so staff would be sent to the
wrong identity provider with the sysadmin check decided by dev's roles and
the banner still reading "dev". init() is already awaited before Keycloak
starts, so a throw means no IdP is ever contacted.

A 200 is guarded too: KEYCLOAK_URL and KEYCLOAK_REALM both default to null in
the model and the controller hydrates rather than leans, so a half-seeded
document is a valid response whose nulls would clobber working values.

eagle-public deliberately keeps its swallow — it is unauthenticated and every
env.js default it falls back to is still correct.

Two related fixes here because they share the file: /admin/env.js was served
immutable for a year, since `location = /env.js` never matches a request
arriving as /admin/env.js and the static-asset regex won instead; and the
build now greps the built env.js, because sed exits 0 when it matches nothing.
@danieltruong
danieltruong merged commit 413b395 into develop Aug 14, 2026
4 checks passed
@danieltruong
danieltruong deleted the fix/config-fetch-hardening branch August 14, 2026 17:46
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