Skip to content

Commit a615349

Browse files
Auth: recognise known OpenFn clients and use the keys we hold for them (#550)
* chore(platform): pin Bun and switch to a text-format lockfile Pin the Bun version in .tool-versions and replace the binary bun.lockb with the text-format bun.lock so dependency changes show up in diffs. Update CI and the Dockerfile to match. * feat(platform): add a Postgres connection pool and migration runner Add a shared pg pool (db/index.ts) and a migration runner (db/migrate.ts) that applies the SQL files in platform/migrations in order. Includes the first migration, which creates the lightning_clients table that client auth reads from. * feat(auth): authenticate /services/* against per-client credentials Add an always-on authenticate hook on /services/* that maps the api_key in the request body to a Lightning client via its SHA-256 in the lightning_clients table. On a known match the inbound key is never forwarded to the LLM: it is swapped for the client's stored anthropic_api_key, or stripped to fall back to the global key when that column is null. An unknown key is forwarded only if it is sk-ant-shaped, otherwise rejected with 401. Internal Apollo-to-Apollo calls are exempt via a per-process token injected into each Python child. Lookups are cached in memory with a single-flight, stale-while-revalidate refresh. Stored keys may be AES-256-GCM encrypted at rest. When the clients DB is unreachable the hook fails closed with 503, and decrypt, refresh and token-mismatch failures are reported to Sentry. * feat(auth): add a client provisioning CLI Add a client CLI (bun run client) for provisioning Lightning clients: create, list, rotate and remove rows in lightning_clients, reading secrets from stdin and encrypting them at rest. Add a migrate script to run pending migrations. * test(auth): cover the auth hook, key resolver, CLI and startup Add tests for the authenticate hook and key resolution, the client CLI (store, commands, secret reading), token hashing against fixed vectors, the encryption helper, the migration runner, and server startup. Extend the existing server tests to inject a configured auth instance. * docs(auth): document client auth, the dual-DB setup and env vars Document the client auth model in the root README and the platform/src/auth README, note the APOLLO_CLIENTS_DB_URL / POSTGRES_URL fallback and the APOLLO_ENC_KEY / APOLLO_INTERNAL_TOKEN variables in .env.example and CLAUDE.md, and add the changeset. * ci(docker): only move :latest for final releases Build the tag list in the manipulate-tag step and append openfn/apollo:latest only when the version has no hyphen. Pre-release tags (e.g. 1.4.0-pre.0) now push only their versioned image and leave :latest pointing at the last final release. * version: 1.4.0 * remove comments --------- Co-authored-by: Joe Clark <jclark@openfn.org>
1 parent c8214bd commit a615349

41 files changed

Lines changed: 4022 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
1-
OPENAI_API_KEY=sk-YOUR-API-KEY-HERE
1+
# Instance auth: /services/* is always gated on the api_key callers send, looked
2+
# up in lightning_clients via APOLLO_CLIENTS_DB_URL (falls back to POSTGRES_URL).
3+
# A known client swaps in its stored Anthropic key; an unknown sk-ant- key is
4+
# forwarded. See platform/src/auth/README.md.
5+
6+
# Database holding the lightning_clients credentials table. Leave unset locally to
7+
# share POSTGRES_URL (one DB for everything). In production point this at a separate
8+
# credentials DB so client secrets don't co-locate with the docs data. The TS auth
9+
# code, `bun run migrate`, and the `client` CLI all resolve this var; the Python docs
10+
# services always use POSTGRES_URL.
11+
# APOLLO_CLIENTS_DB_URL=postgresql://localhost:5432/apollo_clients
12+
13+
# Optional at-rest encryption for stored client Anthropic keys. Base64 of 32 bytes
14+
# (openssl rand -base64 32). See platform/src/auth/README.md.
15+
# APOLLO_ENC_KEY=
16+
17+
# Shared secret for internal Apollo-to-Apollo apollo() calls. In production set
18+
# this to the SAME value across all processes — it is what lets self-calls through
19+
# the gate, and the global ANTHROPIC_API_KEY is a dev-only fallback. If unset,
20+
# each process mints its own token, which only works single-process-per-host.
21+
# APOLLO_INTERNAL_TOKEN=
22+
223
ANTHROPIC_API_KEY=sk-YOUR-API-KEY-HERE
24+
25+
OPENAI_API_KEY=sk-YOUR-API-KEY-HERE
326
PINECONE_KEY=YOUR-API-KEY-HERE
4-
POSTGRES_URL=POSTGRES_URL=postgresql://localhost:5432/apollo_dev
27+
POSTGRES_URL=postgresql://localhost:5432/apollo_dev
528
SENTRY_DSN=YOUR-API-KEY-HERE
629
GITHUB_TOKEN=KEY
730

.github/workflows/dockerize.yaml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ jobs:
2424
echo Docker Tag: $DOCKER_TAG
2525
2626
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
27+
28+
# Only move :latest for final releases. A version with a hyphen
29+
# (e.g. 1.4.0-pre.0) is a pre-release and must not repoint latest.
30+
{
31+
echo "DOCKER_TAGS<<EOF"
32+
echo "openfn/apollo:v${DOCKER_TAG}"
33+
[[ "$DOCKER_TAG" != *-* ]] && echo "openfn/apollo:latest"
34+
echo "EOF"
35+
} >> $GITHUB_ENV
2736
- name: Set up QEMU
2837
uses: docker/setup-qemu-action@v4
2938
- name: Set up Docker Buildx
@@ -38,6 +47,4 @@ jobs:
3847
with:
3948
context: .
4049
push: ${{ github.event_name != 'pull_request' }}
41-
tags: |
42-
openfn/apollo:latest
43-
openfn/apollo:v${{ env.DOCKER_TAG }}
50+
tags: ${{ env.DOCKER_TAGS }}

.github/workflows/unit-tests.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,31 @@ jobs:
3333
runs-on: ubuntu-latest
3434
timeout-minutes: 5
3535

36+
services:
37+
postgres:
38+
image: postgres:16
39+
env:
40+
POSTGRES_USER: apollo
41+
POSTGRES_PASSWORD: apollo
42+
POSTGRES_DB: apollo_test
43+
ports:
44+
- 5432:5432
45+
options: >-
46+
--health-cmd pg_isready
47+
--health-interval 10s
48+
--health-timeout 5s
49+
--health-retries 5
50+
51+
env:
52+
POSTGRES_URL: postgres://apollo:apollo@localhost:5432/apollo_test
53+
3654
steps:
3755
- uses: actions/checkout@v6
3856

3957
- name: Set up Bun
4058
uses: oven-sh/setup-bun@v2
59+
with:
60+
bun-version-file: .tool-versions
4161

4262
- name: Install dependencies
4363
run: bun install --frozen-lockfile

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
python 3.11.9
2-
bun 1.1.13
2+
bun 1.3.14

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# apollo
22

3+
## 1.4.0
4+
5+
### Minor Changes
6+
7+
- b3f8f38: Add authorisation to all service routes.
8+
39
## 1.3.3
410

511
### Patch Changes

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,47 @@ TypeScript) service modules.
4545
- **Service discovery**: `platform/src/util/describe-modules.ts` - Auto-mounts
4646
any `services/<name>/` directory not starting with `_`. Detects service type
4747
by checking for `<name>.py` (Python) or `<name>.ts` (TypeScript) index file.
48+
- **Instance auth** (`platform/src/auth/`): `/services/*` uses a
49+
map-if-known-else-forward auth hook that is always active (no flag). The auth surface
50+
is split into three named concerns: the client-credential authenticate hook and Anthropic-key
51+
resolver on the injectable `InstanceAuth` class (`instance-auth.ts`), the
52+
internal-call exemption (`internal-token.ts`), and the shared `hashToken`
53+
(`hash.ts`). The credential
54+
is the `api_key` the caller (Lightning) already sends in the request body —
55+
there is no bearer token and no Lightning-side change. Its SHA-256 is looked up
56+
in the `lightning_clients` table via `APOLLO_CLIENTS_DB_URL` (falling back to
57+
`POSTGRES_URL` when unset, so local dev needs only the one var; staging/prod point
58+
`APOLLO_CLIENTS_DB_URL` at a separate credentials DB). The inbound `api_key` is
59+
treated purely as a credential and is **never** forwarded to the LLM on a known
60+
match: it is replaced with the matched client's stored `anthropic_api_key`, or
61+
stripped (falling back to the global `ANTHROPIC_API_KEY`) when that column is
62+
`NULL`. An *unknown* key is forwarded unchanged only if it is `sk-ant-`-shaped
63+
(bring-your-own key); an unknown non-`sk-ant-` key is rejected with `401`
64+
(likely a Lightning credential that must not leak to the LLM). No `api_key`
65+
falls back to the global key. The resolver (`InstanceAuth.resolveKey`) returns a
66+
tagged `KeyResolution` (`useKey`/`useGlobal`/`forward`/`passthrough`) dispatched
67+
by a named switch in `services.ts`. The stored
68+
`anthropic_api_key` may be plaintext or AES-256-GCM-encrypted (`enc:v1:`
69+
values, decrypted with `APOLLO_ENC_KEY`; see
70+
`platform/src/util/instance-key-crypto.ts` and the `client` CLI at
71+
`platform/src/auth/client/`). Lookups are
72+
cached in memory (~60s TTL), so the DB is hit at most once per minute per
73+
process, not per request. The refresh is single-flight with
74+
stale-while-revalidate, so a burst of requests at the TTL boundary shares one
75+
DB read (cold start awaits it; a warm-but-stale cache is served while one
76+
background refresh runs) rather than stampeding the DB. If the table can't be
77+
reached, known-client swaps don't resolve and callers degrade to the
78+
shape-checked forward path (the same `sk-ant-` rule applies; it does not
79+
blanket-reject). The auth hook is scoped to
80+
`/services/*`, so health/root endpoints outside that group are unaffected.
81+
Internal Apollo-to-Apollo `apollo()` calls are exempt via a per-process
82+
internal token (`APOLLO_INTERNAL_TOKEN`, minted at startup; `bridge.ts` injects
83+
it into each spawned Python child's env via `getInternalToken()`, and
84+
`services/util.py` echoes it back). This replaces the old loopback exemption so a
85+
co-located Lightning is still required to authenticate. The authenticate hook and resolver
86+
live on a single `InstanceAuth` instance constructed in `server.ts`; tests build
87+
their own configured instance rather than poking module globals. Provisioning
88+
lives in `platform/src/auth/`.
4889

4990
### Services Architecture
5091

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ FROM python:3.11-bullseye
33
WORKDIR /app
44

55
COPY ./pyproject.toml ./poetry.lock ./
6-
COPY ./package.json bun.lockb ./
6+
COPY ./package.json bun.lock ./
7+
COPY ./.tool-versions ./
78
COPY ./tsconfig.json ./
89
COPY ./path.config ./
910

@@ -20,7 +21,8 @@ RUN python -m pipx install poetry
2021
ENV PATH="${PATH}:/root/.local/bin/"
2122
RUN poetry install --only main --no-root
2223

23-
RUN curl -fsSL https://bun.sh/install | bash
24+
RUN BUN_VERSION="$(awk '/^bun / {print $2}' .tool-versions)" \
25+
&& curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
2426
ENV PATH="${PATH}:/root/.bun/bin/"
2527

2628
RUN bun install

0 commit comments

Comments
 (0)