Phase 9's operator path, built ahead of Phase 8 at the customer's direction and live at api.yokogawa.tech/documents. Upload, convert, review, approve, withdraw and restore. The pool screen is explicitly out of scope. Served by ai-api rather than ai-web, and mounted at /documents rather than /docs. ai.yokogawa.tech is SCADA-only since 2026-08-28 and passes through no Authelia, so it has no identity to record; publishers arrive on api.yokogawa.tech where the forward-auth headers still do. /docs stays with Swagger, which the customer is keeping - two things under one prefix with two different access policies is what gets misread during a later edit. Conversion is text extraction, not document parsing: pypdf, python-docx and openpyxl. Docling would be better at this and pulls torch, which lin001 has neither the memory to install nor the business running next to the demo plant's PLC. The cost is real - no layout, no table structure, and a scan cannot be read at all, so it is refused rather than stored empty. It is acceptable only because the converted text is shown to a person before the document can be cited, which is the same safety net the design already required for the header. convert.py is the one file to change if that stops being true. Chunking is mirrored from ingest.py rather than shared, because the two live in different images. They must stay identical: if they drift, the same document chunks differently depending on who loaded it, and the assistant answers or fails to answer depending on that. The step-sequence rule is locked by a test. Identity is self-asserted for the demo - the actor is typed on the form, which section 16 forbids, and the publisher list is one name with no password. Rows are written as `demo:<name>` with actor_groups = 'DEMO-UNVERIFIED' so that when real auth goes on, a name somebody typed stays tellable from a name Authelia proved. doc_actions cannot be deleted from, so an ambiguity there would be permanent. Two rules the code enforces rather than documents: uploading is open to anyone who reaches the page, because uploading changes nothing an operator can see - approving does, and that is what is gated; and an empty publisher list means nobody, not everybody. Verified on the host end to end: withdraw as a non-publisher 403s, with a short reason 400s, and as admin flips 5 chunks and writes a complete audit row; restore puts them back and keeps both rows. The corpus is unchanged afterwards. Requirements are split so the document dependencies install in their own layer - a change there costs four small wheels instead of re-resolving fastapi, langgraph and langfuse on a 2 vCPU shared host. The five divergences from section 16 are recorded in section 14. The one with teeth: files published through the UI stay in the inbox, so `ai-ingest --all` cannot see them and the two paths must not be used on the same document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
"""Who is acting, and whether they are allowed to.
|
|
|
|
Two questions, deliberately separate:
|
|
|
|
actor() WHO. In `authelia` mode this is Remote-User from the
|
|
forward-auth headers and nothing else. A missing header is a
|
|
401, never an anonymous fallback - getting to ai-api without
|
|
passing Authelia is not a state in which to accept a change to
|
|
the document set.
|
|
|
|
require_publisher() WHETHER. A name list today (DOC_PUBLISHERS), the AD
|
|
group AI_DocPublishers later. Empty list means nobody, and every
|
|
mutating endpoint 403s. Fail closed.
|
|
|
|
DEMO MODE. `DOC_IDENTITY_MODE=demo` takes the actor from a form field instead.
|
|
That is precisely what the design forbids - "identity comes from Authelia's
|
|
forwarded headers, never from the request body" - and it is here because the
|
|
customer asked for a demo with no password. Three things make it survivable:
|
|
|
|
1. It is off unless asked for.
|
|
2. Every screen carries a banner saying the name is unverified.
|
|
3. Rows are written as `demo:<name>` with actor_groups = 'DEMO-UNVERIFIED'.
|
|
|
|
(3) is the one that matters in a year. When real auth goes on, the audit trail
|
|
still has to distinguish a name somebody typed from a name Authelia proved. If
|
|
demo rows were written as bare names they would be indistinguishable from real
|
|
ones forever, and `doc_actions` is a table nothing can delete from - so the
|
|
ambiguity would be permanent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from config import settings
|
|
|
|
# Authelia's forward-auth subrequest returns these; Caddy's shared `authelia`
|
|
# snippet on lin001 copies all four upstream (checked 2026-08-27). They are
|
|
# trusted ONLY because nothing outside the proxy network can reach this app.
|
|
# Any container on `proxy` could forge them - that assumption is exactly as
|
|
# strong as the no-published-ports rule.
|
|
_USER_HEADER = "Remote-User"
|
|
_NAME_HEADER = "Remote-Name"
|
|
_EMAIL_HEADER = "Remote-Email"
|
|
_GROUPS_HEADER = "Remote-Groups"
|
|
|
|
DEMO_GROUPS = "DEMO-UNVERIFIED"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Actor:
|
|
"""The person making a change, as it will be written to doc_actions."""
|
|
|
|
# What goes in doc_actions.actor / doc_uploads.*_by. Carries the `demo:`
|
|
# prefix in demo mode - do not strip it anywhere on the write path.
|
|
stored: str
|
|
# What to show on screen. No prefix.
|
|
display: str
|
|
name: str | None
|
|
email: str | None
|
|
groups: str
|
|
verified: bool
|
|
|
|
@property
|
|
def is_demo(self) -> bool:
|
|
return not self.verified
|
|
|
|
|
|
def _header(request: Request, key: str) -> str | None:
|
|
value = request.headers.get(key)
|
|
return value.strip() if value and value.strip() else None
|
|
|
|
|
|
def actor(request: Request, declared_name: str | None = None) -> Actor:
|
|
"""Resolve who is acting, or raise 401.
|
|
|
|
`declared_name` is the form field, and it is ignored outside demo mode -
|
|
passing it in `authelia` mode must not be able to change the recorded
|
|
actor, which is the whole point of taking identity from the headers.
|
|
"""
|
|
cfg = settings()
|
|
remote_user = _header(request, _USER_HEADER)
|
|
|
|
if cfg.doc_identity_mode != "demo":
|
|
if not remote_user:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail=(
|
|
"no Remote-User header - this request did not pass through "
|
|
"Authelia. Refusing to record an anonymous document change."
|
|
),
|
|
)
|
|
return Actor(
|
|
stored=remote_user,
|
|
display=remote_user,
|
|
name=_header(request, _NAME_HEADER),
|
|
email=_header(request, _EMAIL_HEADER),
|
|
groups=_header(request, _GROUPS_HEADER) or "",
|
|
verified=True,
|
|
)
|
|
|
|
# --- demo mode ---------------------------------------------------------
|
|
typed = (declared_name or "").strip()
|
|
if not typed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="a name is required - it is recorded against this change",
|
|
)
|
|
if len(typed) > 64:
|
|
raise HTTPException(status_code=400, detail="name too long")
|
|
|
|
# Record the Authelia identity too WHEN THERE IS ONE. On
|
|
# api.yokogawa.tech there usually is, because that hostname is still gated
|
|
# even though this mode ignores it for authorisation. It costs nothing and
|
|
# gives a cross-check: a demo row whose header disagrees with the typed
|
|
# name is worth a second look.
|
|
groups = DEMO_GROUPS
|
|
if remote_user:
|
|
groups = f"{DEMO_GROUPS}; authelia={remote_user}"
|
|
|
|
return Actor(
|
|
stored=f"demo:{typed}",
|
|
display=typed,
|
|
name=typed,
|
|
email=_header(request, _EMAIL_HEADER),
|
|
groups=groups,
|
|
verified=False,
|
|
)
|
|
|
|
|
|
def is_publisher(who: Actor) -> bool:
|
|
"""May this actor change the document set?
|
|
|
|
Compared against the DISPLAY name, not the stored one - the `demo:` prefix
|
|
is a provenance marker on the audit row, not part of anybody's identity.
|
|
Case-insensitive: "Admin" and "admin" are the same person, and a demo that
|
|
fails on capitalisation teaches nothing.
|
|
"""
|
|
allowed = {n.casefold() for n in settings().doc_publishers}
|
|
if not allowed:
|
|
return False
|
|
return who.display.casefold() in allowed
|
|
|
|
|
|
def require_publisher(who: Actor) -> Actor:
|
|
"""403 unless this actor may act. Called by EVERY mutating endpoint.
|
|
|
|
This is the check that actually enforces. The Authelia rule for the
|
|
document paths is the other one, and the UI hiding a button is neither -
|
|
the design is explicit that a hidden button proves nothing, and the gate
|
|
item for this phase is verified by calling the API directly as a
|
|
non-publisher.
|
|
"""
|
|
if not is_publisher(who):
|
|
if not settings().doc_publishers:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=(
|
|
"no publishers are configured, so nothing may be changed. "
|
|
"Set DOC_PUBLISHERS."
|
|
),
|
|
)
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"{who.display} is not a publisher",
|
|
)
|
|
return who
|