> ## Documentation Index
> Fetch the complete documentation index at: https://docs.energy.nlead.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> How callers are authenticated, what each credential may do, and how it is recorded

The bridge sits between a customer's forecast data and a market submission
that cannot be taken back after gate closure. Its security model is built
around three questions: *who is calling*, *what may they do*, and *what
does the record show afterwards*.

## Credentials

Two credential types are accepted. Endpoints require a **scope**, never a
particular credential, so a caller can move from one to the other without
an endpoint change or a flag day.

<CardGroup cols={2}>
  <Card title="API key" icon="key">
    `X-API-Key: <secret>`

    Named per caller, several per scope, compared in constant time.
    The default for machine callers today.
  </Card>

  <Card title="OIDC bearer token" icon="id-badge">
    `Authorization: Bearer <jwt>`

    RS256, verified against the issuer's JWKS. For OAuth2
    client-credentials clients (Auth0 machine-to-machine).
  </Card>
</CardGroup>

### Scopes

| Scope      | Grants                                                                                      | Held by              |
| ---------- | ------------------------------------------------------------------------------------------- | -------------------- |
| `external` | Forecast ingestion and the data-delivery endpoints                                          | The customer         |
| `process`  | The pipeline steps, the orchestrator and the console — **and** everything `external` grants | The service operator |

A credential that authenticates but lacks the scope is refused with `403`,
distinct from the `401` of a missing or invalid credential. The distinction
matters when reading the log: one is a misconfigured client, the other is
someone knocking.

### Named keys and rotation

A key is configured as `name:secret`, and several may be active per scope:

```bash theme={null}
EXTERNAL_API_KEYS="cloover:s3cr3t-a,partner-b:s3cr3t-b"
PROCESS_API_KEYS="orchestrator:s3cr3t-c"
```

The name is what appears in the event log — the log says *cloover* posted a
forecast, not merely that "a valid key" did. Rotation needs no downtime:
add the new secret alongside the old, move the caller across, then drop the
old entry.

The single-key form (`EXTERNAL_API_KEY`, `PROCESS_API_KEY`) still works and
takes the scope as its caller name.

### OIDC / OAuth2 client credentials

Set an issuer and an audience and the service additionally accepts bearer
tokens:

| Variable              | Meaning                                                    |
| --------------------- | ---------------------------------------------------------- |
| `OIDC_ISSUER`         | e.g. `https://tenant.eu.auth0.com`                         |
| `OIDC_AUDIENCE`       | The API identifier tokens must carry in `aud`              |
| `OIDC_JWKS_URL`       | Defaults to `<issuer>/.well-known/jwks.json`               |
| `OIDC_SCOPE_EXTERNAL` | Token scope granting `external` (default `forecast:write`) |
| `OIDC_SCOPE_PROCESS`  | Token scope granting `process` (default `process:run`)     |

What is verified: the RS256 signature against the issuer's published keys,
the audience, the issuer, and the expiry. The algorithm is **pinned** — an
`alg: none` or an HMAC-signed token is refused whatever its header claims.
Scopes are read from the `scope` claim or from Auth0's `permissions` claim,
so both plain OAuth2 scopes and Auth0 RBAC work. The signing keys are
cached and refetched when a token presents an unknown `kid`, so the issuer
can rotate its keys without a restart here.

Leaving `OIDC_ISSUER` unset disables the path entirely: a bearer token is
then not a credential at all, it is ignored.

<Note>
  The verification path is covered by tests against a locally generated
  signing key — signature, audience, issuer, expiry, algorithm pinning,
  scope mapping and key rotation. It has not yet been run against a live
  tenant; that needs the issuer and audience of the tenant in question.
</Note>

## Fail closed

A service that cannot tell its callers apart serves nobody. With no API key
and no OIDC issuer configured, every authenticated route answers `503` and
names the missing configuration. `/health` stays open so the platform can
still probe and schedule the container.

`ALLOW_UNAUTHENTICATED=true` turns that off for local development. It is
never set by the deployment template, and the service emits a warning event
on every start when it is on.

## Rate limiting

A token bucket per caller: `RATE_LIMIT_PER_MINUTE` (default 120) sustained,
`RATE_LIMIT_BURST` (default 60) in a burst. Exceeding it returns `429` with
a `Retry-After` header. Setting the rate to `0` disables it.

The bucket lives in the process, so with several replicas the effective
limit is per replica. It exists to keep one runaway client — or a
credential-guessing loop — from consuming the instance; a global quota
belongs in a gateway in front of the service.

## The console stream

The browser's `EventSource` cannot set request headers, which is the usual
reason a key ends up in a query string, where gateways and platform logs
record it in plain text. Instead the console exchanges its key for a
short-lived token:

```
POST /v1/console/stream-token   ->  { "token": "...", "expires_in": 300 }
GET  /v1/console/events/stream?token=...
```

