Split load_config into per-section readers, use contextlib.suppress and a single condition where ruff asked, remove stale noqa comments, add the SIM and RUF100 rules, share the sign_in test helper, align AD-6 with the code and record the remaining deferred findings.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
Modular monolith with adapters. One Django project, one Django app per feature, and one adapter per external system. Features call adapters only through services; adapters know nothing about features.
flowchart TD
V[views / templates] --> S[feature services]
S --> AUD[audit service]
S --> ACC[accounts: session user, roles]
S --> AD[adapters]
SCH[scheduler tick] --> S
AD --> DIR[directory: LDAPS]
AD --> MAIL[mailer: SMTP]
AD --> SHR[share: picture folders]
AD --> IDP[idp: OIDC]
S --> DB[(SQLite)]
Arrows are the only allowed dependency direction. Views never call adapters. Features never import each other's models; they call each other's services.
Invariants & Rules
AD-1 — Adapters have exactly one owner each
Binds: all FRs touching AD, mail, the share or the IdP
Prevents: scattered LDAP, SMTP or file access, each with its own escaping and error handling
Rule: only adapters/directory imports ldap; only adapters/mailer imports smtplib; only adapters/share opens paths under the source or processed folders; only adapters/idp talks OIDC. Each adapter exposes a small interface with an in-memory fake.
AD-2 — Directory access is allow-listed and injection-proof
Prevents: LDAP injection, writes outside the OU, writes of unlisted attributes, the service account used anywhere else
Rule: the Service Account is used only inside directory. Filters are built only with LDAP filter escaping; DNs are never built from input — the DN is found by searching sAMAccountName inside the configured OU. Writable attributes are a fixed allow-list: givenName, sn, displayName, mail, telephoneNumber, mobile, unicodePwd. Readable attributes are also a fixed allow-list: the writable ones, sAMAccountName, memberOf (or a group-membership query), thumbnailPhoto, pwdLastSet, the account's enabled and locked state, and the Password Policy (the domain's minPwdLength, pwdProperties, pwdHistoryLength, minPwdAge, maxPwdAge, and the user's msDS-ResultantPSO when a fine-grained policy applies). The Service Account's read rights are limited to the same list. Everything runs over LDAPS with certificate verification against a CA bundle supplied by environment variable (the homelab uses an internal CA); verification is never disabled.
AD-3 — Self-service acts on the session user only
Binds: FR-5, FR-8, FR-10..FR-15, FR-21
Prevents: editing another user through a crafted URL or form
Rule: no self-service view, service or form accepts a username, user id or DN. The acting user is read from the session as one object, SessionUser(key, sam): key is the lower-case value used for every database key, sam is the exact sAMAccountName from AD used for AD calls and file names. No module derives either value itself.
AD-4 — Audit entry in the same transaction as the change
Binds: FR-24, FR-28
Prevents: changes without a record; records without a change
Rule: every state-changing service function writes its Audit Entry (who, what, when, IP) in the same database transaction, in the service layer, never in the view. Entries never contain passwords, tokens or picture content. A service that changes AD makes the AD call inside the transaction, before commit. A failed AD call rolls back and records a failure entry in its own transaction; a commit failure after a successful AD call is logged at error level with the change details.
AD-5 — Identity, roles and account state come from AD, cached briefly
Binds: FR-1, FR-2, FR-25
Prevents: stale admin rights; a disabled account staying signed in; a directory outage granting access
Rule:accounts resolves username, Publisher and Admin membership, and enabled/locked state from directory and caches the result per session for a configured time (default 60 s). A directory failure denies elevated roles and new sessions. The OIDC preferred_username must equal sAMAccountName.
AD-6 — Sessions are server-side with a kill-by-user primitive
Binds: FR-3, FR-7, FR-11
Prevents: sessions that cannot be ended; mismatched idle and absolute limits
Rule: sessions live in the database with the username in its own column. Idle limit 1 h, absolute limit 4 h, both settings. end_sessions(user, except_session_key=None) is the only way to end sessions and is used by password change, undo, sign-out and disabled-account detection. Cookies are secure, HttpOnly and SameSite=Lax.
AD-7 — Throttle before any outbound call; client IP from a trusted proxy only
Binds: FR-4, FR-6, FR-10, FR-11, FR-12
Prevents: AD lockout by an attacker; spoofed IPs defeating throttling
Rule: counters live in the database, keyed by endpoint plus user and endpoint plus IP, and are checked before directory, mailer or upload processing. Client IP: if the TCP peer is in TRUSTED_PROXIES, take the last X-Forwarded-For entry (the one the proxy appended); otherwise use the peer address. X-Real-IP is never used.
AD-8 — Sensitive actions re-verify the password; passwords live only in memory
Binds: FR-5, FR-6, FR-10
Prevents: session hijack changing the password or e-mail; password leakage
Rule: password change and e-mail change call directory.verify_password(user, password) after the throttle check. Passwords are never stored, logged, audited or placed in error reports; sensitive POST parameters are excluded from logging and error pages.
AD-9 — Link actions: GET shows, POST applies; tokens are hashed and single-use
Binds: FR-10, FR-11
Prevents: mail scanners confirming or undoing changes; token theft from the database
Rule: confirmation (1 h) and undo (24 h) tokens are random, stored only as hashes, single-use and expiring. A GET renders a page naming the change; only a POST with CSRF applies it.
AD-10 — The picture pipeline is fixed
Binds: FR-12..FR-15; Q-1, Q-7, Q-9
Prevents: unsafe image input; a different file name than the sync script reads; wrong Pending state
Rule: accepted input is JPEG, PNG, HEIC or WebP up to 10 MB; SVG and everything else is refused. Decode with pixel and size limits, apply orientation, strip all metadata, crop square, re-encode as a 512×512 JPEG. The share adapter writes <sAMAccountName>.jpg to the source folder atomically (temp file, then rename) with timeouts and records the MD5 in the database. The original upload is never stored. Pending clears when processed/<name>.md5 (case-insensitive, trimmed) equals the stored MD5; for a removal it clears when that file differs from the previous MD5. After a configured time the Portal shows "taking longer than expected". If the share is unreachable the user sees an error and nothing changes.
AD-11 — All mail goes through an outbox
Binds: FR-10, FR-11, FR-7, FR-20; Q-2
Prevents: lost or duplicated mail on restart; blocking requests on SMTP; hitting provider limits
Rule: the mailer module owns the outbox table; every other module calls mailer.enqueue(template, recipient, context) and nothing else. The scheduler calls mailer.flush(), which sends rows with retries, backoff and a configured rate limit. Recipients of an Announcement are resolved at publish time: all Users in the OU with a valid mail value, minus stored opt-outs; users without mail are recorded as skipped. Only opt-outs are stored. Every e-mail has a plain-text part; an HTML part, if any, passes the same sanitizer as the page (AD-16). Failures are visible in the Admin UI.
AD-12 — One scheduler thread running idempotent, state-driven jobs
Binds: FR-17, FR-20, FR-24; Q-5, Q-6
Prevents: duplicate runs; jobs that depend on process memory
Rule: the app runs one gunicorn worker with threads and one scheduler thread ticking every 30 s. Every job reads the database for what is due and is safe to repeat: publish due Announcements, send the outbox, expire tokens and sessions, apply audit retention, write the database backup, reconcile with AD nightly (delete opt-outs, pending changes and picture state of users no longer in the OU; audit entries stay until retention). Each run records its status for the Admin UI.
AD-13 — Configuration is partitioned and typed
Binds: FR-23, FR-26
Prevents: secrets in the database or UI; a runtime setting that opens a security hole
Rule: environment variables hold everything that identifies a system or contains a secret (AD, LDAPS, Service Account, OIDC, SMTP, share paths, group names, trusted proxies, time zone default). Runtime settings (accent colour, backgrounds, portal name, default form of address, session limits, throttle limits, audit retention) live in the database and are declared in one registry with type, default and minimum value. Saving the accent colour computes its contrast against the on-brand text colour for the light and the dark theme and returns the result and a warning when it is below 4.5:1; the save is still allowed. A missing or invalid environment variable stops startup with a clear message. Nothing in the repository names ping-mee.
AD-14 — SQLite with WAL on a volume, plus consistent backup files
Binds: FR-27, FR-28; Q-5
Prevents: inconsistent copies of a live database; invisible backup failures
Rule: the database is SQLite in WAL mode on the /data volume. The scheduler writes consistent backup files using the SQLite online backup API into /backups with a configured retention. Host-side copy jobs (Veeam) copy /backups, not the live file. The Admin UI shows the age of the last successful backup.
AD-15 — Time and language
Binds: FR-17, FR-29; Q-3, Q-8
Prevents: mismatched time zones; untranslatable strings
Rule: all timestamps are stored in UTC. Display uses the configured zone (default Europe/Berlin) and e-mails name the zone. Every user-visible string goes through Django translation from the first line of code; German and English ship, and German has a formal and an informal register. Per-device Preferences (theme: light, dark or system; language; German register) are stored in cookies and never on the server. The server reads them to render the page (a theme attribute on the root element, not inline script); system is resolved by CSS only. Language defaults from Accept-Language. Server-sent e-mails use the portal-wide default register.
AD-16 — User content is rendered safely
Binds: FR-16, FR-19, FR-22; NFR §8.1
Prevents: script injection; remote code from CDNs; unsafe uploads by Admins
Rule: templates auto-escape. Announcement bodies are edited in a browser rich-text editor (toolbar: bold, italic, heading, list, link) that converts to Markdown. The server treats the submitted Markdown as untrusted: it is stored as Markdown, rendered by a Markdown parser, and sanitized with an allow-list sanitizer on every render; raw HTML is not allowed. A strict Content-Security-Policy forbids inline scripts and external script sources; all front-end assets, including the rich-text editor and the Fluent web components, are built into the image, with no runtime CDN. Admin background images follow the same re-encode and size limits as pictures and are served only to signed-in Users.
AD-17 — Errors are typed and mapped in one place
Binds: FR-5, FR-9, FR-10, FR-12, FR-14, §8.3
Prevents: raw exceptions or directory messages reaching users; inconsistent messages
Rule: services raise typed errors (ValidationFailed, Throttled, Forbidden, DirectoryUnavailable, ShareUnavailable, MailUnavailable). One mapping layer turns them into translated user messages and HTTP codes. AD password-policy rejections are mapped to specific messages.
AD-18 — Logs and health
Binds: FR-27, FR-28; §8.3
Prevents: secrets in logs; a healthy-looking container that is broken, or one restarted because AD is down
Rule: logs are JSON on stdout with a request ID, never containing secrets or personal content. /healthz checks the database only, never AD, SMTP or the share.
AD-19 — Every table has exactly one owning app
Binds: all features; NFR §8.1
Prevents: two modules writing the same entity in incompatible ways
Rule:accounts owns sessions and throttle counters; profile owns pending e-mail change tokens; picture owns picture state; announcements owns announcements and opt-outs (the profile page calls its service); mailer owns the outbox; adminui owns settings; scheduler owns job runs; audit owns audit entries and is called by all. Other apps read an entity only through its owner's service.
Consistency Conventions
Concern
Convention
Naming
Django apps: accounts, profile, picture, announcements, adminui, audit. Adapters under adapters/: directory, mailer, share, idp. Services in services.py per app. Glossary terms from the PRD are used for class and table names.
Data and formats
Primary keys are integers. Usernames stored lower-case; the exact sAMAccountName from AD is used for file names. Timestamps UTC. Money: none. Tokens: random, hashed at rest.
State and cross-cutting
Mutations only in services, inside a transaction, with the audit entry (AD-4). Entity ownership per AD-19. Errors per AD-17. Settings via the registry (AD-13). No business logic in views or templates.
Testing
Every adapter has an in-memory fake; automated tests use fakes. A manual smoke checklist runs a non-production instance of the same image against a test OU.
Stack
Name
Version
Python
current supported release at scaffold; pin in image
Django
5.2 LTS (security fixes to April 2028)
python-ldap
current at scaffold (maintained)
Authlib
1.7.x
Pillow + pillow-heif
current at scaffold (pillow-heif 1.3+)
nh3 + markdown-it-py
current at scaffold
gunicorn
one worker, threads
SQLite
WAL mode
@fluentui/web-components
3.x, built into the image
Rich-text editor (browser, outputs Markdown)
chosen and pinned at scaffold after a web check; must be open source and bundled into the image
Docker, Docker Compose
on a Linux host; share mounted by the host (CIFS)
Structural Seed
flowchart LR
U[User browser] -->|HTTPS| RP[Zoraxy reverse proxy]
RP -->|HTTP| P[Portal container]
P -->|OIDC| AK[authentik]
P -->|LDAPS| DC[Active Directory]
P -->|SMTP| IO[IONOS mail]
P -->|bind mount source rw, processed ro| SH[(Share via host CIFS mount)]
P --> V[(volume /data SQLite)]
P --> B[(volume /backups)]
B -.copy job.-> VE[Veeam]
SH -.read source.-> SY[PowerShell sync task on DC]
SY -->|thumbnailPhoto| DC
portal/
config/ # settings, env parsing, setting registry
adapters/ # directory, mailer, share, idp (+ fakes)
accounts/ profile/ picture/ announcements/ adminui/ audit/
scheduler/ # tick loop and jobs
templates/ static/ # Fluent web components built into the image
tests/
Dockerfile # multi-stage, non-root
compose.yaml