Compare commits

..

No commits in common. "main" and "pre-credscrub-20260427" have entirely different histories.

702 changed files with 5492 additions and 53194 deletions

14
.gitignore vendored
View File

@ -24,12 +24,8 @@
/bin/prole-agent
/bin/prole-scan
# IDE / tool state — never commit
/.junie/
/knoe-db/.idea/
/tmp/
# MagicMock artifacts (accidental Python materialisation)
/<MagicMock*/
# MagicMock artifacts (likely accidental)
/MagicMock/
/bin/*
!/bin/prole-env.sh
@ -74,7 +70,6 @@ mssql-password*
knoe-db/data/
var/
/target/
**/target/
/pyvenv.cfg
.output.txt
deploy/gcp/terraform-setup.txt
@ -106,7 +101,6 @@ deploy/gcp/terraform-setup.txt
# Ansible
.ansible/
infrastructure/logs/
# Coverage and testing
.coverage
@ -115,7 +109,9 @@ htmlcov/
/ssh-keys/
/mock_val/secrets/
/mock_val/secrets/
/prole-auth/target/surefire-reports/org.prole.auth.session.SessionTokenServiceTest.txt
/prole-auth/target/surefire-reports/org.prole.auth.web.VerifyControllerTest.txt
/prole-db.iml
supabase/helm/generated/values.generated.json
/.claude/
/.venv-ansible-winrm/

View File

