> ## 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`                                                                      |

## Known limits

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

* The rate limit is per replica, not global.
* Dependencies carry lower bounds but no lockfile, so an image rebuilt
  today may not contain what one built last week contained.
* 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.
