An operator standing at the SCADA console should not complete a Duo push to ask a question, and nobody outside the plant should reach the assistant at all. The ai.yokogawa.tech Caddy block now admits remote_ip 10.0.0.21 (yau-poc-cicore1, static) and returns 403 to everything else. Applied on lin001 2026-08-28; snapshot at ~/Caddyfile.bak-ai-scadaonly-20260828. This also settles why the console could not reach the assistant at all: auth.yokogawa.tech has no pinpoint record on the DC, so a LAN browser got a correct 302 to the portal and then died on DNS. It went unnoticed because the device agents write to Influx over the /api/v2/write MFA bypass and never touch the portal - no browser had ever hit Authelia from inside the VNet. Verified before applying that an IP matcher can work here: Caddy sees real client addresses, and WireGuard peers arrive masqueraded as 172.19.0.6 so they do not match and are refused along with the internet. What this costs, recorded in section 14 as a shortcut and not as a security control: it is an IP allowlist on a flat network with no OT/IT boundary, so anything that can take 10.0.0.21 inherits unauthenticated access; Langfuse traces are now anonymous, so there is no record of who asked what; and the assistant is out of browser reach over the VPN. It is in scope for the section 2 OT/safety review, which is still outstanding. api.yokogawa.tech is unchanged and still fully gated - Phase 9 publishing depends on Remote-User/Remote-Groups and stays there. The now-inert ai.yokogawa.tech entry in the Authelia rule is deliberately left in place so restoring the gate is a Caddy reload rather than an Authelia restart that logs out every user on the host. verify.sh treats 403-from-lin001 as the pass for the deny arm and states plainly that the allow arm can only be proved from cicore1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
7.4 KiB
Markdown
157 lines
7.4 KiB
Markdown
# Authelia access rules — the additions, as text
|
|
|
|
**This file is documentation, not configuration. Never commit the real
|
|
`~/authelia/configuration.yml`, and never generate a replacement for it.**
|
|
It contains the AD bind account, session secrets and the Duo integration for
|
|
every service on the host. The four hostnames below are the only part of it
|
|
this project touches.
|
|
|
|
## What has to change
|
|
|
|
Four hostnames join the existing `HTTPS_UserAccess` `two_factor` rule in
|
|
`~/authelia/configuration.yml`, under `access_control.rules`:
|
|
|
|
| Hostname | Phase | Serves |
|
|
|---|---|---|
|
|
| `lf.yokogawa.tech` | 2 | Langfuse — traces, prompts, eval runs |
|
|
| `cube.yokogawa.tech` | 5 | Cube semantic layer, playground and REST API — **not essential**, see below |
|
|
| `api.yokogawa.tech` | 6 | `ai-api` FastAPI |
|
|
| `ai.yokogawa.tech` | 7 | `ai-web` operator UI — **rule is INERT as of 2026-08-28, see below** |
|
|
|
|
> **`ai.yokogawa.tech` no longer passes through Authelia.** On 2026-08-28 the
|
|
> Caddy block for that hostname dropped `import authelia`: the SCADA console
|
|
> (`10.0.0.21`) reaches the assistant unauthenticated and every other source
|
|
> gets 403. Caddy never issues the forward-auth subrequest, so this domain
|
|
> entry in `configuration.yml` is now dead weight.
|
|
>
|
|
> **It was deliberately left in place. Do not remove it.** While the entry
|
|
> stands, restoring the gate is a one-line Caddyfile edit and a `caddy reload`
|
|
> — no Authelia restart, no all-user logout. Deleting it converts that rollback
|
|
> into a config edit plus a restart that logs out everyone on the host,
|
|
> including whoever is mid-demo, at exactly the moment somebody has decided the
|
|
> assistant needs to be gated again.
|
|
>
|
|
> `api.yokogawa.tech` is unchanged and still gated. Phase 9 depends on it.
|
|
|
|
Phase 9 adds no hostname. It adds one **path-scoped rule** on an existing one —
|
|
see "Phase 9: the document publisher rule" below.
|
|
|
|
The shape of the addition — the domain list on the existing trailing rule gains
|
|
these entries, the policy and subject stay exactly as they already are:
|
|
|
|
```yaml
|
|
access_control:
|
|
rules:
|
|
# ... existing rules unchanged ...
|
|
- domain:
|
|
# ... existing domains unchanged ...
|
|
- lf.yokogawa.tech # added <date>, AI PoC Phase 2
|
|
- cube.yokogawa.tech # added <date>, AI PoC Phase 5
|
|
- api.yokogawa.tech # added <date>, AI PoC Phase 6
|
|
- ai.yokogawa.tech # added <date>, AI PoC Phase 7
|
|
policy: two_factor
|
|
subject:
|
|
- group:HTTPS_UserAccess
|
|
```
|
|
|
|
Add each hostname at the phase that needs it. Every domain added here must also
|
|
have a Caddyfile block with `import authelia` (`caddy/ai-routes.caddy`), and
|
|
every Caddyfile block must have a rule here. One without the other is a hole.
|
|
|
|
**`cube.yokogawa.tech` is the one to drop first** if the host is being tidied or the
|
|
hostname is wanted for something else. Nothing depends on it — `ai-api` reaches Cube as
|
|
`cube:4000` over the proxy network and never by name, and the operator UI never touches
|
|
Cube. It exists so an engineer can open the playground to hand-verify a measure, which an
|
|
SSH tunnel does equally well. Removing it means deleting the Caddy block **and** this
|
|
domain; batch that with an Authelia restart that is happening anyway rather than spending
|
|
an all-user logout on it. The same note is on the host, in `~/Caddyfile` above the block
|
|
and inline on this rule.
|
|
|
|
**Applied on lin001:** `lf` on 2026-08-20; `cube`, `api` and `ai` on
|
|
2026-08-27, in one Authelia restart. Backup
|
|
`~/authelia/configuration.yml.bak-ai-20260827`. All four now return 302 to the
|
|
auth portal. Between the Caddy blocks going in and this rule landing, the three
|
|
new hostnames returned **403** — Caddy serving a hostname Authelia has no rule
|
|
for means `default_policy: deny`, which is the safe direction but is not
|
|
obvious from the status code alone. `verify.sh` names that case explicitly.
|
|
|
|
## Phase 9: the document publisher rule
|
|
|
|
Operator document upload splits the API in two. Anyone in `HTTPS_UserAccess`
|
|
may ask a question and upload a document for review. **Approving** a document —
|
|
which is what makes it citable, and what decides whether a superseded revision
|
|
stops being citable — needs a second group, `AI_DocPublishers`.
|
|
|
|
```yaml
|
|
access_control:
|
|
rules:
|
|
# ... existing rules unchanged ...
|
|
|
|
# MUST come BEFORE the general api.yokogawa.tech rule. Authelia applies the
|
|
# FIRST matching rule and stops. Below it, this rule is dead and every
|
|
# authenticated user can approve a procedure revision.
|
|
- domain: api.yokogawa.tech
|
|
resources:
|
|
- '^/docs/.*'
|
|
policy: two_factor
|
|
subject:
|
|
- group:AI_DocPublishers # added <date>, AI PoC Phase 9
|
|
|
|
# ... the existing trailing rule, unchanged, still carries
|
|
# api.yokogawa.tech for everyone in HTTPS_UserAccess ...
|
|
```
|
|
|
|
Three things about this rule specifically:
|
|
|
|
- **`AI_DocPublishers` must be created in AD with DIRECT membership.** The same
|
|
trap as `HTTPS_UserAccess`: a user inside a nested group is silently denied,
|
|
with no useful log line. `svc-authelia` is read-only and cannot fix it.
|
|
- **It is not the only check.** `ai-api` re-reads `Remote-Groups` and returns
|
|
403 on approve, reject and supersede. This rule protects the whole `/docs`
|
|
path; the API protects the three operations that matter, and survives this
|
|
rule being reordered or dropped in a future edit of a file nobody diffs.
|
|
- **Verify it by calling the API directly**, as a user who is authenticated but
|
|
not a publisher. The UI hides the buttons from that user, which proves
|
|
nothing at all:
|
|
|
|
```bash
|
|
curl -si https://api.yokogawa.tech/docs/uploads/<id>/approve -X POST -H 'Content-Type: application/json' -d '{}' # expect 403
|
|
```
|
|
|
|
## How to apply it
|
|
|
|
```bash
|
|
# 1. Back up first. There are plenty of .bak-* precedents on the host.
|
|
sudo cp ~/authelia/configuration.yml ~/authelia/configuration.yml.bak-ai-$(date +%Y%m%d)
|
|
|
|
# 2. Edit with sudo - the file is root-owned. ~/apply_rule.py rewrites the
|
|
# trailing rule if you prefer it to hand-editing.
|
|
sudo nano ~/authelia/configuration.yml
|
|
|
|
# 3. ANNOUNCE FIRST - this logs out every active user on every service.
|
|
docker compose -f ~/authelia-compose.yml restart authelia
|
|
|
|
# 4. Verify. "Up" is not proof.
|
|
curl -sI https://ai.yokogawa.tech # expect 302 -> auth portal
|
|
docker logs --tail 50 authelia
|
|
```
|
|
|
|
## Things that bite
|
|
|
|
- **Restarting Authelia logs out every active user on the host**, including
|
|
whoever is mid-demo on Grafana. Announce it, and batch the domain additions
|
|
so you restart once per phase rather than once per hostname.
|
|
- **AD group membership must be DIRECT.** Authelia resolves direct membership
|
|
only; a user inside a nested group silently gets denied with no useful log
|
|
line. Before Phase 7, confirm the demo operator account is a direct member of
|
|
`HTTPS_UserAccess` and is Duo-enrolled. `svc-authelia` is read-only and
|
|
cannot fix membership for you.
|
|
- **Rule order is a security control, not a style choice.** First match wins.
|
|
A path-scoped rule placed after the domain rule it narrows is inert, and
|
|
nothing warns you — the service keeps working, for everybody.
|
|
- **A missing rule fails open at the wrong layer.** Caddy will happily serve a
|
|
hostname that has `import authelia` before the rule exists — Authelia then
|
|
applies its default policy. Add the rule in the same change as the Caddyfile
|
|
block and verify the 302 before telling anyone the URL.
|
|
- Restarting Authelia is also the supported way to refresh someone's group
|
|
membership after an AD change.
|