@ -1,29 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — all (with coverage)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (prole)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="--cov=knoe --cov-report=html --cov-report=term-missing tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,29 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — gke mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="gke" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (prole)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m gke --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,29 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — k3d mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="k3d" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (prole)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m k3d --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,29 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — min mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="min" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (prole)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m min --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -1,28 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — unit (fast, no deps)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.14 (prole)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="DEBUG_JUST_MY_CODE" value="true" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m &quot;unit and not integration&quot; --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -13,9 +13,9 @@ Two GKE clusters in `us-west3`:
| Cluster | Context | Purpose |
|---|---|---|
| `knoe-dev-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-dev-0` | App cluster — Garage, Registry, OpenBao, Kong, GitLab, monitoring |
| `knoe-dev-cnpg-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0` | DB cluster — CNPG/PostgreSQL only |
| `knoe-cnpg-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0` | DB cluster — CNPG/PostgreSQL only |
**Critical:** SSD quota (300 GB) is fully consumed by CNPG — all non-CNPG PVCs must use `standard` storage class (HDD), not `standard-rwo`/`premium-rwo`. **Garage runs ONLY on `knoe-dev-0`** (removed from DB cluster on 2026-04-29).
**Critical:** Garage must NEVER be deployed to `knoe-cnpg-0`. SSD quota (300 GB) is fully consumed by CNPG — all non-CNPG PVCs must use `standard` storage class (HDD), not `standard-rwo`/`premium-rwo`.
### Deployment environments / modes
| `cluster_env` | `KNOE_MODE` | Target |
@ -110,13 +110,8 @@ Missing `init_cluster.app_cluster_kubecontext` in config causes Garage to deploy
## CNPG / backup specifics
- CNPG backups go to **GCS**: `gs://knoe-0-backups/` (single bucket; `knoe-db/base/` and `knoe-db/wals/` prefixes). `gs://knoe-0-wal/` exists but is unused.
- CNPG operator: **v1.29.0** (upgraded 2026-04-29 to expose `spec.serviceAccountName`)
- plugin-barman-cloud: v0.12.0
- GCP SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com` (`storage.objectAdmin` + `storage.legacyBucketReader` on the bucket)
- K8s SA: cluster pods run as **`cnpg-backup-sa`** in `knoe-db-0`, set via `cluster.spec.serviceAccountName: cnpg-backup-sa`. The SA has the `iam.gke.io/gcp-service-account` annotation. RoleBindings `knoe-db` and `knoe-db-barman-cloud` include `cnpg-backup-sa` as a subject so the pod has the same RBAC the auto-generated SA would have had.
- ObjectStore manifest: `k8s/knoe/knoe-db-barman-objectstore-gcs.yaml` — includes `googleCredentials.gkeEnvironment: true`
- CNPG backups go to **GCS** (not Garage): `gs://knoe-0-backups/` and `gs://knoe-0-wal/`
- Workload Identity SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com`
- ObjectStore manifest: `k8s/knoe/knoe-db-barman-objectstore-gcs.yaml`
- Setup: `etc/init_cnpg_gke.sh` and `etc/init_cnpg_backup.sh`
> **Performance note:** Pod `memory: 512Mi` makes `barman-cloud-backup` runs 3090 min for the 9 GB DB. Bump pending in [`docs/TODO.md`](docs/TODO.md).

142
CLAUDE.md
View File

@ -4,34 +4,29 @@
---
## Repo role: knoe-db platform repo
## Repo role: customer deploy of `knoe-db`
This is `knoe-db` (remote: `git@git-ssh.knoe.dev:knoe.dev/knoe-db.git`, configured as both `origin` and `knoe`). It's the platform's source of truth`authority/`, `knoe/`, `etc/init_*.sh`, `deploy/gcp/gke/*`, the test pipeline, all live here.
This working tree (`prole`) is being converged into a **customer-deploy branch** of the upstream platform repo `knoe-db` (remote: `git@git-ssh.knoe.dev:knoe.dev/knoe-db.git`, configured here as the `knoe` remote). Platform code`authority/`, `knoe/`, `etc/init_*.sh`, `deploy/gcp/gke/*`, the test pipeline — is not maintained in this repo; its source of truth is `knoe-db/main`. Customer-specific divergence (config, branding, on-prem `prole.org` manifests) is what stays on the customer branch.
Customer deploys are intended to live as **branches** in this repo (e.g. a future `customer/prole.org`), not as separate forks. As of this writing, no customer branch is active — the prole→knoe rebrand has merged into `main` and there is no separate `~/dev/prole` working tree under development. See [`docs/plans/customer-deploy-resync.md`](docs/plans/customer-deploy-resync.md) for the original plan, currently dormant.
## Master TODO
Single source of truth for unfinished work, including the reality-vs-intent gaps flagged below: **[`docs/TODO.md`](docs/TODO.md)**.
**Before changing anything in this tree, check whether the change belongs upstream.** If it's platform code, land it on `knoe-db/main` and rebase the customer branch.
## Active plans
- [`docs/plans/customer-deploy-resync.md`](docs/plans/customer-deploy-resync.md) — The active plan for converging `prole` onto `knoe-db/main` as a customer-deploy branch. Inventory + cherry-pick + rebase cadence. **Read this first** if you are about to touch anything in this tree.
- [`docs/plans/README.md`](docs/plans/README.md) — Index and conventions for this directory.
- [`docs/plans/knoe-auth-round-1.md`](docs/plans/knoe-auth-round-1.md) — Identity backbone (Kerberos KDC + invite-OTP + TOTP). **Shipped.**
- [`docs/plans/deployment-modes.md`](docs/plans/deployment-modes.md) — Four installer modes (`min` / `k3d` / `k3s` / `gke`). **Shipped (Phase 0).**
- [`docs/pipeline-phases.md`](docs/pipeline-phases.md) — Autobuild & test pipeline phase reference. Phase 0 ✅, Phase 1 next (first task: f-string fix at `knoe/core/ops/cloudnative_pg.py:1372`).
- [`docs/plans/customer-deploy-resync.md`](docs/plans/customer-deploy-resync.md) — The original plan to converge `~/dev/prole` onto `knoe-db/main` as a customer-deploy branch. **Dormant** (rebrand is now in main, no separate prole tree active).
For platform-level plans (knoe-auth, deployment modes, etc.), read [`knoe-db/docs/plans/`](../knoe-db/docs/plans/) (path relative to your `~/dev/` root). Those documents are the architectural reference for the shipped platform features and are versioned with the upstream code, not here.
---
## Dual-cluster GKE architecture
This project uses **two separate GKE Standard clusters** in `us-west3`, both currently provisioned with `e2-standard-2` × 3 nodes (2 vCPU / 8 GB each, ~7.1 GB allocatable). Verify with `gcloud container clusters list`.
This project uses **two separate GKE Standard clusters** in `us-west3`, both currently provisioned with `e2-standard-2` × 3 nodes (2 vCPU / 8 GB each, ~7.1 GB allocatable). Verify with `gcloud container clusters list` — `knoe/core/actions.py` still carries an `e2-small` default for the app cluster but the live cluster is `e2-standard-2`.
| Cluster | Context | Role |
|---|---|---|
| `knoe-dev-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-dev-0` | App cluster — Garage, registry, OpenBao, Kong, GitLab, monitoring |
| `knoe-dev-cnpg-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0` | DB cluster — CNPG/PostgreSQL only |
| `knoe-cnpg-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0` | DB cluster — CNPG/PostgreSQL only (note: `conf/gke.cfg` currently deploys this as `knoe-dev-cnpg-0`; the code default remains `knoe-cnpg-0`) |
**Storage quota:** the project has `SSD_TOTAL_GB = 300 GB` in `us-west3`, **fully consumed by CNPG**. All non-CNPG PVCs must use `standard` (pd-standard / HDD) — not `standard-rwo` / `premium-rwo`, which are SSD-backed and will fail to provision with a quota error. `GITLAB_GITALY_STORAGE_CLASS = standard` is set in `conf/gke.cfg` accordingly.
@ -39,28 +34,28 @@ This project uses **two separate GKE Standard clusters** in `us-west3`, both cur
| Resource | Cluster | Namespace |
|---|---|---|
| CNPG operator (v1.29.0) | `knoe-dev-cnpg-0` | `cnpg-system` |
| PostgreSQL cluster (`knoe-db`) | `knoe-dev-cnpg-0` | `knoe-db-0` |
| Barman Cloud plugin (v0.12.0) | `knoe-dev-cnpg-0` | `cnpg-system` |
| cert-manager | `knoe-dev-cnpg-0` | `cert-manager` |
| CNPG operator | `knoe-cnpg-0` | `cnpg-system` |
| PostgreSQL cluster (`knoe-db`) | `knoe-cnpg-0` | `knoe-db-0` |
| Barman Cloud plugin | `knoe-cnpg-0` | `cnpg-system` |
| cert-manager | `knoe-cnpg-0` | `cert-manager` |
| Garage (S3 object store) | `knoe-dev-0` | `knoe-system` |
| Registry | `knoe-dev-0` | `knoe-system` |
| OpenBao | `knoe-dev-0` | `knoe-system` |
| Kong API gateway | `knoe-dev-0` | `knoe-system` |
| Monitoring | `knoe-dev-0` | `monitoring` |
**Garage runs ONLY in `knoe-dev-0`.** The DB cluster (`knoe-dev-cnpg-0`) has none — was removed 2026-04-29. Do NOT redeploy Garage to the DB cluster (use GCS for backups there).
**Garage runs ONLY in `knoe-dev-0`.** Do NOT deploy Garage to `knoe-cnpg-0`.
### CNPG backups → GCS
### CNPG backups → GCS (not Garage)
Backups use **GCS with Workload Identity**:
- Data + WAL: `gs://knoe-0-backups/` (single bucket; `knoe-db/base/` and `knoe-db/wals/` prefixes). `gs://knoe-0-wal/` exists but is unused.
- GCP SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com` (roles on bucket: `storage.objectAdmin`, `storage.legacyBucketReader`)
- K8s SA: cluster pods run as **`cnpg-backup-sa`** in `knoe-db-0` (set via `cluster.spec.serviceAccountName`, requires CNPG ≥ v1.29.0). The SA is annotated with `iam.gke.io/gcp-service-account=cnpg-backup@…`. Two `RoleBinding` subjects (`knoe-db` and `knoe-db-barman-cloud`) include `cnpg-backup-sa` so the pod has the same RBAC the auto-generated SA would have had.
- ObjectStore manifest: [`k8s/knoe/knoe-db-barman-objectstore-gcs.yaml`](k8s/knoe/knoe-db-barman-objectstore-gcs.yaml) — includes `googleCredentials.gkeEnvironment: true` (required by plugin-barman-cloud v0.12.0).
Setup script: [`etc/init_cnpg_gke.sh`](etc/init_cnpg_gke.sh) (creates buckets, GCP SA, WI binding, applies CNPG cluster).
Backups use **GCS with Workload Identity** (not Garage):
- Data bucket: `gs://knoe-0-backups/`
- WAL bucket: `gs://knoe-0-wal/`
- GCP SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com`
- K8s SA: `cnpg-backup-sa` in `knoe-db-0` (annotated with WI)
- ObjectStore manifest: `k8s/knoe/knoe-db-barman-objectstore-gcs.yaml`
Setup script: `etc/init_cnpg_gke.sh` (creates buckets, GCP SA, WI binding, applies CNPG cluster).
---
@ -94,15 +89,15 @@ Before running `./install.sh` (especially "Initialization Scripts"), confirm the
```ini
[Inputs]
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
env_setup.APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
[Global]
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-dev-cnpg-0>
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-cnpg-0>
```
### `conf/service/prod.cfg` (unattended deploy — `./deploy.sh`)
@ -112,14 +107,14 @@ Same cluster context entries are required here too:
```ini
[Inputs]
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
init_cluster.db_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
env_setup.APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
env_setup.DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
[Global]
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
APP_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0
DB_CLUSTER_KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
SERVICE_NAMESPACE = knoe-system
REGISTRY_NAMESPACE = knoe-system
@ -127,38 +122,12 @@ REGISTRY_NAMESPACE = knoe-system
**Why these matter:** `Milestone._get_script_env()` (in `knoe/milestone.py`) reads these to set `KUBECONTEXT=app_ctx` for common services and `DB_CLUSTER_KUBECONTEXT=db_ctx` for CNPG ops. Without them, all kubectl calls use the ambient context, which may be the DB cluster.
Missing `init_cluster.app_cluster_kubecontext``_cluster_kubecontext("app")` returns `""` → installer falls back to `Global.KUBECONTEXT` for **both** app and db environments → **Garage deploys to knoe-dev-cnpg-0** (wrong).
> **Env-contamination guard (live):** `deploy.sh` calls
> [`etc/preflight_kubecontext.sh`](etc/preflight_kubecontext.sh) and refuses
> to proceed when `kubectl config current-context` doesn't match the
> `[Global] APP_CLUSTER_KUBECONTEXT` of the active config. `install.sh`
> prints the inherited context up-front (mode-aware strict gate is the
> Python TUI's responsibility once the welcome screen records a mode).
> Bypass with `KNOE_SKIP_KUBECONTEXT_GUARD=true` for deliberate
> cross-cluster maintenance. **History:** the guard was filed in response
> to the 2026-04-28 14:00 UTC outage — an `install.sh --mode k3d` run with
> the shell pointed at GKE replaced the GCS-backed ObjectStore with a
> Garage-backed one, then Garage filled up and backups silently failed for
> hours. Closes drift R4 / queue item #1.
> **Env-contamination guard (live):** `deploy.sh` calls
> [`etc/preflight_kubecontext.sh`](etc/preflight_kubecontext.sh) and refuses
> to proceed when `kubectl config current-context` doesn't match the
> `[Global] APP_CLUSTER_KUBECONTEXT` of the active config. `install.sh`
> prints the inherited context up-front (mode-aware strict gate is the
> Python TUI's responsibility once the welcome screen records a mode).
> Bypass with `KNOE_SKIP_KUBECONTEXT_GUARD=true` for deliberate
> cross-cluster maintenance. **History:** the guard was filed in response
> to the 2026-04-28 14:00 UTC outage — an `install.sh --mode k3d` run with
> the shell pointed at GKE replaced the GCS-backed ObjectStore with a
> Garage-backed one, then Garage filled up and backups silently failed for
> hours. Closes drift R4 / queue item #1.
Missing `init_cluster.app_cluster_kubecontext``_cluster_kubecontext("app")` returns `""` → installer falls back to `Global.KUBECONTEXT` for **both** app and db environments → **Garage deploys to knoe-cnpg-0** (wrong).
### Get current CNPG node names
```bash
kubectl --context=gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0 get nodes -o name
kubectl --context=gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0 get nodes -o name
```
---
@ -178,8 +147,8 @@ kubectl --context=gke_plenary-truck-485623-p7_us-west3_knoe-dev-cnpg-0 get nodes
```python
DEFAULT_APP_CLUSTER_NAME = "knoe-dev-0"
DEFAULT_DB_CLUSTER_NAME = "knoe-dev-cnpg-0"
DEFAULT_APP_CLUSTER_MACHINE_TYPE = "e2-standard-2"
DEFAULT_DB_CLUSTER_NAME = "knoe-cnpg-0" # also: DEFAULT_CNPG_CLUSTER_NAME
DEFAULT_APP_CLUSTER_MACHINE_TYPE = "e2-small" # STALE — live app cluster is e2-standard-2
DEFAULT_DB_CLUSTER_MACHINE_TYPE = "e2-standard-2"
```
@ -192,50 +161,3 @@ DEFAULT_DB_CLUSTER_MACHINE_TYPE = "e2-standard-2"
- Artifact Registry: `us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system`
- CPU quota: 16 vCPUs (all regions) — 2 clusters × 3 × `e2-standard-2` = 12 vCPUs used
- SSD quota: `SSD_TOTAL_GB = 300 GB` — fully consumed by CNPG; all other PVCs must use `standard` (pd-standard / HDD)
---
## Reality TODOs / Drift Log
Quick reference. Each entry links to the master index where context, owner, and rank live.
| # | Drift | Where described above | Where tracked |
|---|---|---|---|
| _(none currently)_ | | | |
**Closed in 2026-04-29 stabilization session:** Garage on DB cluster removed; cluster pods migrated to `cnpg-backup-sa` via CNPG v1.29.0 `spec.serviceAccountName`; both operators restarted clean.
**Closed 2026-05-01:** R4 — installer env-contamination guard now live in `deploy.sh` (strict) + `install.sh` (informational notice). Helper at [`etc/preflight_kubecontext.sh`](etc/preflight_kubecontext.sh).
---
## k3s CNPG database (production)
The prole.org k3s CNPG cluster is this project's **production** PostgreSQL database.
```bash
psql "host=pg.prole.org port=5432 user=chrisfu dbname=postgres sslmode=verify-full sslrootcert=$HOME/.knoe/knoe-db-ca.crt"
```
| Detail | Value |
|---|---|
| External hostname | `pg.prole.org:5432` |
| Internal service | `knoe-db-rw.knoe-db.svc.cluster.local:5432` |
| kubectl context | `prole-service-cluster` |
| Namespace | `knoe-db` |
| CA cert | `~/.knoe/knoe-db-ca.crt` |
| sslmode | `verify-full` |
**DNS**: `pg.prole.org` resolves internally via split-horizon DNS on `myrddin.prole.org` (Samba AD DC) to the k3s ServiceLB node IPs (`10.0.0.3`, `10.0.0.6`). External DNS resolves to the public IP — do not access from outside the LAN without a VPN.
**CA cert refresh** (after CNPG cert rotation):
```bash
kubectl --context=prole-service-cluster -n knoe-db \
get secret knoe-db-ca -o jsonpath='{.data.ca\.crt}' | base64 -d > ~/.knoe/knoe-db-ca.crt
```
**Node mobility**: to move the postgres LoadBalancer to a different node, update the Samba DNS A records:
```bash
ssh myrddin.prole.org "sudo samba-tool dns delete myrddin.prole.org prole.org pg A <OLD_IP> -U Administrator"
ssh myrddin.prole.org "sudo samba-tool dns add myrddin.prole.org prole.org pg A <NEW_IP> -U Administrator"
```

View File

@ -14,13 +14,9 @@ PIPELINE_DIR ?= deploy/opentofu/k3s
DEPLOYMENT_GIT_DIR ?= knoe/deployment
GITEA_SCRIPT ?= knoe/etc/gitea.sh
KUBECONFIG_PATH ?= $(CURDIR)/knoe-k3s.kubeconfig
REGISTRY ?= us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
KNOE_AUTH_VERSION ?= latest
DEPLOYMENT_REPO_URL ?= http://gitea.local/knoe/deployment.git
.PHONY: all knoe build build-auth docker-build-auth docker-push-auth install deploy init clean help requirements test pyconv start \
k3d-knoe-up k3d-knoe-pf k3d-knoe-down \
workstation
.PHONY: all knoe build install deploy init clean help requirements test pyconv start
all: build
@ -36,8 +32,6 @@ help:
@echo " install - Run silent install via install.sh"
@echo " deploy - Run infrastructure deployment via deploy.sh"
@echo " build - Build the 'knoe' CLI binary"
@echo " build-auth - Build the knoe-auth Spring Boot jar (authority/pom.xml)"
@echo " workstation - Configure Kerberos + Chrome SPNEGO on this machine"
@echo " requirements - Install Python dependencies"
@echo " test - Run full test suite"
@echo " pyconv - Check Python code style conventions (black)"
@ -45,11 +39,6 @@ help:
@echo ""
@echo "Environment:"
@echo " KNOE_CONF - Directory containing knoe.cfg (default: conf)"
@echo ""
@echo "k3d dev loop targets:"
@echo " k3d-knoe-up - Bring up local k3d cluster (CNPG + KDC + schema)"
@echo " k3d-knoe-pf - Open port-forwards (5432/88/464) — foreground, ^C to stop"
@echo " k3d-knoe-down - Tear down the k3d-knoe cluster"
requirements:
@echo "Installing dependencies..."
@ -64,24 +53,6 @@ build:
$(PYINSTALLER) --clean --noconfirm knoe.spec
@echo "✓ Build complete: $(DIST_DIR)/Knoe.DB Installer.app"
build-auth:
@command -v mvn >/dev/null 2>&1 || (echo "Error: mvn not found in PATH." && exit 1)
@echo "Building knoe-auth (dev.knoe:auth) via authority/pom.xml..."
mvn -f authority/pom.xml -DskipTests package
@echo "✓ knoe-auth jar: authority/target/knoe-auth.jar"
docker-build-auth: build-auth
@command -v docker >/dev/null 2>&1 || (echo "Error: docker not found in PATH." && exit 1)
@echo "Building Docker image knoe-auth:$(KNOE_AUTH_VERSION)..."
docker build -f authority/Dockerfile.app -t knoe-auth:$(KNOE_AUTH_VERSION) .
@echo "✓ Docker image: knoe-auth:$(KNOE_AUTH_VERSION)"
docker-push-auth: docker-build-auth
@echo "Tagging and pushing $(REGISTRY)/knoe-auth:$(KNOE_AUTH_VERSION)..."
docker tag knoe-auth:$(KNOE_AUTH_VERSION) $(REGISTRY)/knoe-auth:$(KNOE_AUTH_VERSION)
docker push $(REGISTRY)/knoe-auth:$(KNOE_AUTH_VERSION)
@echo "✓ Pushed: $(REGISTRY)/knoe-auth:$(KNOE_AUTH_VERSION)"
install:
@echo "Running silent install..."
KNOE_CONF=$(KNOE_CONF) ./install.sh -s -c $(KNOE_CONF)/knoe.cfg
@ -102,11 +73,6 @@ deploy:
@echo "Running Knoe deployment..."
./deploy.sh
workstation:
@echo "Configuring Kerberos + Chrome SPNEGO on this machine..."
@echo "(You will be prompted for your sudo password)"
@bash infrastructure/bin/install_workstation.sh
test: pyconv
@echo "Running full test suite..."
@./tests/run_tests.sh
@ -118,20 +84,6 @@ pyconv:
@echo "Checking Python code style conventions..."
@$(PYTHON) -m black --check . || (echo "Warning: pyconv (black) found style issues. Run 'black .' to fix." && exit 1)
k3d-knoe-up:
@./scripts/k3d-knoe-up.sh
k3d-knoe-pf:
@./scripts/k3d-knoe-pf.sh
k3d-knoe-down:
@./scripts/k3d-knoe-down.sh
k8s/knoe/knoe-ekosystem-sql.yaml:
@echo "Generating ConfigMap from knoe-db/schema/ekosystem*.sql..."
@$(PYTHON) scripts/gen-ekosystem-configmap.py
@echo "✓ k8s/knoe/knoe-ekosystem-sql.yaml updated"
clean:
@echo "Cleaning build artifacts..."
rm -rf $(BUILD_DIR) $(DIST_DIR) *.spec

263
README.md
View File

@ -1,35 +1,17 @@
<div align="center">
<a href="https://svc.prole.org"><pre>
# #############################################
# ╭───────────────────────────────────────╮ #
<a href="https://svc.knoe.org"><pre>
# ###########################
# ╭──────────────────────╮ #
# │ _ │ #
# │ | _/ _ | _ |_ │ #
# │ |/ | \ | ( |_) │ #
# │ │ #
# │ ___ ____ ___ _ ____ │ #
# │ | _ \| _ \/ _ \| | | ___| │ #
# │ | _/| |_) | | | | | |___ \ │ #
# │ | | | __/| |_| | |___ ___) | │ #
# │ |_| |_| \___/|_____|____/ . │ #
# │ │ #
# │ svc · db · git · api │ #
# ╰───────────────────────────────────────╯ #
# #############################################
# ╰──────────────────────╯ #
# ###########################
</pre></a>
[svc.prole.org](https://svc.prole.org) &nbsp;·&nbsp;
[db.prole.org](https://db.prole.org) &nbsp;·&nbsp;
[git.prole.org](https://git.prole.org) &nbsp;·&nbsp;
[api.prole.org](https://api.prole.org)
</div>
# Prole.
**Prole** is the reference homelab deployment of the **[Knoe](https://git.knoe.dev)** platform — running on a three-node k3s ARM64 cluster with Samba Active Directory, Kerberos SSO, and Google Workspace login across all services.
This repo is the `prole.org` fork of [knoe-db](https://git.knoe.dev/knoe-dev/knoe-db), tracking upstream and carrying the cluster-specific manifests, identity configuration, and operational tooling for `prole.org`.
---
## Knoe platform
# Knoe
**Knoe makes it practical to run a Supabase-style platform across air-gapped, edge, and cloud environments.**
@ -315,229 +297,16 @@ Detailed setup docs, cluster procedures, and host-specific notes should live in
---
## prole.org deployment
This repository is the **prole.org fork** of the upstream knoe-db platform. It runs a production k3s cluster on a three-node ARM64 LAN (myrddin / gandalf / merlin) with Samba AD providing Kerberos identity for single sign-on across all platform services.
### Live services
| Service | URL | Auth |
|---|---|---|
| Supabase Studio | `https://db.prole.org` | SPNEGO → OIDC → Studio |
| Gitea | `https://git.prole.org` | SPNEGO (auto) or Gitea login |
| PostgreSQL | `pg.prole.org:5432` | mTLS + CNPG cert |
| SSH (Gitea) | `git.prole.org:3022` | SSH key |
### Cluster nodes
| Host | Role | IP |
|---|---|---|
| `myrddin.prole.org` | k3s server, Samba AD DC, container registry | 10.0.0.3 |
| `gandalf.prole.org` | k3s agent | 10.0.0.5 |
| `merlin.prole.org` | k3s agent | 10.0.0.6 |
Kubectl context: `prole-service-cluster` (kubeconfig at `knoe-k3s.kubeconfig`).
### Key namespaces
| Namespace | Contents |
|---|---|
| `knoe-system` | knoe-auth, Kong API gateway, KDC, Redis, Traefik |
| `gitea` | Gitea, gitea-spnego-proxy |
| `knoe-db` | CloudNativePG cluster |
| `supabase` | Supabase Studio, Kong (supabase), oauth2-proxy |
---
## Identity & SSO
All platform services use a unified Kerberos SSO stack. The flow from browser to service is:
```
Browser (Chrome, Safari, curl --negotiate)
│ kinit-obtained TGT from PROLE.ORG KDC (myrddin.prole.org)
Traefik (TLS termination, *.prole.org wildcard cert via ACME)
Kong API gateway (knoe-system/knoe-svc-kong)
│ routes by Host header
├── db.prole.org ──► oauth2-proxy ──► knoe-auth (OIDC) ──► Supabase Studio
└── git.prole.org ──► gitea-spnego-proxy (port 4000) ──► Gitea (port 3000)
```
### Kerberos realm
- **Realm:** `PROLE.ORG`
- **KDC / AD DC:** `myrddin.prole.org` (Samba 4, `10.0.0.3`)
- **Cross-realm trust:** `PROLE.ORG ↔ KNOE.LOCAL` (in-cluster MIT KDC for knoe-auth internal use)
- **Encryption:** AES256 + AES128 only (`msDS-SupportedEncryptionTypes=24`); RC4 disabled on all service accounts
### knoe-auth (OIDC provider — `api.prole.org/auth`)
knoe-auth is a Spring Boot 3 / JDK 21 OIDC authorization server that validates Kerberos SPNEGO tokens and issues OIDC tokens for downstream services.
**Key implementation details:**
| Detail | Value |
|---|---|
| Deployment | `knoe-system/knoe-auth` |
| SPNEGO endpoint | `https://api.prole.org/auth/spnego` |
| Keytab secret | `knoe-system/knoe-auth-keytab` (`HTTP/api.prole.org@PROLE.ORG`, AES-only) |
| JDK Subject API | `Subject.callAs()`**not** `Subject.doAs()` (removed in JDK 21) |
| RC4 | Hard-removed in JDK 21 JGSS — keytab and AD account must be AES-only |
| krb5.conf | Mounted via ConfigMap; `permitted_enctypes = aes256 aes128` (cannot re-enable RC4 here) |
Keytab provisioning (`etc/init_knoe_users.sh`):
```bash
# On myrddin — set AES-only, reset password, export and rekey keytab
sudo ldbmodify -H /var/lib/samba/private/sam.ldb <<EOF
dn: CN=knoe-auth,CN=Users,DC=prole,DC=org
changetype: modify
replace: msDS-SupportedEncryptionTypes
msDS-SupportedEncryptionTypes: 24
EOF
sudo samba-tool user setpassword knoe-auth --newpassword="$(openssl rand -base64 32 | tr -d '=/+' | head -c 40)"
# Export full domain keytab; rekey to HTTP/api.prole.org@PROLE.ORG in Python
# (samba-tool exportkeytab --principal=HTTP/... returns empty — see note below)
```
**Note:** `samba-tool domain exportkeytab --principal=HTTP/hostname` always produces an empty keytab on Samba 4 — the filter only matches UPN format, not SPN format. The correct procedure is to export the full domain keytab and rekey the AES entries to the SPN principal name using the Python script embedded in `infrastructure/playbooks/gitea_spnego_keytab.yml` (same logic applies for all HTTP service principals).
### Supabase Studio SSO (`db.prole.org`)
```
Browser → Kong → oauth2-proxy → knoe-auth OIDC → oauth2-proxy (cookie set)
→ Kong (upstream to studio)
→ Supabase Studio
```
- **oauth2-proxy** handles the OIDC callback and sets a session cookie (`_oauth2_proxy`)
- **Supabase Kong** dashboard route: `cors` plugin only — `basic-auth` plugin **removed** (it blocked oauth2-proxy's proxied requests)
- The `basic-auth` removal is applied directly to the live ConfigMap and must be re-applied after any `helm upgrade` of the supabase chart
Supabase Studio access:
```bash
# Via browser (automatic SPNEGO with Chrome policy)
open https://db.prole.org
# Check oauth2-proxy is passing through correctly
curl -I https://db.prole.org/oauth2/sign_in
```
Chrome SPNEGO policy (`/Library/Managed Preferences/com.google.Chrome.plist`):
```xml
<key>AuthServerAllowlist</key>
<string>*.prole.org</string>
<key>AuthNegotiateDelegateAllowlist</key>
<string>*.prole.org</string>
```
Deploy to a Mac workstation:
```bash
make workstation
# or: ansible-playbook infrastructure/playbooks/workstation_kerberos.yml --ask-become-pass
```
### git.prole.org SPNEGO (Gitea)
```
Browser/curl → Kong → gitea-spnego-proxy (:4000) → Gitea (:3000)
├─ No Authorization header → 401 + WWW-Authenticate: Negotiate
├─ Authorization: Negotiate <token> → SPNEGO validate → X-WEBAUTH-USER → Gitea auto-login
└─ Authorization: Basic/token → pass-through → Gitea auth
```
**Component:** `gitea/spnego-proxy/` — Go binary using `gokrb5/v8`, built for `linux/arm64`.
| Detail | Value |
|---|---|
| Image | `myrddin.prole.org:5000/gitea-spnego-proxy:latest` |
| Keytab secret | `gitea/gitea-krb5-keytab` (`HTTP/git.prole.org@PROLE.ORG`, AES-only, KVNO 4) |
| AD account | `CN=gitea-http,CN=Users,DC=prole,DC=org` (`msDS-SupportedEncryptionTypes=24`) |
| Rebuild | Build on myrddin (native arm64); `docker build` then `docker push localhost:5000/...` |
Keytab rotation:
```bash
ansible-playbook infrastructure/playbooks/gitea_spnego_keytab.yml -e force_keytab_reset=true
```
Manual rebuild (if source changed):
```bash
# Transfer source and build on myrddin (all nodes are arm64)
tar -czf /tmp/src.tar.gz gitea/spnego-proxy/ && scp /tmp/src.tar.gz myrddin:/tmp/
ssh myrddin "mkdir -p /tmp/spnego-build && tar -xzf /tmp/src.tar.gz -C /tmp/spnego-build && \
sudo docker build -t localhost:5000/gitea-spnego-proxy:latest /tmp/spnego-build/gitea/spnego-proxy/ && \
sudo docker push localhost:5000/gitea-spnego-proxy:latest"
kubectl --context=prole-service-cluster -n gitea rollout restart deployment/gitea-spnego-proxy
```
Smoke test:
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://git.prole.org/ # → 401 (challenge)
curl -s -o /dev/null -w "%{http_code}\n" --negotiate -u : https://git.prole.org/ # → 200 (authed)
```
---
## Status
**As of 2026-05-28** the following work has shipped on the prole.org fork:
Knoe is an actively evolving platform stack aimed at practical self-hosted, edge, and cloud operation.
**Identity / SSO (May 2026)**
- `feat(gitea)` — SPNEGO Kerberos SSO for `git.prole.org`; proxy issues `WWW-Authenticate: Negotiate` challenge; `gitea_spnego_keytab.yml` Ansible playbook for full provisioning lifecycle (`5077e13`)
- `fix(ansible)` — workstation install script + `make workstation` target for Chrome SPNEGO policy on personal Macs (`b245593`)
- `fix(spnego)``Subject.callAs()`, AES-only keytab, and `krb5.conf` sync for JDK 21 knoe-auth (`c1d2a91`)
- `feat(oidc)` — knoe-auth routing through Kong; Flyway schema baseline for clean OIDC DB (`9523045`)
- `feat(prole)` — knoe-auth bootstrap on k3s; tenant onboarding; cluster stabilisation (`cf33342`)
**Infrastructure (earlier)**
- `fix(cfg)``_validate_cfg_values` prevents MagicMock reprs from leaking into `conf/*.cfg`
- `feat(env)``gke_` kubecontext prefix auto-detected as `prod`; `status.py` context helpers
- `refactor(mock_val)``prole_*` shell lib and cfg tooling renamed to `knoe_*` namespace
- `feat(mock_val)` — init scripts rewritten; 10+ new service init scripts added
- `feat(scripts)` — upstream knoe-db sync script + procedure doc
- `feat(pg-knoe-auth)` — upstream PostgreSQL JWT auth extension imported; compiled in `knoe-db` image
Working tree is clean. Browser SPNEGO SSO is live on `db.prole.org` and `git.prole.org`.
---
## Upstream fork — knoe-db
This repository is an independent fork of the upstream knoe-db project:
```
git@git.knoe.dev:knoe-dev/knoe-db.git
```
Upstream changes are pulled into a dated review branch (`upstream/knoe-db/YYYYMMDD`) using the sync script, inspected, and selectively merged into `main`.
```bash
# Preview what the script will do
./scripts/sync_upstream_knoe_db.sh --dry-run
# Run the sync (requires a clean working tree)
./scripts/sync_upstream_knoe_db.sh
```
See [`docs/upstream-knoe-db-sync.md`](docs/upstream-knoe-db-sync.md) for the full review-and-merge procedure.
### Fork-specific files
Files added or substantially modified in the prole.org fork (not present or not relevant upstream):
| Path | Purpose |
|---|---|
| `infrastructure/` | Ansible roles, playbooks, and inventory for the prole.org cluster |
| `infrastructure/playbooks/gitea_spnego_keytab.yml` | Gitea SPNEGO keytab provisioning |
| `infrastructure/playbooks/workstation_kerberos.yml` | Chrome SPNEGO policy + krb5.conf for macOS workstations |
| `infrastructure/playbooks/kerberos_trust_setup.yml` | PROLE.ORG ↔ KNOE.LOCAL cross-realm trust |
| `infrastructure/bin/install_workstation.sh` | Wrapper for workstation Ansible (adds `--ask-become-pass`) |
| `gitea/spnego-proxy/` | Go SPNEGO reverse proxy for `git.prole.org` |
| `deploy/opentofu/k3s/manifests/knoe/gitea-spnego-proxy.yaml` | k8s deployment for the SPNEGO proxy |
| `conf/k3s.cfg` | prole.org k3s cluster configuration |
Expect the architecture to continue being refined toward:
- cleaner bootstrapping
- better shard isolation
- smoother rejoin/reset behavior for cluster nodes
- clearer service boundaries
- improved onboarding and operations documentation
---

View File

@ -1,27 +0,0 @@
[defaults]
inventory = infrastructure/inventory/hosts.ini
roles_path = infrastructure/roles
collections_paths = infrastructure/collections
interpreter_python = auto_silent
deprecation_warnings = False
vault_password_file = .vault_pass
callback_plugins = lib/ansible/plugins/callback
callbacks_enabled = run_logger
stdout_callback = default
result_format = yaml
forks = 20
timeout = 30
host_key_checking = True
[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False
[ssh_connection]
pipelining = True
# Use a repo-local known_hosts file and accept new keys non-interactively.
# This prevents unattended runs from failing when inventory uses IPs via `ansible_host`
# and the key is not yet present in the user's `~/.ssh/known_hosts`.
ssh_common_args = -o UserKnownHostsFile=.ansible/known_hosts -o StrictHostKeyChecking=accept-new

View File

@ -1,148 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export ANSIBLE_CONFIG="${ROOT_DIR}/ansible.cfg"
# macOS: prevent Objective-C runtime from aborting forked worker processes.
# Required for WinRM connections (pywinrm/requests uses Foundation framework
# internally; macOS kills forked children that load ObjC before fork()).
export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
# Defaults
MODE="fg" # fg|bg
PLAYBOOK="infrastructure/playbooks/site.yml"
VAULT_PASS_FILE=""
if [[ -f "${ROOT_DIR}/.vault_pass" ]]; then
VAULT_PASS_FILE="${ROOT_DIR}/.vault_pass"
fi
LIMIT="" # optional
TAGS="" # optional
EXTRA_ARGS=() # passthrough
# Use local logs if PROLE_LOGS is not writable or looks like a remote path
LOG_BASE="${ROOT_DIR}/.ansible/logs"
if [[ -n "${PROLE_LOGS:-}" ]]; then
# If it's a relative path, or it exists and is writable, or its parent is writable
if [[ ! "${PROLE_LOGS}" =~ ^/ ]] || [[ -w "${PROLE_LOGS}" ]] || [[ -w "$(dirname "${PROLE_LOGS}" 2>/dev/null)" ]]; then
LOG_BASE="${PROLE_LOGS}"
fi
fi
LOG_BASE="${LOG_BASE%/}"
LOG_DIR="${LOG_BASE}/ansible"
SYSLOG_HOST="" # e.g. loghost.prole.org
SYSLOG_PORT="514"
SYSLOG_TAG="ansible"
usage() {
cat <<EOF
Usage: ./ansible.sh [options] [-- <extra ansible-playbook args>]
Options:
-p, --playbook PATH Playbook path (default: ${PLAYBOOK})
-l, --limit HOSTS Limit hosts
-t, --tags TAGS Tags
-v, --vault-pass-file FILE Vault password file
-m, --mode fg|bg Run in foreground or background (default: fg)
--syslog-host HOST Send start/end markers via UDP syslog to HOST
--syslog-port PORT Syslog UDP port (default: 514)
--syslog-tag TAG Syslog tag (default: ansible)
-h, --help Show help
Examples:
./ansible.sh -l pi.prole.org -t iscsi -v .vault_pass
./ansible.sh -m bg -p infrastructure/playbooks/site.yml -v .vault_pass
./ansible.sh --syslog-host loghost.prole.org -m bg -v .vault_pass -- -vv
EOF
}
send_syslog() {
local msg="$1"
if [[ -n "${SYSLOG_HOST}" ]]; then
# -d = UDP, -n host, -P port
logger -d -n "${SYSLOG_HOST}" -P "${SYSLOG_PORT}" -t "${SYSLOG_TAG}" -- "${msg}" || true
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--playbook) PLAYBOOK="$2"; shift 2 ;;
-l|--limit) LIMIT="$2"; shift 2 ;;
-t|--tags) TAGS="$2"; shift 2 ;;
-v|--vault-pass-file) VAULT_PASS_FILE="$2"; shift 2 ;;
-m|--mode) MODE="$2"; shift 2 ;;
--syslog-host) SYSLOG_HOST="$2"; shift 2 ;;
--syslog-port) SYSLOG_PORT="$2"; shift 2 ;;
--syslog-tag) SYSLOG_TAG="$2"; shift 2 ;;
--) shift; EXTRA_ARGS+=("$@"); break ;;
-h|--help) usage; exit 0 ;;
*) EXTRA_ARGS+=("$1"); shift ;;
esac
done
# Default to k3s hosts for the main site run (avoid touching non-k3s Linux/Pi hosts unless explicitly requested)
if [[ -z "${LIMIT}" ]]; then
if [[ "${PLAYBOOK}" =~ (^|/)infrastructure/playbooks/site\.yml$ ]]; then
LIMIT="k3s_hosts"
fi
fi
mkdir -p "${LOG_DIR}"
ts="$(date +%Y%m%d-%H%M%S)"
logfile="${LOG_DIR}/ansible-${ts}.log"
cmd=(ansible-playbook "${PLAYBOOK}")
[[ -n "${LIMIT}" ]] && cmd+=("--limit" "${LIMIT}")
[[ -n "${TAGS}" ]] && cmd+=("--tags" "${TAGS}")
[[ -n "${VAULT_PASS_FILE}" ]] && cmd+=("--vault-password-file" "${VAULT_PASS_FILE}")
cmd+=("${EXTRA_ARGS[@]}")
send_syslog "START playbook=${PLAYBOOK} limit=${LIMIT:-<none>} tags=${TAGS:-<none>} log=${logfile}"
echo "ANSIBLE_CONFIG=${ANSIBLE_CONFIG}"
echo "LOGFILE=${logfile}"
echo "CMD: ${cmd[*]}"
if [[ "${MODE}" == "fg" ]]; then
# Stream to terminal and file
color_env=()
use_pty=false
if [[ -z "${NO_COLOR:-}" ]] && [[ -z "${ANSIBLE_NOCOLOR:-}" ]]; then
[[ -z "${ANSIBLE_FORCE_COLOR:-}" ]] && color_env+=("ANSIBLE_FORCE_COLOR=true")
[[ -z "${PY_COLORS:-}" ]] && color_env+=("PY_COLORS=1")
# Ensure a useful terminal type for ANSI colors when invoked from wrappers.
if [[ -z "${TERM:-}" ]] || [[ "${TERM}" == "dumb" ]]; then
color_env+=("TERM=xterm-256color")
fi
if command -v script >/dev/null 2>&1; then
use_pty=true
fi
fi
run_cmd=("${cmd[@]}")
if [[ "${use_pty}" == "true" ]]; then
# `tee` breaks TTY detection; wrap in a pseudo-tty so Ansible keeps colors.
run_cmd=(script -qF /dev/null "${cmd[@]}")
fi
set +e
env "${color_env[@]}" "${run_cmd[@]}" 2>&1 | tee "${logfile}"
rc=${PIPESTATUS[0]}
set -e
else
# Background: nohup to logfile
nohup "${cmd[@]}" >"${logfile}" 2>&1 &
rc=0
echo "Started in background (pid $!)"
fi
if [[ "${MODE}" == "fg" ]]; then
if [[ $rc -eq 0 ]]; then
send_syslog "END OK playbook=${PLAYBOOK} limit=${LIMIT:-<none>} tags=${TAGS:-<none>} log=${logfile}"
else
send_syslog "END FAIL rc=${rc} playbook=${PLAYBOOK} limit=${LIMIT:-<none>} tags=${TAGS:-<none>} log=${logfile}"
fi
exit $rc
fi

View File

@ -1,5 +0,0 @@
[defaults]
stdout_callback = default
interpreter_python = auto_silent
host_key_checking = False
forks = 1

View File

@ -1,30 +0,0 @@
# Dockerfile.app — Spring Boot runtime image for knoe-auth
# Build the jar first: make build-auth
# Then build this image: make docker-build-auth
#
# Multi-stage: build stage compiles the jar; runtime stage is minimal JRE.
# ── Build stage ──────────────────────────────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY authority/pom.xml pom.xml
# Download dependencies first (layer-cache friendly)
RUN mvn -f pom.xml dependency:go-offline -q
COPY authority/src src
RUN mvn -f pom.xml -DskipTests package -q && \
mv target/knoe-auth.jar /knoe-auth.jar
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM eclipse-temurin:21-jre-jammy
LABEL org.opencontainers.image.title="knoe-auth" \
org.opencontainers.image.description="Knoe authentication service (Spring Boot)" \
org.opencontainers.image.source="https://github.com/chrisfu/knoe-db"
RUN groupadd --system knoe && useradd --system --gid knoe knoe
WORKDIR /app
COPY --from=build /knoe-auth.jar knoe-auth.jar
RUN chown knoe:knoe knoe-auth.jar
USER knoe
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/knoe-auth.jar"]

View File

@ -10,17 +10,16 @@
<relativePath/>
</parent>
<groupId>dev.knoe</groupId>
<artifactId>auth</artifactId>
<groupId>org.knoe</groupId>
<artifactId>authority</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>knoe-auth</name>
<description>Knoe authentication service (knoe-auth)</description>
<name>knoe-authority</name>
<description>Knoe authentication gateway</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -40,7 +39,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Kerberos: implemented directly against JDK GSS-API (org.ietf.jgss.*) and javax.security.auth.*. No external Spring Security Kerberos dep. -->
<!-- Kerberos/Security dependencies -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-kerberos-client</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
@ -58,83 +62,14 @@
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- ── knoe-auth Round 1: enrollment dependencies ────────────────────── -->
<!-- TOTP (RFC 6238 — Google Authenticator compatible) -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>
<!-- Google OAuth2 ID token verification -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>2.4.0</version>
</dependency>
<!-- Database access (CNPG / PostgreSQL) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- BCrypt for OTP hashing (backup code hashing) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
</dependencies>
<build>
<finalName>knoe-auth</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -Dnet.bytebuddy.experimental=true</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
<configuration>
<excludes>
<exclude>java/**</exclude>
<exclude>javax/**</exclude>
<exclude>jdk/**</exclude>
<exclude>sun/**</exclude>
<exclude>com/sun/**</exclude>
<exclude>org/ietf/**</exclude>
<exclude>org/omg/**</exclude>
<exclude>org/w3c/**</exclude>
<exclude>org/xml/**</exclude>
</excludes>
</configuration>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@ -1,116 +0,0 @@
package dev.knoe.auth.admin;
import dev.knoe.auth.enroll.InviteService;
import dev.knoe.auth.enroll.InviteService.InviteResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* AdminController internal API for knoe.dev admin operations.
*
* Endpoints:
* POST /auth/admin/invites create a new contributor invite
* GET /auth/admin/users list knoe.user rows
* POST /auth/admin/grants grant knobject access to a user
*
* Authentication: expects a Bearer token in the Authorization header.
* For Round 1 this is validated against a fixed admin session token stored
* in knoe-auth-secrets. Full RBAC is a Round 2 concern.
*
* NOTE: All endpoints return JSON. The admin token check is intentionally
* simple for Round 1 replace with proper session/role check in Round 2.
*/
@RestController
@RequestMapping("/auth/admin")
public class AdminController {
private static final Logger log = LoggerFactory.getLogger(AdminController.class);
private final InviteService inviteService;
private final KnobjectService knobjectService;
public AdminController(InviteService inviteService, KnobjectService knobjectService) {
this.inviteService = inviteService;
this.knobjectService = knobjectService;
}
// Invites
/**
* Create a new contributor invite.
*
* Request body: { "contact": "email@example.com", "contactType": "email", "nameHint": "..." }
* Response: { "token": "...", "enrollUrl": "...", "otp": "..." }
*
* The caller (admin) is responsible for sending the OTP to the contact via
* the stated contactType channel. The invite system itself does not send email.
*/
@PostMapping(
path = "/invites",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Map<String, String>> createInvite(
@RequestBody CreateInviteRequest req,
@RequestHeader(value = "X-Knoe-Admin", required = false) String adminHint) {
// Round 1: accept any authenticated request admin auth enforcement in Round 2
String createdBy = adminHint != null ? adminHint : "admin";
log.info("Admin {} creating invite for contact={} type={}", createdBy, req.contact(), req.contactType());
InviteResult result = inviteService.createInvite(
req.contact(),
req.contactType() != null ? req.contactType() : "email",
req.nameHint(),
createdBy
);
return ResponseEntity.ok(Map.of(
"token", result.token(),
"enrollUrl", result.enrollUrl(),
"otp", result.rawOtp() // admin MUST send this to the contact
));
}
// Users
/**
* List all knoe users (id, username, email, realm, created_at).
*/
@GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> listUsers() {
return ResponseEntity.ok(knobjectService.listUsers());
}
// Access grants
/**
* Grant a user access to a knobject.
*
* Request body: { "userId": 1, "knobjectId": 2, "role": "developer", "grantedBy": "admin" }
*/
@PostMapping(
path = "/grants",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<Map<String, Object>> createGrant(@RequestBody GrantRequest req) {
log.info("Granting {} access to knobject {} role={} by {}",
req.userId(), req.knobjectId(), req.role(), req.grantedBy());
int grantId = knobjectService.grantAccess(req.userId(), req.knobjectId(),
req.role(), req.grantedBy());
return ResponseEntity.ok(Map.of("grantId", grantId, "status", "granted"));
}
// Request records
public record CreateInviteRequest(String contact, String contactType, String nameHint) {}
public record GrantRequest(int userId, int knobjectId, String role, String grantedBy) {}
}

View File

@ -1,146 +0,0 @@
package dev.knoe.auth.admin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
/**
* KnobjectService CRUD on knoe.knobject, knoe.access_grant, and knoe.provisioning_job.
*
* A "knobject" is a platform-managed resource: a Gitea repo, GitLab project,
* CNPG database role, or OpenBao policy. Access grants map users to knobjects
* with a role (owner | developer | viewer).
*/
@Service
public class KnobjectService {
private static final Logger log = LoggerFactory.getLogger(KnobjectService.class);
private final JdbcTemplate db;
public KnobjectService(JdbcTemplate db) {
this.db = db;
}
// Users
public List<Map<String, Object>> listUsers() {
return db.queryForList("""
SELECT id, username, email, realm, created_at
FROM knoe.user
ORDER BY created_at DESC
""");
}
// Knobjects
public List<Map<String, Object>> listKnobjects() {
return db.queryForList("SELECT * FROM knoe.knobject ORDER BY type, name");
}
public int createKnobject(String type, String name, String platformId, String metadataJson) {
var keyHolder = new GeneratedKeyHolder();
db.update(con -> {
PreparedStatement ps = con.prepareStatement("""
INSERT INTO knoe.knobject (type, name, platform_id, metadata)
VALUES (?, ?, ?, ?::jsonb)
RETURNING id
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, type);
ps.setString(2, name);
ps.setString(3, platformId);
ps.setString(4, metadataJson);
return ps;
}, keyHolder);
int id = ((Number) keyHolder.getKeys().get("id")).intValue();
log.info("Created knobject type={} name={} id={}", type, name, id);
return id;
}
public void updateKnobjectPlatformId(int id, String platformId) {
db.update("UPDATE knoe.knobject SET platform_id = ? WHERE id = ?", platformId, id);
}
// Access grants
/**
* Grant a user access to a knobject.
*
* @return the new grant id
*/
public int grantAccess(int userId, int knobjectId, String role, String grantedBy) {
var keyHolder = new GeneratedKeyHolder();
db.update(con -> {
PreparedStatement ps = con.prepareStatement("""
INSERT INTO knoe.access_grant (user_id, knobject_id, role, granted_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, knobject_id)
DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by,
revoked_at = NULL
RETURNING id
""", Statement.RETURN_GENERATED_KEYS);
ps.setInt(1, userId);
ps.setInt(2, knobjectId);
ps.setString(3, role);
ps.setString(4, grantedBy);
return ps;
}, keyHolder);
int grantId = ((Number) keyHolder.getKeys().get("id")).intValue();
log.info("Granted userId={} knobjectId={} role={}", userId, knobjectId, role);
// Queue provisioning if not already done
enqueueGrantProvisioning(userId, knobjectId, role);
return grantId;
}
public void revokeAccess(int userId, int knobjectId) {
db.update("""
UPDATE knoe.access_grant SET revoked_at = now()
WHERE user_id = ? AND knobject_id = ? AND revoked_at IS NULL
""", userId, knobjectId);
log.info("Revoked access: userId={} knobjectId={}", userId, knobjectId);
}
public List<Map<String, Object>> listGrants(int userId) {
return db.queryForList("""
SELECT ag.*, ko.type, ko.name
FROM knoe.access_grant ag
JOIN knoe.knobject ko ON ko.id = ag.knobject_id
WHERE ag.user_id = ? AND ag.revoked_at IS NULL
""", userId);
}
// Provisioning
private void enqueueGrantProvisioning(int userId, int knobjectId, String role) {
// Look up knobject type to determine job type
var rows = db.queryForList("SELECT type, name FROM knoe.knobject WHERE id = ?", knobjectId);
if (rows.isEmpty()) return;
String type = (String) rows.get(0).get("type");
String name = (String) rows.get(0).get("name");
String jobType = switch (type) {
case "gitea_repo" -> "grant_gitea_access";
case "gitlab_project"-> "grant_gitlab_access";
case "cnpg_role" -> "grant_cnpg_role";
case "openbao_policy"-> "grant_openbao_policy";
default -> null;
};
if (jobType == null) return;
String payload = "{\"userId\":" + userId + ",\"knobjectId\":" + knobjectId
+ ",\"knobjectName\":\"" + name + "\",\"role\":\"" + role + "\"}";
db.update("""
INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at)
VALUES (?, ?, 'pending', ?::jsonb, now(), now())
""", userId, jobType, payload);
}
}

View File

@ -1,355 +0,0 @@
package dev.knoe.auth.enroll;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.security.SecureRandom;
import java.util.Map;
import java.util.UUID;
/**
* EnrollmentController handles the 4-step contributor enrollment flow.
*
* Step 0: GET /auth/enroll?token=<uuid> landing / OTP entry
* Step 1: POST /auth/enroll/verify-otp prove contact ownership
* Step 2a: POST /auth/enroll/identity/start store PII, redirect to Google
* Step 2b: GET /auth/enroll/google-callback receive Google identity
* Step 3: GET /auth/enroll/totp TOTP QR code display
* Step 3v: POST /auth/enroll/totp/verify verify TOTP code
* Step 4: POST /auth/enroll/complete provision everything
*
* Session attributes used:
* enroll.token invite token
* enroll.otpVerified boolean gate
* enroll.username chosen username
* enroll.displayName display name
* enroll.googleState OAuth2 CSRF state
* enroll.googleNonce ID token nonce
* enroll.googleId GoogleOAuthService.GoogleIdentity (after callback)
* enroll.totpSecret plaintext TOTP secret (cleared after provisioning)
* enroll.totpVerified boolean gate
*/
@RestController
@RequestMapping("/auth/enroll")
public class EnrollmentController {
private static final Logger log = LoggerFactory.getLogger(EnrollmentController.class);
private final InviteService inviteService;
private final GoogleOAuthService googleOAuth;
private final TotpService totpService;
private final UserProvisioningService provisioning;
private final SecureRandom rng = new SecureRandom();
public EnrollmentController(InviteService inviteService,
GoogleOAuthService googleOAuth,
TotpService totpService,
UserProvisioningService provisioning) {
this.inviteService = inviteService;
this.googleOAuth = googleOAuth;
this.totpService = totpService;
this.provisioning = provisioning;
}
// Step 0: Landing page
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> landing(@RequestParam String token, HttpSession session) {
var invite = inviteService.findValidInvite(token);
if (invite == null) {
return html(HttpStatus.BAD_REQUEST, errorPage("Invalid or expired invite link.",
"This invite has expired or has already been used. Contact your admin for a new one."));
}
session.setAttribute("enroll.token", token);
String nameHint = (String) invite.getOrDefault("name_hint", "");
String contact = (String) invite.get("contact");
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Join Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>You've been invited to knoe.dev</h1>
<p>Welcome%s. To get started, enter the verification code that was sent to
<strong>%s</strong>.</p>
<form method="POST" action="/auth/enroll/verify-otp">
<label for="otp">Verification code</label>
<input id="otp" name="otp" type="text" inputmode="numeric"
pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
placeholder="6-digit code" required autofocus />
<input type="hidden" name="token" value="%s" />
<button type="submit">Verify &amp; Continue</button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE, nameHint.isBlank() ? "" : (", " + nameHint), contact, token));
}
// Step 1: Verify OTP
@PostMapping(path = "/verify-otp", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> verifyOtp(@RequestParam String token,
@RequestParam String otp,
HttpSession session) {
var result = inviteService.verifyOtp(token, otp);
return switch (result) {
case OK -> {
session.setAttribute("enroll.token", token);
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
yield redirectTo("/auth/enroll/identity");
}
case WRONG_OTP -> html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code",
"That code is wrong. Please check and try again. You have limited attempts."));
case OTP_EXPIRED -> html(HttpStatus.BAD_REQUEST, errorPage("Code expired",
"The verification code has expired. Contact your admin to resend the invite."));
case LOCKED -> html(HttpStatus.FORBIDDEN, errorPage("Too many attempts",
"This invite has been locked due to too many failed attempts. Contact your admin."));
case INVALID_TOKEN -> html(HttpStatus.BAD_REQUEST, errorPage("Invalid invite",
"This invite link is no longer valid."));
};
}
// Step 2a: PII + identity form
@GetMapping(path = "/identity", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> identityForm(HttpSession session) {
if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) {
return html(HttpStatus.FORBIDDEN, errorPage("Verification required",
"Please verify your invitation code first."));
}
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Your Identity Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>Step 1 of 3 Your details</h1>
<p>Choose your knoe.dev username and link your Google account for identity corroboration.</p>
<form method="POST" action="/auth/enroll/identity/start">
<label>Username (a-z, 0-9, hyphen)
<input name="username" type="text" pattern="[a-z0-9\\-]{3,30}"
required autofocus placeholder="e.g. chrisfu" />
</label>
<label>Display name
<input name="displayName" type="text" required placeholder="Chris Fu" />
</label>
<button type="submit">Continue with Google </button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE));
}
@PostMapping(path = "/identity/start", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> identityStart(@RequestParam String username,
@RequestParam String displayName,
HttpSession session) {
if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) {
return html(HttpStatus.FORBIDDEN, errorPage("Verification required", "OTP not verified."));
}
// Basic username validation
if (!username.matches("[a-z0-9\\-]{3,30}")) {
return html(HttpStatus.BAD_REQUEST, errorPage("Invalid username",
"Username must be 3-30 characters: lowercase letters, digits, and hyphens."));
}
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
session.setAttribute("enroll.username", username);
session.setAttribute("enroll.displayName", displayName);
session.setAttribute("enroll.googleState", state);
session.setAttribute("enroll.googleNonce", nonce);
String authUrl = googleOAuth.buildAuthorizationUrl(state, nonce);
return redirectTo(authUrl);
}
// Step 2b: Google OAuth2 callback
@GetMapping(path = "/google-callback", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> googleCallback(@RequestParam String code,
@RequestParam String state,
HttpSession session) {
String expectedState = (String) session.getAttribute("enroll.googleState");
if (expectedState == null || !expectedState.equals(state)) {
return html(HttpStatus.BAD_REQUEST, errorPage("Security error",
"State mismatch — possible CSRF. Please start enrollment again."));
}
try {
var googleId = googleOAuth.exchangeCode(code);
session.setAttribute("enroll.googleId", googleId);
log.info("Google identity linked: sub={} email={} hd={}", googleId.sub(), googleId.email(), googleId.hd());
return redirectTo("/auth/enroll/totp");
} catch (GoogleOAuthService.GoogleOAuthException e) {
log.warn("Google OAuth callback failed: {}", e.getMessage());
return html(HttpStatus.BAD_REQUEST, errorPage("Google sign-in failed", e.getMessage()));
}
}
// Step 3: TOTP setup
@GetMapping(path = "/totp", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> totpSetup(HttpSession session) {
var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId");
if (googleId == null) {
return html(HttpStatus.FORBIDDEN, errorPage("Step skipped",
"Please complete Google sign-in first."));
}
String username = (String) session.getAttribute("enroll.username");
String secret = totpService.generateSecret();
session.setAttribute("enroll.totpSecret", secret);
String qrUri = totpService.buildQrUri(username, secret);
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Authenticator Setup Knoe.DEV</title>%s</head><body>
<div class="card">
<h1>Step 2 of 3 Set up your authenticator</h1>
<p>Open <strong>Google Authenticator</strong>, <strong>Authy</strong>, or any
compatible app and scan this QR code:</p>
<div class="qr-wrap">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=%s"
alt="TOTP QR code" width="220" height="220" />
</div>
<details><summary>Can't scan? Enter manually</summary>
<code class="manual-key">%s</code>
</details>
<form method="POST" action="/auth/enroll/totp/verify">
<label for="code">Enter the 6-digit code from your app</label>
<input id="code" name="code" type="text" inputmode="numeric"
pattern="[0-9]{6}" maxlength="6" required autofocus
autocomplete="one-time-code" placeholder="123456" />
<button type="submit">Verify &amp; Finish</button>
</form>
</div></body></html>
""".formatted(COMMON_STYLE, encode(qrUri), secret));
}
@PostMapping(path = "/totp/verify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> totpVerify(@RequestParam String code, HttpSession session) {
String secret = (String) session.getAttribute("enroll.totpSecret");
if (secret == null) {
return html(HttpStatus.FORBIDDEN, errorPage("Session expired", "Please start setup again."));
}
if (!totpService.verify(secret, code)) {
return html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code",
"That code doesn't match. Make sure your device clock is correct and try again."));
}
session.setAttribute("enroll.totpVerified", Boolean.TRUE);
return redirectTo("/auth/enroll/complete");
}
// Step 4: Complete
@PostMapping(path = "/complete")
public ResponseEntity<String> complete(HttpSession session) {
String token = (String) session.getAttribute("enroll.token");
String username = (String) session.getAttribute("enroll.username");
String displayName = (String) session.getAttribute("enroll.displayName");
var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId");
String totpSecret = (String) session.getAttribute("enroll.totpSecret");
boolean totpOk = Boolean.TRUE.equals(session.getAttribute("enroll.totpVerified"));
boolean otpOk = Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"));
if (!otpOk || googleId == null || !totpOk || token == null || username == null) {
return html(HttpStatus.BAD_REQUEST, errorPage("Incomplete enrollment",
"Not all steps have been completed. Please start from the beginning."));
}
try {
var result = provisioning.provision(token, username, displayName,
googleId.email(), googleId, totpSecret);
// Clear sensitive session data
session.removeAttribute("enroll.totpSecret");
session.removeAttribute("enroll.googleId");
log.info("Enrollment complete for {}", username);
return html(HttpStatus.OK, """
<!DOCTYPE html><html><head><title>Welcome Knoe.DEV</title>%s</head><body>
<div class="card success">
<h1>Welcome to knoe.dev, %s!</h1>
<p>Your knoe.dev identity is ready.</p>
<ul>
<li><strong>Username:</strong> %s</li>
<li><strong>Kerberos principal:</strong> %s@KNOE.DEV</li>
<li><strong>Email:</strong> %s</li>
</ul>
<p>Your developer environment is being set up. You will receive an email
when your GitLab and Gitea accounts are ready.</p>
<p><a href="https://git.knoe.dev">git.knoe.dev</a></p>
</div></body></html>
""".formatted(COMMON_STYLE, displayName, username, username, googleId.email()));
} catch (UserProvisioningService.ProvisioningException e) {
log.error("Provisioning failed for {}: {}", username, e.getMessage(), e);
return html(HttpStatus.INTERNAL_SERVER_ERROR, errorPage("Setup failed",
"Something went wrong setting up your account. Your admin has been notified."));
}
}
// Helpers
private static ResponseEntity<String> html(HttpStatus status, String body) {
return ResponseEntity.status(status)
.contentType(MediaType.TEXT_HTML)
.body(body);
}
private static ResponseEntity<String> redirectTo(String location) {
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, location)
.build();
}
private static String errorPage(String title, String detail) {
return """
<!DOCTYPE html><html><head><title>%s Knoe.DEV</title>%s</head><body>
<div class="card error">
<h1>%s</h1>
<p>%s</p>
<p><a href="/">Return to start</a></p>
</div></body></html>
""".formatted(title, COMMON_STYLE, title, detail);
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
private static final String COMMON_STYLE = """
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:#f0f2f5;display:flex;justify-content:center;align-items:flex-start;
min-height:100vh;padding:3rem 1rem}
.card{background:#fff;border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,.1);
max-width:480px;width:100%;padding:2.5rem}
h1{font-size:1.5rem;margin-bottom:1rem;color:#1a1a2e}
p{color:#444;line-height:1.6;margin-bottom:1rem}
label{display:block;margin-bottom:1.2rem;color:#333;font-weight:500}
input[type=text]{display:block;width:100%;margin-top:.4rem;padding:.7rem .9rem;
border:1.5px solid #ddd;border-radius:7px;font-size:1.1rem;
transition:border-color .2s}
input[type=text]:focus{border-color:#4a90d9;outline:none}
button{width:100%;padding:.85rem;background:#1a1a2e;color:#fff;
border:none;border-radius:7px;font-size:1rem;cursor:pointer;margin-top:.5rem}
button:hover{background:#2d2d5e}
.qr-wrap{text-align:center;margin:1.5rem 0}
.manual-key{font-family:monospace;word-break:break-all;font-size:.9rem}
details{margin-bottom:1.2rem;color:#555}
.error h1{color:#c0392b}
.success h1{color:#27ae60}
ul{margin:.5rem 0 1rem 1.2rem;color:#444;line-height:1.8}
a{color:#4a90d9}
</style>
""";
}

View File

@ -1,186 +0,0 @@
package dev.knoe.auth.enroll;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Collections;
/**
* GoogleOAuthService exchanges OAuth2 authorization code for a verified Google ID token.
*
* Enrollment flow (no hd=): any Google account is accepted; trust comes from the invite OTP.
* Login flow (hd= set via knoe.google.allowedDomain): only @allowedDomain accounts may
* complete the /auth/login/google path to obtain a knoe-auth session token.
*
* The hd claim is recorded in knoe.identity.provider_hd for audit in both flows.
*/
@Service
public class GoogleOAuthService {
private static final Logger log = LoggerFactory.getLogger(GoogleOAuthService.class);
private static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
private static final String AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
@Value("${knoe.google.clientId:}")
private String clientId;
@Value("${knoe.google.clientSecret:}")
private String clientSecret;
@Value("${knoe.google.redirectUri:https://auth.knoe.dev/auth/enroll/google-callback}")
private String redirectUri;
/** Optional: if set, hd= is added to the authorization URL and verified on callback. */
@Value("${knoe.google.allowedDomain:}")
private String allowedDomain;
/**
* Build the Google OAuth2 authorization URL using the default configured redirectUri.
*
* @param state session-bound CSRF state
* @param nonce nonce for id_token replay protection
*/
public String buildAuthorizationUrl(String state, String nonce) {
return buildAuthorizationUrl(state, nonce, redirectUri);
}
/**
* Build the Google OAuth2 authorization URL with an explicit redirectUri.
* Adds hd= restriction when the redirectUri is provided by the caller (login flow).
*
* @param state session-bound CSRF state
* @param nonce nonce for id_token replay protection
* @param callbackUri the OAuth2 redirect_uri to embed in the authorization URL
*/
public String buildAuthorizationUrl(String state, String nonce, String callbackUri) {
UriComponentsBuilder b = UriComponentsBuilder.fromHttpUrl(AUTH_ENDPOINT)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", callbackUri)
.queryParam("response_type", "code")
.queryParam("scope", "openid email profile")
.queryParam("state", state)
.queryParam("nonce", nonce)
.queryParam("access_type", "online");
// NOTE: No hd= on enrollment flow any Google account is accepted (invite OTP is the anchor).
// The /auth/login/google flow adds hd= if an allowedDomain is configured.
if (allowedDomain != null && !allowedDomain.isBlank()) {
b.queryParam("hd", allowedDomain);
}
return b.build().toUriString();
}
/**
* Exchange the authorization code for a verified GoogleIdentity using the configured redirectUri.
*
* @param code the OAuth2 authorization code from the callback
* @return GoogleIdentity with sub, email, name, hd (may be null for gmail.com)
* @throws GoogleOAuthException on any error
*/
public GoogleIdentity exchangeCode(String code) {
return exchangeCode(code, redirectUri);
}
/**
* Exchange the authorization code for a verified GoogleIdentity with an explicit redirectUri.
* When {@code allowedDomain} is configured, the hd claim must match.
*
* @param code the OAuth2 authorization code from the callback
* @param callbackUri the redirect_uri that was registered in the original authorization request
* @return GoogleIdentity with sub, email, name, hd
* @throws GoogleOAuthException on any error or hd mismatch
*/
public GoogleIdentity exchangeCode(String code, String callbackUri) {
if (clientId == null || clientId.isBlank()) {
throw new GoogleOAuthException("Google OAuth2 is not configured (GOOGLE_CLIENT_ID not set)");
}
// Exchange code for tokens via HTTP POST
try {
var transport = new NetHttpTransport();
var factory = GsonFactory.getDefaultInstance();
// Token exchange
var tokenRequest = new com.google.api.client.http.GenericUrl(TOKEN_ENDPOINT);
String body = "code=" + encode(code)
+ "&client_id=" + encode(clientId)
+ "&client_secret=" + encode(clientSecret)
+ "&redirect_uri=" + encode(callbackUri)
+ "&grant_type=authorization_code";
var request = transport.createRequestFactory()
.buildPostRequest(tokenRequest,
new com.google.api.client.http.ByteArrayContent(
"application/x-www-form-urlencoded",
body.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
var response = request.execute();
var json = factory.createJsonParser(response.getContent()).parseAndClose(
com.google.api.client.util.GenericData.class);
String idTokenStr = (String) json.get("id_token");
if (idTokenStr == null) {
throw new GoogleOAuthException("No id_token in Google token response");
}
// Verify ID token signature and claims
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, factory)
.setAudience(Collections.singletonList(clientId))
.build();
GoogleIdToken idToken = verifier.verify(idTokenStr);
if (idToken == null) {
throw new GoogleOAuthException("Google ID token verification failed");
}
GoogleIdToken.Payload payload = idToken.getPayload();
if (!Boolean.TRUE.equals(payload.getEmailVerified())) {
throw new GoogleOAuthException("Google account email is not verified");
}
String sub = payload.getSubject();
String email = payload.getEmail();
String name = (String) payload.get("name");
String hd = payload.getHostedDomain(); // null for gmail.com / personal accounts
log.info("Google identity verified: sub={} email={} hd={}", sub, email, hd);
if (allowedDomain != null && !allowedDomain.isBlank()) {
if (!allowedDomain.equals(hd)) {
throw new GoogleOAuthException("Google account domain '" + hd + "' is not allowed; expected '" + allowedDomain + "'");
}
}
return new GoogleIdentity(sub, email, name, hd);
} catch (GoogleOAuthException e) {
throw e;
} catch (Exception e) {
throw new GoogleOAuthException("Google token exchange failed: " + e.getMessage(), e);
}
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
// Value types
/** Verified Google identity from the OAuth2 id_token. */
public record GoogleIdentity(
String sub, // Google subject ID (stable, use as external key)
String email, // verified email address
String name, // display name
String hd // hosted domain null for gmail.com recorded for audit, not access control
) {}
public static class GoogleOAuthException extends RuntimeException {
public GoogleOAuthException(String msg) { super(msg); }
public GoogleOAuthException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -1,169 +0,0 @@
package dev.knoe.auth.enroll;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
/**
* InviteService creates and validates invite tokens against knoe.invitation.
*
* Trust model:
* The OTP delivered to the invite contact (email or phone) is the first and
* only trust anchor. knoe.dev has zero pre-knowledge of the developer's home
* organisation. The Google link step in enrollment is corroboration only.
*/
@Service
public class InviteService {
private static final Logger log = LoggerFactory.getLogger(InviteService.class);
private final JdbcTemplate db;
private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(12);
private final SecureRandom rng = new SecureRandom();
@Value("${knoe.enroll.inviteTtlHours:72}")
private int inviteTtlHours;
@Value("${knoe.enroll.otpTtlMinutes:10}")
private int otpTtlMinutes;
@Value("${knoe.enroll.otpMaxAttempts:3}")
private int otpMaxAttempts;
@Value("${knoe.enroll.baseUrl:https://auth.knoe.dev}")
private String baseUrl;
public InviteService(JdbcTemplate db) {
this.db = db;
}
/**
* Create a new invite and return the enrollment URL + raw OTP.
* The caller is responsible for sending the OTP to the contact.
*
* @param contact email address or phone number
* @param contactType "email" or "sms"
* @param nameHint optional display name hint from admin
* @param createdBy admin username
* @return InviteResult containing enrollUrl and rawOtp to dispatch
*/
public InviteResult createInvite(String contact, String contactType,
String nameHint, String createdBy) {
String token = UUID.randomUUID().toString();
String rawOtp = String.format("%06d", rng.nextInt(1_000_000));
String otpHash = bcrypt.encode(rawOtp);
Instant now = Instant.now();
Instant otpExpiry = now.plusSeconds(otpTtlMinutes * 60L);
Instant invExpiry = now.plusSeconds(inviteTtlHours * 3600L);
db.update("""
INSERT INTO knoe.invitation
(token, contact, contact_type, name_hint, otp_hash,
otp_expires_at, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
token, contact, contactType, nameHint, otpHash,
java.sql.Timestamp.from(otpExpiry), createdBy,
java.sql.Timestamp.from(invExpiry)
);
String enrollUrl = baseUrl + "/auth/enroll?token=" + token;
log.info("Invite created for {} by {} — token={} expires={}",
contact, createdBy, token, invExpiry);
return new InviteResult(token, enrollUrl, rawOtp);
}
/**
* Look up a valid (non-expired, not-used) invite by token.
* Returns null if no matching valid invite exists.
*/
public Map<String, Object> findValidInvite(String token) {
var rows = db.queryForList("""
SELECT * FROM knoe.invitation
WHERE token = ?
AND used_at IS NULL
AND expires_at > now()
""", token);
return rows.isEmpty() ? null : rows.get(0);
}
/**
* Verify the OTP for a given token.
*
* @return OtpResult indicating success, invalid, expired, or locked
*/
public OtpResult verifyOtp(String token, String rawOtp) {
Map<String, Object> inv = findValidInvite(token);
if (inv == null) {
return OtpResult.INVALID_TOKEN;
}
Instant otpExpiry = ((java.sql.Timestamp) inv.get("otp_expires_at")).toInstant();
if (Instant.now().isAfter(otpExpiry)) {
return OtpResult.OTP_EXPIRED;
}
int attempts = (int) inv.get("otp_attempts");
if (attempts >= otpMaxAttempts) {
return OtpResult.LOCKED;
}
String otpHash = (String) inv.get("otp_hash");
if (!bcrypt.matches(rawOtp, otpHash)) {
db.update("UPDATE knoe.invitation SET otp_attempts = otp_attempts + 1 WHERE token = ?",
token);
int remaining = otpMaxAttempts - attempts - 1;
log.warn("OTP mismatch for token={} attempts={} remaining={}", token, attempts + 1, remaining);
return remaining <= 0 ? OtpResult.LOCKED : OtpResult.WRONG_OTP;
}
// Mark OTP as verified
db.update("UPDATE knoe.invitation SET otp_verified_at = now() WHERE token = ?", token);
log.info("OTP verified for token={}", token);
return OtpResult.OK;
}
/**
* Check that the OTP has been verified for this token (gate before steps 2-4).
*/
public boolean isOtpVerified(String token) {
Integer count = db.queryForObject("""
SELECT COUNT(*) FROM knoe.invitation
WHERE token = ? AND otp_verified_at IS NOT NULL
AND used_at IS NULL AND expires_at > now()
""", Integer.class, token);
return count != null && count > 0;
}
/**
* Mark an invite as fully used after successful enrollment.
*/
public void markUsed(String token, String username) {
db.update("""
UPDATE knoe.invitation
SET used_at = now(), used_by = ?
WHERE token = ?
""", username, token);
}
// Value types
public record InviteResult(String token, String enrollUrl, String rawOtp) {}
public enum OtpResult {
OK,
WRONG_OTP,
OTP_EXPIRED,
LOCKED,
INVALID_TOKEN
}
}

View File

@ -1,90 +0,0 @@
package dev.knoe.auth.enroll;
import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.CodeVerifier;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.code.HashingAlgorithm;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* TotpService generates TOTP secrets and QR code URIs (RFC 6238).
*
* Compatible with Google Authenticator, Authy, and any RFC 6238 app.
* The TOTP secret is stored encrypted (AES-GCM via OpenBao) in knoe.totp_credential.
* This service handles generation and verification only encryption is the
* responsibility of UserProvisioningService.
*/
@Service
public class TotpService {
private static final Logger log = LoggerFactory.getLogger(TotpService.class);
private static final int DIGITS = 6;
private static final int PERIOD = 30; // seconds
private static final int WINDOW = 1; // ±1 period tolerance
private static final HashingAlgorithm ALGO = HashingAlgorithm.SHA1; // GA compat
@Value("${knoe.enroll.totpIssuer:Knoe.DEV}")
private String issuer;
private final SecretGenerator secretGen = new DefaultSecretGenerator(32);
private final TimeProvider timeProvider = new SystemTimeProvider();
private final CodeGenerator codeGen = new DefaultCodeGenerator(ALGO, DIGITS);
private final CodeVerifier verifier = new DefaultCodeVerifier(codeGen, timeProvider);
/**
* Generate a new random TOTP secret (Base32-encoded, 32 chars).
* This is the plaintext value encrypt before persisting.
*/
public String generateSecret() {
return secretGen.generate();
}
/**
* Build the otpauth:// URI for rendering as a QR code.
*
* @param username knoe username (label shown in the authenticator app)
* @param secret plaintext Base32 TOTP secret
* @return QR URI string
*/
public String buildQrUri(String username, String secret) {
QrData data = new QrData.Builder()
.label(username)
.secret(secret)
.issuer(issuer)
.algorithm(ALGO)
.digits(DIGITS)
.period(PERIOD)
.build();
return data.getUri();
}
/**
* Verify a 6-digit TOTP code against the secret.
* Accepts ±WINDOW periods to handle minor clock skew.
*
* @param secret plaintext Base32 TOTP secret
* @param code 6-digit code from authenticator app
* @return true if valid
*/
public boolean verify(String secret, String code) {
if (secret == null || code == null) return false;
try {
boolean valid = verifier.isValidCode(secret, code);
log.debug("TOTP verify code={} valid={}", code, valid);
return valid;
} catch (Exception e) {
log.warn("TOTP verification error: {}", e.getMessage());
return false;
}
}
}

View File

@ -1,126 +0,0 @@
package dev.knoe.auth.enroll;
import dev.knoe.auth.kerberos.KadminClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
/**
* UserProvisioningService orchestrates enrollment completion.
*
* Called after all three enrollment gates have passed:
* 1. OTP verified (contact ownership proven)
* 2. Google identity linked (corroboration recorded)
* 3. TOTP secret verified (device 2FA confirmed)
*
* Creates the knoe.user row, links identity, stores TOTP credential,
* creates the Kerberos principal, marks the invite used, and queues
* async provisioning jobs (Gitea, GitLab accounts).
*
* NOTE: TOTP secret encryption via OpenBao is deferred to Round 1.5.
* For now the secret is stored as-is this will be replaced with
* AES-GCM envelope encryption using a transit key.
*/
@Service
public class UserProvisioningService {
private static final Logger log = LoggerFactory.getLogger(UserProvisioningService.class);
private final JdbcTemplate db;
private final KadminClient kadmin;
private final InviteService inviteService;
public UserProvisioningService(JdbcTemplate db,
KadminClient kadmin,
InviteService inviteService) {
this.db = db;
this.kadmin = kadmin;
this.inviteService = inviteService;
}
/**
* Complete enrollment for a verified session.
*
* @param inviteToken the invite token (used to mark invite consumed)
* @param username chosen knoe username
* @param displayName display name from PII form
* @param email canonical email (from Google ID token)
* @param googleId verified GoogleIdentity
* @param totpSecret plaintext TOTP secret (verified in enrollment step 3)
*/
@Transactional
public ProvisionResult provision(String inviteToken,
String username,
String displayName,
String email,
GoogleOAuthService.GoogleIdentity googleId,
String totpSecret) {
log.info("Provisioning user: username={} email={} hd={}",
username, email, googleId.hd());
// 1. Insert knoe.user
Integer userId = db.queryForObject("""
INSERT INTO knoe.user (username, display_name, email, realm, created_at)
VALUES (?, ?, ?, 'KNOE.DEV', now())
RETURNING id
""", Integer.class, username, displayName, email);
if (userId == null) {
throw new ProvisioningException("Failed to create user row for " + username);
}
// 2. Insert knoe.identity (Google corroboration)
db.update("""
INSERT INTO knoe.identity
(user_id, provider, provider_sub, provider_email, provider_hd, verified_at)
VALUES (?, 'google', ?, ?, ?, now())
""", userId, googleId.sub(), googleId.email(), googleId.hd());
// 3. Insert knoe.totp_credential
// TODO Round 1.5: encrypt secret with OpenBao transit key before storing.
db.update("""
INSERT INTO knoe.totp_credential (user_id, secret, verified_at, created_at)
VALUES (?, ?, now(), now())
""", userId, totpSecret);
// 4. Create Kerberos principal
try {
kadmin.addPrincipal(username + "@KNOE.DEV");
} catch (Exception e) {
log.error("kadmin addprinc failed for {}: {}", username, e.getMessage());
throw new ProvisioningException("Kerberos principal creation failed: " + e.getMessage(), e);
}
// 5. Mark invite used
inviteService.markUsed(inviteToken, username);
// 6. Queue provisioning jobs
queueJob(userId, "create_gitea_user",
"{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}");
queueJob(userId, "create_gitlab_user",
"{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}");
log.info("Provisioning complete for user {} (id={})", username, userId);
return new ProvisionResult(userId, username, email);
}
private void queueJob(int userId, String jobType, String payloadJson) {
db.update("""
INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at)
VALUES (?, ?, 'pending', ?::jsonb, now(), now())
""", userId, jobType, payloadJson);
}
// Value types
public record ProvisionResult(int userId, String username, String email) {}
public static class ProvisioningException extends RuntimeException {
public ProvisioningException(String msg) { super(msg); }
public ProvisioningException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -1,136 +0,0 @@
package dev.knoe.auth.kerberos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* KadminClient shells out to kadmin.local to manage Kerberos principals.
*
* Runs in the same pod as the KDC sidecar, so kadmin.local has direct database access.
* All operations are idempotent: addPrincipal is a no-op if the principal already exists.
*
* For use in enrollment only not exposed via any HTTP endpoint.
*/
@Component
public class KadminClient {
private static final Logger log = LoggerFactory.getLogger(KadminClient.class);
private static final int TIMEOUT_SECONDS = 30;
@Value("${knoe.kerberos.realm:KNOE.DEV}")
private String realm;
/**
* Create a new principal with a random key (no password Kerberos keytab only).
* For human users this is overridden at first kinit with kadmin changepass.
*
* @param principal fully-qualified principal, e.g. "chrisfu@KNOE.DEV"
*/
public void addPrincipal(String principal) {
String fqPrincipal = qualified(principal);
// Check if it already exists idempotent
try {
runKadmin("get_principal " + fqPrincipal);
log.info("Principal {} already exists — skipping addprinc.", fqPrincipal);
return;
} catch (KadminException e) {
// Principal doesn't exist proceed to create
}
runKadmin("addprinc -randkey " + fqPrincipal);
log.info("Created Kerberos principal: {}", fqPrincipal);
}
/**
* Set the password for a principal (used during password reset flows).
*
* @param principal fully-qualified or bare principal
* @param password new password (will not be logged)
*/
public void changePrincipalPassword(String principal, String password) {
String fqPrincipal = qualified(principal);
runKadmin("cpw -pw " + password + " " + fqPrincipal);
log.info("Password changed for principal: {}", fqPrincipal);
}
/**
* Delete a principal (used when an account is disabled).
*
* @param principal fully-qualified or bare principal
*/
public void deletePrincipal(String principal) {
String fqPrincipal = qualified(principal);
runKadmin("delprinc -force " + fqPrincipal);
log.info("Deleted principal: {}", fqPrincipal);
}
/**
* Export a keytab for a principal to a file path.
*
* @param principal fully-qualified principal
* @param keytabPath absolute path on the local filesystem
*/
public void exportKeytab(String principal, String keytabPath) {
String fqPrincipal = qualified(principal);
runKadmin("ktadd -k " + keytabPath + " " + fqPrincipal);
log.info("Exported keytab for {} to {}", fqPrincipal, keytabPath);
}
// Internal
private String qualified(String principal) {
if (principal.contains("@")) return principal;
return principal + "@" + realm;
}
private void runKadmin(String query) {
List<String> cmd = List.of("kadmin.local", "-q", query);
log.debug("kadmin.local -q \"{}\"", query.startsWith("cpw") ? "cpw -pw *** ..." : query);
try {
Process proc = new ProcessBuilder(cmd)
.redirectErrorStream(true)
.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = proc.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
proc.destroyForcibly();
throw new KadminException("kadmin.local timed out after " + TIMEOUT_SECONDS + "s");
}
int exit = proc.exitValue();
if (exit != 0) {
// get_principal returns 1 for "Principal does not exist" caller handles
if (output.contains("Principal does not exist")) {
throw new KadminException("Principal does not exist");
}
throw new KadminException("kadmin.local exited " + exit + ": " + output);
}
} catch (KadminException e) {
throw e;
} catch (Exception e) {
throw new KadminException("kadmin.local execution failed: " + e.getMessage(), e);
}
}
public static class KadminException extends RuntimeException {
public KadminException(String msg) { super(msg); }
public KadminException(String msg, Throwable cause) { super(msg, cause); }
}
}

View File

@ -1,191 +0,0 @@
package dev.knoe.auth.provisioning;
import dev.knoe.auth.admin.KnobjectService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.List;
import java.util.Map;
/**
* ProvisioningWorker polls knoe.provisioning_job and dispatches pending jobs.
*
* Supported job types for Round 1:
* create_gitea_user create Gitea account via Gitea API
* create_gitlab_user create GitLab account via GitLab API (stub)
* grant_gitea_access add member to Gitea org/repo
* grant_gitlab_access add member to GitLab group/project (stub)
*
* Runs on a fixed delay to avoid concurrent execution on multi-replica deployments.
* In Round 2 this should use advisory locks or a proper job queue.
*/
@Component
@EnableScheduling
public class ProvisioningWorker {
private static final Logger log = LoggerFactory.getLogger(ProvisioningWorker.class);
private final JdbcTemplate db;
private final KnobjectService knobjectService;
private final RestTemplate http = new RestTemplate();
@Value("${knoe.provisioning.giteaUrl:https://git.knoe.dev}")
private String giteaUrl;
@Value("${knoe.provisioning.giteaToken:}")
private String giteaToken;
public ProvisioningWorker(JdbcTemplate db, KnobjectService knobjectService) {
this.db = db;
this.knobjectService = knobjectService;
}
@Scheduled(fixedDelayString = "${knoe.provisioning.pollIntervalMs:10000}")
public void poll() {
List<Map<String, Object>> jobs = db.queryForList("""
SELECT id, user_id, job_type, payload
FROM knoe.provisioning_job
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
""");
if (jobs.isEmpty()) return;
log.debug("Provisioning: {} pending job(s)", jobs.size());
for (var job : jobs) {
int jobId = (int) job.get("id");
int userId = (int) job.get("user_id");
String jobType = (String) job.get("job_type");
Object payloadObj = job.get("payload");
String payload = payloadObj != null ? payloadObj.toString() : "{}";
markRunning(jobId);
try {
dispatch(jobId, userId, jobType, payload);
markDone(jobId, "{\"status\":\"ok\"}");
log.info("Job {} ({}) completed for userId={}", jobId, jobType, userId);
} catch (Exception e) {
log.error("Job {} ({}) failed for userId={}: {}", jobId, jobType, userId, e.getMessage());
markFailed(jobId, "{\"error\":\"" + escape(e.getMessage()) + "\"}");
}
}
}
private void dispatch(int jobId, int userId, String jobType, String payload) {
switch (jobType) {
case "create_gitea_user" -> createGiteaUser(payload);
case "create_gitlab_user" -> createGitlabUser(payload);
case "grant_gitea_access" -> grantGiteaAccess(payload);
case "grant_gitlab_access"-> grantGitlabAccess(payload);
default -> log.warn("Unknown job type '{}' for jobId={}", jobType, jobId);
}
}
// Gitea
private void createGiteaUser(String payloadJson) {
if (giteaToken == null || giteaToken.isBlank()) {
log.warn("GITEA_TOKEN not set — skipping create_gitea_user");
return;
}
// Parse username + email from JSON payload (simple extraction, no full JSON parser needed)
String username = extractJsonField(payloadJson, "username");
String email = extractJsonField(payloadJson, "email");
String body = """
{"login_name":"%s","username":"%s","email":"%s",
"password":"%s","must_change_password":true,
"send_notify":true,"source_id":0}
""".formatted(username, username, email, generateTempPassword());
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "token " + giteaToken);
var response = http.postForEntity(
giteaUrl + "/api/v1/admin/users",
new HttpEntity<>(body, headers),
String.class);
log.info("Gitea user {} created — HTTP {}", username, response.getStatusCode());
}
private void grantGiteaAccess(String payloadJson) {
if (giteaToken == null || giteaToken.isBlank()) {
log.warn("GITEA_TOKEN not set — skipping grant_gitea_access");
return;
}
// Stub implemented fully in Round 1.5 when repo structure is defined
log.info("grant_gitea_access (stub): {}", payloadJson);
}
// GitLab
private void createGitlabUser(String payloadJson) {
// Stub for Round 1 GitLab is on git.prole.org which may not be accessible
// from knoe-dev-0. Implement via GitLab API in Round 1.5.
log.info("create_gitlab_user (stub — implement in Round 1.5): {}", payloadJson);
}
private void grantGitlabAccess(String payloadJson) {
log.info("grant_gitlab_access (stub — implement in Round 1.5): {}", payloadJson);
}
// Helpers
private void markRunning(int jobId) {
db.update("UPDATE knoe.provisioning_job SET status='running', updated_at=now() WHERE id=?",
jobId);
}
private void markDone(int jobId, String resultJson) {
db.update("""
UPDATE knoe.provisioning_job
SET status='done', result=?::jsonb, updated_at=now()
WHERE id=?
""", resultJson, jobId);
}
private void markFailed(int jobId, String resultJson) {
db.update("""
UPDATE knoe.provisioning_job
SET status='failed', result=?::jsonb, updated_at=now()
WHERE id=?
""", resultJson, jobId);
}
/** Very simple JSON field extraction — avoids adding a JSON dep just for this. */
private static String extractJsonField(String json, String field) {
String marker = "\"" + field + "\":\"";
int start = json.indexOf(marker);
if (start < 0) return "";
start += marker.length();
int end = json.indexOf('"', start);
return end < 0 ? "" : json.substring(start, end);
}
private static String escape(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String generateTempPassword() {
// 20-char random alphanumeric user must change on first login
var sb = new StringBuilder(20);
var rng = new java.security.SecureRandom();
String chars = "ABCDEFGHJKMNPQRSTWXYZabcdefghjkmnpqrstwxyz23456789@#$!";
for (int i = 0; i < 20; i++) sb.append(chars.charAt(rng.nextInt(chars.length())));
return sb.toString();
}
}

View File

@ -1,30 +0,0 @@
package dev.knoe.auth.session;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class OidcCodeService {
private final Map<String, AuthorizationRequest> codes = new ConcurrentHashMap<>();
public String createCode(AuthorizationRequest request) {
String code = UUID.randomUUID().toString();
codes.put(code, request);
// In a real app, you'd add expiration logic here
return code;
}
public AuthorizationRequest consumeCode(String code) {
return codes.remove(code);
}
public record AuthorizationRequest(
String clientId,
String redirectUri,
String state,
String nonce,
SessionUser user
) {}
}

View File

@ -1,94 +0,0 @@
package dev.knoe.auth.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.config.KerberosProperties;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class OidcTokenService {
private final AuthProperties authProperties;
private final KerberosProperties kerberosProperties;
private final ObjectMapper objectMapper;
private KeyPair keyPair;
private String kid;
public OidcTokenService(AuthProperties authProperties, KerberosProperties kerberosProperties, ObjectMapper objectMapper) {
this.authProperties = authProperties;
this.kerberosProperties = kerberosProperties;
this.objectMapper = objectMapper;
}
@PostConstruct
public void init() throws Exception {
String signingKey = authProperties.getOidc().getSigningKey();
if (signingKey != null && !signingKey.isBlank()) {
byte[] keyBytes = Base64.getDecoder().decode(signingKey);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
PrivateKey privateKey = kf.generatePrivate(spec);
// Derive public key from private key (simplified for RSA)
RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(new java.security.spec.RSAPublicKeySpec(
((java.security.interfaces.RSAPrivateCrtKey) privateKey).getModulus(),
((java.security.interfaces.RSAPrivateCrtKey) privateKey).getPublicExponent()
));
this.keyPair = new KeyPair(publicKey, privateKey);
} else {
// Fallback to ephemeral key if not provided (not recommended for production)
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
this.keyPair = kpg.generateKeyPair();
}
this.kid = UUID.nameUUIDFromBytes(keyPair.getPublic().getEncoded()).toString();
}
public Map<String, Object> getJwks() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
return Map.of("keys", List.of(Map.of(
"kty", "RSA",
"use", "sig",
"kid", kid,
"alg", "RS256",
"n", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getModulus().toByteArray()),
"e", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getPublicExponent().toByteArray())
)));
}
public String issueIdToken(SessionUser user, String nonce, String audience) {
Instant now = Instant.now();
Instant exp = now.plus(authProperties.getSessionTtl());
return Jwts.builder()
.setIssuer(authProperties.getOidc().getIssuer())
.setSubject(user.username())
.setAudience(audience)
.setExpiration(Date.from(exp))
.setIssuedAt(Date.from(now))
.claim("nonce", nonce)
.claim("preferred_username", user.username())
.claim("email", user.email())
.claim("realm", kerberosProperties.getRealm())
.setHeaderParam("kid", kid)
.signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
.compact();
}
}

View File

@ -1,29 +0,0 @@
package dev.knoe.auth.session;
import dev.knoe.auth.config.AuthProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
@Service
public class SessionService {
private final AuthProperties authProperties;
private final SessionTokenService sessionTokenService;
public SessionService(AuthProperties authProperties, SessionTokenService sessionTokenService) {
this.authProperties = authProperties;
this.sessionTokenService = sessionTokenService;
}
public Optional<SessionUser> getSessionUser(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, authProperties.getCookieName());
if (cookie == null || cookie.getValue() == null || cookie.getValue().isBlank()) {
return Optional.empty();
}
return sessionTokenService.verify(authProperties.getSessionSecret(), cookie.getValue());
}
}

View File

@ -1,31 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.session.OidcTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class JwksController {
private final AuthProperties authProperties;
private final OidcTokenService oidcTokenService;
public JwksController(AuthProperties authProperties, OidcTokenService oidcTokenService) {
this.authProperties = authProperties;
this.oidcTokenService = oidcTokenService;
}
@GetMapping("/jwks.json")
public ResponseEntity<Map<String, Object>> getJwks() {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(oidcTokenService.getJwks());
}
}

View File

@ -1,87 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.session.OidcCodeService;
import dev.knoe.auth.session.SessionService;
import dev.knoe.auth.session.SessionUser;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
@Controller
@RequestMapping("/auth")
public class OidcAuthorizeController {
private final AuthProperties authProperties;
private final SessionService sessionService;
private final OidcCodeService oidcCodeService;
public OidcAuthorizeController(AuthProperties authProperties, SessionService sessionService, OidcCodeService oidcCodeService) {
this.authProperties = authProperties;
this.sessionService = sessionService;
this.oidcCodeService = oidcCodeService;
}
@GetMapping("/authorize")
public String authorize(
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "nonce", required = false) String nonce,
@RequestParam(value = "response_type") String responseType,
@RequestParam(value = "scope", required = false) String scope,
HttpServletRequest request) {
if (!authProperties.getOidc().isEnabled()) {
return "redirect:/error?message=OIDC+disabled";
}
// Basic validation
if (!authProperties.getOidc().isClientAllowed(clientId)) {
return "redirect:" + redirectUri + "?error=invalid_client&state=" + state;
}
if (!"code".equals(responseType)) {
return "redirect:" + redirectUri + "?error=unsupported_response_type&state=" + state;
}
Optional<SessionUser> userOpt = sessionService.getSessionUser(request);
if (userOpt.isEmpty()) {
// Build absolute URLs using the configured OIDC issuer so the redirect
// is not polluted by Kong's internal HTTP host/port (api.0.knoe.dev:8000).
// Skip null nonce bare &nonce (no =) in the query string breaks URI parsers.
String issuer = authProperties.getOidc().getIssuer();
UriComponentsBuilder authorizeBuilder = UriComponentsBuilder.fromHttpUrl(issuer + "/authorize")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("state", state)
.queryParam("response_type", responseType)
.queryParam("scope", scope);
if (nonce != null && !nonce.isBlank()) {
authorizeBuilder.queryParam("nonce", nonce);
}
// encode() here so spaces in scope become %20 prevents double-decode
// issues when this URL is later embedded as a query param value.
String authorizeUrl = authorizeBuilder.build().encode().toUriString();
String loginUrl = UriComponentsBuilder.fromHttpUrl(issuer + "/login")
.queryParam("next", authorizeUrl)
.build()
.encode()
.toUriString();
return "redirect:" + loginUrl;
}
// User is authenticated, generate code
String code = oidcCodeService.createCode(new OidcCodeService.AuthorizationRequest(
clientId, redirectUri, state, nonce, userOpt.get()
));
return "redirect:" + redirectUri + "?code=" + code + (state != null ? "&state=" + state : "");
}
}

View File

@ -1,45 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.config.AuthProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class OidcDiscoveryController {
private final AuthProperties authProperties;
public OidcDiscoveryController(AuthProperties authProperties) {
this.authProperties = authProperties;
}
@GetMapping("/.well-known/openid-configuration")
public ResponseEntity<Map<String, Object>> getConfiguration() {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.notFound().build();
}
String issuer = authProperties.getOidc().getIssuer();
Map<String, Object> config = Map.ofEntries(
Map.entry("issuer", issuer),
Map.entry("authorization_endpoint", issuer + "/authorize"),
Map.entry("token_endpoint", issuer + "/token"),
Map.entry("userinfo_endpoint", issuer + "/userinfo"),
Map.entry("jwks_uri", issuer + "/jwks.json"),
Map.entry("response_types_supported", List.of("code")),
Map.entry("subject_types_supported", List.of("public")),
Map.entry("id_token_signing_alg_values_supported", List.of("RS256")),
Map.entry("scopes_supported", List.of("openid", "profile", "email")),
Map.entry("token_endpoint_auth_methods_supported", List.of("client_secret_post", "client_secret_basic")),
Map.entry("claims_supported", List.of("sub", "iss", "auth_time", "name", "preferred_username", "email"))
);
return ResponseEntity.ok(config);
}
}

View File

@ -1,99 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.session.OidcCodeService;
import dev.knoe.auth.session.OidcTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class OidcTokenController {
private final AuthProperties authProperties;
private final OidcCodeService oidcCodeService;
private final OidcTokenService oidcTokenService;
public OidcTokenController(AuthProperties authProperties, OidcCodeService oidcCodeService, OidcTokenService oidcTokenService) {
this.authProperties = authProperties;
this.oidcCodeService = oidcCodeService;
this.oidcTokenService = oidcTokenService;
}
@PostMapping("/token")
public ResponseEntity<Map<String, Object>> token(
@RequestParam("grant_type") String grantType,
@RequestParam("code") String code,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam(value = "client_id", required = false) String clientId,
@RequestParam(value = "client_secret", required = false) String clientSecret,
HttpServletRequest request) {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.status(403).body(Map.of("error", "oidc_disabled"));
}
if (!"authorization_code".equals(grantType)) {
return ResponseEntity.badRequest().body(Map.of("error", "unsupported_grant_type"));
}
String effectiveClientId = clientId;
String effectiveClientSecret = clientSecret;
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Basic ")) {
try {
String decoded = new String(java.util.Base64.getDecoder().decode(authHeader.substring(6)));
String[] parts = decoded.split(":", 2);
if (parts.length == 2) {
effectiveClientId = parts[0];
effectiveClientSecret = parts[1];
}
} catch (Exception e) {
// Ignore invalid basic auth
}
}
// Primary client: validate both ID and secret.
// Allowed secondary clients (allowedClientIds): validate ID only the proxy
// manages its own session security; we trust the client_id as identity.
boolean clientOk;
String primaryId = authProperties.getOidc().getClientId();
String primarySecret = authProperties.getOidc().getClientSecret();
if (effectiveClientId == null) {
clientOk = false;
} else if (primaryId != null && !primaryId.isBlank() && primaryId.equals(effectiveClientId)) {
clientOk = primarySecret != null && primarySecret.equals(effectiveClientSecret);
} else {
clientOk = authProperties.getOidc().getAllowedClientIds().contains(effectiveClientId);
}
if (!clientOk) {
return ResponseEntity.status(401).body(Map.of("error", "invalid_client"));
}
OidcCodeService.AuthorizationRequest authRequest = oidcCodeService.consumeCode(code);
if (authRequest == null) {
return ResponseEntity.badRequest().body(Map.of("error", "invalid_grant"));
}
if (!authRequest.redirectUri().equals(redirectUri)) {
return ResponseEntity.badRequest().body(Map.of("error", "invalid_grant", "error_description", "redirect_uri_mismatch"));
}
String idToken = oidcTokenService.issueIdToken(authRequest.user(), authRequest.nonce(), effectiveClientId);
String accessToken = oidcTokenService.issueAccessToken(authRequest.user());
return ResponseEntity.ok(Map.of(
"access_token", accessToken,
"token_type", "Bearer",
"expires_in", 3600,
"id_token", idToken
));
}
}

View File

@ -1,53 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.session.OidcTokenService;
import dev.knoe.auth.session.SessionUser;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class OidcUserInfoController {
private final AuthProperties authProperties;
private final OidcTokenService oidcTokenService;
public OidcUserInfoController(AuthProperties authProperties, OidcTokenService oidcTokenService) {
this.authProperties = authProperties;
this.oidcTokenService = oidcTokenService;
}
@GetMapping("/userinfo")
public ResponseEntity<Map<String, Object>> userInfo(HttpServletRequest request) {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.status(403).build();
}
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.status(401).build();
}
SessionUser user = oidcTokenService.verifyAccessToken(authHeader.substring("Bearer ".length()));
if (user == null) {
return ResponseEntity.status(401).build();
}
Map<String, Object> claims = new HashMap<>();
claims.put("sub", user.username());
claims.put("preferred_username", user.username());
claims.put("email", user.email());
claims.put("email_verified", true);
claims.put("name", user.username());
claims.put("groups", user.groups());
return ResponseEntity.ok(claims);
}
}

View File

@ -1,4 +1,4 @@
package dev.knoe.auth;
package org.knoe.authority;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth;
package org.knoe.authority;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.config;
package org.knoe.authority.config;
import java.time.Duration;
import java.util.ArrayList;
@ -16,30 +16,6 @@ public class AuthProperties {
private String emailDomain = "knoe.dev";
private boolean formEnabled = false;
private List<String> adminPrincipals = new ArrayList<>();
private OidcProperties oidc = new OidcProperties();
/** Nested OIDC provider settings (knoe.auth.oidc.*). */
public static class OidcProperties {
private boolean enabled = false;
private String issuer = "";
private String clientId = "";
private String clientSecret = "";
private String signingKey = "";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public String getClientId() { return clientId; }
public void setClientId(String clientId) { this.clientId = clientId; }
public String getClientSecret() { return clientSecret; }
public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
public String getSigningKey() { return signingKey; }
public void setSigningKey(String signingKey) { this.signingKey = signingKey; }
}
public OidcProperties getOidc() { return oidc; }
public void setOidc(OidcProperties oidc) { this.oidc = oidc; }
public boolean isEnabled() {
return enabled;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.config;
package org.knoe.authority.config;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.kerberos;
package org.knoe.authority.kerberos;
import java.io.IOException;
import java.util.Map;

View File

@ -1,7 +1,7 @@
package dev.knoe.auth.kerberos;
package org.knoe.authority.kerberos;
import java.security.PrivilegedExceptionAction;
import java.util.Map;
import java.util.concurrent.Callable;
import javax.security.auth.Subject;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
@ -16,17 +16,10 @@ import org.springframework.stereotype.Component;
@Component
public class KerberosSpnegoService {
/**
* See knoe-auth/src/main/java copy for full history comment.
* Short version: Subject.callAs (not doAs) is required in JDK 21 so that
* Subject.current() is set correctly for the JGSS credential lookup.
*/
private static final Oid SPNEGO_OID;
private static final Oid KRB5_OID;
static {
try {
SPNEGO_OID = new Oid("1.3.6.1.5.5.2");
KRB5_OID = new Oid("1.2.840.113554.1.2.2");
} catch (Exception e) {
throw new RuntimeException("Failed to init Kerberos OID", e);
@ -49,9 +42,10 @@ public class KerberosSpnegoService {
loginContext.login();
Subject subject = loginContext.getSubject();
return Subject.callAs(subject, (Callable<Result>) () -> {
return Subject.doAs(subject, (PrivilegedExceptionAction<Result>) () -> {
GSSManager manager = GSSManager.getInstance();
GSSContext ctx = manager.createContext((GSSCredential) null);
GSSCredential creds = manager.createCredential(null, GSSCredential.DEFAULT_LIFETIME, KRB5_OID, GSSCredential.ACCEPT_ONLY);
GSSContext ctx = manager.createContext(creds);
byte[] outToken = ctx.acceptSecContext(token, 0, token.length);
boolean established = ctx.isEstablished();

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.session;
package org.knoe.authority.session;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.session;
package org.knoe.authority.session;
import java.util.List;

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.user;
package org.knoe.authority.user;
import java.util.Locale;
import java.util.Optional;
@ -7,22 +7,6 @@ import org.springframework.stereotype.Component;
@Component
public class PrincipalNormalizer {
/**
* Derive a bare username from an email address (local-part before the '@').
* Returns empty if the email is null/blank or has no '@'.
*/
public Optional<String> normalizeUsernameFromEmail(String email) {
if (email == null || email.isBlank()) {
return Optional.empty();
}
int at = email.indexOf('@');
if (at <= 0) {
return Optional.empty();
}
String local = email.substring(0, at).trim().toLowerCase(Locale.ROOT);
return local.isBlank() ? Optional.empty() : Optional.of(local);
}
public Optional<String> normalizeUsernameFromKerberosPrincipal(String kerberosPrincipal) {
if (kerberosPrincipal == null || kerberosPrincipal.isBlank()) {
return Optional.empty();

View File

@ -1,31 +1,22 @@
package dev.knoe.auth.web;
package org.knoe.authority.web;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.config.KerberosProperties;
import dev.knoe.auth.enroll.GoogleOAuthService;
import dev.knoe.auth.kerberos.KerberosPasswordService;
import dev.knoe.auth.kerberos.KerberosSpnegoService;
import dev.knoe.auth.session.SessionTokenService;
import dev.knoe.auth.session.SessionUser;
import dev.knoe.auth.user.PrincipalNormalizer;
import org.springframework.beans.factory.annotation.Value;
import org.knoe.authority.config.AuthProperties;
import org.knoe.authority.config.KerberosProperties;
import org.knoe.authority.kerberos.KerberosPasswordService;
import org.knoe.authority.kerberos.KerberosSpnegoService;
import org.knoe.authority.session.SessionTokenService;
import org.knoe.authority.session.SessionUser;
import org.knoe.authority.user.PrincipalNormalizer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@ -40,17 +31,12 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/auth")
public class LoginController {
private static final Logger log = LoggerFactory.getLogger(LoginController.class);
private final AuthProperties auth;
private final KerberosProperties kerberos;
private final KerberosSpnegoService spnego;
private final KerberosPasswordService passwordAuth;
private final PrincipalNormalizer normalizer;
private final SessionTokenService sessionTokenService;
private final GoogleOAuthService googleOAuth;
@Value("${KNOE_AUTH_BASE_URL:https://api.0.knoe.dev/auth}")
private String baseUrl;
public LoginController(
AuthProperties auth,
@ -58,8 +44,7 @@ public class LoginController {
KerberosSpnegoService spnego,
KerberosPasswordService passwordAuth,
PrincipalNormalizer normalizer,
SessionTokenService sessionTokenService,
GoogleOAuthService googleOAuth
SessionTokenService sessionTokenService
) {
this.auth = auth;
this.kerberos = kerberos;
@ -67,7 +52,6 @@ public class LoginController {
this.passwordAuth = passwordAuth;
this.normalizer = normalizer;
this.sessionTokenService = sessionTokenService;
this.googleOAuth = googleOAuth;
}
@PostConstruct
@ -83,10 +67,6 @@ public class LoginController {
@GetMapping(value = "/login", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> loginPage(@RequestParam(name = "next", required = false) String next) {
String safeNext = safeNext(next).toString();
// URL-encode for embedding as a query-param value inside hrefs.
// escapeHtmlAttr turns & into &amp; which browsers decode back to &,
// breaking multi-param next URLs. Percent-encoding is safe in HTML attrs.
String safeNextEncoded = URLEncoder.encode(safeNext, StandardCharsets.UTF_8);
String html = """
<!doctype html>
<html lang="en">
@ -99,7 +79,7 @@ public class LoginController {
code { background: #f3f3f3; padding: 2px 4px; }
.box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; }
.row { margin: 8px 0; }
input { width: 100%%; padding: 8px; }
input { width: 100%; padding: 8px; }
button { padding: 10px 14px; }
</style>
</head>
@ -111,12 +91,6 @@ public class LoginController {
<p><a href="/auth/spnego?next=%s"><button>Login with Kerberos</button></a></p>
</div>
<div class="box">
<h2>Google Workspace</h2>
<p>Sign in with your @%s Google account.</p>
<p><a href="/auth/login/google?next=%s"><button>Sign in with Google</button></a></p>
</div>
<div class="box">
<h2>Fallback form</h2>
<p>Only available when enabled by configuration.</p>
@ -129,7 +103,7 @@ public class LoginController {
</div>
</body>
</html>
""".formatted(safeNextEncoded, auth.getEmailDomain(), safeNextEncoded, escapeHtmlAttr(safeNext));
""".formatted(escapeHtmlAttr(safeNext), escapeHtmlAttr(safeNext));
return ResponseEntity.ok(html);
}
@ -205,79 +179,6 @@ public class LoginController {
return ResponseEntity.status(HttpStatus.FOUND).location(safeNext(next)).build();
}
// Google OAuth2 login (Path A)
/**
* Initiates Google OAuth2 login. Stores CSRF state + next URL in HTTP session,
* then redirects the browser to Google's authorization endpoint.
*/
@GetMapping("/login/google")
public ResponseEntity<Void> googleLogin(
@RequestParam(name = "next", required = false) String next,
HttpServletRequest request
) {
if (!auth.isEnabled()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
String callbackUri = baseUrl + "/callback/google";
HttpSession session = request.getSession(true);
session.setAttribute("login.googleState", state);
session.setAttribute("login.next", next);
String url = googleOAuth.buildAuthorizationUrl(state, nonce, callbackUri);
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build();
}
/**
* Handles the Google OAuth2 callback. Validates state, exchanges the code for a
* verified Google identity, creates a knoe-auth session, and redirects to 'next'.
*/
@GetMapping("/callback/google")
public ResponseEntity<Void> googleCallback(
@RequestParam(name = "code", required = false) String code,
@RequestParam(name = "state", required = false) String state,
@RequestParam(name = "error", required = false) String error,
HttpServletRequest request,
HttpServletResponse response
) {
if (!auth.isEnabled()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
if (error != null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
HttpSession session = request.getSession(false);
if (session == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
String expectedState = (String) session.getAttribute("login.googleState");
String next = (String) session.getAttribute("login.next");
session.removeAttribute("login.googleState");
session.removeAttribute("login.next");
if (expectedState == null || !expectedState.equals(state)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
String callbackUri = baseUrl + "/callback/google";
GoogleOAuthService.GoogleIdentity identity;
try {
identity = googleOAuth.exchangeCode(code, callbackUri);
} catch (GoogleOAuthService.GoogleOAuthException e) {
log.error("Google token exchange failed: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String username = normalizer.normalizeUsernameFromEmail(identity.email())
.orElse(identity.email().split("@")[0]);
setSessionCookie(response, new SessionUser(username, identity.email(), resolveGroups(username)));
return ResponseEntity.status(HttpStatus.FOUND).location(safeNext(next)).build();
}
@GetMapping("/logout")
public ResponseEntity<Void> logout(@RequestParam(name = "next", required = false) String next, HttpServletResponse response) {
clearCookie(response);
@ -337,28 +238,31 @@ public class LoginController {
}
private URI safeNext(String next) {
String apex = apex();
URI defaultNext = URI.create("https://svc." + apex + "/");
if (next == null || next.isBlank()) return defaultNext;
// Require https:// reject http and protocol-relative URLs.
if (!next.startsWith("https://")) return defaultNext;
// Extract host without full URI parsing so bare params and partially-decoded
// spaces don't cause IllegalArgumentException.
String afterScheme = next.substring("https://".length());
String host = afterScheme.split("[/?#]", 2)[0];
if (!host.equals(apex) && !host.endsWith("." + apex)) return defaultNext;
URI defaultNext = URI.create("https://svc.knoe.dev/");
if (next == null || next.isBlank()) {
return defaultNext;
}
try {
return new URI(next.replace(" ", "%20"));
URI u = URI.create(next);
if (u.isAbsolute()) {
if (!"https".equalsIgnoreCase(u.getScheme())) {
return defaultNext;
}
String host = u.getHost();
if (host == null || !host.endsWith(".knoe.dev")) {
return defaultNext;
}
return u;
}
if (next.startsWith("/")) {
return URI.create("https://svc.knoe.dev" + next);
}
return defaultNext;
} catch (Exception e) {
return defaultNext;
}
}
private String apex() {
String cd = auth.getCookieDomain();
return (cd != null && cd.startsWith(".")) ? cd.substring(1) : "knoe.dev";
}
private static String escapeHtmlAttr(String v) {
if (v == null) {
return "";

View File

@ -1,13 +1,13 @@
package dev.knoe.auth.web;
package org.knoe.authority.web;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Optional;
import dev.knoe.auth.config.AuthProperties;
import dev.knoe.auth.session.SessionTokenService;
import dev.knoe.auth.session.SessionUser;
import org.knoe.authority.config.AuthProperties;
import org.knoe.authority.session.SessionTokenService;
import org.knoe.authority.session.SessionUser;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

View File

@ -1,68 +0,0 @@
# Spring profile: k3d
# Activated by `-Dspring-boot.run.profiles=k3d` (see docs/local-dev-knoe-auth.md).
#
# Overrides for application.yml when knoe-auth runs on the laptop against
# the k3d-resident CNPG + KDC stack (see docs/plans/k3d-gke-mirror.md).
# Production / GKE values are NOT here — only the dev-loop adjustments.
#
# Phase 2 OIDC provider is ENABLED in this profile. The signing key is
# read from KNOE_AUTH_OIDC_SIGNING_KEY (base64 PKCS#8 RS256 private key);
# generate one with `bash etc/gen_oidc_signing_key.sh` (idempotent — runs
# automatically as part of `make k3d-knoe-up`).
server:
port: 8080
knoe:
auth:
enabled: true
cookieDomain: localhost
sessionSecret: ${KNOE_AUTH_SESSION_SECRET:k3d-dev-session-secret-change-me-needs-32-chars-min}
formEnabled: true
# Bare usernames granted admin group membership for local dev.
adminPrincipals:
- admin
- developer
kerberos:
realm: KNOE.LOCAL
servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:HTTP/localhost@KNOE.LOCAL}
keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:}
# The krb5.conf is set via JVM arg in `mvn spring-boot:run`:
# -Djava.security.krb5.conf=etc/krb5.local.conf
# See docs/local-dev-knoe-auth.md.
# ── Phase 2 OIDC provider ──────────────────────────────────────────────
# Local issuer is http://localhost:8080. /.well-known/openid-configuration,
# /jwks.json, /authorize, /token, /userinfo are all reachable once knoe-auth
# is running. signingKey: leave blank to let OidcTokenService generate an
# ephemeral key on each startup (tokens don't survive restart but flow works);
# set KNOE_AUTH_OIDC_SIGNING_KEY for a persistent key (gen_oidc_signing_key.sh
# produces it).
oidc:
enabled: true
issuer: ${KNOE_AUTH_OIDC_ISSUER:http://localhost:8080}
signingKey: ${KNOE_AUTH_OIDC_SIGNING_KEY:}
clientId: ${KNOE_AUTH_OIDC_CLIENT_ID:knoe-local}
clientSecret: ${KNOE_AUTH_OIDC_CLIENT_SECRET:}
enroll:
baseUrl: ${KNOE_AUTH_BASE_URL:http://localhost:8080}
# ── Google upstream (optional locally) ─────────────────────────────────
# Phase 2 OIDC dev primarily exercises LOCAL token issuance + JWKS, not
# the Google federation path. If you want to test the upstream Google
# bounce, set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in your shell env
# (and register http://localhost:8080/auth/enroll/google-callback as an
# authorized redirect URI on the dev OAuth client).
google:
clientId: ${GOOGLE_CLIENT_ID:}
clientSecret: ${GOOGLE_CLIENT_SECRET:}
redirectUri: ${KNOE_AUTH_BASE_URL:http://localhost:8080}/auth/enroll/google-callback
spring:
datasource:
# Local k3d port-forward (see scripts/k3d-knoe-pf.sh)
url: ${KNOE_DB_URL:jdbc:postgresql://localhost:5432/knoe-db}
username: ${KNOE_DB_USER:postgres}
password: ${KNOE_DB_PASSWORD:}

View File

@ -1,33 +0,0 @@
# Spring profile for the prole.org homelab k3s cluster.
# Activate with: --spring.profiles.active=prole
# or: SPRING_PROFILES_ACTIVE=prole
#
# Google OAuth2 credentials are injected from Kubernetes Secret
# google-prole-oauth2-secret (keys: GOOGLE_PROLE_CLIENT_ID, GOOGLE_PROLE_CLIENT_SECRET).
# Do NOT hardcode credentials here.
knoe:
auth:
enabled: true
cookieDomain: .prole.org
emailDomain: prole.org
oidc:
enabled: ${KNOE_AUTH_OIDC_ENABLED:true}
issuer: ${KNOE_AUTH_OIDC_ISSUER:https://api.prole.org/auth}
signingKey: ${KNOE_AUTH_OIDC_SIGNING_KEY:}
# OIDC relying-party credentials (e.g. oauth2-proxy for supabase-studio).
# Injected from Secret knoe-auth-oidc-client.
clientId: ${KNOE_AUTH_OIDC_CLIENT_ID:}
clientSecret: ${KNOE_AUTH_OIDC_CLIENT_SECRET:}
google:
clientId: ${GOOGLE_PROLE_CLIENT_ID:}
clientSecret: ${GOOGLE_PROLE_CLIENT_SECRET:}
# Restrict login to prole.org Google Workspace accounts (hd= claim check).
allowedDomain: prole.org
redirectUri: ${KNOE_AUTH_BASE_URL:https://api.prole.org/auth}/auth/enroll/google-callback
kerberos:
servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:HTTP/api.prole.org@PROLE.ORG}
keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:/etc/knoe-auth/http.keytab}
realm: ${KNOE_KERBEROS_REALM:PROLE.ORG}

View File

@ -15,55 +15,13 @@ knoe:
sessionTtl: 8h
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
sessionSecret: ""
emailDomain: knoey.com
emailDomain: knoe.dev
formEnabled: false
# Comma-separated list of bare usernames granted admin group membership.
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
adminPrincipals: []
# ── OIDC Provider ──────────────────────────────────────────────────────
oidc:
enabled: ${KNOE_AUTH_OIDC_ENABLED:false}
issuer: ${KNOE_AUTH_OIDC_ISSUER:https://api.knoe.dev/auth}
clientId: ${KNOE_AUTH_OIDC_CLIENT_ID:}
clientSecret: ${KNOE_AUTH_OIDC_CLIENT_SECRET:}
signingKey: ${KNOE_AUTH_OIDC_SIGNING_KEY:}
kerberos:
# REQUIRED for SPNEGO when enabled. Provide via env.
servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:}
keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:}
realm: ${KNOE_KERBEROS_REALM:}
# ── Enrollment (knoe-auth Round 1) ──────────────────────────────────────
enroll:
inviteTtlHours: ${KNOE_ENROLL_INVITE_TTL_HOURS:72}
otpTtlMinutes: ${KNOE_ENROLL_OTP_TTL_MINUTES:10}
otpMaxAttempts: ${KNOE_ENROLL_OTP_MAX_ATTEMPTS:3}
totpIssuer: ${KNOE_ENROLL_TOTP_ISSUER:Knoe.DEV}
# Base URL used in invite emails and enrollment redirect URIs
baseUrl: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev}
# ── Google OAuth2 corroboration ──────────────────────────────────────────
# No allowed-domains list — any Google account is accepted.
# Trust is established by invite OTP, not the developer's home domain.
# provider_hd is recorded in knoe.identity for audit only.
google:
clientId: ${GOOGLE_CLIENT_ID:}
clientSecret: ${GOOGLE_CLIENT_SECRET:}
redirectUri: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev}/auth/enroll/google-callback
# ── Async provisioning worker ─────────────────────────────────────────────
provisioning:
pollIntervalMs: ${KNOE_PROVISIONING_POLL_INTERVAL_MS:10000}
giteaUrl: ${KNOE_GITEA_URL:https://git.knoe.dev}
giteaToken: ${KNOE_GITEA_TOKEN:}
spring:
# ── Database ─────────────────────────────────────────────────────────────
datasource:
url: ${KNOE_DB_URL:jdbc:postgresql://localhost:5432/knoe}
username: ${KNOE_DB_USER:knoe}
password: ${KNOE_DB_PASSWORD:}
driver-class-name: org.postgresql.Driver
sql:
init:
mode: never
servicePrincipal: ""
keytabPath: ""
realm: ""

View File

@ -1,23 +0,0 @@
-- V1__oidc_authorization_codes.sql
-- OIDC authorization code store: replaces the in-memory ConcurrentHashMap
-- in OidcCodeService with a durable, single-use, expiring table.
CREATE SCHEMA IF NOT EXISTS knoe;
CREATE TABLE IF NOT EXISTS knoe.oidc_authorization_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
state TEXT,
nonce TEXT,
-- Flattened from SessionUser
user_username TEXT NOT NULL,
user_email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + INTERVAL '60' SECOND
);
CREATE INDEX ON knoe.oidc_authorization_codes (expires_at);
-- Grant to the application role (matches existing table grants in schema migrations)
-- GRANT SELECT, INSERT, DELETE ON knoe.oidc_authorization_codes TO "knoe-auth";

View File

@ -1,14 +0,0 @@
package dev.knoe.auth;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class HealthControllerTest {
private final HealthController controller = new HealthController();
@Test
void healthReturnsOk() {
assertEquals("ok", controller.health());
}
}

View File

@ -1,115 +0,0 @@
package dev.knoe.auth.admin;
import dev.knoe.auth.enroll.InviteService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter"
})
@AutoConfigureMockMvc
class AdminControllerTest {
@Autowired private MockMvc mvc;
@MockBean private InviteService inviteService;
@MockBean private KnobjectService knobjectService;
// POST /auth/admin/invites
@Test
void createInviteReturnsTokenAndOtp() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), anyString(), anyString()))
.thenReturn(new InviteService.InviteResult(
"tok-abc", "https://auth.knoe.dev/enroll?token=tok-abc", "654321"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"contact\":\"alice@corp.com\",\"contactType\":\"email\",\"nameHint\":\"Alice\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("tok-abc"))
.andExpect(jsonPath("$.otp").value("654321"))
.andExpect(jsonPath("$.enrollUrl").exists());
}
@Test
void createInviteWithAdminHeader() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), anyString(), anyString()))
.thenReturn(new InviteService.InviteResult("tok-xyz", "https://auth.knoe.dev/enroll?token=tok-xyz", "111222"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Knoe-Admin", "chrisfu")
.content("{\"contact\":\"bob@corp.com\",\"contactType\":\"email\",\"nameHint\":\"Bob\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("tok-xyz"));
}
@Test
void createInviteWithNullContactType() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), any(), anyString()))
.thenReturn(new InviteService.InviteResult("tok-1", "https://auth.knoe.dev/enroll?token=tok-1", "000001"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"contact\":\"carol@corp.com\",\"nameHint\":\"Carol\"}"))
.andExpect(status().isOk());
}
// GET /auth/admin/users
@Test
void listUsersReturnsJsonArray() throws Exception {
when(knobjectService.listUsers()).thenReturn(List.of(
Map.of("id", 1, "username", "alice", "email", "alice@knoe.org"),
Map.of("id", 2, "username", "bob", "email", "bob@knoe.org")
));
mvc.perform(get("/auth/admin/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].username").value("alice"))
.andExpect(jsonPath("$[1].username").value("bob"));
}
@Test
void listUsersReturnsEmptyArray() throws Exception {
when(knobjectService.listUsers()).thenReturn(List.of());
mvc.perform(get("/auth/admin/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
// POST /auth/admin/grants
@Test
void createGrantReturnsGrantId() throws Exception {
when(knobjectService.grantAccess(1, 10, "developer", "admin")).thenReturn(42);
mvc.perform(post("/auth/admin/grants")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":1,\"knobjectId\":10,\"role\":\"developer\",\"grantedBy\":\"admin\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.grantId").value(42))
.andExpect(jsonPath("$.status").value("granted"));
}
}

View File

@ -1,125 +0,0 @@
package dev.knoe.auth.enroll;
import dev.knoe.auth.admin.AdminController;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for pure value types (records, enums, exceptions) in the enroll/admin packages.
* No Spring context or DB required.
*/
class EnrollValueTypesTest {
// InviteService.InviteResult
@Test
void inviteResultFields() {
InviteService.InviteResult r = new InviteService.InviteResult("tok-1", "https://auth.knoe.dev/enroll?token=tok-1", "123456");
assertEquals("tok-1", r.token());
assertEquals("https://auth.knoe.dev/enroll?token=tok-1", r.enrollUrl());
assertEquals("123456", r.rawOtp());
}
// InviteService.OtpResult
@Test
void otpResultEnumValues() {
assertNotNull(InviteService.OtpResult.OK);
assertNotNull(InviteService.OtpResult.WRONG_OTP);
assertNotNull(InviteService.OtpResult.OTP_EXPIRED);
assertNotNull(InviteService.OtpResult.LOCKED);
assertNotNull(InviteService.OtpResult.INVALID_TOKEN);
assertEquals(5, InviteService.OtpResult.values().length);
}
@Test
void otpResultValueOf() {
assertEquals(InviteService.OtpResult.OK, InviteService.OtpResult.valueOf("OK"));
assertEquals(InviteService.OtpResult.LOCKED, InviteService.OtpResult.valueOf("LOCKED"));
}
// GoogleOAuthService.GoogleIdentity
@Test
void googleIdentityFields() {
GoogleOAuthService.GoogleIdentity id = new GoogleOAuthService.GoogleIdentity(
"sub-123", "alice@gmail.com", "Alice Smith", null);
assertEquals("sub-123", id.sub());
assertEquals("alice@gmail.com", id.email());
assertEquals("Alice Smith", id.name());
assertNull(id.hd());
}
@Test
void googleIdentityWithHostedDomain() {
GoogleOAuthService.GoogleIdentity id = new GoogleOAuthService.GoogleIdentity(
"sub-456", "bob@corp.com", "Bob", "corp.com");
assertEquals("corp.com", id.hd());
}
// GoogleOAuthService.GoogleOAuthException
@Test
void googleOAuthExceptionMessageOnly() {
GoogleOAuthService.GoogleOAuthException ex = new GoogleOAuthService.GoogleOAuthException("oauth failed");
assertEquals("oauth failed", ex.getMessage());
assertNull(ex.getCause());
assertInstanceOf(RuntimeException.class, ex);
}
@Test
void googleOAuthExceptionWithCause() {
RuntimeException cause = new RuntimeException("network error");
GoogleOAuthService.GoogleOAuthException ex = new GoogleOAuthService.GoogleOAuthException("wrapped", cause);
assertEquals("wrapped", ex.getMessage());
assertSame(cause, ex.getCause());
}
// UserProvisioningService.ProvisionResult
@Test
void provisionResultFields() {
UserProvisioningService.ProvisionResult r = new UserProvisioningService.ProvisionResult(42, "alice", "alice@knoe.org");
assertEquals(42, r.userId());
assertEquals("alice", r.username());
assertEquals("alice@knoe.org", r.email());
}
// UserProvisioningService.ProvisioningException
@Test
void provisioningExceptionMessageOnly() {
UserProvisioningService.ProvisioningException ex = new UserProvisioningService.ProvisioningException("provision failed");
assertEquals("provision failed", ex.getMessage());
assertNull(ex.getCause());
assertInstanceOf(RuntimeException.class, ex);
}
@Test
void provisioningExceptionWithCause() {
RuntimeException cause = new RuntimeException("db error");
UserProvisioningService.ProvisioningException ex = new UserProvisioningService.ProvisioningException("wrapped", cause);
assertSame(cause, ex.getCause());
}
// AdminController.CreateInviteRequest
@Test
void createInviteRequestFields() {
AdminController.CreateInviteRequest req = new AdminController.CreateInviteRequest("alice@corp.com", "email", "Alice");
assertEquals("alice@corp.com", req.contact());
assertEquals("email", req.contactType());
assertEquals("Alice", req.nameHint());
}
// AdminController.GrantRequest
@Test
void grantRequestFields() {
AdminController.GrantRequest req = new AdminController.GrantRequest(1, 10, "read", "admin");
assertEquals(1, req.userId());
assertEquals(10, req.knobjectId());
assertEquals("read", req.role());
assertEquals("admin", req.grantedBy());
}
}

View File

@ -1,217 +0,0 @@
package dev.knoe.auth.enroll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter"
})
@AutoConfigureMockMvc
class EnrollmentControllerTest {
@Autowired private MockMvc mvc;
@MockBean private InviteService inviteService;
@MockBean private GoogleOAuthService googleOAuth;
@MockBean private TotpService totpService;
@MockBean private UserProvisioningService provisioning;
// GET /auth/enroll?token=...
@Test
void landingWithInvalidTokenReturnsBadRequest() throws Exception {
when(inviteService.findValidInvite(anyString())).thenReturn(null);
mvc.perform(get("/auth/enroll").param("token", "bad-token"))
.andExpect(status().isBadRequest());
}
@Test
void landingWithValidTokenReturnsOk() throws Exception {
when(inviteService.findValidInvite("good-token"))
.thenReturn(Map.of("contact", "alice@corp.com", "name_hint", "Alice"));
mvc.perform(get("/auth/enroll").param("token", "good-token"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("invited to knoe.dev")));
}
// POST /auth/enroll/verify-otp
@Test
void verifyOtpOkRedirects() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.OK);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "123456"))
.andExpect(status().is3xxRedirection());
}
@Test
void verifyOtpWrongCodeReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.WRONG_OTP);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
@Test
void verifyOtpExpiredReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.OTP_EXPIRED);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
@Test
void verifyOtpLockedReturnsForbidden() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.LOCKED);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isForbidden());
}
@Test
void verifyOtpInvalidTokenReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.INVALID_TOKEN);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
// GET /auth/enroll/identity (session-gated)
@Test
void identityFormWithoutOtpVerifiedReturnsForbidden() throws Exception {
mvc.perform(get("/auth/enroll/identity"))
.andExpect(status().isForbidden());
}
@Test
void identityFormWithOtpVerifiedReturnsOk() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
mvc.perform(get("/auth/enroll/identity").session(session))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Your details")));
}
// POST /auth/enroll/identity/start
@Test
void identityStartWithoutOtpVerifiedReturnsForbidden() throws Exception {
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("displayName", "Alice"))
.andExpect(status().isForbidden());
}
@Test
void identityStartWithInvalidUsernameReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "A!").param("displayName", "Alice")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void identityStartWithValidUsernameRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
when(googleOAuth.buildAuthorizationUrl(anyString(), anyString()))
.thenReturn("https://accounts.google.com/o/oauth2/auth?state=x");
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("displayName", "Alice")
.session(session))
.andExpect(status().is3xxRedirection());
}
// GET /auth/enroll/google-callback
@Test
void googleCallbackStateMismatchReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "expected-state");
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "wrong-state")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackNoSessionStateReturnsBadRequest() throws Exception {
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "some-state"))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackSuccessRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "state-123");
when(googleOAuth.exchangeCode(anyString()))
.thenReturn(new GoogleOAuthService.GoogleIdentity("sub", "alice@gmail.com", "Alice", null));
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "state-123")
.session(session))
.andExpect(status().is3xxRedirection());
}
@Test
void googleCallbackOAuthExceptionReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "state-123");
when(googleOAuth.exchangeCode(anyString()))
.thenThrow(new GoogleOAuthService.GoogleOAuthException("token exchange failed"));
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "state-123")
.session(session))
.andExpect(status().isBadRequest());
}
// GET /auth/enroll/totp (session-gated)
@Test
void totpSetupWithoutGoogleIdReturnsForbidden() throws Exception {
mvc.perform(get("/auth/enroll/totp"))
.andExpect(status().isForbidden());
}
@Test
void totpSetupWithGoogleIdReturnsOk() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleId",
new GoogleOAuthService.GoogleIdentity("sub", "alice@gmail.com", "Alice", null));
session.setAttribute("enroll.username", "alice");
when(totpService.generateSecret()).thenReturn("JBSWY3DPEHPK3PXP");
when(totpService.buildQrUri(anyString(), anyString())).thenReturn("otpauth://totp/alice");
mvc.perform(get("/auth/enroll/totp").session(session))
.andExpect(status().isOk());
}
}

