diff --git a/docs/adr/0005-personal-data-isolation-and-authentik-boundary.md b/docs/adr/0005-personal-data-isolation-and-authentik-boundary.md new file mode 100644 index 0000000..09d438c --- /dev/null +++ b/docs/adr/0005-personal-data-isolation-and-authentik-boundary.md @@ -0,0 +1,154 @@ +--- +status: "accepted" +date: 2026-10-11 +decision-makers: ["Moritz Graf", "Antigravity"] +consulted: [] +informed: [] +--- + +# ADR 0005: Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation + +## Context and Problem Statement + +On 2026-10-11, during the development of mobile-first PWA dashboards for baby monitoring (Mila Graf) and personal nutrition/Garmin energy balance (Moritz Graf), personal health, sleep, diaper, and nutrition data was temporarily made available at unauthenticated root URLs: +- `https://hosting.haumdaucher.de/mila.html` +- `https://hosting.haumdaucher.de/moritz.html` + +While [ADR 0004](0004-structured-personal-data-datastore.md) established the conceptual routing design (`/family/*` protected by Traefik ForwardAuth, public recipes at `/recipes/*`), a gap analysis revealed critical systemic vulnerabilities: +1. **Unapplied Kubernetes Ingress:** The ForwardAuth Ingress (`minio-hosting-family`) declared in `k8s/minio/hosting-ingress.yaml` was never applied to the Kubernetes cluster. +2. **Missing Authentik Identity Provider:** Authentik had no Proxy Provider or Application configured for `hosting.haumdaucher.de`, returning HTTP 404 on ForwardAuth checks. +3. **Dual-Publishing Anti-Pattern:** Dashboard generator scripts (`update_website.py`, `update_moritz_website.py`) mirrored files to root (`/mila.html`, `/moritz.html`) for convenience, completely bypassing the planned `/family/` path boundary. +4. **Lack of Automated Ingress & Storage Auditing:** No automated test or CI/Cron audit prevented personal data from being uploaded to public prefixes. + +How do we establish a permanent, verifiable, and automated security boundary that guarantees personal and family data can never be exposed publicly without authentication? + +--- + +## Decision Drivers + +* **Zero Public Exposure for Personal Data:** Health, sleep, nutrition, body, and family data must NEVER be accessible without authentication. +* **Defense-in-Depth:** Security must not rely on a single layer (e.g. secret URLs or developer memory). Protection must be enforced at identity provider, reverse proxy, storage bucket, and generator script levels. +* **Declarative Infrastructure as Code (ADR 0003):** All Authentik providers, policies, groups, and Kubernetes Ingresses must be versioned in Git and applied via Terraform/Kubectl. +* **Automated Guardrails & Fast Feedback:** Continuous audits must detect and quarantine any unauthorized files outside protected paths. +* **Seamless PWA Experience:** The PWA capabilities (manifest, service worker, offline shell) must function within the authenticated cookie session without breaking ForwardAuth. + +--- + +## Decision Outcome + +Chosen Strategy: **Multi-Layer Boundary Enforcement with ForwardAuth, Strict Storage Segregation, and Continuous Auditing**. + +### 1. Storage & URL Path Segmentation Architecture + +``` +Internet Request to https://hosting.haumdaucher.de + │ + ▼ + ┌───────────────────┐ + │ Traefik Ingress │ + └─────────┬─────────┘ + │ + Path Inspection & Priority + │ + ┌────────────────┴────────────────┐ + │ │ +Path: /family/* Path: /* (Fallback) +Priority: 100 Priority: 10 + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Authentik ForwardAuth │ │ Public Ingress │ +│ • Host: hosting.haumd...│ │ • ONLY /recipes/* │ +│ • Allowed: 'family' & │ │ • ONLY /showcase/* │ +│ 'infra-admins' groups │ │ • NO personal files │ +└────────────┬────────────┘ └────────────┬────────────┘ + │ │ + HTTP 200 (Authed) │ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ MinIO Bucket │ │ MinIO Bucket │ + │ key: family/* │ │ key: public/* │ + │ (Protected Data) │ │ (Public Static) │ + └──────────────────┘ └──────────────────┘ +``` + +#### Rule 1: Canonical Protected Prefix `/family/` +- All dashboards containing personal, familial, or physiological metrics MUST reside under `/family/`: + - `https://hosting.haumdaucher.de/family/mila.html` (Baby sleep, feeds, diapers) + - `https://hosting.haumdaucher.de/family/moritz.html` (Personal nutrition, Garmin deficits, health) + - `https://hosting.haumdaucher.de/family/nutrition.html` (Family nutrition overview) + - `https://hosting.haumdaucher.de/family/sleep.html` (Sleep analytics) +- Associated PWA assets (manifests, service workers) MUST also be scoped within `/family/` (e.g. `/family/mila.webmanifest`, `/family/sw.js`). + +#### Rule 2: Root Prefix `/` Strictly Restricted +- The root of `hosting.haumdaucher.de` is restricted to public static assets (`/recipes/*`, `/showcase/*`). +- Publishing scripts MUST NEVER write personal dashboards to the bucket root. + +--- + +### 2. Declarative Authentik ForwardAuth Configuration + +Authentik configuration is codified in `terraform/authentik.tf`: +1. **Proxy Provider (`authentik_provider_proxy.hosting_family`):** + - External Host: `https://hosting.haumdaucher.de` + - Mode: `forward_single` + - Bound to `authentik_outpost.embedded` + - Session duration: 30 days (`access_token_validity = "days=30"`) +2. **Application (`authentik_application.hosting_family`):** + - Slug: `hosting-family` + - Protocol Provider: `authentik_provider_proxy.hosting_family.id` +3. **Role-Based Policy Bindings:** + - Group `family` (Moritz & Amelie Graf): Granted access + - Group `infra-admins`: Granted access + - Unauthenticated requests are redirected (HTTP 302) to `https://auth.haumdaucher.de/application/o/authorize/...` + +--- + +### 3. Kubernetes Ingress Deployment + +`k8s/minio/hosting-ingress.yaml` defines the two ingress tiers: +- **`minio-hosting-family` (Priority 100):** + - Path: `/family` (Prefix) + - Middlewares: `auth-authentik-forwardauth@kubernetescrd,minio-hosting-add-prefix@kubernetescrd` +- **`minio-hosting` (Priority 10):** + - Path: `/` (Prefix) + - Middlewares: `minio-hosting-add-prefix@kubernetescrd` + +--- + +### 4. Generator Scripts & Codebase Hardening + +1. **Elimination of Root Duplication:** + - `skills/datastore/scripts/update_website.py`: Removed all S3 upload and local sync calls to root `mila.html`. Target is strictly `family/mila.html`. + - `skills/datastore/scripts/update_moritz_website.py`: Removed S3 upload and local sync calls to root `moritz.html`. Target is strictly `family/moritz.html` (and `family/nutrition.html`). +2. **PWA Scope Alignment:** + - Web App Manifests configure `start_url: "/family/mila.html"` and `scope: "/family/"`. + - Service workers are registered at `/family/sw.js`. +3. **Telegram Link Consistency:** + - Agent prompts for `mila` (Topic 20) and `koch` (Topic 21) only broadcast the secure URL: `https://hosting.haumdaucher.de/family/...`. + +--- + +### 5. Automated Continuous Leak Audit Guard + +An automated audit check runs continuously (via OpenClaw periodic audit and datastore maintenance): +- Inspects the MinIO `hosting` bucket root (`prefix=""`). +- Detects any forbidden files matching patterns: `*mila*`, `*moritz*`, `*nutrition*`, `*sleep*`, `*schlaf*`, `*health*`, `*diaper*`. +- If detected: + 1. Immediately deletes/quarantines the offending object from the public bucket root. + 2. Raises a critical notification/event to the system log. + +--- + +## Consequences + +### Positive +- **Guaranteed Confidentiality:** Family and personal health data is physically impossible to access without passing Authentik ForwardAuth. +- **Reproducible Infrastructure:** The entire authentication chain is codified in Terraform and Kubernetes manifests. +- **Persistent Mobile PWA:** Authentik's 30-day session lifetime ensures seamless standalone mobile app operation without repetitive login prompts. +- **Automated Safety Net:** Even if a developer or agent script mistakenly attempts to mirror a file to root, the automated audit deletes it. + +### Negative & Mitigations +- **Authentication Step on First Mobile Launch:** + - *Mitigation*: When installing the PWA on iOS/Android, the user authenticates once via Authentik. The persistent session cookie maintains login across app launches. diff --git a/docs/adr/README.md b/docs/adr/README.md index ef91b90..b00c229 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,6 +14,7 @@ We follow the [Markdown Architectural Decision Records (MADR)](https://adr.githu | [**0002**](0002-openclaw-model-selection-tiering-and-economics.md) | **OpenClaw Multi-Tier Model Selection, Latency Optimization, and Inference Economics** | `proposed` | 2026-09-13 | OpenClaw / AI / Models | | [**0003**](0003-everything-as-code-and-reproducible-infrastructure.md) | **Everything as Code (EaC) and Reproducible Declarative Infrastructure** | `accepted` | 2026-09-17 | Architecture / Monorepo / EaC | | [**0004**](0004-structured-personal-data-datastore.md) | **Structured Personal Data Layer (DuckDB + JSONL in Knowledge Wiki)** | `accepted` | 2026-10-06 | OpenClaw / Data / Storage | +| [**0005**](0005-personal-data-isolation-and-authentik-boundary.md) | **Personal Data Isolation, Authentik Authentication Boundaries, and Hosting Segregation** | `accepted` | 2026-10-11 | Security / Storage / Authentik | --- diff --git a/k8s/openclaw/openclaw-instance.secret.yaml b/k8s/openclaw/openclaw-instance.secret.yaml index 7b59849..2742658 100644 Binary files a/k8s/openclaw/openclaw-instance.secret.yaml and b/k8s/openclaw/openclaw-instance.secret.yaml differ diff --git a/knowledge-wiki b/knowledge-wiki index eaf4e9f..bdf2beb 160000 --- a/knowledge-wiki +++ b/knowledge-wiki @@ -1 +1 @@ -Subproject commit eaf4e9ffd454eb0a86abf87dcf53b216fa944a2e +Subproject commit bdf2beb0054784c3a6e6f878ef2ba220fefe4ead diff --git a/terraform/authentik.tf b/terraform/authentik.tf index e8b2ed0..aec8e5e 100644 --- a/terraform/authentik.tf +++ b/terraform/authentik.tf @@ -359,6 +359,23 @@ resource "authentik_application" "silverbullet" { protocol_provider = authentik_provider_proxy.silverbullet.id } +# 10. Hosting Family Dashboards +resource "authentik_provider_proxy" "hosting_family" { + name = "Hosting Family" + external_host = "https://hosting.haumdaucher.de" + mode = "forward_single" + authorization_flow = data.authentik_flow.default_authorization_flow.id + invalidation_flow = data.authentik_flow.default_invalidation_flow.id + intercept_header_auth = true + access_token_validity = "days=30" +} + +resource "authentik_application" "hosting_family" { + name = "Hosting Family" + slug = "hosting-family" + protocol_provider = authentik_provider_proxy.hosting_family.id +} + # --- Outpost: Embedded Proxy Outpost --- resource "authentik_outpost" "embedded" { @@ -374,6 +391,7 @@ resource "authentik_outpost" "embedded" { authentik_provider_proxy.openclaw.id, authentik_provider_proxy.n8n.id, authentik_provider_proxy.silverbullet.id, + authentik_provider_proxy.hosting_family.id, ] } @@ -759,6 +777,18 @@ resource "authentik_policy_binding" "silverbullet_family" { order = 1 } +resource "authentik_policy_binding" "hosting_family_admin" { + target = authentik_application.hosting_family.uuid + group = authentik_group.infra_admins.id + order = 0 +} + +resource "authentik_policy_binding" "hosting_family_family" { + target = authentik_application.hosting_family.uuid + group = authentik_group.family.id + order = 1 +} + # 2. Shared & Member Applications (Accessible to all authenticated users) resource "authentik_policy_binding" "grafana_users" { diff --git a/terraform/terraform.tfstate b/terraform/terraform.tfstate index 44c43aa..e89d4a2 100644 --- a/terraform/terraform.tfstate +++ b/terraform/terraform.tfstate @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.14.3", - "serial": 417, + "serial": 423, "lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e", "outputs": { "coder_oauth_client_secret": { @@ -211,7 +211,8 @@ "date_joined": "2026-09-05T14:23:01.528947Z", "email": "", "groups": [ - "ad33b003-77a4-4571-bec8-8301f6d6552b" + "ad33b003-77a4-4571-bec8-8301f6d6552b", + "5e91f468-a0f2-4433-aa23-a7b6ad69df38" ], "id": "7", "is_active": true, @@ -495,6 +496,41 @@ } ] }, + { + "mode": "managed", + "type": "authentik_application", + "name": "hosting_family", + "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "backchannel_providers": null, + "group": "", + "id": "hosting-family", + "meta_description": "", + "meta_hide": false, + "meta_icon": "", + "meta_launch_url": "", + "meta_publisher": "", + "name": "Hosting Family", + "open_in_new_tab": false, + "policy_engine_mode": "any", + "protocol_provider": 16, + "slug": "hosting-family", + "uuid": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3" + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "private": "bnVsbA==", + "dependencies": [ + "authentik_provider_proxy.hosting_family", + "data.authentik_flow.default_authorization_flow", + "data.authentik_flow.default_invalidation_flow" + ] + } + ] + }, { "mode": "managed", "type": "authentik_application", @@ -1007,8 +1043,8 @@ "id": "5e91f468-a0f2-4433-aa23-a7b6ad69df38", "is_superuser": false, "name": "family", - "parents": null, - "roles": null, + "parents": [], + "roles": [], "users": [ 7 ] @@ -1118,7 +1154,8 @@ 7, 3, 10, - 15 + 15, + 16 ], "service_connection": "", "type": "proxy" @@ -1130,6 +1167,7 @@ "authentik_provider_proxy.alertmanager", "authentik_provider_proxy.gitea_phpmyadmin", "authentik_provider_proxy.hass_coder", + "authentik_provider_proxy.hosting_family", "authentik_provider_proxy.n8n", "authentik_provider_proxy.nextcloud_phpmyadmin", "authentik_provider_proxy.openclaw", @@ -1393,6 +1431,73 @@ } ] }, + { + "mode": "managed", + "type": "authentik_policy_binding", + "name": "hosting_family_admin", + "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "enabled": true, + "failure_result": false, + "group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690", + "id": "55b2f74f-f650-40a2-9781-a00a5978e1e8", + "negate": false, + "order": 0, + "policy": "", + "target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3", + "timeout": 30, + "user": 0 + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "private": "bnVsbA==", + "dependencies": [ + "authentik_application.hosting_family", + "authentik_group.infra_admins", + "authentik_provider_proxy.hosting_family", + "data.authentik_flow.default_authorization_flow", + "data.authentik_flow.default_invalidation_flow" + ] + } + ] + }, + { + "mode": "managed", + "type": "authentik_policy_binding", + "name": "hosting_family_family", + "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "enabled": true, + "failure_result": false, + "group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38", + "id": "b598a889-f04b-4955-8a01-6558cfaad60e", + "negate": false, + "order": 1, + "policy": "", + "target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3", + "timeout": 30, + "user": 0 + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "private": "bnVsbA==", + "dependencies": [ + "authentik_application.hosting_family", + "authentik_group.family", + "authentik_provider_proxy.hosting_family", + "data.authentik_flow.default_authorization_flow", + "data.authentik_flow.default_invalidation_flow", + "data.authentik_user.amy" + ] + } + ] + }, { "mode": "managed", "type": "authentik_policy_binding", @@ -2281,6 +2386,48 @@ } ] }, + { + "mode": "managed", + "type": "authentik_provider_proxy", + "name": "hosting_family", + "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "access_token_validity": "days=30", + "authentication_flow": "", + "authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42", + "basic_auth_enabled": false, + "basic_auth_password_attribute": "", + "basic_auth_username_attribute": "", + "client_id": "27XovQEtW8nMAgUN6kANZnQZMMbWVEbi8Mh6VsGP", + "cookie_domain": "", + "external_host": "https://hosting.haumdaucher.de", + "id": "16", + "intercept_header_auth": true, + "internal_host": "", + "internal_host_ssl_validation": true, + "invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba", + "jwks_sources": null, + "jwt_federation_providers": null, + "jwt_federation_sources": null, + "mode": "forward_single", + "name": "Hosting Family", + "property_mappings": null, + "refresh_token_validity": "days=30", + "skip_path_regex": "" + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "private": "bnVsbA==", + "dependencies": [ + "data.authentik_flow.default_authorization_flow", + "data.authentik_flow.default_invalidation_flow" + ] + } + ] + }, { "mode": "managed", "type": "authentik_provider_proxy", diff --git a/terraform/terraform.tfstate.backup b/terraform/terraform.tfstate.backup index 7a45dce..44c43aa 100644 --- a/terraform/terraform.tfstate.backup +++ b/terraform/terraform.tfstate.backup @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.14.3", - "serial": 415, + "serial": 417, "lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e", "outputs": { "coder_oauth_client_secret": { @@ -2458,7 +2458,7 @@ { "schema_version": 0, "attributes": { - "access_token_validity": "minutes=10", + "access_token_validity": "days=7", "authentication_flow": "", "authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42", "basic_auth_enabled": false,