Skip to content

feat(resolver): per-host auth for remote template fetches#731

Merged
jdrouet merged 6 commits into
mainfrom
feat/resolver-per-host-auth
Jun 13, 2026
Merged

feat(resolver): per-host auth for remote template fetches#731
jdrouet merged 6 commits into
mainfrom
feat/resolver-per-host-auth

Conversation

@jdrouet

@jdrouet jdrouet commented Jun 13, 2026

Copy link
Copy Markdown
Owner

lets operators bind auth to specific upstream hosts when catapulte fetches remote mjml templates. each entry is pinned to an exact host, so a token never gets sent to the wrong upstream (and the host still has to be in ALLOWED_DOMAINS to be fetched at all).

started out as bearer-per-host:

CATAPULTE_RESOLVER_TOKENS=github
CATAPULTE_RESOLVER_TOKEN_GITHUB_HOST=raw.githubusercontent.com
CATAPULTE_RESOLVER_TOKEN_GITHUB_BEARER_TOKEN=...

but bearer isn't enough for everyone (#729): private github repos go through api.github.com and need Accept: application/vnd.github.raw on top of the bearer, and gitlab doesn't use bearer at all, it's a PRIVATE-TOKEN header. so each entry can now carry arbitrary static headers too. bearer stays as sugar for a single Authorization header.

github through the api:

CATAPULTE_RESOLVER_TOKEN_GITHUB_HOST=api.github.com
CATAPULTE_RESOLVER_TOKEN_GITHUB_BEARER_TOKEN=ghp_...
CATAPULTE_RESOLVER_TOKEN_GITHUB_HEADERS=Accept
CATAPULTE_RESOLVER_TOKEN_GITHUB_HEADER_ACCEPT_VALUE=application/vnd.github.raw

gitlab (no bearer):

CATAPULTE_RESOLVER_TOKEN_GITLAB_HOST=gitlab.com
CATAPULTE_RESOLVER_TOKEN_GITLAB_HEADERS=PRIVATE-TOKEN
CATAPULTE_RESOLVER_TOKEN_GITLAB_HEADER_PRIVATE_TOKEN_VALUE=glpat_...

the value var fragment is just the header name uppercased with dashes turned into underscores (PRIVATE-TOKEN -> PRIVATE_TOKEN).

header values and the bearer are marked sensitive so they stay out of logs. config fails fast on the obvious mistakes: duplicate hosts, a header listed with no value, two header names that collapse to the same fragment, a bearer plus an explicit authorization header, or an entry with a host but nothing to send.

closes #729

notes:

  • all contained in the outbound-resolver adapter + readme, binary wiring is unchanged.
  • branch still carries the pending dependabot bumps, they'll drop out of the diff once those land on main.

jdrouet added 6 commits June 13, 2026 19:02
Operators can now bind a different bearer token to each upstream host
that templates are fetched from, configured through named entries:

  CATAPULTE_RESOLVER_TOKENS=github,gitlab
  CATAPULTE_RESOLVER_TOKEN_GITHUB_HOST=raw.githubusercontent.com
  CATAPULTE_RESOLVER_TOKEN_GITHUB_BEARER_TOKEN=...

Each request only carries the token bound to its exact host, so a token
is never sent to a different upstream. The host must also be in
ALLOWED_DOMAINS to be fetched at all. Token values are marked sensitive
and validated at build time; duplicate hosts and missing entry vars fail
fast. Config parsing mirrors the SMTP multi-sender from_lookup idiom.

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
ResolverAuthEntry gains a `headers: Vec<(String, String)>` field and
`bearer_token` becomes `Option<String>`. The adapter builds one
HeaderMap per host (bearer under Authorization if present, then each
generic header via append so repeated names accumulate). Conflicts
between a bearer_token and an explicit Authorization header are
rejected at new(). resolve_remote() now attaches the full HeaderMap
instead of a single Authorization value.

Update existing tests to the new struct shape and add:
- generic_header_attached_to_matching_host
- new_with_bearer_and_explicit_authorization_header_conflicts
- new_with_invalid_header_name_returns_err

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
Bearer token is now optional when at least one header is configured.
New env vars per auth entry:
- _HEADERS: comma-separated list of header name fragments to attach to
  requests to the matching host (e.g. Accept,PRIVATE-TOKEN).
- _HEADER_<FRAGMENT>_VALUE: value for the named header; FRAGMENT is the
  header name uppercased with hyphens replaced by underscores (e.g.
  ACCEPT, PRIVATE_TOKEN). Values are treated as secrets and never
  logged.

Fragment collision guard rejects entries that map two different header
names to the same fragment. Host-but-no-auth guard rejects entries that
list a host but configure neither a bearer token nor any header.

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
Add two new variables to the Template Resolver table:
- TOKEN_<NAME>_HEADERS: comma-separated header names to attach per host.
- TOKEN_<NAME>_HEADER_<FRAGMENT>_VALUE: value for a named header (FRAGMENT
  is the header name uppercased with hyphens replaced by underscores).

Mark TOKEN_<NAME>_BEARER_TOKEN as optional now that headers are supported.
Note the requirement that each entry must configure at least a bearer token
or one header.

Add worked examples: GitHub (bearer + Accept header) and GitLab (PRIVATE-TOKEN
header, no bearer).

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
List all failure modes: invalid header name or value, bearer token
combined with explicit Authorization header, alongside the existing
ones.

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
@jdrouet jdrouet force-pushed the feat/resolver-per-host-auth branch from 2b7a869 to 0e46b17 Compare June 13, 2026 17:03
@jdrouet jdrouet marked this pull request as ready for review June 13, 2026 19:36
@jdrouet jdrouet merged commit 49d4dc3 into main Jun 13, 2026
6 checks passed
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.

allow custom headers on resolver auth entries

1 participant