View File

@ -1,74 +0,0 @@
package dev.knoe.auth.enroll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TotpServiceTest {
private TotpService totpService;
@BeforeEach
void setUp() throws Exception {
totpService = new TotpService();
// inject issuer via reflection since @Value is not wired in plain unit tests
var field = TotpService.class.getDeclaredField("issuer");
field.setAccessible(true);
field.set(totpService, "Knoe.DEV");
}
@Test
void generateSecretIsNotNull() {
String secret = totpService.generateSecret();
assertNotNull(secret);
assertFalse(secret.isBlank());
}
@Test
void generateSecretIsBase32Like() {
String secret = totpService.generateSecret();
// Base32 alphabet: A-Z and 2-7
assertTrue(secret.matches("[A-Z2-7]+"), "Expected Base32 string, got: " + secret);
}
@Test
void generateSecretIsDifferentEachTime() {
String s1 = totpService.generateSecret();
String s2 = totpService.generateSecret();
assertNotEquals(s1, s2);
}
@Test
void buildQrUriContainsExpectedParts() {
String secret = totpService.generateSecret();
String uri = totpService.buildQrUri("alice", secret);
assertNotNull(uri);
assertTrue(uri.startsWith("otpauth://totp/"), "URI should start with otpauth://totp/");
assertTrue(uri.contains("alice"), "URI should contain the username");
assertTrue(uri.contains("Knoe.DEV"), "URI should contain the issuer");
assertTrue(uri.contains(secret), "URI should contain the secret");
}
@Test
void verifyReturnsFalseForNullSecret() {
assertFalse(totpService.verify(null, "123456"));
}
@Test
void verifyReturnsFalseForNullCode() {
assertFalse(totpService.verify(totpService.generateSecret(), null));
}
@Test
void verifyReturnsFalseForWrongCode() {
String secret = totpService.generateSecret();
// "000000" is almost certainly wrong for any real secret at any time
assertFalse(totpService.verify(secret, "000000"));
}
@Test
void verifyReturnsFalseForInvalidSecret() {
// Garbage secret library should throw internally, service should return false
assertFalse(totpService.verify("not-valid-base32!!!", "123456"));
}
}

