Files
Ebbe BaßandClaude Sonnet 5.5 24de5878ed Add BMAD planning artifacts: brief, PRD, architecture, UX, spec, tickets
Includes the BMAD install config, the initiative ticket tree, the portal
context-resources scripts, and the .gitignore change that tracks BMAD files
(generated _bmad/render/ is ignored).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
2026-10-15 13:33:02 +02:00

18 KiB
Raw Blame History

name, type, purpose, altitude, paradigm, scope, status, created, updated, binds, sources, companions
name type purpose altitude paradigm scope status created updated binds sources companions
ping-mee Self-Service Portal architecture-spine build-substrate initiative Modular monolith with adapters (ports and adapters) for every external system The whole Portal: one Django container, its adapters to AD, OIDC, SMTP and the picture share, its data, and its operation final 2026-10-15 2026-10-15
PRD FR-1..FR-32
PRD §8 cross-cutting requirements
PRD Q-1..Q-9
UX DESIGN.md and EXPERIENCE.md
_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md
_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md
_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md
_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/addendum.md
_bmad-output/briefs/brief-ping-mee-self-service-platform-2026-10-15/brief.md
context-resources/*.ps1
_bmad-output/prds/prd-ping-mee-self-service-platform-2026-10-15/prd.md

Architecture Spine — ping-mee Self-Service Portal

Design Paradigm

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

  • Binds: FR-1, FR-5, FR-8, FR-9, FR-10, FR-14, FR-20; NFR §8.1
  • 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(username, except_current=bool) 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
erDiagram
    SESSION { string username }
    AUDIT_ENTRY { string actor }
    ANNOUNCEMENT ||--o{ OUTBOX_MAIL : queues
    OPT_OUT { string username }
    PENDING_TOKEN { string username }
    PICTURE_STATE { string username }
    SETTING { string key }
    JOB_RUN { string job }

Users themselves are not stored: AD is the source of truth. The Portal stores only data keyed by username.

Capability → Architecture Map

Capability / Area Lives in Governed by
Sign-in, roles, sessions (FR-1..FR-3) accounts, adapters/idp, adapters/directory AD-5, AD-6
Dashboard (FR-30) profile (view only; calls announcements and picture services) AD-1, AD-3
Preferences (FR-31) accounts (cookie middleware) AD-15
Throttling (FR-4, FR-6) accounts AD-7
Password change and Password Policy (FR-5..FR-7) profile AD-2, AD-3, AD-8
Profile data, e-mail change (FR-8..FR-11) profile AD-2, AD-3, AD-8, AD-9
Picture (FR-12..FR-15) picture, adapters/share AD-10
Announcements and mail (FR-16..FR-21) announcements, adapters/mailer AD-11, AD-12, AD-16
Admin UI and Status page (FR-22..FR-25, FR-32) adminui, audit, scheduler job records AD-4, AD-12, AD-13, AD-14, AD-16
Platform and operations (FR-26..FR-29) config, scheduler, image AD-12..AD-15, AD-18

Deferred

  • Container image location (registry or build on the host): decide at first deployment.
  • Default values for throttle limits, backup retention and the Pending timeout: set at implementation, within the setting registry.
  • The rich-text editor library: chosen at scaffold. The look and component set are in the UX documents.
  • Moving to PostgreSQL or more than one worker: only if the user count grows.
  • Alert e-mails or monitoring beyond the Admin UI: not needed now.
  • A staging environment: not available; the test OU takes its place.
  • Whether sign-out also ends the authentik session (single logout): decided at first deployment.