The token is an HMAC over its own expiry, signed with a secret derived from
the configured process keys — stable across replicas and restarts, useless
after five minutes, and valid for the stream endpoint only. The API key is
no longer accepted there.

## What gets recorded

Every state-changing call and every refusal lands in the event log next to
the pipeline events:

| Event           | When                                                                                      |
| --------------- | ----------------------------------------------------------------------------------------- |
| `api_call`      | A `POST`/`PUT`/`PATCH`/`DELETE` succeeded — with caller, path, status and credential type |
| `access_denied` | A `401`, `403` or `429` — with path and status                                            |
| `auth_disabled` | The service started with authentication switched off                                      |

Read-only traffic is not logged; it would bury the pipeline events the
console exists to show. The health probe, the console page and the
stream-token exchange are excluded for the same reason.

## Secrets at rest

By default the Bicep template stores the secrets as Container Apps secrets.
Those are encrypted at rest but readable to anyone who can run
`az containerapp show --show-secrets` on the resource group.

Deploying with `useKeyVault=true` instead creates a Key Vault and a
user-assigned managed identity, writes the secrets there, and leaves only
references on the container app. Access then goes through the identity and
is auditable in the vault's own log.

```bash theme={null}
az deployment group create -g <rg> -f infra/main.bicep \
  -p useKeyVault=true externalApiKeys='cloover:...' processApiKey='...'
```

## Outbound

The call to Engrate uses their scheme — the API key raw in the
`authorization` header, over TLS, per their
[authentication guide](https://docs.engrate.io/guides/authentication). The
key is held as a deployment secret and is never written to the repository,
the audit trail or the event log; the console masks it.

## Hardening

Beyond authentication, the checks that a review asks about:

| Control                                           | Where                                                                                                                                        |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Untrusted text rendered as **text, never markup** | The console builds every row with `textContent`; event messages quote request paths, MaLo ids and other systems' error bodies                |
| **Content-Security-Policy** on the console page   | `default-src 'none'`, `connect-src 'self'` — no external script, nothing exfiltrated                                                         |
| **Request size limit**                            | `MAX_REQUEST_BYTES` (8 MB), checked on the declared length before the body is read                                                           |
| **Atomic writes** to the audit trail              | Written to a temporary file and renamed; a reader never sees a half-written record                                                           |
| **Serialised state updates**                      | The orchestrator and a manual console step can touch one delivery day at once without losing each other's writes                             |
| Wrong credentials are **wrong, not fatal**        | A non-ASCII key or stream token answers `401`, not `500`                                                                                     |
| **Reproducible builds**                           | Dependencies are pinned in `requirements.lock` and installed with `--require-hashes`; a rebuilt image contains what the tested one contained |

### Dependencies

`pyproject.toml` states what the service is compatible with;
`requirements.lock` and `requirements-dev.lock` state what it is actually
built and tested against — every package pinned to an exact version and
verified by hash, transitive dependencies included. The image installs from
the lock with `--require-hashes`, so a tampered or substituted package
fails the build rather than reaching production.

```bash theme={null}
./scripts/lock_dependencies.sh            # apply range changes, keep pins
./scripts/lock_dependencies.sh --upgrade  # deliberately take newer releases
pytest                                    # then prove it still works
```

CI builds the image, starts it, and runs the whole suite **inside it** —
against the interpreter that actually ships, not the runner's. Without that,
a change to the base image passes every check while being completely
unexercised, which is the most convincing kind of green there is.

CI also regenerates the locks and fails if they differ from what is
committed, so the pins cannot drift away from the ranges unnoticed. Without
`--upgrade` existing pins are kept, so that check does not turn red merely
because something was published upstream this morning.

### Keeping the pins moving

Pinning freezes the versions, which is the point — and it also means a
patched dependency no longer arrives on its own. Two mechanisms cover that:

| What                                         | How                                                                                                                                                    |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Python packages                              | A weekly workflow runs the lock script with `--upgrade`, **runs the test suite against the result**, and opens a pull request saying whether it passed |
| GitHub Actions, and the container base image | Dependabot, weekly                                                                                                                                     |

Python is deliberately not left to Dependabot: it detects requirements
files by a `*.txt` glob and regenerates pip-compile output from a `.in`
input, neither of which matches a hash-verified `.lock` compiled from
`pyproject.toml`. It would bump a pin and leave the hashes stale — a pull
request that cannot build. The workflow uses the project's own script, so
what lands is a correct lock or a clearly-labelled failure.

## Known limits

Stated plainly, because a security page that lists only strengths is not
worth reading:

* The rate limit is per replica, not global.
* The console is served by the same app as the API; it is protected by the
  process scope, and anyone holding that credential can change runtime
  configuration.
* The audit trail and event log are files on an Azure Files share; their
  integrity depends on the storage account's access control, and they are
  not signed or append-only at the storage layer.
* The ACR pull still uses the registry's admin password rather than the
  managed identity.