View File

@ -1,72 +0,0 @@
package dev.knoe.auth.kerberos;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
class KadminClientTest {
// KadminException constructors
@Test
void exceptionMessageOnly() {
KadminClient.KadminException ex = new KadminClient.KadminException("something went wrong");
assertEquals("something went wrong", ex.getMessage());
assertNull(ex.getCause());
}
@Test
void exceptionWithCause() {
RuntimeException cause = new RuntimeException("root cause");
KadminClient.KadminException ex = new KadminClient.KadminException("wrapped", cause);
assertEquals("wrapped", ex.getMessage());
assertSame(cause, ex.getCause());
}
@Test
void exceptionIsRuntimeException() {
assertInstanceOf(RuntimeException.class, new KadminClient.KadminException("err"));
}
// qualified() private method tested via reflection
@Test
void qualifiedAppendsRealmWhenMissing() throws Exception {
KadminClient client = new KadminClient();
// inject realm field
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("alice@KNOE.DEV", qualified.invoke(client, "alice"));
}
@Test
void qualifiedLeavesFullyQualifiedPrincipalUnchanged() throws Exception {
KadminClient client = new KadminClient();
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("alice@KNOE.DEV", qualified.invoke(client, "alice@KNOE.DEV"));
}
@Test
void qualifiedWithServicePrincipal() throws Exception {
KadminClient client = new KadminClient();
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("HTTP/api.knoe.dev@KNOE.DEV", qualified.invoke(client, "HTTP/api.knoe.dev@KNOE.DEV"));
}
}

View File

@ -1,24 +0,0 @@
package dev.knoe.auth.kerberos;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class KerberosSpnegoResultTest {
@Test
void establishedResult() {
byte[] token = new byte[]{1, 2, 3};
KerberosSpnegoService.Result r = new KerberosSpnegoService.Result(true, "alice@KNOE.DEV", token);
assertTrue(r.established());
assertEquals("alice@KNOE.DEV", r.clientPrincipal());
assertArrayEquals(token, r.responseToken());
}
@Test
void notEstablishedResult() {
KerberosSpnegoService.Result r = new KerberosSpnegoService.Result(false, null, new byte[0]);
assertFalse(r.established());
assertNull(r.clientPrincipal());
assertEquals(0, r.responseToken().length);
}
}

View File

@ -1,78 +0,0 @@
package dev.knoe.auth.regression;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Regression tests to ensure we don't re-introduce org.knoe packages
* or @knoe.dev user emails in Java source.
*/
class IdentityRegressionTest {
@Test
void noOrgKnoeInSource() throws IOException {
Path srcDir = Path.of("src/main/java");
if (!Files.exists(srcDir)) {
// Fallback for different execution contexts
srcDir = Path.of("authority/src/main/java");
}
try (Stream<Path> paths = Files.walk(srcDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(this::checkFileForOrgKnoe);
}
}
@Test
void noKnoeDevUserEmailsInSource() throws IOException {
Path srcDir = Path.of("src/main/java");
if (!Files.exists(srcDir)) {
srcDir = Path.of("authority/src/main/java");
}
try (Stream<Path> paths = Files.walk(srcDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(this::checkFileForKnoeDevEmails);
}
}
private void checkFileForOrgKnoe(Path path) {
try {
String content = Files.readString(path);
assertFalse(content.contains("package org.knoe"),
"File " + path + " contains forbidden package org.knoe");
assertFalse(content.contains("import org.knoe"),
"File " + path + " contains forbidden import org.knoe");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void checkFileForKnoeDevEmails(Path path) {
// Skip files that are allowed to have knoe.dev (like config or issuer logic)
String fileName = path.getFileName().toString();
if (fileName.equals("AuthProperties.java") ||
fileName.equals("OidcDiscoveryController.java") ||
fileName.equals("OidcTokenService.java")) {
return;
}
try {
String content = Files.readString(path);
// Check for user-like emails @knoe.dev
// We allow https://api.knoe.dev and Kerberos realm @KNOE.DEV
assertFalse(content.matches("(?s).*\"[a-zA-Z0-9._%+-]+@knoe\\.dev\".*"),
"File " + path + " contains forbidden user email @knoe.dev");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -1,61 +0,0 @@
package dev.knoe.auth.session;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OidcCodeServiceTest {
private final OidcCodeService service = new OidcCodeService();
private OidcCodeService.AuthorizationRequest sampleRequest() {
return new OidcCodeService.AuthorizationRequest(
"client-id", "https://app.knoe.dev/callback", "state-xyz", "nonce-abc",
new SessionUser("alice", "alice@knoe.org", java.util.List.of()));
}
@Test
void createCodeReturnsNonNullString() {
String code = service.createCode(sampleRequest());
assertNotNull(code);
assertFalse(code.isBlank());
}
@Test
void createCodeReturnsDifferentCodesEachTime() {
String c1 = service.createCode(sampleRequest());
String c2 = service.createCode(sampleRequest());
assertNotEquals(c1, c2);
}
@Test
void consumeCodeReturnsRequest() {
OidcCodeService.AuthorizationRequest req = sampleRequest();
String code = service.createCode(req);
OidcCodeService.AuthorizationRequest consumed = service.consumeCode(code);
assertNotNull(consumed);
assertEquals("client-id", consumed.clientId());
assertEquals("alice", consumed.user().username());
}
@Test
void consumeCodeRemovesIt() {
String code = service.createCode(sampleRequest());
service.consumeCode(code);
assertNull(service.consumeCode(code));
}
@Test
void consumeUnknownCodeReturnsNull() {
assertNull(service.consumeCode("does-not-exist"));
}
@Test
void authorizationRequestFieldsAccessible() {
OidcCodeService.AuthorizationRequest req = sampleRequest();
assertEquals("client-id", req.clientId());
assertEquals("https://app.knoe.dev/callback", req.redirectUri());
assertEquals("state-xyz", req.state());
assertEquals("nonce-abc", req.nonce());
assertEquals("alice", req.user().username());
}
}

View File

@ -1,67 +0,0 @@
package dev.knoe.auth.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.knoe.auth.config.AuthProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class SessionServiceTest {
private SessionTokenService tokenService;
private SessionService sessionService;
private AuthProperties authProperties;
@BeforeEach
void setUp() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC);
tokenService = SessionTokenService.forTests(new ObjectMapper(), clock);
authProperties = new AuthProperties();
authProperties.setSessionSecret("test-secret");
authProperties.setCookieName("knoe_session");
authProperties.setSessionTtl(Duration.ofHours(1));
sessionService = new SessionService(authProperties, tokenService);
}
@Test
void noCookieReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
@Test
void blankCookieReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", " "));
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
@Test
void validCookieReturnsUser() {
String token = tokenService.issue("test-secret",
new SessionUser("alice", "alice@knoe.org", List.of()), Duration.ofMinutes(10));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", token));
Optional<SessionUser> user = sessionService.getSessionUser(request);
assertTrue(user.isPresent());
assertEquals("alice", user.get().username());
}
@Test
void invalidTokenReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", "not-a-valid-token"));
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
}

View File

@ -1,64 +0,0 @@
package dev.knoe.auth.user;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class PrincipalNormalizerTest {
private final PrincipalNormalizer normalizer = new PrincipalNormalizer();
@Test
void nullReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(null));
}
@Test
void blankReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(" "));
}
@Test
void simpleUsernameAtRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice@KNOE.DEV"));
}
@Test
void usernameWithInstanceAndRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice/admin@KNOE.DEV"));
}
@Test
void usernameWithInstanceNoRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice/admin"));
}
@Test
void bareUsernameNoRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice"));
}
@Test
void uppercaseNormalisedToLower() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("ALICE@KNOE.DEV"));
}
@Test
void leadingTrailingWhitespace() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal(" alice@KNOE.DEV "));
}
@Test
void atSignOnlyKeepsRealm() {
// "@REALM" at index is 0, so realm is NOT stripped (at > 0 guard),
// result is "@knoe.dev" lowercased not blank, so returned as-is.
// This documents the current behaviour; input is invalid anyway.
Optional<String> result = normalizer.normalizeUsernameFromKerberosPrincipal("@KNOE.DEV");
assertTrue(result.isPresent());
}
@Test
void emptyStringReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(""));
}
}

View File

@ -1,136 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.enroll.GoogleOAuthService;
import dev.knoe.auth.kerberos.KerberosPasswordService;
import dev.knoe.auth.kerberos.KerberosSpnegoService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration test: given a mock Google ID token with hd=prole.org,
* the /auth/callback/google endpoint returns a valid knoe-auth session.
*
* Acceptance criterion from the issue:
* "A new integration test: given a mock Google ID token with hd=prole.org,
* the /login/google callback returns a valid knoe-auth ID token."
*
* The GoogleOAuthService is mocked to avoid live Google API calls; the rest of
* the knoe-auth session/cookie stack runs for real.
*/
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=prole-test-session-secret-32ch!",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.auth.emailDomain=prole.org",
"knoe.auth.cookieDomain=.prole.org",
"knoe.kerberos.servicePrincipal=HTTP/api.prole.org@PROLE.ORG",
"knoe.kerberos.keytabPath=/tmp/http.keytab",
"knoe.kerberos.realm=PROLE.ORG",
"knoe.google.allowedDomain=prole.org",
"KNOE_AUTH_BASE_URL=https://api.prole.org/auth"
})
@AutoConfigureMockMvc
class GoogleLoginProleOrgTest {
@Autowired
private MockMvc mvc;
@MockBean private KerberosSpnegoService spnego;
@MockBean private KerberosPasswordService passwordAuth;
@MockBean private GoogleOAuthService googleOAuth;
/**
* Main acceptance test: mock Google ID token with hd=prole.org
* callback issues a knoe_session cookie (the knoe-auth ID token mechanism).
*/
@Test
void googleCallbackWithProleOrgHdIssuesSessionCookie() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "csrf-state-prole");
session.setAttribute("login.next", "/dashboard");
// Simulate GoogleOAuthService returning a verified prole.org identity.
when(googleOAuth.exchangeCode(anyString(), anyString()))
.thenReturn(new GoogleOAuthService.GoogleIdentity(
"google-sub-1234567890",
"alice@prole.org",
"Alice",
"prole.org" // hd claim: Google Workspace hosted domain
));
mvc.perform(get("/auth/callback/google")
.param("code", "mock-auth-code")
.param("state", "csrf-state-prole")
.session(session))
.andExpect(status().is3xxRedirection())
// Must issue the knoe_session cookie
.andExpect(header().string("Set-Cookie", containsString("knoe_session=")))
// Cookie must be Secure and HttpOnly (not a plaintext session)
.andExpect(header().string("Set-Cookie", containsString("Secure")))
.andExpect(header().string("Set-Cookie", containsString("HttpOnly")))
// Redirect target: the 'next' param passed during login initiation
.andExpect(header().string("Location", containsString("/dashboard")));
}
/**
* The /auth/login/google endpoint must redirect to Google's authorization URL
* with the prole.org hd= hint included (enforced by knoe.google.allowedDomain).
*/
@Test
void googleLoginRedirectsWithHdHintForProleOrg() throws Exception {
when(googleOAuth.buildAuthorizationUrl(anyString(), anyString(), anyString()))
.thenReturn("https://accounts.google.com/o/oauth2/v2/auth"
+ "?client_id=test&redirect_uri=https://api.prole.org/auth/callback/google"
+ "&response_type=code&scope=openid+email+profile&state=x&nonce=y"
+ "&access_type=online&hd=prole.org");
mvc.perform(get("/auth/login/google"))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", containsString("accounts.google.com")));
}
/**
* If a non-prole.org account fails GoogleOAuthService's domain check,
* the exception propagates through the controller (no session cookie is issued).
* The controller does not silently swallow domain-restriction errors.
*/
@Test
void googleCallbackWithWrongDomainDoesNotIssueSessionCookie() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "csrf-state-x");
// Simulate GoogleOAuthService enforcing the allowedDomain restriction.
when(googleOAuth.exchangeCode(anyString(), anyString()))
.thenThrow(new GoogleOAuthService.GoogleOAuthException(
"Google account domain 'gmail.com' is not allowed; expected 'prole.org'"));
try {
mvc.perform(get("/auth/callback/google")
.param("code", "mock-code")
.param("state", "csrf-state-x")
.session(session));
// If we reach here, the request completed without an exception.
// Assert no session cookie was set.
} catch (Exception ex) {
// Exception propagated from the controller (domain rejection) no session issued.
// Verify the root cause is the expected GoogleOAuthException.
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
org.junit.jupiter.api.Assertions.assertTrue(
cause.getMessage().contains("is not allowed"),
"Expected domain-restriction error but got: " + cause.getMessage());
}
}
}

View File

@ -1,195 +0,0 @@
package dev.knoe.auth.web;
import dev.knoe.auth.enroll.GoogleOAuthService;
import dev.knoe.auth.kerberos.KerberosPasswordService;
import dev.knoe.auth.kerberos.KerberosSpnegoService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret-for-login",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.auth.emailDomain=knoe.org",
"knoe.auth.cookieDomain=knoe.dev",
"knoe.auth.formEnabled=true",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter",
"knoe.kerberos.realm=EXAMPLE.TEST"
})
@AutoConfigureMockMvc
class LoginControllerTest {
@Autowired private MockMvc mvc;
@MockBean private KerberosSpnegoService spnego;
@MockBean private KerberosPasswordService passwordAuth;
@MockBean private GoogleOAuthService googleOAuth;
// GET /auth/login
@Test
void loginPageReturnsHtml() throws Exception {
mvc.perform(get("/auth/login"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Knoe Login")));
}
@Test
void loginPageWithNextParam() throws Exception {
mvc.perform(get("/auth/login").param("next", "/dashboard"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Knoe Login")));
}
// GET /auth/spnego
@Test
void spnegoWithoutNegotiateHeaderReturns401() throws Exception {
mvc.perform(get("/auth/spnego"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate", "Negotiate"));
}
@Test
void spnegoWithValidTokenRedirects() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(true, "alice@EXAMPLE.TEST", new byte[0]));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().is3xxRedirection());
}
@Test
void spnegoWithFailedNegotiationReturns401() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(false, null, new byte[0]));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().isUnauthorized());
}
@Test
void spnegoWithResponseTokenSetsNegotiateHeader() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(true, "alice@EXAMPLE.TEST", new byte[]{1, 2, 3}));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().is3xxRedirection())
.andExpect(header().exists("WWW-Authenticate"));
}
// POST /auth/form
@Test
void formLoginWithBlankUsernameReturns401() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "").param("password", "secret"))
.andExpect(status().isUnauthorized());
}
@Test
void formLoginWithBlankPasswordReturns401() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", ""))
.andExpect(status().isUnauthorized());
}
@Test
void formLoginSuccessRedirects() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", "correct"))
.andExpect(status().is3xxRedirection());
}
@Test
void formLoginWithNextParam() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", "correct")
.param("next", "/dashboard"))
.andExpect(status().is3xxRedirection());
}
// GET /auth/logout
@Test
void logoutRedirects() throws Exception {
mvc.perform(get("/auth/logout"))
.andExpect(status().is3xxRedirection());
}
@Test
void logoutClearsCookie() throws Exception {
mvc.perform(get("/auth/logout"))
.andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("Max-Age=0")));
}
// GET /auth/login/google
@Test
void googleLoginRedirectsToGoogle() throws Exception {
when(googleOAuth.buildAuthorizationUrl(anyString(), anyString(), anyString()))
.thenReturn("https://accounts.google.com/o/oauth2/auth?state=x&nonce=y");
mvc.perform(get("/auth/login/google"))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString("accounts.google.com")));
}
// GET /auth/callback/google
@Test
void googleCallbackStateMismatchReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "expected");
mvc.perform(get("/auth/callback/google")
.param("code", "code").param("state", "wrong")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackNoStateReturnsBadRequest() throws Exception {
mvc.perform(get("/auth/callback/google")
.param("code", "code").param("state", "any"))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackSuccessRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "state-abc");
session.setAttribute("login.next", null);
when(googleOAuth.exchangeCode(anyString(), anyString()))
.thenReturn(new GoogleOAuthService.GoogleIdentity("sub", "alice@corp.com", "Alice", "corp.com"));
mvc.perform(get("/auth/callback/google")
.param("code", "auth-code").param("state", "state-abc")
.session(session))
.andExpect(status().is3xxRedirection());
}
}

View File

@ -1,4 +1,4 @@
package dev.knoe.auth.session;
package org.knoe.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;

View File

@ -1,9 +1,9 @@
package dev.knoe.auth.web;
package org.knoe.authority.web;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import dev.knoe.auth.session.SessionTokenService;
import dev.knoe.auth.session.SessionUser;
import org.knoe.authority.session.SessionTokenService;
import org.knoe.authority.session.SessionUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;

View File

@ -1,15 +1,23 @@
{
"assignments": {},
"assignments": {
"0": "gke-knoe-dev-0-default-pool-02b40136-m9h9",
"1": "gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"2": "gke-knoe-dev-0-default-pool-d65ac672-9ncf"
},
"cluster_name": "knoe-db",
"desired_instances": 3,
"eligible_nodes": [],
"eligible_nodes": [
"gke-knoe-dev-0-default-pool-02b40136-m9h9",
"gke-knoe-dev-0-default-pool-6fb9e725-8ldx",
"gke-knoe-dev-0-default-pool-d65ac672-9ncf"
],
"metadata": {
"prior_plan_present": true,
"reason": "new_cluster",
"regenerated": true,
"reused": false
"reason": "reused",
"regenerated": false,
"reused": true
},
"plan_hash": "965d2fde4035059f",
"plan_id": "cnpg-placement-965d2fde4035059f",
"plan_hash": "a44825aeb5ed4e13",
"plan_id": "cnpg-placement-a44825aeb5ed4e13",
"schema_version": "v1"
}

View File

@ -1,9 +0,0 @@
; Knoe Master Configuration File
; Generated by knoe_conf
[Global]
CLUSTER_ENV = dev
NAMESPACE = knoe-db
DATABASE_NAMESPACE = knoe-db
SERVICE_NAMESPACE = knoe-system
CLUSTER_NAME = knoe-db

View File

@ -124,9 +124,9 @@ GITLAB_DOMAIN = git.knoe.dev
GITLAB_GITALY_STORAGE_CLASS = standard
GITLAB_GLOBAL_STATIC_IP_NAME =
GITLAB_INGRESS_CLASS = gce
GITLAB_OIDC_CLIENT_ID = secretref://gitlab-oidc-client-id
GITLAB_OIDC_CLIENT_SECRET = secretref://gitlab-oidc-client-secret
GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth
GITLAB_OIDC_CLIENT_ID = secretref://google-oidc-client-id
GITLAB_OIDC_CLIENT_SECRET = secretref://google-oidc-client-secret
GITLAB_OIDC_ISSUER = https://accounts.google.com
GITLAB_PUBLIC_HOSTS = git.knoe.dev
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
GITLAB_SHELL_LOADBALANCER_IP = 34.106.243.154
@ -137,17 +137,14 @@ GITLAB_WEBSERVICE_PUMA_THREADS_MIN = 2
GITLAB_WEBSERVICE_REQUESTS_CPU = 500m
GITLAB_WEBSERVICE_REQUESTS_MEMORY = 2Gi
GITLAB_WEBSERVICE_WORKER_PROCESSES = 1
GRAFANA_GOOGLE_CLIENT_ID = secretref://google-oidc-client-id
GRAFANA_GOOGLE_CLIENT_SECRET = secretref://google-oidc-client-secret
GRAFANA_HOSTNAME = svc.knoe.dev
GRAFANA_UPSTREAM_URL = http://prometheus-grafana.monitoring.svc.cluster.local:80
K3S_SERVER =
K3S_TOKEN =
KNOE_DB_USER = chrisfu
KNOE_USER_GITLAB_API_BASE = https://git.knoe.dev/api/v4
KNOE_USER_GITLAB_AUTH_PROVIDER = openid_connect
KNOE_USER_GITLAB_JIT_AUTO_CREATE_USERS = true
KNOE_USER_GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth
KNOE_USER_GITLAB_OIDC_ISSUER = https://accounts.google.com
KNOE_USER_GITLAB_OIDC_REDIRECT_URI = https://git.knoe.dev/users/auth/openid_connect/callback
KNOE_USER_GITLAB_PROVISIONING_READY = true
OPENTOFU_URL = http://127.0.0.1:8080
@ -167,13 +164,6 @@ SUPABASE_INGRESS_CLASS = gce
SUPABASE_STUDIO_GLOBAL_STATIC_IP_NAME = supabase-studio
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
SVC_KNOE_GLOBAL_STATIC_IP_NAME = svc-knoe
KNOE_AUTH_OIDC_ENABLED = true
KNOE_AUTH_OIDC_CLIENT_ID = secretref://gitlab-oidc-client-id
KNOE_AUTH_OIDC_CLIENT_SECRET = secretref://gitlab-oidc-client-secret
KNOE_AUTH_OIDC_SIGNING_KEY = secretref://knoe-auth-oidc-signing-key
DB_OIDC_CLIENT_ID = secretref://db-oidc-client-id
DB_OIDC_CLIENT_SECRET = secretref://db-oidc-client-secret
DB_OIDC_COOKIE_SECRET = secretref://db-oidc-cookie-secret
[Welcome]
; No configuration values captured yet for this section.
@ -198,8 +188,8 @@ PORT_FORWARD_K3D_MAPPING_3 = id=openbao;namespace=knoe-system;target=svc/openbao
PORT_FORWARD_K3D_MAPPING_4 = id=opentofu;namespace=knoe-system;target=svc/opentofu;address=0.0.0.0;hostPort=8080;servicePort=8080;protocol=TCP;description=OpenTofu
PORT_FORWARD_K3D_MAPPING_5 = id=dashboard;namespace=kubernetes-dashboard;target=svc/kubernetes-dashboard-kong-proxy;address=127.0.0.1;hostPort=8443;servicePort=443;protocol=TCP;description=Kubernetes Dashboard
PORT_FORWARD_K3D_MAPPING_6 = id=postgres;namespace=${DATABASE_NAMESPACE};target=svc/knoe-db-rw;address=0.0.0.0;hostPort=5432;servicePort=5432;protocol=TCP;description=PostgreSQL (primary)
PORT_FORWARD_K3D_MAPPING_7 = id=prometheus;namespace=monitoring;target=svc/prometheus-kube-prometheus-stack-prometheus;address=127.0.0.1;hostPort=9090;servicePort=9090;protocol=TCP;description=Prometheus UI
PORT_FORWARD_K3D_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/prometheus-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI
PORT_FORWARD_K3D_MAPPING_7 = id=prometheus;namespace=monitoring;target=svc/kps-kube-prometheus-stack-prometheus;address=127.0.0.1;hostPort=9090;servicePort=9090;protocol=TCP;description=Prometheus UI
PORT_FORWARD_K3D_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI
PORT_FORWARD_K3D_MAPPING_9 = id=supabase-kong;namespace=supabase;target=svc/kong;address=0.0.0.0;hostPort=8000;servicePort=8000;protocol=TCP;description=Supabase API (Kong)
[System Environment]

View File

@ -1,21 +1,17 @@
; Knoe Master Configuration File
; Generated by install.py on 2026-05-22 16:13:00
; Generated by install.py on 2026-04-14 06:09:00
; This file is used as input for Ansible deployment and k8s cluster creation.
[User]
; User-editable values; derived values below reference these by default.
KNOE_CONF = mock_val
KNOE_HOME = mock_val
KNOE_SERVICE = mock_val
PROLE_DATA = mock_val
PROLE_LOGS = mock_val
; No user values captured yet for this section.
[Inputs]
; Screen-scoped inputs used for unattended replays (-S)
argocd.node_selector =
build.deploy_env = Dev
build.run_build = false
database_options.distribution =
database_options.distribution = percona
database_options.ext.dblink = true
database_options.ext.pg_buffercache = true
database_options.ext.pg_cron = true
@ -31,155 +27,173 @@ database_options.ext.pgrowlocks = true
database_options.ext.pgvector = true
database_options.ext.postgis = true
database_options.ext.postgres_fdw = true
database_options.version_type =
database_options.version_type = v18
dependencies.ansible.install = true
dependencies.auto_install_missing = true
dependencies.brew.install = true
dependencies.containerd.install = true
dependencies.docker-buildx.install = true
dependencies.docker.install = true
dependencies.gcloud.install = true
dependencies.k3d.install = true
dependencies.kubectl.install = true
dependencies.kubectx.install = true
dependencies.op.install = true
dependencies.opentofu.install = true
dependencies.python.install = true
dependencies.verify_all = true
disk_selection.disk_type =
disk_selection.local_path =
dependencies.verify_all = false
disk_selection.disk_type = local
disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist
disk_selection.removable_mount =
env_setup.CLUSTER_NAME =
env_setup.DATABASE_NAMESPACE =
env_setup.KNOE_CONF = /Users/chrisfu/dev/prole/conf
env_setup.KNOE_HOME = mock_val
env_setup.KNOE_SERVICE = mock_val/etc
env_setup.PROLE_DATA = mock_val/data
env_setup.PROLE_LOGS = mock_val/logs
gitops.git_provider =
env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf
env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data
env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe
env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
env_setup.KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
gitops.git_provider = Gitea
gitops.node_selector =
init_cluster.argocd_enabled = true
init_cluster.argocd_enabled = false
init_cluster.at_rest_encryption_enabled = true
init_cluster.cluster_env = dev
init_cluster.deployment_target =
init_cluster.deployment_target = knoe-dev-cluster
init_cluster.gitops_enabled = true
init_cluster.k3s_server_url =
init_cluster.k3s_token =
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
init_cluster.kerberos_enabled = true
init_cluster.mode =
init_cluster.mode = k3d
init_cluster.start_cluster = true
init_cluster.supabase_enabled = true
init_cluster.supabase_enabled = false
init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true
init_password.cluster_name = knoe-db
init_password.cluster_name = ${CLUSTER_NAME}
init_password.db_host_port = 5432
init_password.db_namespace = knoe-system
init_password.db_namespace = ${DATABASE_NAMESPACE}
init_password.db_password =
init_password.db_password_confirm =
init_password.db_username = knoe-db
init_password.db_username = root
init_password.generate_ssh_key = true
init_scripts.run_scripts = true
kerberos_config.enabled = true
kerberos_config.kdc =
kerberos_config.kdc = 10.0.0.3
kerberos_config.password =
kerberos_config.realm =
kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false
kerberos_config.user =
kerberos_config.user = administrator
network_scan.run = true
ollama_config.model =
ollama_config.server_host =
ollama_config.server_port =
supabase_config.pv_base_dir =
supabase_config.pv_node =
ollama_config.server_port = 11434
supabase_config.pv_base_dir = /synology/d005
supabase_config.pv_node = gandalf.knoe.org
[Global]
; Variables used by name in more than one place or assumed global scope
ARGOCD_NODE_SELECTOR = mock_val
ARGOCD_NAMESPACE = argocd
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
AUTHORITY_ENABLED = true
AUTH_HOSTNAME = api.knoe.org
AUTH_LOGIN_PATH = /auth/login
AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups
AUTH_VERIFY_PATH = /auth/verify
CLUSTER_ENV = dev
CLUSTER_NAME = mock_val
DATABASE_NAMESPACE = mock_val
DB_HOST_PORT = mock_val
DB_PASSWORD =
DEPLOYMENT_TARGET =
CLUSTER_NAME = knoe-db
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f
DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013-18-014
DB_HOST_PORT = 5432
DEPLOYMENT_MODE = k3d
DEPLOYMENT_TARGET = knoe-dev-cluster
DOCKER_PRELOAD = false
GITEA_NODE_SELECTOR = mock_val
KNOE_DB_USER = knoe-db
KNOE_HOME = mock_val
KUBECONTEXT = mock_val
GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.knoe.org
K3S_SERVER = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==}
KNOE_DB_USER = root
KUBECONTEXT = dev
MONITORING_STORAGE_CLASS = local-path
OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_K3S_SERVER = mock_val
PROLE_K3S_TOKEN =
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
PROLE_OPENTOFU_URL = http://127.0.0.1:8080
SERVICE_NAMESPACE = default
SUPABASE_PV_BASE = mock_val
SUPABASE_PV_BASE_DIR = mock_val
SUPABASE_PV_NODE = mock_val
PROTECTED_DB_HOSTS = db.0.knoe.dev,db.knoe.org
PROTECTED_GIT_HOSTS = git.knoe.dev,git.knoe.org
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
SUPABASE_PV_BASE = /synology/d005
SUPABASE_PV_BASE_DIR = /synology/d005
SUPABASE_PV_NODE = gandalf.knoe.org
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev,db.knoe.org
[Welcome]
; No configuration values captured yet for this section.
[Dependencies]
; No configuration values captured yet for this section.
STATUS = All installed
[Network]
AD_DC_HOST = myrddin.prole.org
AD_DC_HOST = myrddin.knoe.org
AD_DC_IP = 10.0.0.3
ANSIBLE_INFRASTRUCTURE = $HOME/dev/prole/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/prole/infrastructure/inventory
ANSIBLE_TOPOLOGY = {"domain":"","realm":"","internal_records":{},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":false},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["merlin.prole.org","gandalf.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"tailscale":["merlin.prole.org","gandalf.prole.org"],"merlin_bootstrap":["merlin"],"workstations":["morgana.prole.org","zinfandel.prole.org"],"workstations_windows":["morgoth.prole.org","fairyland.prole.org"],"workstations:children":["workstations_windows"]},"hosts":{},"unmapped_hosts":["fairyland.prole.org","gandalf.prole.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica","merlin","merlin.prole.org","morgana.prole.org","morgoth.prole.org","myrddin.prole.org","pi.prole.org","raspberry.prole.org","retropie.prole.org","workstations_windows","zinfandel.prole.org"]}
ANSIBLE_DOMAIN = knoe.org
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED =
KERBEROS_AUTO_ENABLED = false
KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True
[Port Forwards]
; No configuration values captured yet for this section.
[System Environment]
KNOE_CONF = mock_val
KNOE_HOME = mock_val
KNOE_SERVICE = mock_val
PROLE_DATA = mock_val
PROLE_LOGS = mock_val
KNOE_CONF = $HOME/dev/knoe/conf
PROLE_DATA = $HOME/dev/knoe/data
KNOE_HOME = $HOME/dev/knoe
PROLE_LOGS = $HOME/dev/knoe/logs
KNOE_SERVICE = $HOME/dev/knoe/etc
[Monitoring]
; No configuration values captured yet for this section.
MONITORING_STORAGE_CLASS = local-path
[Kerberos Authentication]
; No configuration values captured yet for this section.
[Ollama]
; No configuration values captured yet for this section.
OLLAMA_SERVERS = 10.0.0.208:11434,fairyland.knoe.org:11434,k3d.localhost:11434,morgoth.knoe.org:11434
[Optional Features]
AT_REST_ENCRYPTION_ENABLED = false
GITOPS_ENABLED = False
KERBEROS_ENABLED = false
AT_REST_ENCRYPTION_ENABLED = true
GITOPS_ENABLED = True
GITOPS_PROVIDER = GitLab
KERBEROS_ENABLED = true
SUPABASE_ENABLED = false
[GitOps]
; No configuration values captured yet for this section.
[Database Creation]
; No configuration values captured yet for this section.
APP_CLUSTER_NAME = knoe-dev-0
DB_CLUSTER_NAME = knoe-cnpg-0
DB_USER = root
[Initialize Cluster]
ENVIRONMENT = mock_val
K3S_SERVER_URL = mock_val
K3S_TOKEN =
ENVIRONMENT = dev
K3S_SERVER_URL = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==}
[Dev Cluster (k3d)]
CLUSTER_ENV = dev
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = mock_val
KUBECTL_CONTEXT = dev
MODE = k3d
[Service Cluster (k3s)]
CLUSTER_ENV = knoe-service-cluster
DISPLAY_NAME = knoe-service-cluster
K3S_SERVER_URL = mock_val
K3S_TOKEN =
K3S_SERVER_URL = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[GCP]
BILLING_ACCOUNT = 01193C-25783B-3211AD
@ -188,20 +202,21 @@ ORG_ID = 584001916389
PROJECT_ID = plenary-truck-485623-p7
[Prod Cluster (k8s)]
ARTIFACTS_DIR = mock_val
ARTIFACTS_DIR = $HOME/dev/knoe/data/staging
CLUSTER_ENV = knoe-prod-cluster
DISPLAY_NAME = knoe-prod-cluster
MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build]
; No configuration values captured yet for this section.
LOCAL_REGISTRY = localhost:5000
LOCAL_REGISTRY_INTERNAL = k3d-knoe-registry.localhost:5000
[Initialization Scripts]
; No configuration values captured yet for this section.
[Deployment]
TARGET =
MODE = k3d
TARGET = knoe-dev-cluster
[Install]
STATUS = Finished
STATUS = Failed

View File

@ -1,21 +1,17 @@
; Knoe Master Configuration File
; Generated by install.py on 2026-05-22 16:13:09
; Generated by install.py on 2026-04-17 00:35:15
; This file is used as input for Ansible deployment and k8s cluster creation.
[User]
; User-editable values; derived values below reference these by default.
KNOE_CONF = mock_val
KNOE_HOME = mock_val
KNOE_SERVICE = mock_val
PROLE_DATA = mock_val
PROLE_LOGS = mock_val
; No user values captured yet for this section.
[Inputs]
; Screen-scoped inputs used for unattended replays (-S)
argocd.node_selector = knoe-service-cluster
argocd.node_selector =
build.deploy_env = Dev
build.run_build = false
database_options.distribution = knoe-service-cluster
database_options.distribution = percona
database_options.ext.dblink = true
database_options.ext.pg_buffercache = true
database_options.ext.pg_cron = true
@ -31,118 +27,182 @@ database_options.ext.pgrowlocks = true
database_options.ext.pgvector = true
database_options.ext.postgis = true
database_options.ext.postgres_fdw = true
database_options.version_type = knoe-service-cluster
database_options.version_type = v18
dependencies.ansible.install = true
dependencies.auto_install_missing = true
dependencies.brew.install = true
dependencies.containerd.install = true
dependencies.docker-buildx.install = true
dependencies.docker.install = true
dependencies.gcloud.install = true
dependencies.k3d.install = true
dependencies.kubectl.install = true
dependencies.kubectx.install = true
dependencies.op.install = true
dependencies.opentofu.install = true
dependencies.python.install = true
dependencies.verify_all = true
disk_selection.disk_type = knoe-service-cluster
disk_selection.local_path = knoe-service-cluster
disk_selection.removable_mount = knoe-service-cluster
dependencies.verify_all = false
disk_selection.disk_type = local
disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist
disk_selection.removable_mount =
env_setup.APP_CLUSTER_KUBECONTEXT =
env_setup.APP_CLUSTER_MODE = standard
env_setup.APP_CLUSTER_NAME = knoe-dev-0
env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
env_setup.KNOE_CONF = /Users/chrisfu/dev/prole/conf
env_setup.KNOE_HOME = mock_val
env_setup.KNOE_SERVICE = mock_val/etc
env_setup.PROLE_DATA = mock_val/data
env_setup.PROLE_LOGS = mock_val/logs
gitops.git_provider = knoe-service-cluster
gitops.node_selector = knoe-service-cluster
init_cluster.argocd_enabled = true
env_setup.DB_CLUSTER_KUBECONTEXT =
env_setup.DB_CLUSTER_MODE = standard
env_setup.DB_CLUSTER_NAME = knoe-cnpg-0
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf
env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data
env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe
env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
env_setup.KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
gitops.git_provider = Gitea
gitops.node_selector =
init_cluster.app_cluster_kubecontext =
init_cluster.app_cluster_machine_type = e2-small
init_cluster.app_cluster_mode = standard
init_cluster.app_cluster_name = knoe-dev-0
init_cluster.app_cluster_node_count = 3
init_cluster.argocd_enabled = false
init_cluster.at_rest_encryption_enabled = true
init_cluster.cluster_env = knoe-service-cluster
init_cluster.cluster_env = service
init_cluster.db_boot_disk_size_gb = 50
init_cluster.db_boot_disk_type = pd-standard
init_cluster.db_cluster_kubecontext =
init_cluster.db_cluster_machine_type = e2-standard-2
init_cluster.db_cluster_mode = standard
init_cluster.db_cluster_name = knoe-cnpg-0
init_cluster.db_cluster_node_count = 3
init_cluster.db_cluster_region =
init_cluster.db_cluster_zones =
init_cluster.deployment_target = knoe-service-cluster
init_cluster.gitops_enabled = true
init_cluster.k3s_server_url = knoe-service-cluster
init_cluster.k3s_token = ${KNOE_SECRET:v1:78Q345eo8NLP-0eB:zmZSit0eedhrDsp0TOpeeGKJTCYZhKW3Ypr3UaLDbL942Lbt}
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
init_cluster.kerberos_enabled = true
init_cluster.mode = k3s
init_cluster.start_cluster = true
init_cluster.supabase_enabled = true
init_cluster.supabase_analytics_enabled = true
init_cluster.supabase_auth_enabled = true
init_cluster.supabase_enabled = false
init_cluster.supabase_meta_enabled = true
init_cluster.supabase_realtime_enabled = true
init_cluster.supabase_studio_enabled = false
init_cluster.supabase_studio_url = db.0.knoe.dev
init_cnpg_deploy.force_rollout = false
init_cnpg_deploy.run_deploy = true
init_db_build.run_build = true
init_password.app_cluster_name = knoe-dev-0
init_password.cluster_name = ${CLUSTER_NAME}
init_password.db_host_port = knoe-service-cluster
init_password.db_cluster_name = knoe-cnpg-0
init_password.db_host_port = 5432
init_password.db_namespace = ${DATABASE_NAMESPACE}
init_password.db_password = ${KNOE_SECRET:v1:IUf_1zjEhFT6AOzB:XKjfqr9oCDReIdU3W-oH--g6eQTl-D9X58wq6pFXbmovahzz}
init_password.db_password_confirm = ${KNOE_SECRET:v1:IUf_1zjEhFT6AOzB:XKjfqr9oCDReIdU3W-oH--g6eQTl-D9X58wq6pFXbmovahzz}
init_password.db_username = knoe-service-cluster
init_password.db_password = ${KNOE_SECRET:v1:CsdBnE_l6fe36We6:QO1ZEnnsfovmzFHFfEtPwvi2FPKzLxu0pmg0K3X3DSTIt-Fx}
init_password.db_password_confirm = ${KNOE_SECRET:v1:CsdBnE_l6fe36We6:QO1ZEnnsfovmzFHFfEtPwvi2FPKzLxu0pmg0K3X3DSTIt-Fx}
init_password.db_username = root
init_password.generate_ssh_key = true
init_scripts.run_scripts = true
kerberos_config.enabled = true
kerberos_config.kdc = knoe-service-cluster
kerberos_config.password = ${KNOE_SECRET:v1:X9uYEH0PrmGsWeV2:-K_WV52_930vuAcPEHevsLIokTXN7sstc8t_gGK5rT_YAbd8}
kerberos_config.realm = knoe-service-cluster
kerberos_config.init_authority = true
kerberos_config.kdc = 10.0.0.3
kerberos_config.password =
kerberos_config.realm = PROLE.ORG
kerberos_config.test_connection = false
kerberos_config.user = knoe-service-cluster
kerberos_config.user = administrator
network_scan.run = true
ollama_config.model = knoe-service-cluster
ollama_config.server_host = knoe-service-cluster
ollama_config.server_port = knoe-service-cluster
supabase_config.pv_base_dir = knoe-service-cluster
supabase_config.pv_node = knoe-service-cluster
ollama_config.model =
ollama_config.server_host =
ollama_config.server_port = 11434
supabase_config.pv_base_dir = /synology/d005
supabase_config.pv_node = gandalf.knoe.org
[Global]
; Variables used by name in more than one place or assumed global scope
ARGOCD_NODE_SELECTOR = knoe-service-cluster
CLUSTER_ENV = knoe-service-cluster
CLUSTER_NAME = knoe-service-cluster
DATABASE_NAMESPACE = knoe-service-cluster
DB_HOST_PORT = knoe-service-cluster
DB_PASSWORD = ${KNOE_SECRET:v1:hZgLhrzDo1mWQbyV:IDy89i7N8kOivFmMesHzLqJBth3I0-WtLC8lNmoc1yMFx5uS}
ARGOCD_NAMESPACE = argocd
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
AUTHORITY_ENABLED = true
AUTH_HOSTNAME = api.knoe.org
AUTH_LOGIN_PATH = /auth/login
AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups
AUTH_VERIFY_PATH = /auth/verify
CLUSTER_ENV = service
CLUSTER_NAME = knoe-db
CNPG_ELIGIBLE_NODES = gandalf.knoe.org,merlin.knoe.org,myrddin.knoe.org
CNPG_PLACEMENT_PLAN_FILE = $HOME/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json
CNPG_PLACEMENT_PLAN_HASH = 962fb2e7bfd2a48b
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-962fb2e7bfd2a48b
CNPG_STAGE1_NODE = gandalf.knoe.org
DATABASE_NAMESPACE = knoe-db
DB_HOST_PORT = 5432
DB_PASSWORD = ${KNOE_SECRET:v1:Vc5Sow_MQksbOtOJ:bvD1ABxenFlo0304dhf0Me_nzBX0SvLJ7oFvE_TkHGqc0YF8}
DEPLOYMENT_MODE = k3s
DEPLOYMENT_TARGET = knoe-service-cluster
DOCKER_IMPORT_DIR =
DOCKER_PRELOAD = false
GITEA_NODE_SELECTOR = knoe-service-cluster
KNOE_DB_USER = knoe-service-cluster
KNOE_HOME = mock_val
KUBECONTEXT = knoe-service-cluster
GITEA_HOSTNAME = git-internal.knoe.org
GITLAB_PUBLIC_HOSTS = git.knoe.org
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
K3S_SERVER = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==}
KNOE_DB_USER = root
KNOE_IMAGE_REGISTRY = registry.knoe.org
MONITORING_STORAGE_CLASS = local-path
OPENTOFU_URL = http://127.0.0.1:8080
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
PROLE_K3S_SERVER = knoe-service-cluster
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:GDtMiPCotXoKy84s:fuE9UNyI-lXrIYAQqS3JjJXpImTg9TKSvbVmfTzgLimZV4nR}
SERVICE_NAMESPACE = knoe-service-cluster
SUPABASE_PV_BASE = knoe-service-cluster
SUPABASE_PV_BASE_DIR = knoe-service-cluster
SUPABASE_PV_NODE = knoe-service-cluster
KNOE_HOME = $HOME/dev/knoe
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
PROTECTED_DB_HOSTS = db.knoe.org
PROTECTED_GIT_HOSTS = git.knoe.org
REDIS_HOST = redis-master.knoe-system.svc.cluster.local
REGISTRY_NAMESPACE = knoe-system
SERVICE_HOSTNAME = svc.knoe.org
SERVICE_NAMESPACE = knoe-system
SUPABASE_API_HOSTNAME = supabase.knoe.org
SUPABASE_PV_BASE = /synology/d005
SUPABASE_PV_BASE_DIR = /synology/d005
SUPABASE_PV_NODE = gandalf.knoe.org
SUPABASE_STUDIO_HOSTNAME = db.knoe.org
SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005
[Welcome]
; No configuration values captured yet for this section.
[Dependencies]
; No configuration values captured yet for this section.
STATUS = All installed
[Network]
AD_DC_HOST = myrddin.prole.org
AD_DC_HOST = myrddin.knoe.org
AD_DC_IP = 10.0.0.3
ANSIBLE_INFRASTRUCTURE = $HOME/dev/prole/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/prole/infrastructure/inventory
ANSIBLE_TOPOLOGY = {"domain":"","realm":"","internal_records":{},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":false},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["merlin.prole.org","gandalf.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"tailscale":["merlin.prole.org","gandalf.prole.org"],"merlin_bootstrap":["merlin"],"workstations":["morgana.prole.org","zinfandel.prole.org"],"workstations_windows":["morgoth.prole.org","fairyland.prole.org"],"workstations:children":["workstations_windows"]},"hosts":{},"unmapped_hosts":["fairyland.prole.org","gandalf.prole.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica","merlin","merlin.prole.org","morgana.prole.org","morgoth.prole.org","myrddin.prole.org","pi.prole.org","raspberry.prole.org","retropie.prole.org","workstations_windows","zinfandel.prole.org"]}
ANSIBLE_DOMAIN = knoe.org
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory
ANSIBLE_REALM = PROLE.ORG
ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]}
KDC_ANSIBLE_DETECTED = 10.0.0.3
KDC_AUTO_DETECTED =
KERBEROS_AUTO_ENABLED = false
KDC_AUTO_DETECTED = 10.0.0.3
KERBEROS_AUTO_ENABLED = True
[Port Forwards]
; No configuration values captured yet for this section.
PORT_FORWARD_K3S_MAPPING_1 = id=argocd;namespace=argocd;target=svc/argocd-server;address=0.0.0.0;hostPort=8081;servicePort=80;protocol=TCP;description=ArgoCD
PORT_FORWARD_K3S_MAPPING_10 = id=gitea-ssh;namespace=gitea;target=svc/gitea-ssh;address=0.0.0.0;hostPort=22;servicePort=22;protocol=TCP;description=Gitea SSH
PORT_FORWARD_K3S_MAPPING_2 = id=garage;namespace=knoe-system;target=svc/garage;address=0.0.0.0;hostPort=3900;servicePort=3900;protocol=TCP;description=Garage S3
PORT_FORWARD_K3S_MAPPING_3 = id=openbao;namespace=knoe-system;target=svc/openbao;address=0.0.0.0;hostPort=8200;servicePort=8200;protocol=TCP;description=OpenBao
PORT_FORWARD_K3S_MAPPING_4 = id=opentofu;namespace=knoe-system;target=svc/opentofu;address=0.0.0.0;hostPort=8080;servicePort=8080;protocol=TCP;description=OpenTofu
PORT_FORWARD_K3S_MAPPING_5 = id=dashboard;namespace=kubernetes-dashboard;target=svc/kubernetes-dashboard-kong-proxy;address=127.0.0.1;hostPort=8443;servicePort=443;protocol=TCP;description=Kubernetes Dashboard
PORT_FORWARD_K3S_MAPPING_6 = id=postgres;namespace=${DATABASE_NAMESPACE};target=svc/knoe-db-rw;address=0.0.0.0;hostPort=5432;servicePort=5432;protocol=TCP;description=PostgreSQL (primary)
PORT_FORWARD_K3S_MAPPING_7 = id=prometheus;namespace=monitoring;target=svc/kps-kube-prometheus-stack-prometheus;address=127.0.0.1;hostPort=9090;servicePort=9090;protocol=TCP;description=Prometheus UI
PORT_FORWARD_K3S_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana UI
PORT_FORWARD_K3S_MAPPING_9 = id=gitea-http;namespace=gitea;target=svc/gitea-http;address=0.0.0.0;hostPort=13000;servicePort=3000;protocol=TCP;description=Gitea Web
[System Environment]
KNOE_CONF = mock_val
KNOE_HOME = mock_val
KNOE_SERVICE = mock_val
PROLE_DATA = mock_val
PROLE_LOGS = mock_val
KNOE_CONF = $HOME/dev/knoe/conf
PROLE_DATA = $HOME/dev/knoe/data
KNOE_HOME = $HOME/dev/knoe
PROLE_LOGS = $HOME/dev/knoe/logs
KNOE_SERVICE = $HOME/dev/knoe/etc
[Monitoring]
; No configuration values captured yet for this section.
MONITORING_STORAGE_CLASS = local-path
[Kerberos Authentication]
; No configuration values captured yet for this section.
@ -151,46 +211,48 @@ PROLE_LOGS = mock_val
; No configuration values captured yet for this section.
[Optional Features]
AT_REST_ENCRYPTION_ENABLED = false
GITOPS_ENABLED = False
KERBEROS_ENABLED = false
AT_REST_ENCRYPTION_ENABLED = true
GITOPS_ENABLED = true
GITOPS_PROVIDER = GitLab
KERBEROS_ENABLED = true
SUPABASE_ENABLED = false
[GitOps]
; No configuration values captured yet for this section.
[Database Creation]
; No configuration values captured yet for this section.
APP_CLUSTER_NAME = knoe-dev-0
DB_CLUSTER_NAME = knoe-cnpg-0
DB_USER = root
[Initialize Cluster]
ENVIRONMENT = mock_val
K3S_SERVER_URL = mock_val
K3S_TOKEN =
ENVIRONMENT = service
K3S_SERVER_URL = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
[Dev Cluster (k3d)]
CLUSTER_ENV = dev
CLUSTER_ENV = k3d-knoe-dev-cluster
DISPLAY_NAME = knoe-dev-cluster
KUBECTL_CONTEXT = knoe-service-cluster
KUBECTL_CONTEXT = service
MODE = k3d
[Service Cluster (k3s)]
CLUSTER_ENV = knoe-service-cluster
DISPLAY_NAME = knoe-service-cluster
K3S_SERVER_URL = knoe-service-cluster
K3S_TOKEN = ${KNOE_SECRET:v1:ugYbCQ3VG5jU52u2:C1ZYWLqL-0aDalk3vSup6bNzTmWfr_2rHlOf1vqjI45cCX2V}
K3S_SERVER_URL = https://myrddin.knoe.org:6443
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
KUBECTL_CONTEXT = knoe-service-cluster
MODE = k3s
PIPELINE_URL = http://127.0.0.1:8080
[GCP]
BILLING_ACCOUNT = 01193C-25783B-3211AD
BILLING_PROJECT = plenary-truck-485623-p7
ORG_ID = 584001916389
PROJECT_ID = plenary-truck-485623-p7
; No configuration values captured yet for this section.
[Prod Cluster (k8s)]
ARTIFACTS_DIR = knoe-service-cluster
ARTIFACTS_DIR =
CLUSTER_ENV = knoe-prod-cluster
DISPLAY_NAME = knoe-prod-cluster
MODE = k8s
PIPELINE_URL = http://127.0.0.1:8080
[Docker Build]
; No configuration values captured yet for this section.
@ -199,8 +261,8 @@ MODE = k8s
; No configuration values captured yet for this section.
[Deployment]
MODE = k3s
TARGET =
MODE = k3d
TARGET = knoe-dev-cluster
[Install]
STATUS = Finished
STATUS = Failed

View File

@ -1 +0,0 @@
/Users/chrisfu/dev/prole/conf/dev/knoe.cfg

View File

@ -1,11 +1,18 @@
# Port mappings for Knoe Tools (generated).
# Format: key: local=... remote=... ns=... svc=... address=...
argocd: local=8081 remote=80 ns=argocd svc=argocd-server address=0.0.0.0
supabase-studio: local=18080 remote=3000 ns=supabase svc=studio address=0.0.0.0
supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=127.0.0.1
supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0
supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0
gitea-http: local=13000 remote=3000 ns=gitea svc=gitea-http address=0.0.0.0
gitea-ssh: local=22 remote=22 ns=gitea svc=gitea-ssh address=0.0.0.0
garage: local=3900 remote=3900 ns=knoe-system svc=garage address=0.0.0.0
openbao: local=8200 remote=8200 ns=knoe-system svc=openbao address=0.0.0.0
opentofu: local=8080 remote=8080 ns=knoe-system svc=opentofu address=0.0.0.0
postgres: local=5432 remote=5432 ns=knoe-db svc=knoe-db-rw address=0.0.0.0
prometheus: local=9090 remote=9090 ns=monitoring svc=prometheus-kube-prometheus-stack-prometheus address=127.0.0.1
grafana: local=3000 remote=80 ns=monitoring svc=prometheus-grafana address=0.0.0.0
gitea-http: local=13000 remote=3000 ns=gitea svc=gitea-http address=0.0.0.0
dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1
postgres: local=5432 remote=5432 ns=knoe-db-0 svc=knoe-db-rw address=0.0.0.0
prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1
grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0
supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0

View File

@ -1,9 +0,0 @@
; Knoe Master Configuration File
; Generated by knoe_conf
[Global]
CLUSTER_ENV = service
NAMESPACE = knoe-db
DATABASE_NAMESPACE = knoe-db
SERVICE_NAMESPACE = knoe-system
CLUSTER_NAME = knoe-db

View File

@ -79,12 +79,11 @@ init_password.db_password_confirm= vzx.wC4Akd4x-Dj6Wguh
init_password.db_username = chrisfu
init_password.generate_ssh_key = true
init_scripts.run_scripts = true
kerberos_config.enabled = true
kerberos_config.enabled = False
kerberos_config.init_authority = false
kerberos_config.kdc = 10.0.0.3
kerberos_config.admin_server = 10.0.0.3
kerberos_config.kdc = 10.0.0.205
kerberos_config.password =
kerberos_config.realm = PROLE.ORG
kerberos_config.realm =
kerberos_config.test_connection = false
kerberos_config.user = administrator
network_scan.run = true
@ -117,22 +116,6 @@ KNOE_HOME = /Users/chrisfu/dev/knoe
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
; prole.org homelab overrides
SERVICE_HOST_DOMAIN = prole.org
DB_UI_HOST = db.prole.org
OIDC_ISSUER_URL = https://api.prole.org/auth
GOOGLE_WORKSPACE_DOMAIN = prole.org
; GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET: retrieve real values from OpenBao at
; bao/kv/knoe/oauth2/google-prole-org
GOOGLE_CLIENT_ID =
GOOGLE_CLIENT_SECRET =
PROLE_KDC_TRUST_REALM = PROLE.ORG
PROLE_KDC_TRUST_ADMIN = administrator@PROLE.ORG
; k3s has no default StorageClass; use the static prole-iscsi SC (backed by /synology/d001/kdc)
PROLE_KDC_STORAGE_CLASS = prole-iscsi
SERVICE_INGRESS_TLS_ENABLED = 1
MONITORING_RELEASE = prometheus
[Welcome]
; No configuration values captured yet for this section.
@ -159,11 +142,11 @@ PORT_FORWARD_K3D_MAPPING_8 = id=grafana;namespace=monitoring;target=svc/kps-graf
PORT_FORWARD_K3D_MAPPING_9 = id=supabase-kong;namespace=supabase;target=svc/kong;address=0.0.0.0;hostPort=8000;servicePort=8000;protocol=TCP;description=Supabase API (Kong)
[System Environment]
KNOE_CONF = /home/chrisfu/dev/prole/conf
PROLE_DATA = /home/chrisfu/dev/prole/data
KNOE_HOME = /home/chrisfu/dev/prole
PROLE_LOGS = /home/chrisfu/dev/prole/logs
KNOE_SERVICE = /home/chrisfu/dev/prole/etc
KNOE_CONF = /Users/chrisfu/dev/knoe/conf
PROLE_DATA = /Users/chrisfu/dev/knoe/data
KNOE_HOME = /Users/chrisfu/dev/knoe
PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
[Monitoring]
; No configuration values captured yet for this section.

View File

@ -1,130 +0,0 @@
-- 01_schema_setup.sql
-- Creates the demo schema, legacy table, OLTP workload table,
-- and all monitoring views for the CI migration danger demo.
--
-- Run as superuser (postgres) against knoe-db.
-- Safe to re-run: all objects use IF NOT EXISTS / CREATE OR REPLACE.
CREATE SCHEMA IF NOT EXISTS demo;
-- ── Legacy table (source of the backfill) ────────────────────────────────────
-- Represents the old non-partitioned ci_job_artifacts table on a 10 TB GitLab
-- DB. In our scaled demo we'll fill this with ~5 million rows (~1 GB).
CREATE TABLE IF NOT EXISTS demo.ci_job_artifacts_legacy (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
file_type SMALLINT NOT NULL DEFAULT 0,
size_bytes BIGINT,
file_store SMALLINT NOT NULL DEFAULT 1,
checksum TEXT,
created_at TIMESTAMP NOT NULL,
expire_at TIMESTAMP,
locked BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_legacy_created_at
ON demo.ci_job_artifacts_legacy (created_at);
-- ── OLTP workload table (concurrent traffic during migration) ─────────────────
-- Simulates background CI pipeline activity during the migration window.
-- UPDATEs to this table generate dead tuples that autovacuum must clean.
-- When a long-running migration transaction holds an old snapshot, autovacuum
-- is blocked and dead tuples pile up — this is the MVCC bloat we'll observe.
CREATE TABLE IF NOT EXISTS demo.ci_build_status (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- ── Monitoring views ──────────────────────────────────────────────────────────
CREATE OR REPLACE VIEW demo.mvcc_bloat_monitor AS
SELECT
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
ROUND(n_dead_tup::NUMERIC / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,
last_autovacuum,
last_autoanalyze,
pg_size_pretty(pg_total_relation_size('demo.' || relname)) AS total_size,
pg_size_pretty(pg_relation_size('demo.' || relname)) AS table_size
FROM pg_stat_user_tables
WHERE schemaname = 'demo'
ORDER BY n_dead_tup DESC;
-- Shows any transaction open longer than 5 seconds — the migration blocker.
CREATE OR REPLACE VIEW demo.long_running_tx AS
SELECT
pid,
now() - xact_start AS tx_duration,
now() - query_start AS query_duration,
state,
wait_event_type,
wait_event,
left(query, 200) AS query_snippet,
backend_type,
application_name
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > INTERVAL '5 seconds'
AND pid <> pg_backend_pid()
ORDER BY tx_duration DESC;
-- WAL generation tracker — shows how fast WAL is growing.
-- Run before and after to calculate the delta.
CREATE OR REPLACE VIEW demo.wal_progress AS
SELECT
pg_current_wal_lsn() AS current_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0'::pg_lsn)) AS total_wal,
(SELECT count(*) FROM pg_ls_waldir()) AS wal_segment_count,
pg_size_pretty((SELECT sum(size) FROM pg_ls_waldir())) AS wal_dir_size;
-- Lock wait graph — shows who is blocking whom.
CREATE OR REPLACE VIEW demo.lock_waits AS
SELECT
blocked_a.pid AS blocked_pid,
blocking_a.pid AS blocking_pid,
now() - blocked_a.query_start AS wait_duration,
left(blocked_a.query, 120) AS blocked_query,
left(blocking_a.query, 120) AS blocking_query,
blocked_l.locktype,
blocked_l.relation::regclass AS locked_relation
FROM pg_catalog.pg_locks blocked_l
JOIN pg_catalog.pg_locks blocking_l
ON blocking_l.locktype = blocked_l.locktype
AND blocking_l.relation = blocked_l.relation
AND blocking_l.granted
AND NOT blocked_l.granted
JOIN pg_stat_activity blocked_a ON blocked_a.pid = blocked_l.pid
JOIN pg_stat_activity blocking_a ON blocking_a.pid = blocking_l.pid
ORDER BY wait_duration DESC;
-- Autovacuum activity — shows if autovacuum is currently running or stuck.
CREATE OR REPLACE VIEW demo.autovacuum_status AS
SELECT
pid,
now() - xact_start AS running_for,
query AS vacuum_query,
wait_event_type,
wait_event
FROM pg_stat_activity
WHERE query LIKE 'autovacuum:%'
ORDER BY xact_start;
-- Oldest transaction horizon — the horizon that blocks dead tuple cleanup.
-- When this is far in the past and dead tuples are climbing, we have MVCC bloat.
CREATE OR REPLACE VIEW demo.xmin_horizon AS
SELECT
pid,
backend_xmin AS xmin,
age(backend_xmin) AS xmin_age,
now() - xact_start AS tx_age,
state,
left(query, 120) AS query_snippet
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;
\echo 'Schema and views created. Run 02_generate_data.sql next.'

View File

@ -1,65 +0,0 @@
-- 02_generate_data.sql
-- Populates demo.ci_job_artifacts_legacy with ~5 million rows (~1 GB of data).
-- Also seeds demo.ci_build_status for the OLTP workload.
--
-- Runtime estimate: 36 minutes on the CNPG cluster.
-- Scale: represents 1 GB out of the real 10 TB (1:10000 ratio).
--
-- Data distribution:
-- - Rows span the last 180 days (6 months of history)
-- - 70% of expire_at values are in the past (already expired)
-- - 5% of rows are locked (should be skipped by cleanup)
-- - project_id values spread across 50,000 "projects"
-- - job_id values spread across 1,000,000 "jobs"
\echo 'Generating 5,000,000 rows in demo.ci_job_artifacts_legacy...'
\echo 'This will take 3-6 minutes. Watch progress with: SELECT count(*) FROM demo.ci_job_artifacts_legacy;'
INSERT INTO demo.ci_job_artifacts_legacy
(job_id, project_id, file_type, size_bytes, file_store,
checksum, created_at, expire_at, locked)
SELECT
(random() * 999999 + 1)::BIGINT AS job_id,
(random() * 49999 + 1)::BIGINT AS project_id,
(random() * 9)::SMALLINT AS file_type,
(random() * 104857600)::BIGINT AS size_bytes, -- up to 100MB per artifact
1 AS file_store,
encode(sha256((random()::TEXT || i::TEXT)::BYTEA), 'hex') AS checksum,
NOW() - (random() * INTERVAL '180 days') AS created_at,
CASE
WHEN random() < 0.70
THEN NOW() - (random() * INTERVAL '90 days') -- 70% already expired
ELSE NOW() + (random() * INTERVAL '30 days') -- 30% not yet expired
END AS expire_at,
random() < 0.05 AS locked -- 5% locked
FROM generate_series(1, 5000000) AS gs(i);
\echo 'Legacy table populated. Running ANALYZE...'
ANALYZE demo.ci_job_artifacts_legacy;
-- Seed the OLTP workload table
\echo 'Seeding OLTP workload table (100,000 rows)...'
INSERT INTO demo.ci_build_status (job_id, status)
SELECT
(random() * 999999 + 1)::BIGINT,
CASE (random() * 2)::INT
WHEN 0 THEN 'pending'
WHEN 1 THEN 'running'
ELSE 'created'
END
FROM generate_series(1, 100000);
ANALYZE demo.ci_build_status;
\echo ''
\echo 'Data generation complete. Verify with:'
\echo ' SELECT count(*), pg_size_pretty(sum(pg_column_size(t.*))) FROM demo.ci_job_artifacts_legacy t;'
SELECT
count(*) AS row_count,
pg_size_pretty(pg_total_relation_size('demo.ci_job_artifacts_legacy')) AS table_size,
min(created_at)::DATE AS earliest_row,
max(created_at)::DATE AS latest_row,
count(*) FILTER (WHERE expire_at < NOW()) AS expired_rows,
count(*) FILTER (WHERE locked = TRUE) AS locked_rows
FROM demo.ci_job_artifacts_legacy;

View File

@ -1,85 +0,0 @@
-- 03_broken_migration.sql
-- THE BROKEN MIGRATION — do not run this on production.
-- This is the verbatim migration from the change request, with annotations.
-- It is intentionally left broken to demonstrate the failure modes.
--
-- Run in Session A. Watch Session B (monitoring) while this runs.
--
-- ISSUES DEMONSTRATED:
-- 1. Single transaction wrapping the entire backfill → MVCC bloat
-- 2. Partition range only covers FUTURE months → immediate INSERT failure
-- 3. CREATE INDEX without CONCURRENTLY inside the transaction → ShareLock
-- 4. No chunking → all-or-nothing, no progress on failure
--
-- EXPECTED OUTCOME:
-- The INSERT will fail with:
-- ERROR: no partition of relation "ci_job_artifacts" found for row
-- But before it fails, the open BEGIN will already be visible in:
-- demo.long_running_tx, demo.xmin_horizon
-- And dead tuples in demo.ci_build_status will be climbing because
-- autovacuum cannot advance past our snapshot.
-- ── DROP target if it exists from a previous run ─────────────────────────────
DROP TABLE IF EXISTS demo.ci_job_artifacts CASCADE;
BEGIN;
-- ── CREATE partitioned table ──────────────────────────────────────────────────
CREATE TABLE demo.ci_job_artifacts (
id BIGSERIAL,
job_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
file_type SMALLINT NOT NULL DEFAULT 0,
size_bytes BIGINT,
file_store SMALLINT NOT NULL DEFAULT 1,
checksum TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP,
locked BOOLEAN DEFAULT FALSE,
partition_key DATE NOT NULL DEFAULT CURRENT_DATE,
PRIMARY KEY (id, partition_key)
) PARTITION BY RANGE (partition_key);
-- ISSUE 2: Creates partitions from CURRENT_DATE forward (future months only).
-- The backfill below selects rows from the PAST 6 months.
-- These rows have no matching partition → every INSERT row will error.
DO $$
DECLARE
start_date DATE := DATE_TRUNC('month', CURRENT_DATE);
partition_name TEXT;
i INT;
BEGIN
FOR i IN 0..5 LOOP
partition_name := 'ci_job_artifacts_' || TO_CHAR(start_date + (i || ' months')::INTERVAL, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE demo.%I PARTITION OF demo.ci_job_artifacts
FOR VALUES FROM (%L) TO (%L)',
partition_name,
start_date + (i || ' months')::INTERVAL,
start_date + ((i + 1) || ' months')::INTERVAL
);
END LOOP;
END $$;
-- ISSUE 3: Indexes created inside the transaction, without CONCURRENTLY.
-- These hold ShareLock for the entire transaction duration.
CREATE INDEX idx_ci_job_artifacts_job_id ON demo.ci_job_artifacts (job_id);
CREATE INDEX idx_ci_job_artifacts_project_id ON demo.ci_job_artifacts (project_id);
CREATE INDEX idx_ci_job_artifacts_expire_at ON demo.ci_job_artifacts (expire_at);
CREATE INDEX idx_ci_job_artifacts_checksum ON demo.ci_job_artifacts (checksum);
-- ISSUE 1 + 2: Backfill inside a single transaction, targeting past 6 months.
-- Will fail because no partition exists for those months.
-- Even if we fix the partition range, running this as a single INSERT on
-- 5M rows (or 10TB at real scale) holds the transaction open for hours,
-- bloating MVCC dead tuples on every table touched by concurrent sessions.
INSERT INTO demo.ci_job_artifacts
SELECT
id, job_id, project_id, file_type, size_bytes, file_store,
checksum, created_at, expire_at, locked,
DATE_TRUNC('month', created_at)::DATE AS partition_key
FROM demo.ci_job_artifacts_legacy
WHERE created_at >= NOW() - INTERVAL '6 months';
-- This COMMIT will never be reached due to the partition error above.
COMMIT;

View File

@ -1,76 +0,0 @@
-- 03b_broken_migration_long_tx.sql
-- MVCC BLOAT DEMONSTRATION — the backfill that survives (partition bug fixed).
-- This version creates the correct historical partitions so the INSERT runs,
-- but keeps the fatal single-transaction pattern to demonstrate bloat.
--
-- Run in Session A. While it runs, hammer Session B (04_traffic_sim.sh),
-- and watch Session C (05_monitor.sql) for dead tuple accumulation.
--
-- The ~5M row INSERT will take several minutes.
-- Every UPDATE to demo.ci_build_status during that window creates a dead tuple
-- that autovacuum cannot collect because our open snapshot holds the xmin horizon.
--
-- Watch for:
-- demo.mvcc_bloat_monitor → dead_rows climbing on ci_build_status
-- demo.long_running_tx → this session visible for the duration
-- demo.xmin_horizon → our xmin locking autovacuum out
-- demo.wal_progress → WAL size growing at alarming rate
DROP TABLE IF EXISTS demo.ci_job_artifacts CASCADE;
BEGIN;
CREATE TABLE demo.ci_job_artifacts (
id BIGSERIAL,
job_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
file_type SMALLINT NOT NULL DEFAULT 0,
size_bytes BIGINT,
file_store SMALLINT NOT NULL DEFAULT 1,
checksum TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP,
locked BOOLEAN DEFAULT FALSE,
partition_key DATE NOT NULL DEFAULT CURRENT_DATE,
PRIMARY KEY (id, partition_key)
) PARTITION BY RANGE (partition_key);
-- Partitions covering past 6 months AND next 6 months (12 total).
DO $$
DECLARE
start_date DATE := DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 months');
partition_name TEXT;
i INT;
BEGIN
FOR i IN 0..11 LOOP
partition_name := 'ci_job_artifacts_' || TO_CHAR(start_date + (i || ' months')::INTERVAL, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE demo.%I PARTITION OF demo.ci_job_artifacts
FOR VALUES FROM (%L) TO (%L)',
partition_name,
start_date + (i || ' months')::INTERVAL,
start_date + ((i + 1) || ' months')::INTERVAL
);
END LOOP;
END $$;
-- Indexes in the same transaction (ShareLock held while INSERT runs below).
CREATE INDEX idx_cia_job_id ON demo.ci_job_artifacts (job_id);
CREATE INDEX idx_cia_project_id ON demo.ci_job_artifacts (project_id);
CREATE INDEX idx_cia_expire_at ON demo.ci_job_artifacts (expire_at);
CREATE INDEX idx_cia_checksum ON demo.ci_job_artifacts (checksum);
-- THE PROBLEM: 5 million rows in one statement, one transaction.
-- At 10 TB real scale this runs for hours.
-- Autovacuum cannot advance past our xmin for the entire duration.
INSERT INTO demo.ci_job_artifacts
SELECT
id, job_id, project_id, file_type, size_bytes, file_store,
checksum, created_at, expire_at, locked,
DATE_TRUNC('month', created_at)::DATE AS partition_key
FROM demo.ci_job_artifacts_legacy
WHERE created_at >= NOW() - INTERVAL '6 months';
COMMIT;
\echo 'Migration committed. Check demo.mvcc_bloat_monitor for bloat residue.'

View File

@ -1,28 +0,0 @@
-- 04_pgbench_workload.sql
-- pgbench custom script for OLTP traffic simulation.
-- Run with:
-- pgbench -h 127.0.0.1 -p 15432 -U postgres knoe-db \
-- -c 10 -j 2 -T 600 \
-- -f demo/ci-migration/04_pgbench_workload.sql
--
-- Each worker randomly UPDATEs and SELECTs ci_build_status rows.
-- This creates a steady stream of dead tuples that autovacuum must collect.
-- When a long migration transaction holds an old xmin, these dead tuples
-- accumulate unboundedly — the core of the MVCC bloat problem.
\set job_id random(1, 999999)
\set row_id random(1, 100000)
UPDATE demo.ci_build_status
SET status = CASE (:job_id % 3)
WHEN 0 THEN 'running'
WHEN 1 THEN 'completed'
ELSE 'failed'
END,
updated_at = NOW()
WHERE id = :row_id;
SELECT count(*)
FROM demo.ci_build_status
WHERE status = 'running'
AND updated_at > NOW() - INTERVAL '1 minute';

View File

@ -1,54 +0,0 @@
-- 05_monitor.sql
-- Run this in a SEPARATE psql session while the migration is executing.
-- Poll every few seconds to watch MVCC bloat build up in real time.
--
-- Usage (run continuously):
-- watch -n 3 'PGPASSWORD=... psql -h 127.0.0.1 -p 15432 -U postgres knoe-db -f demo/ci-migration/05_monitor.sql'
--
-- Or in interactive psql with \watch:
-- \i demo/ci-migration/05_monitor.sql
-- \watch 3
\echo '═══════════════════════════════════════════════════════════'
\echo ' MVCC BLOAT MONITOR — updated every \watch cycle'
\echo '═══════════════════════════════════════════════════════════'
\echo ''
\echo '── Long-running transactions (xmin holders) ─────────────'
SELECT pid,
tx_duration,
state,
wait_event_type || '/' || COALESCE(wait_event,'') AS wait,
left(query_snippet, 80) AS query
FROM demo.long_running_tx
LIMIT 5;
\echo ''
\echo '── xmin horizon (blocks autovacuum cleanup) ─────────────'
SELECT pid, xmin_age, tx_age, state, left(query_snippet, 60) AS query
FROM demo.xmin_horizon
LIMIT 5;
\echo ''
\echo '── Dead tuple accumulation ──────────────────────────────'
SELECT table_name, live_rows, dead_rows, dead_pct, total_size
FROM demo.mvcc_bloat_monitor
LIMIT 10;
\echo ''
\echo '── Active lock waits ────────────────────────────────────'
SELECT blocked_pid, blocking_pid, wait_duration,
left(blocked_query, 60) AS blocked_q,
left(blocking_query, 60) AS blocking_q
FROM demo.lock_waits
LIMIT 5;
\echo ''
\echo '── WAL generation progress ──────────────────────────────'
SELECT current_lsn, total_wal, wal_segment_count, wal_dir_size
FROM demo.wal_progress;
\echo ''
\echo '── Autovacuum activity ──────────────────────────────────'
SELECT pid, running_for, left(vacuum_query, 80) AS query
FROM demo.autovacuum_status;

View File

@ -1,144 +0,0 @@
-- 06_fixed_migration.sql
-- THE FIXED MIGRATION — safe to run on a live database.
-- Demonstrates the pg_chunker approach: small autonomous transactions,
-- each committing independently so autovacuum stays unblocked throughout.
--
-- Key differences from the broken version:
-- ✓ No wrapping BEGIN/COMMIT — each chunk is its own transaction
-- ✓ Partitions cover BOTH historical and future months
-- ✓ Indexes created with CONCURRENTLY after data load (no table lock)
-- ✓ pg_sleep(0.05) breathing room between chunks for autovacuum
-- ✓ Progress reporting at each chunk boundary
-- ✓ Idempotent: safe to re-run if interrupted (ON CONFLICT DO NOTHING)
--
-- Reference: https://github.com/eyupmiduck/pg_chunker
-- (The DO $$ chunker loop below implements the same chunked-keyset pattern.)
-- ── Step 1: Create the partitioned table (outside any transaction) ────────────
DROP TABLE IF EXISTS demo.ci_job_artifacts CASCADE;
CREATE TABLE demo.ci_job_artifacts (
id BIGINT NOT NULL,
job_id BIGINT NOT NULL,
project_id BIGINT NOT NULL,
file_type SMALLINT NOT NULL DEFAULT 0,
size_bytes BIGINT,
file_store SMALLINT NOT NULL DEFAULT 1,
checksum TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expire_at TIMESTAMP,
locked BOOLEAN DEFAULT FALSE,
partition_key DATE NOT NULL,
PRIMARY KEY (id, partition_key)
) PARTITION BY RANGE (partition_key);
-- ── Step 2: Create partitions covering past 6 months AND next 6 months ────────
DO $$
DECLARE
start_date DATE := DATE_TRUNC('month', CURRENT_DATE - INTERVAL '6 months');
partition_name TEXT;
i INT;
BEGIN
FOR i IN 0..11 LOOP
partition_name := 'ci_job_artifacts_'
|| TO_CHAR(start_date + (i || ' months')::INTERVAL, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS demo.%I
PARTITION OF demo.ci_job_artifacts
FOR VALUES FROM (%L) TO (%L)',
partition_name,
(start_date + (i || ' months')::INTERVAL)::DATE,
(start_date + ((i+1) || ' months')::INTERVAL)::DATE
);
END LOOP;
RAISE NOTICE 'Created 12 partitions from % to %',
start_date,
start_date + INTERVAL '12 months';
END $$;
-- ── Step 3: Chunked backfill (pg_chunker keyset pattern) ─────────────────────
-- Each iteration is a small autonomous transaction (~50K rows).
-- Autovacuum runs freely between chunks because no long snapshot is held.
-- On a 10 TB table at real scale: 200M chunks × 50K rows each = fine.
DO $$
DECLARE
v_min_id BIGINT;
v_max_id BIGINT;
v_cursor BIGINT := 0;
v_chunk INT := 50000;
v_inserted INT := 0;
v_total INT := 0;
v_chunk_num INT := 0;
BEGIN
SELECT COALESCE(MIN(id), 0), COALESCE(MAX(id), 0)
INTO v_min_id, v_max_id
FROM demo.ci_job_artifacts_legacy
WHERE created_at >= NOW() - INTERVAL '6 months';
RAISE NOTICE 'Backfill range: id % to % (eligible rows for past 6 months)',
v_min_id, v_max_id;
v_cursor := v_min_id - 1;
WHILE v_cursor < v_max_id LOOP
-- Each INSERT is its own implicit transaction (no explicit BEGIN here).
-- In a real migration script this would be called from a shell loop or
-- pg_chunker, which gives each chunk its own connection/transaction.
INSERT INTO demo.ci_job_artifacts
(id, job_id, project_id, file_type, size_bytes, file_store,
checksum, created_at, expire_at, locked, partition_key)
SELECT
id, job_id, project_id, file_type, size_bytes, file_store,
checksum, created_at, expire_at, locked,
DATE_TRUNC('month', created_at)::DATE AS partition_key
FROM demo.ci_job_artifacts_legacy
WHERE id > v_cursor
AND id <= v_cursor + v_chunk
AND created_at >= NOW() - INTERVAL '6 months'
ON CONFLICT (id, partition_key) DO NOTHING; -- idempotent re-run
GET DIAGNOSTICS v_inserted = ROW_COUNT;
v_total := v_total + v_inserted;
v_chunk_num := v_chunk_num + 1;
v_cursor := v_cursor + v_chunk;
IF v_chunk_num % 20 = 0 THEN
RAISE NOTICE 'Chunk %: inserted % rows this chunk, % total, cursor at %',
v_chunk_num, v_inserted, v_total, v_cursor;
END IF;
-- Yield briefly so autovacuum can run between chunks.
-- This is what keeps dead_pct from exploding during the backfill.
PERFORM pg_sleep(0.05);
END LOOP;
RAISE NOTICE 'Backfill complete: % total rows inserted in % chunks',
v_total, v_chunk_num;
END $$;
-- ── Step 4: Indexes CONCURRENTLY — no table lock, runs alongside traffic ──────
-- Must be run OUTSIDE any transaction block (cannot use CONCURRENTLY in a tx).
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cia_job_id
ON demo.ci_job_artifacts (job_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cia_project_id
ON demo.ci_job_artifacts (project_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cia_expire_at
ON demo.ci_job_artifacts (expire_at);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cia_checksum
ON demo.ci_job_artifacts (checksum);
\echo ''
\echo 'Fixed migration complete. Check demo.mvcc_bloat_monitor:'
\echo ' dead_rows on ci_build_status should be near zero throughout.'
SELECT
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
ROUND(n_dead_tup::NUMERIC / NULLIF(n_live_tup,0) * 100, 2) AS dead_pct
FROM pg_stat_user_tables
WHERE schemaname = 'demo'
ORDER BY n_dead_tup DESC;

View File

@ -1,151 +0,0 @@
-- 07_corrected_functions.sql
-- Corrected versions of all four utility functions from the change request.
-- Each fix is annotated with the issue number it addresses (see REVIEW.md).
-- ── Fix for cleanup_expired_artifacts ────────────────────────────────────────
-- Issue 4: Added partition_key to WHERE and IN-list for partition pruning.
-- Issue 4: Replaced FOR UPDATE with FOR UPDATE SKIP LOCKED to prevent
-- stacking behind a concurrent cleanup job.
-- Bonus: RETURNING clause allows callers to log what was actually deleted.
CREATE OR REPLACE FUNCTION demo.cleanup_expired_artifacts(
p_batch_size INT DEFAULT 10000
)
RETURNS INT AS $$
DECLARE
v_deleted INT;
BEGIN
DELETE FROM demo.ci_job_artifacts
WHERE (id, partition_key) IN (
SELECT id, partition_key
FROM demo.ci_job_artifacts
WHERE expire_at < NOW()
AND locked = FALSE
AND partition_key < DATE_TRUNC('month', NOW())::DATE -- prune to old partitions only
ORDER BY expire_at ASC
LIMIT p_batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS v_deleted = ROW_COUNT;
RAISE NOTICE 'Cleaned up % expired artifacts', v_deleted;
RETURN v_deleted;
END;
$$ LANGUAGE plpgsql;
-- ── Fix for terminate_idle_sessions ──────────────────────────────────────────
-- Issue 5: Added filter to exclude PgBouncer pooler connections.
-- In transaction-mode PgBouncer the backend appears idle between
-- client transactions. Terminating it breaks the pool and causes
-- "SSL connection has been closed unexpectedly" for active clients.
-- Bonus: filter also excludes replication and background workers.
CREATE OR REPLACE FUNCTION demo.terminate_idle_sessions(
p_idle_threshold INTERVAL DEFAULT '10 minutes',
p_max_terminated INT DEFAULT 50,
p_pgbouncer_app TEXT DEFAULT 'pgbouncer' -- set to your app_name
)
RETURNS TABLE(pid INT, username TEXT, idle_duration INTERVAL, query TEXT) AS $$
BEGIN
RETURN QUERY
WITH idle_sessions AS (
SELECT
a.pid,
a.usename::TEXT,
NOW() - a.state_change AS duration,
a.query
FROM pg_stat_activity a
WHERE a.state = 'idle'
AND NOW() - a.state_change > p_idle_threshold
AND a.pid <> pg_backend_pid()
AND a.backend_type = 'client backend'
AND a.application_name NOT ILIKE '%' || p_pgbouncer_app || '%'
AND a.client_addr IS NOT NULL -- exclude unix socket (local tools)
ORDER BY duration DESC
LIMIT p_max_terminated
)
SELECT s.pid, s.usename, s.duration, s.query
FROM idle_sessions s
WHERE pg_terminate_backend(s.pid);
END;
$$ LANGUAGE plpgsql;
-- ── Fix for calculate_namespace_storage ──────────────────────────────────────
-- Issue 6: Cache race condition fixed with FOR UPDATE SKIP LOCKED.
-- Callers that can't acquire the cache row lock return the stale
-- cached value rather than all recomputing simultaneously.
-- Issue 7: Recursive CTE depth limit enforced (max 20 levels).
-- Requires tables: namespaces(id, parent_id), project_storages(namespace_id, storage_bytes),
-- namespace_storage_cache(namespace_id, total_bytes, calculated_at)
-- (These don't exist in our demo schema; this is illustrative code only.)
CREATE OR REPLACE FUNCTION demo.calculate_namespace_storage(
p_namespace_id BIGINT
)
RETURNS BIGINT AS $$
DECLARE
v_total_bytes BIGINT := 0;
v_cached_bytes BIGINT;
v_cached_at TIMESTAMP;
BEGIN
-- Attempt to lock the cache row exclusively.
-- SKIP LOCKED means concurrent callers return the stale value immediately
-- instead of piling up and all recomputing the expensive CTE.
SELECT total_bytes, calculated_at
INTO v_cached_bytes, v_cached_at
FROM namespace_storage_cache
WHERE namespace_id = p_namespace_id
FOR UPDATE SKIP LOCKED;
-- Cache hit: return without recomputing
IF v_cached_at IS NOT NULL AND v_cached_at > NOW() - INTERVAL '1 hour' THEN
RETURN v_cached_bytes;
END IF;
-- Cache miss or expired: recompute with depth-limited recursive CTE.
-- Issue 7 fix: depth column added to prevent infinite recursion on cycles.
WITH RECURSIVE ns_tree AS (
SELECT id, 0 AS depth
FROM namespaces
WHERE id = p_namespace_id
UNION ALL
SELECT n.id, t.depth + 1
FROM namespaces n
JOIN ns_tree t ON n.parent_id = t.id
WHERE t.depth < 20 -- hard cap; cyclic namespaces stop here
)
SELECT COALESCE(SUM(ps.storage_bytes), 0)
INTO v_total_bytes
FROM ns_tree nt
JOIN project_storages ps ON ps.namespace_id = nt.id;
INSERT INTO namespace_storage_cache (namespace_id, total_bytes, calculated_at)
VALUES (p_namespace_id, v_total_bytes, NOW())
ON CONFLICT (namespace_id) DO UPDATE
SET total_bytes = EXCLUDED.total_bytes,
calculated_at = EXCLUDED.calculated_at;
RETURN v_total_bytes;
END;
$$ LANGUAGE plpgsql;
-- ── Fix for database_health view ─────────────────────────────────────────────
-- Issue 8: NULLIF prevents division by zero if n_live_tup is ever 0.
-- The original WHERE n_live_tup > 1000 makes the crash unlikely in practice,
-- but this is fragile to future refactoring that removes the filter.
CREATE OR REPLACE VIEW demo.database_health AS
SELECT
schemaname,
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
ROUND(n_dead_tup::NUMERIC / NULLIF(n_live_tup, 0) * 100, 2) AS dead_row_pct,
last_vacuum,
last_autovacuum,
last_analyze,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_dead_tup DESC;

View File

@ -1,188 +0,0 @@
#!/usr/bin/env bash
# run_demo.sh — CI migration danger demo orchestration script.
#
# Prerequisites:
# - kubectl configured with access to the knoe-db namespace
# - psql and pgbench in PATH
# - Port-forward to knoe-db-rw running on localhost:15432
#
# Usage:
# ./demo/ci-migration/run_demo.sh [setup|broken|fixed|monitor|reset]
#
# Phases:
# setup — create schema, generate 5M rows, seed workload table
# broken — run the broken single-transaction migration (will fail on partitions)
# bloat — run the long-tx variant that actually inserts (shows MVCC bloat)
# fixed — run the chunked safe migration
# monitor — start watch loop showing MVCC metrics
# reset — drop and recreate the target table, reset workload table
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Connection settings ───────────────────────────────────────────────────────
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_PORT="${DB_PORT:-15432}"
DB_USER="${DB_USER:-postgres}"
DB_NAME="${DB_NAME:-knoe-db}"
# Retrieve password from Kubernetes secret if not set
if [[ -z "${PGPASSWORD:-}" ]]; then
PGPASSWORD="$(kubectl -n knoe-db get secret knoe-db-superuser \
-o jsonpath='{.data.password}' | base64 -d)"
export PGPASSWORD
fi
PSQL="psql -h $DB_HOST -p $DB_PORT -U $DB_USER $DB_NAME"
PGBENCH="pgbench -h $DB_HOST -p $DB_PORT -U $DB_USER $DB_NAME"
# ── Helpers ───────────────────────────────────────────────────────────────────
start_port_forward() {
if ! lsof -ti tcp:15432 &>/dev/null; then
echo "[+] Starting port-forward to knoe-db-rw on :15432..."
# Find the current primary pod
PRIMARY=$(kubectl -n knoe-db get pods \
--field-selector=status.phase=Running \
-o jsonpath='{range .items[?(@.status.containerStatuses[0].ready==true)]}{.metadata.name}{"\n"}{end}' \
| grep '^knoe-db-' | head -1)
echo " Primary pod: $PRIMARY"
kubectl -n knoe-db port-forward "pod/$PRIMARY" 15432:5432 &>/tmp/pf_knoe.log &
sleep 3
else
echo "[✓] Port-forward already running on :15432"
fi
}
run_sql() {
local file="$1"
echo "[+] Running $file..."
$PSQL -f "$file"
}
# ── Commands ──────────────────────────────────────────────────────────────────
case "${1:-help}" in
setup)
start_port_forward
echo ""
echo "=== PHASE 1: Schema setup ==="
run_sql "$SCRIPT_DIR/01_schema_setup.sql"
echo ""
echo "=== PHASE 2: Data generation (5M rows — takes 3-6 min) ==="
run_sql "$SCRIPT_DIR/02_generate_data.sql"
echo ""
echo "Setup complete. Run './run_demo.sh monitor' in another terminal,"
echo "then './run_demo.sh broken' to start the demo."
;;
broken)
start_port_forward
echo ""
echo "=== BROKEN MIGRATION (Issue 2 exposed — partition mismatch) ==="
echo "Watch for: ERROR: no partition of relation found for row"
echo ""
run_sql "$SCRIPT_DIR/03_broken_migration.sql" || true
echo ""
echo "Expected failure demonstrated. Now run './run_demo.sh bloat'"
echo "to see the long-transaction MVCC pileup."
;;
bloat)
start_port_forward
echo ""
echo "=== MVCC BLOAT DEMO (partition bug fixed, long tx left in place) ==="
echo "Start traffic in another terminal first:"
echo " ./run_demo.sh traffic"
echo ""
read -p "Press Enter when traffic is running to start the long migration..."
echo ""
echo "Recording WAL start position..."
WAL_START=$($PSQL -tAc "SELECT pg_current_wal_lsn();")
echo "WAL start: $WAL_START"
echo ""
run_sql "$SCRIPT_DIR/03b_broken_migration_long_tx.sql"
echo ""
WAL_END=$($PSQL -tAc "SELECT pg_current_wal_lsn();")
echo "WAL end: $WAL_END"
WAL_DIFF=$($PSQL -tAc "SELECT pg_size_pretty(pg_wal_lsn_diff('$WAL_END'::pg_lsn, '$WAL_START'::pg_lsn));")
echo "WAL generated: $WAL_DIFF"
;;
fixed)
start_port_forward
echo ""
echo "=== FIXED MIGRATION (chunked, no long transaction) ==="
echo "Start traffic in another terminal first:"
echo " ./run_demo.sh traffic"
echo ""
read -p "Press Enter when traffic is running to start the chunked migration..."
echo ""
echo "Recording WAL start position..."
WAL_START=$($PSQL -tAc "SELECT pg_current_wal_lsn();")
echo "WAL start: $WAL_START"
echo ""
run_sql "$SCRIPT_DIR/06_fixed_migration.sql"
echo ""
WAL_END=$($PSQL -tAc "SELECT pg_current_wal_lsn();")
WAL_DIFF=$($PSQL -tAc "SELECT pg_size_pretty(pg_wal_lsn_diff('$WAL_END'::pg_lsn, '$WAL_START'::pg_lsn));")
echo "WAL generated: $WAL_DIFF"
echo "(Compare to the bloat run — same data, far less WAL spike)"
;;
traffic)
start_port_forward
echo ""
echo "=== OLTP TRAFFIC SIMULATOR ==="
echo "Running 10 concurrent workers for 600 seconds (10 min)."
echo "Ctrl+C to stop early."
echo ""
$PGBENCH -c 10 -j 2 -T 600 \
-f "$SCRIPT_DIR/04_pgbench_workload.sql" \
--no-vacuum \
-P 10
;;
monitor)
start_port_forward
echo ""
echo "=== MONITORING (updates every 3 seconds) ==="
echo "Ctrl+C to stop."
echo ""
watch -n 3 "$PSQL -f $SCRIPT_DIR/05_monitor.sql 2>&1"
;;
reset)
start_port_forward
echo ""
echo "=== RESET: dropping ci_job_artifacts, truncating ci_build_status ==="
$PSQL -c "DROP TABLE IF EXISTS demo.ci_job_artifacts CASCADE;"
$PSQL -c "TRUNCATE demo.ci_build_status;"
$PSQL -c "INSERT INTO demo.ci_build_status (job_id, status)
SELECT (random()*999999+1)::BIGINT, 'running'
FROM generate_series(1,100000);"
echo "Reset complete. Run './run_demo.sh bloat' or './run_demo.sh fixed'."
;;
help|*)
echo "Usage: $0 [setup|broken|bloat|fixed|traffic|monitor|reset]"
echo ""
echo " setup — create schema, generate 5M rows of test data"
echo " broken — run the broken migration (partition error demo)"
echo " bloat — run the long-tx backfill to demonstrate MVCC bloat"
echo " fixed — run the chunked safe migration"
echo " traffic — start pgbench OLTP workload (run in separate terminal)"
echo " monitor — watch MVCC metrics in real time (run in separate terminal)"
echo " reset — drop target table and reset workload for a clean re-run"
echo ""
echo "Typical demo flow:"
echo " Terminal 1: ./run_demo.sh setup"
echo " Terminal 2: ./run_demo.sh traffic"
echo " Terminal 3: ./run_demo.sh monitor"
echo " Terminal 1: ./run_demo.sh broken (show Issue 2)"
echo " Terminal 1: ./run_demo.sh reset"
echo " Terminal 1: ./run_demo.sh bloat (show MVCC pileup)"
echo " Terminal 1: ./run_demo.sh reset"
echo " Terminal 1: ./run_demo.sh fixed (show safe approach)"
;;
esac

View File

@ -4,16 +4,6 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_PATH="${PROLE_DEPLOY_CFG:-${ROOT_DIR}/conf/gke.cfg}"
# Env-contamination guard. Refuses to proceed if `kubectl config
# current-context` doesn't match the [Global] APP_CLUSTER_KUBECONTEXT in
# the config we're about to deploy with. Filed in response to the
# 2026-04-28 14:00 UTC outage; see docs/TODO.md queue item #1 + drift R4.
# Bypass with KNOE_SKIP_KUBECONTEXT_GUARD=true if you're doing deliberate
# cross-cluster maintenance.
# shellcheck source=etc/preflight_kubecontext.sh
source "${ROOT_DIR}/etc/preflight_kubecontext.sh"
verify_kubecontext_matches_config "${CONFIG_PATH}"
if [[ -x "${ROOT_DIR}/.venv/bin/python3" ]]; then
PYTHON_BIN="${ROOT_DIR}/.venv/bin/python3"
elif [[ -x "${ROOT_DIR}/bin/python3" ]]; then
@ -142,7 +132,7 @@ def get_runtime_config(path):
gitlab_public_hosts_raw = _cfg_first(g, e, i, "GITLAB_PUBLIC_HOSTS", "gitlab_public_hosts")
gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname")
if not gitlab_domain:
gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.prole.org"
gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.knoe.org"
gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()]
if not gitlab_public_hosts:
gitlab_public_hosts = [gitlab_domain]
@ -155,11 +145,11 @@ def get_runtime_config(path):
auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname")
if not auth_host:
auth_host = "api.knoe.dev" if mode == "k8s" else "api.prole.org"
auth_host = "api.knoe.dev" if mode == "k8s" else "api.knoe.org"
service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname")
if not service_host:
service_host = "svc.knoe.dev" if mode == "k8s" else "svc.prole.org"
service_host = "svc.knoe.dev" if mode == "k8s" else "svc.knoe.org"
service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system"

View File

@ -1,40 +0,0 @@
# Internal LoadBalancer exposing the DB-cluster Prometheus to the app cluster.
#
# Both knoe-dev-0 (app) and knoe-dev-cnpg-0 (DB) sit on the same VPC subnet
# (us-west3 default). An internal-LB IP from that subnet is reachable from
# pods in the app cluster, so the app-cluster Grafana can use this URL as a
# datasource without the metric stream ever leaving Google's network.
#
# Why an ILB instead of cluster-local DNS: cluster-local DNS (.svc.cluster.local)
# is NOT cross-cluster. The two GKE clusters have separate kube-dns; the app
# cluster can't resolve services in the DB cluster's DNS namespace. The ILB
# gives a stable VPC-routable IP that crosses the cluster boundary.
#
# Apply (DB cluster):
# kubectl --context=$DB_CTX apply -f deploy/gcp/gke/cnpg-prometheus-ilb.yaml
---
apiVersion: v1
kind: Service
metadata:
name: prometheus-cnpg-ilb
namespace: monitoring
labels:
app.kubernetes.io/name: prometheus
app.kubernetes.io/instance: kps-kube-prometheus-stack-prometheus
app.kubernetes.io/managed-by: knoe-installer
annotations:
# GKE internal LB (RFC1918 IP from the VPC default subnet).
networking.gke.io/load-balancer-type: "Internal"
# Don't expand allow-list to other VPCs/regions; same-region/same-VPC only.
networking.gke.io/internal-load-balancer-allow-global-access: "false"
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster # OK for an ILB; source-IP preservation not needed
selector:
app.kubernetes.io/name: prometheus
app.kubernetes.io/instance: kps-kube-prometheus-stack-prometheus
ports:
- name: http
port: 9090
targetPort: 9090
protocol: TCP

View File

@ -1,29 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-google-oidc
namespace: monitoring
labels:
app: grafana
app.kubernetes.io/managed-by: knoe-installer
# Google OAuth 2.0 client credentials for Grafana's native Google sign-in
# (Grafana auth.google plugin) on the prole.org k3s homelab cluster.
# Mounted into kps-grafana via `envFromSecret: grafana-google-oidc` in
# monitoring/kps-values-k3s.yaml. Grafana reads GF_AUTH_GOOGLE_CLIENT_ID /
# GF_AUTH_GOOGLE_CLIENT_SECRET and uses them as auth.google.client_id / client_secret.
#
# Create the OAuth client at: https://console.cloud.google.com/apis/credentials
# (in the prole.org GCP project — separate from the knoey.com project)
# - Application type: Web application
# - Name: prole.org Grafana
# - Authorized JavaScript origins: https://svc.prole.org
# - Authorized redirect URIs: https://svc.prole.org/grafana/login/google
# - OAuth consent screen: Internal (prole.org Workspace only)
# - Scopes: openid, email, profile
#
# Why a separate client from prole.org Studio: independent rotation surface.
# Companion to oauth2-proxy-google-oidc-secret-prole.example.yaml.
type: Opaque
stringData:
GF_AUTH_GOOGLE_CLIENT_ID: "${GRAFANA_GOOGLE_CLIENT_ID}"
GF_AUTH_GOOGLE_CLIENT_SECRET: "${GRAFANA_GOOGLE_CLIENT_SECRET}"

View File

@ -1,30 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-google-oidc
namespace: monitoring
labels:
app: grafana
app.kubernetes.io/managed-by: knoe-installer
# Google OAuth 2.0 client credentials for Grafana's native Google sign-in
# (Grafana auth.google plugin, NOT oauth2-proxy). Mounted into kps-grafana
# via the chart's `envFromSecret: grafana-google-oidc` value. Grafana reads
# `GF_AUTH_GOOGLE_CLIENT_ID` / `GF_AUTH_GOOGLE_CLIENT_SECRET` env vars and
# uses them as `auth.google.client_id` / `client_secret`.
#
# Create the OAuth client at: https://console.cloud.google.com/apis/credentials
# - Application type: Web application
# - Name: knoe.dev Grafana
# - Authorized JavaScript origins: https://svc.knoe.dev
# - Authorized redirect URIs: https://svc.knoe.dev/grafana/login/google
# - OAuth consent screen: Internal (knoey.com Workspace)
# - Scopes: openid, email, profile (default for grafana auth.google)
#
# Why a separate client (not reusing knoe.dev Studio's): independent rotation
# surface — if Grafana access is compromised, rotating one client doesn't
# disrupt Studio sign-in (and vice versa). Same convention as gitlab-google-oidc
# and oauth2-proxy-google-oidc.
type: Opaque
stringData:
GF_AUTH_GOOGLE_CLIENT_ID: "${GRAFANA_GOOGLE_CLIENT_ID}"
GF_AUTH_GOOGLE_CLIENT_SECRET: "${GRAFANA_GOOGLE_CLIENT_SECRET}"

View File

@ -1,330 +0,0 @@
---
# knoe-auth-deployment.yaml
# Deploys the knoe-auth pod (Spring Boot + KDC sidecar) to knoe-dev-0 / knoe-system.
# Based on deploy/opentofu/k3s/manifests/knoe/knoe-auth-deployment.yaml.
# Realm: KNOE.DEV (not PROLE.LOCAL)
# Image: pulled from Artifact Registry — built by knoe-auth CI pipeline.
#
# Apply: kubectl -n knoe-system apply -f deploy/gcp/gke/knoe-auth-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: knoe-auth
namespace: knoe-system
labels:
app: knoe-auth
spec:
replicas: 1
selector:
matchLabels:
app: knoe-auth
template:
metadata:
labels:
app: knoe-auth
spec:
initContainers:
# keytab-bootstrap: runs kadmin.local inside the shared KDC data volume
# to create the HTTP service principal and export the keytab before the
# Spring Boot container starts.
- name: keytab-bootstrap
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-auth:latest
imagePullPolicy: Always
command:
- /bin/bash
- -lc
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
realm="${KNOE_KDC_REALM:-KNOE.DEV}"
admin_principal="${KNOE_KDC_ADMIN_PRINCIPAL:-admin/admin}"
if [[ "${admin_principal}" != *"@"* ]]; then
admin_principal="${admin_principal}@${realm}"
fi
svc_principal="${KNOE_KERBEROS_SERVICE_PRINCIPAL:?Missing KNOE_KERBEROS_SERVICE_PRINCIPAL}"
if [[ "${svc_principal}" != *"@"* ]]; then
svc_principal="${svc_principal}@${realm}"
fi
keytab_out="/etc/knoe/keytabs/http.keytab"
mkdir -p "$(dirname "${keytab_out}")"
# If a pre-provisioned keytab is provided as a Secret, use it directly.
if [[ -f /mnt/keytab-secret/http.keytab ]]; then
cp /mnt/keytab-secret/http.keytab "${keytab_out}"
chmod 0400 "${keytab_out}" || true
exit 0
fi
if ! command -v kadmin.local >/dev/null 2>&1; then
echo "Installing Kerberos packages..."
echo "krb5-config krb5-config/default_realm string ${realm}" | debconf-set-selections || true
echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true
echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true
apt-get update -qq
apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates
rm -rf /var/lib/apt/lists/*
fi
mkdir -p /etc/krb5kdc /var/lib/krb5kdc
[[ -f /opt/knoe-kdc/krb5.conf ]] && cp /opt/knoe-kdc/krb5.conf /etc/krb5.conf
[[ -f /opt/knoe-kdc/kdc.conf ]] && cp /opt/knoe-kdc/kdc.conf /etc/krb5kdc/kdc.conf
[[ -f /opt/knoe-kdc/kadm5.acl ]] && cp /opt/knoe-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl
if [[ -z "${KNOE_KDC_MASTER_PASSWORD:-}" ]]; then
echo "ERROR: Missing KNOE_KDC_MASTER_PASSWORD" >&2; exit 1
fi
if [[ -z "${KNOE_KDC_ADMIN_PASSWORD:-}" ]]; then
echo "ERROR: Missing KNOE_KDC_ADMIN_PASSWORD" >&2; exit 1
fi
if [[ ! -f /var/lib/krb5kdc/principal ]]; then
echo "Initializing realm database for ${realm}..."
kdb5_util create -s -r "${realm}" -P "${KNOE_KDC_MASTER_PASSWORD}"
fi
if ! kadmin.local -q "get_principal ${admin_principal}" >/dev/null 2>&1; then
kadmin.local -q "addprinc -pw ${KNOE_KDC_ADMIN_PASSWORD} ${admin_principal}"
fi
if ! kadmin.local -q "get_principal ${svc_principal}" >/dev/null 2>&1; then
echo "Creating service principal ${svc_principal}..."
kadmin.local -q "addprinc -randkey ${svc_principal}"
fi
kadmin.local -q "ktadd -k ${keytab_out} -norandkey ${svc_principal}"
chmod 0400 "${keytab_out}" || true
echo "Keytab written to ${keytab_out}"
env:
- name: KNOE_KDC_REALM
value: "KNOE.DEV"
- name: KNOE_KDC_ADMIN_PRINCIPAL
value: "admin/admin"
- name: KNOE_KDC_MASTER_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: master_password
- name: KNOE_KDC_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: admin_password
- name: KNOE_KERBEROS_SERVICE_PRINCIPAL
value: "HTTP/auth.knoe.dev"
volumeMounts:
- name: keytab
mountPath: /etc/knoe/keytabs
- name: keytab-secret
mountPath: /mnt/keytab-secret
readOnly: true
- name: knoe-kdc-config
mountPath: /opt/knoe-kdc
- name: knoe-kdc-data
mountPath: /var/lib/krb5kdc
- name: knoe-kdc-etc
mountPath: /etc/krb5kdc
containers:
# ── Spring Boot / knoe-auth ──────────────────────────────────────────
- name: knoe-auth
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-auth:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
env:
- name: KNOE_AUTH_COOKIE_DOMAIN
value: ".knoe.dev"
- name: KNOE_AUTH_SESSION_SECRET
valueFrom:
secretKeyRef:
name: knoe-auth-secrets
key: sessionSecret
# ── Kerberos ───────────────────────────────────────────────────
- name: KNOE_KERBEROS_SERVICE_PRINCIPAL
value: "HTTP/auth.knoe.dev@KNOE.DEV"
- name: KNOE_KERBEROS_KEYTAB_PATH
value: "/etc/knoe/keytabs/http.keytab"
- name: KNOE_KERBEROS_REALM
value: "KNOE.DEV"
# ── Google OAuth2 (enrollment corroboration) ───────────────────
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: knoe-auth-google-oidc
key: client_id
optional: true
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: knoe-auth-google-oidc
key: client_secret
optional: true
# ── Database (CNPG via cluster-internal service) ───────────────
- name: KNOE_DB_URL
value: "jdbc:postgresql://knoe-db-rw.knoe-db-0.svc.cluster.local:5432/knoe"
- name: KNOE_DB_USER
value: "knoe"
- name: KNOE_DB_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-db-app
key: password
optional: true
# ── Enrollment ────────────────────────────────────────────────
- name: KNOE_ENROLL_INVITE_TTL_HOURS
value: "72"
- name: KNOE_ENROLL_OTP_TTL_MINUTES
value: "10"
- name: KNOE_ENROLL_OTP_MAX_ATTEMPTS
value: "3"
- name: KNOE_ENROLL_TOTP_ISSUER
value: "Knoe.DEV"
- name: KNOE_AUTH_BASE_URL
value: "https://api.knoe.dev/auth"
# ── OIDC Provider (Path B) ────────────────────────────────────
- name: KNOE_AUTH_OIDC_ENABLED
value: "true"
- name: KNOE_AUTH_OIDC_ISSUER
value: "https://api.knoe.dev/auth"
- name: KNOE_AUTH_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: knoe-auth-oidc
key: client-id
optional: true
- name: KNOE_AUTH_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: knoe-auth-oidc
key: client-secret
optional: true
- name: KNOE_AUTH_OIDC_SIGNING_KEY
valueFrom:
secretKeyRef:
name: knoe-auth-oidc
key: signing-key
optional: true
# ── Provisioning ─────────────────────────────────────────────
- name: KNOE_PROVISIONING_POLL_INTERVAL_MS
value: "10000"
- name: KNOE_GITEA_URL
value: "https://git.knoe.dev"
- name: KNOE_GITEA_TOKEN
valueFrom:
secretKeyRef:
name: knoe-gitea-admin
key: token
optional: true
volumeMounts:
- name: keytab
mountPath: /etc/knoe/keytabs
readOnly: true
- name: knoe-kdc-config
mountPath: /etc/krb5.conf
subPath: krb5.conf
readOnly: true
# ── MIT Kerberos KDC sidecar ─────────────────────────────────────────
- name: kdc
image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-auth:latest
imagePullPolicy: Always
command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"]
env:
- name: KNOE_KDC_REALM
value: "KNOE.DEV"
- name: KNOE_KDC_ADMIN_PRINCIPAL
value: "admin/admin"
- name: KNOE_KDC_MASTER_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: master_password
- name: KNOE_KDC_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: knoe-kdc-secrets
key: admin_password
ports:
- name: krb5-udp
containerPort: 88
protocol: UDP
- name: krb5-tcp
containerPort: 88
protocol: TCP
- name: kpasswd-udp
containerPort: 464
protocol: UDP
- name: kpasswd-tcp
containerPort: 464
protocol: TCP
- name: kadmin
containerPort: 749
protocol: TCP
volumeMounts:
- name: knoe-kdc-config
mountPath: /opt/knoe-kdc
- name: knoe-kdc-data
mountPath: /var/lib/krb5kdc
- name: knoe-kdc-etc
mountPath: /etc/krb5kdc
volumes:
- name: keytab
emptyDir: {}
- name: keytab-secret
secret:
secretName: knoe-auth-keytab
optional: true
- name: knoe-kdc-config
configMap:
name: knoe-kdc-config
defaultMode: 0755
- name: knoe-kdc-data
emptyDir: {}
- name: knoe-kdc-etc
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: knoe-auth
namespace: knoe-system
labels:
app: knoe-auth
spec:
selector:
app: knoe-auth
ports:
- name: http
port: 80
targetPort: 8080
- name: krb5-tcp
port: 88
targetPort: 88
protocol: TCP
- name: krb5-udp
port: 88
targetPort: 88
protocol: UDP
- name: kadmin
port: 749
targetPort: 749
protocol: TCP
type: ClusterIP

View File

@ -1,11 +0,0 @@
---
apiVersion: v1
kind: Secret
metadata:
name: knoe-auth-oidc
namespace: knoe-system
type: Opaque
stringData:
client-id: "${KNOE_AUTH_OIDC_CLIENT_ID}"
client-secret: "${KNOE_AUTH_OIDC_CLIENT_SECRET}"
signing-key: "${KNOE_AUTH_OIDC_SIGNING_KEY}"

View File

@ -9,3 +9,19 @@ metadata:
annotations:
# Set by etc/init_cnpg_gke.sh — substituted at deploy time
iam.gke.io/gcp-service-account: "cnpg-backup@${GCP_PROJECT_ID}.iam.gserviceaccount.com"
---
# Daily scheduled backup at 02:00 UTC.
# Backup data lands in gs://knoe-0-backups/ (WAL is streamed continuously by CNPG).
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: knoe-db-daily
namespace: knoe-db-0
spec:
schedule: "0 0 2 * * *" # 6-field CNPG cron: sec min hour dom mon dow
backupOwnerReference: self
cluster:
name: knoe-db
method: barmanObjectStore
immediate: false

View File

@ -1,58 +0,0 @@
# External TCP LoadBalancer for direct psql access to the CNPG primary.
#
# Hostname: pg.0.knoe.dev (DNS A record pointed manually at Namecheap to the
# IP allocated by this Service).
#
# Auth model:
# - Phase 1 (current): per-engineer postgres roles with strong passwords +
# VALID UNTIL 30d. SCRAM-SHA-256 over TLS (hostssl in pg_hba).
# - Phase 2 (queued): PostgreSQL 18 OAUTHBEARER with a custom validator
# library (knoe_oauth.so) that maps Google email -> postgres role.
# Connection target stays the same; only the credential issuance changes.
#
# TLS: CNPG issues the server cert and auto-rotates it. The cluster spec's
# `certificates.serverAltDnsNames` is extended with `pg.0.knoe.dev` so the
# auto-issued cert validates externally. Engineers download the CNPG-issued
# CA cert (kubectl get secret knoe-db-ca -n knoe-db-0 -o jsonpath='{.data.ca\.crt}' | base64 -d)
# and use `sslmode=verify-full sslrootcert=<ca.crt>`.
#
# Source-IP allowlist: open today (loadBalancerSourceRanges: 0.0.0.0/0). Tighten
# to known engineer/CI IP ranges in a follow-up; SCRAM-SHA-256 is brute-force-
# resistant so the open window is acceptable for the Phase 1 horizon (~30d).
#
# Phase 1 verification (after DNS A record is up):
# psql "host=pg.0.knoe.dev port=5432 user=chrisfu dbname=postgres sslmode=verify-full sslrootcert=$HOME/.knoe/knoe-db-ca.crt"
---
apiVersion: v1
kind: Service
metadata:
name: knoe-db-rw-elb
namespace: knoe-db-0
labels:
app.kubernetes.io/name: knoe-db
app.kubernetes.io/component: postgres-external
app.kubernetes.io/managed-by: knoe-installer
annotations:
# GKE L4 Regional Backend Service (newer LB type; required for SCTP and
# for some traffic-policy combinations; allocates faster than the legacy
# network LB on this cluster).
cloud.google.com/l4-rbs: "enabled"
spec:
type: LoadBalancer
# `Local` preserves the client's real source IP at the postgres backend
# (vs `Cluster` which SNATs to the receiving node IP, making external
# clients look like they live in the cluster's 10.0.0.0/8 range and bypass
# the `hostnossl reject` rule in pg_hba).
externalTrafficPolicy: Local
loadBalancerSourceRanges:
# Phase 1: open. Tighten in a follow-up once we have an engineer IP allowlist
# or a corp VPN egress range.
- 0.0.0.0/0
selector:
cnpg.io/cluster: knoe-db
cnpg.io/instanceRole: primary
ports:
- name: psql
port: 5432
targetPort: 5432
protocol: TCP

View File

@ -1,33 +0,0 @@
---
# Manually-managed PodMonitor for the knoe-db CNPG cluster.
#
# Replaces the deprecated spec.monitoring.enablePodMonitor +
# spec.monitoring.podMonitorRelabelings fields in knoe-db.yaml (both flagged
# for removal by the CNPG operator; see queue #13 in docs/TODO.md).
#
# The relabeling rule injects a `cluster` label on every scraped sample,
# sourced from the cnpg.io/cluster pod label. The CNPG Grafana dashboards
# filter every panel by `cluster="$cluster"`, so without this relabel only
# 2 of 85 CNPG metrics (the operator-collector ones) render.
#
# Apply alongside knoe-db.yaml:
# kubectl apply -f deploy/gcp/gke/knoe-db-podmonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: knoe-db
namespace: knoe-db-0
labels:
# Match the label selector used by kube-prometheus-stack's Prometheus
# instance so this PodMonitor is picked up automatically.
release: kps
spec:
selector:
matchLabels:
cnpg.io/cluster: knoe-db
podMetricsEndpoints:
- port: metrics
relabelings:
- sourceLabels: ["__meta_kubernetes_pod_label_cnpg_io_cluster"]
targetLabel: cluster
action: replace

View File

@ -4,11 +4,6 @@ metadata:
name: knoe-db
namespace: knoe-db-0
spec:
# Run cluster pods as cnpg-backup-sa (annotated for Workload Identity to the
# cnpg-backup@... GCP SA). This is how barman-cloud authenticates to
# gs://knoe-0-backups/ without a static key. Requires CNPG v1.29+.
# The SA is provisioned by etc/init_cnpg_gke.sh § "Apply ServiceAccount + annotate with WI".
serviceAccountName: cnpg-backup-sa
instances: 3
enablePDB: false
# Image pulled from GCP Artifact Registry — set ARTIFACT_REGISTRY in conf/prod/gcp.cfg
@ -45,36 +40,12 @@ spec:
- pg_stat_statements
- pg_tde
pg_hba:
# Local Unix-socket connections (CNPG default + knoe role)
- local all postgres trust
- local all knoe scram-sha-256
# postgres / knoe-db / knoe roles: cluster-internal (RFC1918) only.
# Cluster pod CIDRs: db cluster 10.24.0.0/14, app cluster 10.84.0.0/14;
# node subnet 10.180.0.0/16. 10.0.0.0/8 covers all of those.
- host all postgres 10.0.0.0/8 scram-sha-256
- host knoe knoe-db 10.0.0.0/8 scram-sha-256
- hostssl knoe knoe-db 10.0.0.0/8 scram-sha-256
# PHASE 1 EXTERNAL ACCESS — any member of `knoe_developer`, over TLS+SCRAM.
# `+rolename` in pg_hba matches role membership (not just literal name),
# so `etc/onboard_engineer.sh` adds new engineers via `GRANT knoe_developer
# TO <user>` without ever editing pg_hba — that's the reusable property.
# Phase 2 (queued for Junie) replaces this with libpq OAUTHBEARER:
# hostssl all all 0.0.0.0/0 oauth issuer=https://accounts.google.com validator=knoe_oauth scope="openid email"
- hostssl all +knoe_developer all scram-sha-256
# Internal cluster (RFC1918) — all roles, SCRAM (allows the supabase
# services in app cluster knoe-dev-0 to reach the DB cluster).
- host all all 10.0.0.0/8 scram-sha-256
- hostssl all all 10.0.0.0/8 scram-sha-256
# Block any plaintext from external (TLS required for the public LB)
- hostnossl all all 0.0.0.0/0 reject
# Catch-all reject for anything not matched above
- host all all 0.0.0.0/0 reject
- hostssl all all 0.0.0.0/0 reject
- host all postgres all scram-sha-256
- host knoe knoe-db all scram-sha-256
- host all all all scram-sha-256
- hostssl knoe knoe-db all scram-sha-256
bootstrap:
initdb:
@ -85,14 +56,7 @@ spec:
secret:
name: knoe-db-user
postInitTemplateSQL:
# Supabase convention: relocatable extensions live in `extensions`, not
# `public`. Studio's Database Advisor flags `public.pg_stat_statements`
# as a Security warning the moment a user opens the dashboard. Without
# an explicit SCHEMA clause `CREATE EXTENSION` lands the relocatable
# extension in the first writable schema in the connecting role's
# search_path, which for `postgres` is `public`.
- CREATE SCHEMA IF NOT EXISTS extensions;
- CREATE EXTENSION IF NOT EXISTS pg_stat_statements SCHEMA extensions;
- CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
postInitSQL:
- DO $do$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'knoe') THEN CREATE ROLE knoe LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; END IF; END $do$;
- DO $do$ DECLARE owner_password text; BEGIN SELECT rolpassword INTO owner_password FROM pg_authid WHERE rolname = 'knoe'; IF owner_password IS NOT NULL THEN EXECUTE format('ALTER ROLE knoe PASSWORD %L', owner_password); END IF; END $do$;
@ -133,14 +97,6 @@ spec:
- GRANT SELECT, INSERT, UPDATE ON knoe.user TO knoe;
- GRANT SELECT, INSERT, UPDATE ON knoe.user_role TO knoe;
- GRANT USAGE, SELECT ON SEQUENCE knoe.user_id_seq TO knoe;
# Task 4: align knoe.user with ekosystem user UUIDs
- ALTER TABLE knoe.user ADD COLUMN IF NOT EXISTS ekosystem_uuid text UNIQUE;
postInitApplicationSQLRefs:
configMapRefs:
- name: knoe-ekosystem-sql
key: ekosystem.sql
- name: knoe-ekosystem-sql
key: ekosystem_objects.sql
managed:
roles:
@ -163,34 +119,13 @@ spec:
resources:
requests:
cpu: "100m"
# 512Mi (was 128Mi) — postgres baseline working set is ~290Mi on the
# primary (shared_buffers + wal_buffers + per-backend memory + a small
# OS page cache visible to cgroups), so 128Mi caused the cnpg-grafana
# "Resource Pressure" tile to flag Memory at working_set / request ≈ 2x,
# which the dashboard maps to a red "Data Loss" label (>0.98 ratio).
# Right-sizing to 512Mi puts the steady-state ratio in the green
# "Healthy" zone (<0.8) and gives the scheduler an accurate signal for
# spreading replicas across nodes. Pods still have plenty of headroom:
# 2Gi limit is unchanged.
memory: "512Mi"
memory: "128Mi"
limits:
cpu: "500m"
# 2Gi (was 512Mi) — barman-cloud-backup is single-threaded gzip + GCS
# upload buffering and the throughput tops out at the memory ceiling.
# 2Gi cuts a 9 GB DB backup from 3090 min down to 510 min.
memory: "2Gi"
memory: "512Mi"
enableSuperuserAccess: true
# CNPG-issued server cert is auto-rotated by the operator. Listing
# pg.0.knoe.dev as an alt DNS name lets engineers connect with
# `sslmode=verify-full` after fetching the CNPG-issued CA cert from the
# `knoe-db-ca` Secret. Phase 1 of the per-engineer psql access plan; replaced
# by libpq OAUTHBEARER + Let's Encrypt in Phase 2.
certificates:
serverAltDNSNames:
- pg.0.knoe.dev
storage:
size: 50Gi
pvcTemplate:
@ -212,7 +147,4 @@ spec:
storageClassName: premium-rwo # pd-ssd; restore to premium-rwo after quota increase (matches PGDATA above)
monitoring:
# enablePodMonitor and podMonitorRelabelings removed — both fields are
# deprecated by the CNPG operator and will be removed in a future release.
# The PodMonitor is now managed as a sibling resource:
# deploy/gcp/gke/knoe-db-podmonitor.yaml (queue #13).
enablePodMonitor: true # kube-prometheus-stack (Prometheus Operator) is installed before CNPG — PodMonitor CRD is present

View File

@ -1,28 +0,0 @@
---
# knoe-kdc-secrets.yaml
# Placeholder / example structure. Real values are created by init_knoe_auth.sh
# using 1Password (op) and applied directly — this file is NOT committed with
# real secrets.
#
# To create manually:
# kubectl -n knoe-system create secret generic knoe-kdc-secrets \
# --from-literal=master_password="$(op item get 'knoe-kdc-master' --fields password)" \
# --from-literal=admin_password="$(op item get 'knoe-kdc-admin' --fields password)"
#
# Fields:
# master_password — KDC database master key (kdb5_util -P)
# admin_password — admin/admin@KNOE.DEV principal password (kadmin)
#
# Secret is referenced by:
# - knoe-kdc (KDC sidecar container)
# - keytab-bootstrap (initContainer)
apiVersion: v1
kind: Secret
metadata:
name: knoe-kdc-secrets
namespace: knoe-system
type: Opaque
stringData:
master_password: "REPLACE_WITH_STRONG_RANDOM_VALUE"
admin_password: "REPLACE_WITH_STRONG_RANDOM_VALUE"

View File

@ -1,347 +0,0 @@
# knoe-onboard: tiny static-content service that renders an engineer's
# psql onboarding details from a URL fragment.
#
# How it fits:
# etc/onboard_engineer.sh <user> <email>
# -> CREATE ROLE + temp password (24h)
# -> Builds URL: https://db.0.knoe.dev/onboard.html#user=...&pw=...&exp=...
# -> Outputs URL + QR-code rendering for chrisfu to email or screenshare
# Engineer clicks URL or scans QR
# -> oauth2-proxy `--skip-auth-route` matches /onboard.* (page itself is
# ungated; URL secrecy + 24h expiry + immediate rotation = the security
# envelope, since URL fragments don't survive an OAuth redirect anyway)
# -> Kong routes /onboard/* to this nginx pod
# -> Browser loads onboard.html; vanilla JS reads window.location.hash,
# decodes the password, displays it with [Copy] buttons + a ready-to-paste
# psql connection string. No backend calls. No state.
#
# Phase 2 (Junie's queue): when libpq OAUTHBEARER lands, the script stops
# generating passwords; the onboard page becomes "you're already authorized
# (Google), here's your connection string with oauth_issuer=...". The
# Deployment + Service + Kong route here stay; only the HTML changes.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: knoe-onboard-html
namespace: supabase
labels:
app: knoe-onboard
app.kubernetes.io/managed-by: knoe-installer
data:
onboard.html: |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow, noarchive">
<title>knoe-db onboarding</title>
<style>
:root {
color-scheme: dark;
--bg: #0e1116;
--fg: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--warn: #f85149;
--ok: #3fb950;
--code-bg: #161b22;
--border: #30363d;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2rem 1rem; min-height: 100vh;
background: var(--bg); color: var(--fg);
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
main { max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 0.5rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
p { margin: 0.5rem 0; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; font-size: 0.92em; }
pre {
background: var(--code-bg); border: 1px solid var(--border);
border-radius: 6px; padding: 0.85rem 1rem; margin: 0;
overflow-x: auto; word-break: break-all; white-space: pre-wrap;
}
.row { display: flex; gap: 0.5rem; align-items: stretch; margin: 0.5rem 0 1rem; }
.row pre { flex: 1; }
button {
background: var(--accent); color: var(--bg);
border: 0; border-radius: 6px;
padding: 0 1rem; font-weight: 600; font-size: 0.92rem;
cursor: pointer; min-width: 90px;
}
button:hover { filter: brightness(1.1); }
button.copied { background: var(--ok); }
.warn {
background: rgba(248, 81, 73, 0.1);
border-left: 3px solid var(--warn);
padding: 0.85rem 1rem; margin: 1.5rem 0; border-radius: 4px;
}
.ok {
background: rgba(63, 185, 80, 0.1);
border-left: 3px solid var(--ok);
padding: 0.85rem 1rem; margin: 1rem 0; border-radius: 4px;
}
.muted { color: var(--muted); font-size: 0.9rem; }
.expired { background: rgba(248, 81, 73, 0.15); padding: 1.5rem; border-radius: 6px; text-align: center; }
</style>
</head>
<body>
<main id="app">
<noscript>This page needs JavaScript to decode your onboarding URL.</noscript>
</main>
<script>
(function() {
var app = document.getElementById('app');
function parseFragment() {
var h = window.location.hash.replace(/^#/, '');
if (!h) return null;
var params = {};
h.split('&').forEach(function(kv) {
var i = kv.indexOf('=');
if (i < 0) return;
params[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
});
return params;
}
function renderExpired(reason) {
app.innerHTML =
'<h1>knoe-db onboarding</h1>' +
'<div class="expired">' +
'<p><strong>This onboarding link is no longer valid.</strong></p>' +
'<p class="muted">' + reason + '</p>' +
'<p>Ask chrisfu to rerun <code>etc/onboard_engineer.sh</code> for a fresh link.</p>' +
'</div>';
}
function el(tag, attrs, children) {
var e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function(k) {
if (k === 'class') e.className = attrs[k];
else e.setAttribute(k, attrs[k]);
});
(children || []).forEach(function(c) {
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
function copyButton(text) {
var btn = el('button', null, ['Copy']);
btn.addEventListener('click', function() {
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
}).catch(function() {
btn.textContent = 'Copy failed';
});
});
return btn;
}
function copyableBlock(text) {
return el('div', { class: 'row' }, [
el('pre', null, [text]),
copyButton(text)
]);
}
function render(p) {
// exp is ISO 8601 (UTC). If present and past, page expires.
if (p.exp) {
var expDate = new Date(p.exp);
if (!isNaN(expDate) && expDate < new Date()) {
renderExpired('Expired at ' + expDate.toLocaleString() + '.');
return;
}
}
if (!p.user || !p.pw) {
renderExpired('URL is missing required fields.');
return;
}
var pw = p.pw;
// Try base64 decode (script encodes pw as base64 for URL safety).
try { pw = atob(pw.replace(/-/g, '+').replace(/_/g, '/')); } catch (e) { /* leave as-is */ }
var connStr = 'psql "host=pg.0.knoe.dev port=5432 user=' + p.user +
' dbname=postgres sslmode=verify-full' +
' sslrootcert=$HOME/.knoe/knoe-db-ca.crt"';
var bootstrap =
'mkdir -p ~/.knoe && \\\n' +
'curl -sSfL https://raw.githubusercontent.com/knoey/knoe-db/main/etc/knoe-db-ca.crt -o ~/.knoe/knoe-db-ca.crt && \\\n' +
connStr;
app.innerHTML = '';
app.appendChild(el('h1', null, ['Welcome to knoe-db, ' + p.user + '.']));
app.appendChild(el('p', { class: 'muted' }, [
'This is a one-time onboarding link. The temporary password below ' +
'is valid for 24 hours; rotate it as soon as you connect.'
]));
app.appendChild(el('h2', null, ['Your temporary password (24h)']));
app.appendChild(copyableBlock(pw));
app.appendChild(el('div', { class: 'warn' }, [
el('strong', null, ['Save this to your personal 1Password vault now.']),
el('br'),
el('span', { class: 'muted' }, ['You won\'t be able to retrieve it from this page after closing the browser.'])
]));
app.appendChild(el('h2', null, ['psql connection string']));
app.appendChild(copyableBlock(connStr));
app.appendChild(el('h2', null, ['First-connect bootstrap (fetches CA cert + connects)']));
app.appendChild(copyableBlock(bootstrap));
app.appendChild(el('h2', null, ['Rotate immediately after connecting']));
app.appendChild(el('pre', null, [
connStr + '\n' +
'Password: <paste the password from above>\n\n' +
'postgres=> \\password\n' +
'Enter new password: <a strong password you generate locally>\n' +
'Enter it again: <repeat>\n' +
'postgres=> -- save the new password to your personal 1Password,\n' +
'postgres=> -- then everything from here uses your real password.\n'
]));
app.appendChild(el('div', { class: 'ok' }, [
'Phase 2 (queued): the password mechanism is being replaced by ' +
'libpq OAUTHBEARER (Google sign-in). Your role name (',
el('code', null, [p.user]),
') stays the same — only the connection string will swap from ',
el('code', null, ['-W']),
' to ',
el('code', null, ['oauth_issuer=...']),
'.'
]));
// Strip the fragment from history so reload / back-button don't re-leak.
if (history.replaceState) {
history.replaceState(null, '', window.location.pathname);
}
}
var p = parseFragment();
if (p) render(p);
else renderExpired('No onboarding details in URL.');
})();
</script>
</body>
</html>
---
apiVersion: v1
kind: Service
metadata:
name: knoe-onboard
namespace: supabase
labels:
app: knoe-onboard
app.kubernetes.io/managed-by: knoe-installer
spec:
type: ClusterIP
selector:
app: knoe-onboard
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: knoe-onboard
namespace: supabase
labels:
app: knoe-onboard
app.kubernetes.io/managed-by: knoe-installer
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: knoe-onboard
template:
metadata:
labels:
app: knoe-onboard
spec:
securityContext:
runAsNonRoot: true
runAsUser: 101 # nginx in alpine image
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: nginx
image: nginx:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
volumeMounts:
# ConfigMap mounted directly at the nginx html root so we don't
# need a writable rootfs (readOnlyRootFilesystem: true).
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
livenessProbe:
httpGet:
path: /onboard.html
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /onboard.html
port: http
initialDelaySeconds: 2
periodSeconds: 5
timeoutSeconds: 3
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumes:
- name: html
configMap:
name: knoe-onboard-html
items:
# Two paths under the same volume: nginx serves /onboard.html
# directly, and / falls back to index.html (same content) for
# convenience if the trailing .html ever gets dropped.
- key: onboard.html
path: onboard.html
- key: onboard.html
path: index.html
- name: nginx-cache
emptyDir: {}
- name: nginx-run
emptyDir: {}

View File

@ -1,193 +0,0 @@
# oauth2-proxy gate in front of Supabase Studio at db.0.knoe.dev.
#
# Sits between the GCE Ingress and supabase-kong; redirects unauthenticated
# requests to Google Workspace, allows any @knoey.com identity through, then
# proxies the request upstream to supabase-kong:8000 (which serves Studio at
# the / route). Outside-domain users are rejected at this layer.
#
# Bootstrap: ./etc/init_oauth2_proxy.sh
# Cluster: knoe-dev-0 (GKE app cluster)
# Namespace: supabase (alongside supabase-* workloads + the existing Ingress)
#
# After this Deployment is Ready, the supabase-kong Ingress is patched to
# route db.0.knoe.dev through oauth2-proxy:80, and the basic-auth plugin on
# the Kong dashboard route is removed (oauth2-proxy is the gate now).
#
# Unified host model (Studio + SDK on the same hostname):
# the `--skip-auth-route` args below let SDK requests on /auth/v1, /rest/v1,
# /realtime/v1, /storage/v1, /functions/v1, /graphql/v1, /pg/* bypass the
# Google sign-in flow and pass straight through to supabase-kong, where
# Kong's `key-auth` plugin enforces the existing anon/service-role API keys.
# The Studio UI at / remains Google-gated (no skip rule). Net effect: one
# URL (db.0.knoe.dev) covers humans-in-Studio AND robots-via-supabase-py,
# with the same security envelope as before — Kong's key-auth is the
# enforcer on API paths regardless of which hostname they came in on.
#
# When knoe-auth Round 1 ships an OIDC OP at https://api.knoe.dev/auth, swap
# the args below from `--provider=google` to `--provider=oidc` +
# `--oidc-issuer-url=https://api.knoe.dev/auth` and reapply — no other
# manifest changes needed.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: oauth2-proxy
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
---
apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
name: oauth2-proxy-backend-config
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
spec:
healthCheck:
type: HTTP
requestPath: /ping
port: 4180
timeoutSec: 60
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
annotations:
cloud.google.com/backend-config: '{"default": "oauth2-proxy-backend-config"}'
spec:
type: ClusterIP
selector:
app: oauth2-proxy
ports:
- name: http
port: 80
targetPort: 4180
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: oauth2-proxy
template:
metadata:
labels:
app: oauth2-proxy
spec:
serviceAccountName: oauth2-proxy
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2
imagePullPolicy: IfNotPresent
args:
- --provider=google
- --email-domain=knoey.com
- --upstream=http://supabase-kong:8000
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --cookie-secure=true
- --cookie-name=_knoe_studio_oauth2
- --cookie-domain=db.0.knoe.dev
- --whitelist-domain=db.0.knoe.dev
- --redirect-url=https://db.0.knoe.dev/oauth2/callback
- --skip-provider-button=true
- --scope=openid email profile
# NB: do NOT set --pass-authorization-header / --set-authorization-header.
# Those flags rewrite (or strip) the request's Authorization header,
# which clobbers the supabase-py / Studio Authorization: Bearer <apikey>
# value that the storage and other Supabase services expect to parse
# against JWT_SECRET. When per-user identity passthrough lands (post
# knoe-auth Round 1), use --pass-user-headers / --set-xauthrequest
# instead — those use X-Forwarded-* headers and don't collide with
# Supabase's own auth.
#
# Bypass Google auth for Supabase API surfaces — Kong's key-auth
# is the enforcer on these paths (anon / service-role keys).
- --skip-auth-route=^/(auth|rest|realtime|storage|functions|graphql)/v1/.*
- --skip-auth-route=^/pg/.*
# Bypass Google auth for the onboarding reveal page. URL fragments
# don't survive an OAuth redirect, so we can't gate this with
# Google sign-in; instead, the engineer's URL is single-shot
# delivery (email / QR), the temp password expires in 24h, and
# the page recommends immediate rotation via `\password`.
- --skip-auth-route=^/onboard\.html$
# Public support endpoint — 302s to mailto:support@knoe.dev. Skipping
# auth so an unauthenticated user looking for help isn't bounced into
# a confusing Google sign-in detour.
- --skip-auth-route=^/support$
- --request-logging=true
- --auth-logging=true
- --standard-logging=true
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: oauth2-proxy-google-oidc
key: client_id
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oauth2-proxy-google-oidc
key: client_secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: oauth2-proxy-google-oidc
key: cookie_secret
ports:
- name: http
containerPort: 4180
protocol: TCP
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true

View File

@ -1,32 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-google-oidc
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
# Google OAuth 2.0 client credentials + cookie secret for the oauth2-proxy
# gating Supabase Studio at db.prole.org on the k3s homelab cluster.
# Applied by etc/init_oauth2_proxy_prole.sh via envsubst from
# etc/secrets/oauth2-proxy-{client-id,client-secret,cookie-secret}-prole.
#
# Create the OAuth client at: https://console.cloud.google.com/apis/credentials
# (in the prole.org GCP project — separate from the knoey.com project)
# - Application type: Web application
# - Name: prole.org Studio
# - Authorized JavaScript origins: https://db.prole.org
# - Authorized redirect URIs: https://db.prole.org/oauth2/callback
# - OAuth consent screen: Internal (prole.org Workspace only)
# - Scopes: openid, email, profile
#
# cookie_secret: must be 32 bytes. Generate with:
# openssl rand -base64 32
#
# Why a separate client from prole.org Grafana: independent rotation surface.
# Companion to grafana-google-oidc-secret-prole.example.yaml.
type: Opaque
stringData:
client_id: "${OAUTH2_PROXY_CLIENT_ID}"
client_secret: "${OAUTH2_PROXY_CLIENT_SECRET}"
cookie_secret: "${OAUTH2_PROXY_COOKIE_SECRET}"

View File

@ -1,31 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-google-oidc
namespace: supabase
labels:
app: oauth2-proxy
app.kubernetes.io/managed-by: knoe-installer
# Google OAuth 2.0 client credentials + cookie secret for the oauth2-proxy
# gating Supabase Studio at db.0.knoe.dev. Applied by etc/init_oauth2_proxy.sh
# via envsubst from etc/secrets/oauth2-proxy-{client-id,client-secret,cookie-secret}.
#
# Create the OAuth client at: https://console.cloud.google.com/apis/credentials
# - Application type: Web application
# - Name: knoe.dev Studio
# - Authorized JavaScript origins: https://db.0.knoe.dev
# - Authorized redirect URIs: https://db.0.knoe.dev/oauth2/callback
# - OAuth consent screen: Internal (knoey.com Workspace)
# - Scopes: openid, email, profile (default for oauth2-proxy)
#
# cookie_secret: must be 32 bytes (24-byte base64-encoded). Generate with:
# openssl rand -base64 32
#
# Why a separate client (not reusing knoe.dev GitLab): independent rotation
# surface — if Studio access is compromised, rotating one client doesn't
# disrupt the GitLab login flow (and vice versa).
type: Opaque
stringData:
client_id: "${OAUTH2_PROXY_CLIENT_ID}"
client_secret: "${OAUTH2_PROXY_CLIENT_SECRET}"
cookie_secret: "${OAUTH2_PROXY_COOKIE_SECRET}"

View File

@ -16,7 +16,7 @@ spec:
app: garage
spec:
nodeSelector:
kubernetes.io/hostname: myrddin.prole.org
kubernetes.io/hostname: myrddin.knoe.org
containers:
- name: garage
image: dxflrs/garage:v1.3.1

View File

@ -1,128 +0,0 @@
# gitea-spnego-proxy.yaml
# Apache + mod_auth_gssapi sidecar that handles SPNEGO/Kerberos negotiation for
# git.prole.org. Sits between Kong and Gitea; injects X-WEBAUTH-USER for reverse
# proxy auto-login. Gitea remains on port 3000 (cluster-internal); this proxy
# listens on port 4000 and is the Kong upstream target.
#
# Identity chain:
# Browser → Traefik TLS → Kong → gitea-spnego-proxy:4000
# → GSSAPI (HTTP/git.prole.org@PROLE.ORG keytab)
# → X-WEBAUTH-USER: <username>
# → gitea-http.gitea:3000
---
apiVersion: v1
kind: ConfigMap
metadata:
name: gitea-krb5-conf
namespace: gitea
data:
krb5.conf: |
[libdefaults]
default_realm = PROLE.ORG
dns_lookup_realm = false
dns_lookup_kdc = false
forwardable = true
renewable = true
rdns = false
[realms]
PROLE.ORG = {
kdc = prole-kerberos-ad-dc.knoe-system.svc.cluster.local:88
admin_server = prole-kerberos-ad-dc.knoe-system.svc.cluster.local:88
}
KNOE.LOCAL = {
kdc = auth.knoe-system.svc.cluster.local:88
admin_server = auth.knoe-system.svc.cluster.local:749
}
[domain_realm]
.prole.org = PROLE.ORG
prole.org = PROLE.ORG
.prole.local = KNOE.LOCAL
prole.local = KNOE.LOCAL
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-spnego-proxy
namespace: gitea
labels:
app: gitea-spnego-proxy
spec:
replicas: 1
selector:
matchLabels:
app: gitea-spnego-proxy
template:
metadata:
labels:
app: gitea-spnego-proxy
spec:
nodeSelector:
kubernetes.io/hostname: gandalf.prole.org
containers:
- name: proxy
image: myrddin.prole.org:5000/gitea-spnego-proxy:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 4000
protocol: TCP
env:
- name: KRB5_CONFIG
value: /etc/krb5.conf
- name: KRB5_KTNAME
value: /etc/krb5/http.keytab
volumeMounts:
- name: keytab
mountPath: /etc/krb5
readOnly: true
- name: krb5-conf
mountPath: /etc/krb5.conf
subPath: krb5.conf
readOnly: true
livenessProbe:
httpGet:
path: /_healthz
port: 4000
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /_healthz
port: 4000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
volumes:
- name: keytab
secret:
secretName: gitea-krb5-keytab
defaultMode: 0400
- name: krb5-conf
configMap:
name: gitea-krb5-conf
---
apiVersion: v1
kind: Service
metadata:
name: gitea-spnego-proxy
namespace: gitea
labels:
app: gitea-spnego-proxy
spec:
selector:
app: gitea-spnego-proxy
ports:
- name: http
port: 4000
targetPort: 4000
protocol: TCP
type: ClusterIP

View File

@ -2,7 +2,6 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: knoe-grafana-proxy-nginx
namespace: monitoring
data:
nginx.conf: |
worker_processes 1;
@ -17,20 +16,43 @@ data:
listen 80;
server_name _;
# Strip any inbound auth headers — Grafana auth.google handles
# Google users directly. Kerberos auth_request (X-WEBAUTH-USER
# injection) re-enabled once knoe-auth /auth/verify is ready.
# Never trust inbound auth headers from clients.
proxy_set_header X-WEBAUTH-USER "";
proxy_set_header X-Knoe-Groups "";
location = /_auth_verify {
internal;
proxy_pass http://knoe-auth:8080/auth/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
location / {
auth_request /_auth_verify;
auth_request_set $knoe_user $upstream_http_x_knoe_user;
auth_request_set $knoe_groups $upstream_http_x_knoe_groups;
error_page 401 = @login;
error_page 403 = @login;
proxy_set_header X-WEBAUTH-USER $knoe_user;
proxy_set_header X-Knoe-Groups $knoe_groups;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://prometheus-grafana.monitoring.svc.cluster.local:80;
proxy_pass http://kps-grafana.monitoring.svc.cluster.local:80;
}
location @login {
return 302 https://api.knoe.org/auth/login?next=$scheme://$host$request_uri;
}
}
}

View File

@ -2,7 +2,6 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: knoe-grafana-proxy
namespace: monitoring
labels:
app: knoe-grafana-proxy
spec:

View File

@ -2,7 +2,6 @@ apiVersion: v1
kind: Service
metadata:
name: knoe-grafana-proxy
namespace: monitoring
labels:
app: knoe-grafana-proxy
spec:

Some files were not shown because too many files have changed in this diff Show More