mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:34:31 +00:00
Compare commits
No commits in common. "main" and "pre-credscrub-20260427" have entirely different histories.
main
...
pre-credsc
14
.gitignore
vendored
14
.gitignore
vendored
@ -24,12 +24,8 @@
|
|||||||
/bin/prole-agent
|
/bin/prole-agent
|
||||||
/bin/prole-scan
|
/bin/prole-scan
|
||||||
|
|
||||||
# IDE / tool state — never commit
|
# MagicMock artifacts (likely accidental)
|
||||||
/.junie/
|
/MagicMock/
|
||||||
/knoe-db/.idea/
|
|
||||||
/tmp/
|
|
||||||
# MagicMock artifacts (accidental Python materialisation)
|
|
||||||
/<MagicMock*/
|
|
||||||
|
|
||||||
/bin/*
|
/bin/*
|
||||||
!/bin/prole-env.sh
|
!/bin/prole-env.sh
|
||||||
@ -74,7 +70,6 @@ mssql-password*
|
|||||||
knoe-db/data/
|
knoe-db/data/
|
||||||
var/
|
var/
|
||||||
/target/
|
/target/
|
||||||
**/target/
|
|
||||||
/pyvenv.cfg
|
/pyvenv.cfg
|
||||||
.output.txt
|
.output.txt
|
||||||
deploy/gcp/terraform-setup.txt
|
deploy/gcp/terraform-setup.txt
|
||||||
@ -106,7 +101,6 @@ deploy/gcp/terraform-setup.txt
|
|||||||
|
|
||||||
# Ansible
|
# Ansible
|
||||||
.ansible/
|
.ansible/
|
||||||
infrastructure/logs/
|
|
||||||
|
|
||||||
# Coverage and testing
|
# Coverage and testing
|
||||||
.coverage
|
.coverage
|
||||||
@ -115,7 +109,9 @@ htmlcov/
|
|||||||
|
|
||||||
/ssh-keys/
|
/ssh-keys/
|
||||||
/mock_val/secrets/
|
/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
|
/prole-db.iml
|
||||||
supabase/helm/generated/values.generated.json
|
supabase/helm/generated/values.generated.json
|
||||||
/.claude/
|
/.claude/
|
||||||
/.venv-ansible-winrm/
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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 "unit and not integration" --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>
|
|
||||||
15
AGENTS.md
15
AGENTS.md
@ -13,9 +13,9 @@ Two GKE clusters in `us-west3`:
|
|||||||
| Cluster | Context | Purpose |
|
| 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-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
|
### Deployment environments / modes
|
||||||
| `cluster_env` | `KNOE_MODE` | Target |
|
| `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 / 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 backups go to **GCS** (not Garage): `gs://knoe-0-backups/` and `gs://knoe-0-wal/`
|
||||||
- CNPG operator: **v1.29.0** (upgraded 2026-04-29 to expose `spec.serviceAccountName`)
|
- Workload Identity SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com`
|
||||||
- plugin-barman-cloud: v0.12.0
|
- ObjectStore manifest: `k8s/knoe/knoe-db-barman-objectstore-gcs.yaml`
|
||||||
- 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`
|
|
||||||
- Setup: `etc/init_cnpg_gke.sh` and `etc/init_cnpg_backup.sh`
|
- Setup: `etc/init_cnpg_gke.sh` and `etc/init_cnpg_backup.sh`
|
||||||
|
|
||||||
> **Performance note:** Pod `memory: 512Mi` makes `barman-cloud-backup` runs 30–90 min for the 9 GB DB. Bump pending in [`docs/TODO.md`](docs/TODO.md).
|
|
||||||
|
|
||||||
|
|||||||
144
CLAUDE.md
144
CLAUDE.md
@ -4,63 +4,58 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
**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.
|
||||||
|
|
||||||
## Master TODO
|
|
||||||
|
|
||||||
Single source of truth for unfinished work, including the reality-vs-intent gaps flagged below: **[`docs/TODO.md`](docs/TODO.md)**.
|
|
||||||
|
|
||||||
## Active plans
|
## 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/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).**
|
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.
|
||||||
- [`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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dual-cluster GKE architecture
|
## 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 |
|
| 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-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.
|
**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.
|
||||||
|
|
||||||
### Resource allocation
|
### Resource allocation
|
||||||
|
|
||||||
| Resource | Cluster | Namespace |
|
| Resource | Cluster | Namespace |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| CNPG operator (v1.29.0) | `knoe-dev-cnpg-0` | `cnpg-system` |
|
| CNPG operator | `knoe-cnpg-0` | `cnpg-system` |
|
||||||
| PostgreSQL cluster (`knoe-db`) | `knoe-dev-cnpg-0` | `knoe-db-0` |
|
| PostgreSQL cluster (`knoe-db`) | `knoe-cnpg-0` | `knoe-db-0` |
|
||||||
| Barman Cloud plugin (v0.12.0) | `knoe-dev-cnpg-0` | `cnpg-system` |
|
| Barman Cloud plugin | `knoe-cnpg-0` | `cnpg-system` |
|
||||||
| cert-manager | `knoe-dev-cnpg-0` | `cert-manager` |
|
| cert-manager | `knoe-cnpg-0` | `cert-manager` |
|
||||||
| Garage (S3 object store) | `knoe-dev-0` | `knoe-system` |
|
| Garage (S3 object store) | `knoe-dev-0` | `knoe-system` |
|
||||||
| Registry | `knoe-dev-0` | `knoe-system` |
|
| Registry | `knoe-dev-0` | `knoe-system` |
|
||||||
| OpenBao | `knoe-dev-0` | `knoe-system` |
|
| OpenBao | `knoe-dev-0` | `knoe-system` |
|
||||||
| Kong API gateway | `knoe-dev-0` | `knoe-system` |
|
| Kong API gateway | `knoe-dev-0` | `knoe-system` |
|
||||||
| Monitoring | `knoe-dev-0` | `monitoring` |
|
| 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**:
|
Backups use **GCS with Workload Identity** (not Garage):
|
||||||
- Data + WAL: `gs://knoe-0-backups/` (single bucket; `knoe-db/base/` and `knoe-db/wals/` prefixes). `gs://knoe-0-wal/` exists but is unused.
|
- Data bucket: `gs://knoe-0-backups/`
|
||||||
- GCP SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com` (roles on bucket: `storage.objectAdmin`, `storage.legacyBucketReader`)
|
- WAL bucket: `gs://knoe-0-wal/`
|
||||||
- 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.
|
- GCP SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com`
|
||||||
- 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).
|
- 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`](etc/init_cnpg_gke.sh) (creates buckets, GCP SA, WI binding, applies CNPG cluster).
|
|
||||||
|
|
||||||
|
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
|
```ini
|
||||||
[Inputs]
|
[Inputs]
|
||||||
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
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.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]
|
[Global]
|
||||||
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
||||||
APP_CLUSTER_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
|
||||||
CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-dev-cnpg-0>
|
CNPG_ELIGIBLE_NODES = <comma-separated node names from knoe-cnpg-0>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `conf/service/prod.cfg` (unattended deploy — `./deploy.sh`)
|
### `conf/service/prod.cfg` (unattended deploy — `./deploy.sh`)
|
||||||
@ -112,14 +107,14 @@ Same cluster context entries are required here too:
|
|||||||
```ini
|
```ini
|
||||||
[Inputs]
|
[Inputs]
|
||||||
init_cluster.app_cluster_kubecontext = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
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.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]
|
[Global]
|
||||||
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
KUBECONTEXT = gke_plenary-truck-485623-p7_us-west3_knoe-dev-0
|
||||||
APP_CLUSTER_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
|
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
|
||||||
SERVICE_NAMESPACE = knoe-system
|
SERVICE_NAMESPACE = knoe-system
|
||||||
REGISTRY_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.
|
**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).
|
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).
|
||||||
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
### Get current CNPG node names
|
### Get current CNPG node names
|
||||||
|
|
||||||
```bash
|
```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
|
```python
|
||||||
DEFAULT_APP_CLUSTER_NAME = "knoe-dev-0"
|
DEFAULT_APP_CLUSTER_NAME = "knoe-dev-0"
|
||||||
DEFAULT_DB_CLUSTER_NAME = "knoe-dev-cnpg-0"
|
DEFAULT_DB_CLUSTER_NAME = "knoe-cnpg-0" # also: DEFAULT_CNPG_CLUSTER_NAME
|
||||||
DEFAULT_APP_CLUSTER_MACHINE_TYPE = "e2-standard-2"
|
DEFAULT_APP_CLUSTER_MACHINE_TYPE = "e2-small" # STALE — live app cluster is e2-standard-2
|
||||||
DEFAULT_DB_CLUSTER_MACHINE_TYPE = "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`
|
- 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
|
- 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)
|
- 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"
|
|
||||||
```
|
|
||||||
|
|||||||
50
Makefile
50
Makefile
@ -14,13 +14,9 @@ PIPELINE_DIR ?= deploy/opentofu/k3s
|
|||||||
DEPLOYMENT_GIT_DIR ?= knoe/deployment
|
DEPLOYMENT_GIT_DIR ?= knoe/deployment
|
||||||
GITEA_SCRIPT ?= knoe/etc/gitea.sh
|
GITEA_SCRIPT ?= knoe/etc/gitea.sh
|
||||||
KUBECONFIG_PATH ?= $(CURDIR)/knoe-k3s.kubeconfig
|
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
|
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 \
|
.PHONY: all knoe build install deploy init clean help requirements test pyconv start
|
||||||
k3d-knoe-up k3d-knoe-pf k3d-knoe-down \
|
|
||||||
workstation
|
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@ -36,8 +32,6 @@ help:
|
|||||||
@echo " install - Run silent install via install.sh"
|
@echo " install - Run silent install via install.sh"
|
||||||
@echo " deploy - Run infrastructure deployment via deploy.sh"
|
@echo " deploy - Run infrastructure deployment via deploy.sh"
|
||||||
@echo " build - Build the 'knoe' CLI binary"
|
@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 " requirements - Install Python dependencies"
|
||||||
@echo " test - Run full test suite"
|
@echo " test - Run full test suite"
|
||||||
@echo " pyconv - Check Python code style conventions (black)"
|
@echo " pyconv - Check Python code style conventions (black)"
|
||||||
@ -45,11 +39,6 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Environment:"
|
@echo "Environment:"
|
||||||
@echo " KNOE_CONF - Directory containing knoe.cfg (default: conf)"
|
@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:
|
requirements:
|
||||||
@echo "Installing dependencies..."
|
@echo "Installing dependencies..."
|
||||||
@ -64,24 +53,6 @@ build:
|
|||||||
$(PYINSTALLER) --clean --noconfirm knoe.spec
|
$(PYINSTALLER) --clean --noconfirm knoe.spec
|
||||||
@echo "✓ Build complete: $(DIST_DIR)/Knoe.DB Installer.app"
|
@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:
|
install:
|
||||||
@echo "Running silent install..."
|
@echo "Running silent install..."
|
||||||
KNOE_CONF=$(KNOE_CONF) ./install.sh -s -c $(KNOE_CONF)/knoe.cfg
|
KNOE_CONF=$(KNOE_CONF) ./install.sh -s -c $(KNOE_CONF)/knoe.cfg
|
||||||
@ -102,11 +73,6 @@ deploy:
|
|||||||
@echo "Running Knoe deployment..."
|
@echo "Running Knoe deployment..."
|
||||||
./deploy.sh
|
./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
|
test: pyconv
|
||||||
@echo "Running full test suite..."
|
@echo "Running full test suite..."
|
||||||
@./tests/run_tests.sh
|
@./tests/run_tests.sh
|
||||||
@ -118,20 +84,6 @@ pyconv:
|
|||||||
@echo "Checking Python code style conventions..."
|
@echo "Checking Python code style conventions..."
|
||||||
@$(PYTHON) -m black --check . || (echo "Warning: pyconv (black) found style issues. Run 'black .' to fix." && exit 1)
|
@$(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:
|
clean:
|
||||||
@echo "Cleaning build artifacts..."
|
@echo "Cleaning build artifacts..."
|
||||||
rm -rf $(BUILD_DIR) $(DIST_DIR) *.spec
|
rm -rf $(BUILD_DIR) $(DIST_DIR) *.spec
|
||||||
|
|||||||
265
README.md
265
README.md
@ -1,35 +1,17 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://svc.prole.org"><pre>
|
<a href="https://svc.knoe.org"><pre>
|
||||||
# #############################################
|
# ###########################
|
||||||
# ╭───────────────────────────────────────╮ #
|
# ╭──────────────────────╮ #
|
||||||
# │ │ #
|
# │ _ │ #
|
||||||
# │ ___ ____ ___ _ ____ │ #
|
# │ | _/ _ | _ |_ │ #
|
||||||
# │ | _ \| _ \/ _ \| | | ___| │ #
|
# │ |/ | \ | ( |_) │ #
|
||||||
# │ | _/| |_) | | | | | |___ \ │ #
|
# │ │ #
|
||||||
# │ | | | __/| |_| | |___ ___) | │ #
|
# ╰──────────────────────╯ #
|
||||||
# │ |_| |_| \___/|_____|____/ . │ #
|
# ###########################
|
||||||
# │ │ #
|
|
||||||
# │ svc · db · git · api │ #
|
|
||||||
# ╰───────────────────────────────────────╯ #
|
|
||||||
# #############################################
|
|
||||||
</pre></a>
|
</pre></a>
|
||||||
|
|
||||||
[svc.prole.org](https://svc.prole.org) ·
|
|
||||||
[db.prole.org](https://db.prole.org) ·
|
|
||||||
[git.prole.org](https://git.prole.org) ·
|
|
||||||
[api.prole.org](https://api.prole.org)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Prole.
|
# Knoe
|
||||||
|
|
||||||
**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 makes it practical to run a Supabase-style platform across air-gapped, edge, and cloud environments.**
|
**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
|
## 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)**
|
Expect the architecture to continue being refined toward:
|
||||||
- `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`)
|
- cleaner bootstrapping
|
||||||
- `fix(ansible)` — workstation install script + `make workstation` target for Chrome SPNEGO policy on personal Macs (`b245593`)
|
- better shard isolation
|
||||||
- `fix(spnego)` — `Subject.callAs()`, AES-only keytab, and `krb5.conf` sync for JDK 21 knoe-auth (`c1d2a91`)
|
- smoother rejoin/reset behavior for cluster nodes
|
||||||
- `feat(oidc)` — knoe-auth routing through Kong; Flyway schema baseline for clean OIDC DB (`9523045`)
|
- clearer service boundaries
|
||||||
- `feat(prole)` — knoe-auth bootstrap on k3s; tenant onboarding; cluster stabilisation (`cf33342`)
|
- improved onboarding and operations documentation
|
||||||
|
|
||||||
**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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
27
ansible.cfg
27
ansible.cfg
@ -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
|
|
||||||
148
ansible.sh
148
ansible.sh
@ -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
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
stdout_callback = default
|
|
||||||
interpreter_python = auto_silent
|
|
||||||
host_key_checking = False
|
|
||||||
forks = 1
|
|
||||||
@ -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"]
|
|
||||||
@ -10,17 +10,16 @@
|
|||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>dev.knoe</groupId>
|
<groupId>org.knoe</groupId>
|
||||||
<artifactId>auth</artifactId>
|
<artifactId>authority</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
<name>knoe-auth</name>
|
<name>knoe-authority</name>
|
||||||
<description>Knoe authentication service (knoe-auth)</description>
|
<description>Knoe authentication gateway</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -40,7 +39,12 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-api</artifactId>
|
<artifactId>jjwt-api</artifactId>
|
||||||
@ -58,83 +62,14 @@
|
|||||||
<version>0.11.5</version>
|
<version>0.11.5</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<finalName>knoe-auth</finalName>
|
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</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>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@ -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) {}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 & 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 & 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>
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
@ -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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 : "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth;
|
package org.knoe.authority;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.config;
|
package org.knoe.authority.config;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -16,30 +16,6 @@ public class AuthProperties {
|
|||||||
private String emailDomain = "knoe.dev";
|
private String emailDomain = "knoe.dev";
|
||||||
private boolean formEnabled = false;
|
private boolean formEnabled = false;
|
||||||
private List<String> adminPrincipals = new ArrayList<>();
|
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() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.config;
|
package org.knoe.authority.config;
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.kerberos;
|
package org.knoe.authority.kerberos;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -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.Map;
|
||||||
import java.util.concurrent.Callable;
|
|
||||||
import javax.security.auth.Subject;
|
import javax.security.auth.Subject;
|
||||||
import javax.security.auth.login.AppConfigurationEntry;
|
import javax.security.auth.login.AppConfigurationEntry;
|
||||||
import javax.security.auth.login.Configuration;
|
import javax.security.auth.login.Configuration;
|
||||||
@ -16,18 +16,11 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class KerberosSpnegoService {
|
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;
|
private static final Oid KRB5_OID;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
try {
|
try {
|
||||||
SPNEGO_OID = new Oid("1.3.6.1.5.5.2");
|
KRB5_OID = new Oid("1.2.840.113554.1.2.2");
|
||||||
KRB5_OID = new Oid("1.2.840.113554.1.2.2");
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("Failed to init Kerberos OID", e);
|
throw new RuntimeException("Failed to init Kerberos OID", e);
|
||||||
}
|
}
|
||||||
@ -49,9 +42,10 @@ public class KerberosSpnegoService {
|
|||||||
loginContext.login();
|
loginContext.login();
|
||||||
|
|
||||||
Subject subject = loginContext.getSubject();
|
Subject subject = loginContext.getSubject();
|
||||||
return Subject.callAs(subject, (Callable<Result>) () -> {
|
return Subject.doAs(subject, (PrivilegedExceptionAction<Result>) () -> {
|
||||||
GSSManager manager = GSSManager.getInstance();
|
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);
|
byte[] outToken = ctx.acceptSecContext(token, 0, token.length);
|
||||||
boolean established = ctx.isEstablished();
|
boolean established = ctx.isEstablished();
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.session;
|
package org.knoe.authority.session;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.session;
|
package org.knoe.authority.session;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.user;
|
package org.knoe.authority.user;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -7,22 +7,6 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class PrincipalNormalizer {
|
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) {
|
public Optional<String> normalizeUsernameFromKerberosPrincipal(String kerberosPrincipal) {
|
||||||
if (kerberosPrincipal == null || kerberosPrincipal.isBlank()) {
|
if (kerberosPrincipal == null || kerberosPrincipal.isBlank()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
@ -1,31 +1,22 @@
|
|||||||
package dev.knoe.auth.web;
|
package org.knoe.authority.web;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpSession;
|
|
||||||
|
|
||||||
import dev.knoe.auth.config.AuthProperties;
|
import org.knoe.authority.config.AuthProperties;
|
||||||
import dev.knoe.auth.config.KerberosProperties;
|
import org.knoe.authority.config.KerberosProperties;
|
||||||
import dev.knoe.auth.enroll.GoogleOAuthService;
|
import org.knoe.authority.kerberos.KerberosPasswordService;
|
||||||
import dev.knoe.auth.kerberos.KerberosPasswordService;
|
import org.knoe.authority.kerberos.KerberosSpnegoService;
|
||||||
import dev.knoe.auth.kerberos.KerberosSpnegoService;
|
import org.knoe.authority.session.SessionTokenService;
|
||||||
import dev.knoe.auth.session.SessionTokenService;
|
import org.knoe.authority.session.SessionUser;
|
||||||
import dev.knoe.auth.session.SessionUser;
|
import org.knoe.authority.user.PrincipalNormalizer;
|
||||||
import dev.knoe.auth.user.PrincipalNormalizer;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@ -40,17 +31,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/auth")
|
@RequestMapping("/auth")
|
||||||
public class LoginController {
|
public class LoginController {
|
||||||
private static final Logger log = LoggerFactory.getLogger(LoginController.class);
|
|
||||||
private final AuthProperties auth;
|
private final AuthProperties auth;
|
||||||
private final KerberosProperties kerberos;
|
private final KerberosProperties kerberos;
|
||||||
private final KerberosSpnegoService spnego;
|
private final KerberosSpnegoService spnego;
|
||||||
private final KerberosPasswordService passwordAuth;
|
private final KerberosPasswordService passwordAuth;
|
||||||
private final PrincipalNormalizer normalizer;
|
private final PrincipalNormalizer normalizer;
|
||||||
private final SessionTokenService sessionTokenService;
|
private final SessionTokenService sessionTokenService;
|
||||||
private final GoogleOAuthService googleOAuth;
|
|
||||||
|
|
||||||
@Value("${KNOE_AUTH_BASE_URL:https://api.0.knoe.dev/auth}")
|
|
||||||
private String baseUrl;
|
|
||||||
|
|
||||||
public LoginController(
|
public LoginController(
|
||||||
AuthProperties auth,
|
AuthProperties auth,
|
||||||
@ -58,8 +44,7 @@ public class LoginController {
|
|||||||
KerberosSpnegoService spnego,
|
KerberosSpnegoService spnego,
|
||||||
KerberosPasswordService passwordAuth,
|
KerberosPasswordService passwordAuth,
|
||||||
PrincipalNormalizer normalizer,
|
PrincipalNormalizer normalizer,
|
||||||
SessionTokenService sessionTokenService,
|
SessionTokenService sessionTokenService
|
||||||
GoogleOAuthService googleOAuth
|
|
||||||
) {
|
) {
|
||||||
this.auth = auth;
|
this.auth = auth;
|
||||||
this.kerberos = kerberos;
|
this.kerberos = kerberos;
|
||||||
@ -67,7 +52,6 @@ public class LoginController {
|
|||||||
this.passwordAuth = passwordAuth;
|
this.passwordAuth = passwordAuth;
|
||||||
this.normalizer = normalizer;
|
this.normalizer = normalizer;
|
||||||
this.sessionTokenService = sessionTokenService;
|
this.sessionTokenService = sessionTokenService;
|
||||||
this.googleOAuth = googleOAuth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@ -83,10 +67,6 @@ public class LoginController {
|
|||||||
@GetMapping(value = "/login", produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(value = "/login", produces = MediaType.TEXT_HTML_VALUE)
|
||||||
public ResponseEntity<String> loginPage(@RequestParam(name = "next", required = false) String next) {
|
public ResponseEntity<String> loginPage(@RequestParam(name = "next", required = false) String next) {
|
||||||
String safeNext = safeNext(next).toString();
|
String safeNext = safeNext(next).toString();
|
||||||
// URL-encode for embedding as a query-param value inside hrefs.
|
|
||||||
// escapeHtmlAttr turns & into & 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 = """
|
String html = """
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@ -99,7 +79,7 @@ public class LoginController {
|
|||||||
code { background: #f3f3f3; padding: 2px 4px; }
|
code { background: #f3f3f3; padding: 2px 4px; }
|
||||||
.box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; }
|
.box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; }
|
||||||
.row { margin: 8px 0; }
|
.row { margin: 8px 0; }
|
||||||
input { width: 100%%; padding: 8px; }
|
input { width: 100%; padding: 8px; }
|
||||||
button { padding: 10px 14px; }
|
button { padding: 10px 14px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -111,12 +91,6 @@ public class LoginController {
|
|||||||
<p><a href="/auth/spnego?next=%s"><button>Login with Kerberos</button></a></p>
|
<p><a href="/auth/spnego?next=%s"><button>Login with Kerberos</button></a></p>
|
||||||
</div>
|
</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">
|
<div class="box">
|
||||||
<h2>Fallback form</h2>
|
<h2>Fallback form</h2>
|
||||||
<p>Only available when enabled by configuration.</p>
|
<p>Only available when enabled by configuration.</p>
|
||||||
@ -129,7 +103,7 @@ public class LoginController {
|
|||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""".formatted(safeNextEncoded, auth.getEmailDomain(), safeNextEncoded, escapeHtmlAttr(safeNext));
|
""".formatted(escapeHtmlAttr(safeNext), escapeHtmlAttr(safeNext));
|
||||||
|
|
||||||
return ResponseEntity.ok(html);
|
return ResponseEntity.ok(html);
|
||||||
}
|
}
|
||||||
@ -205,79 +179,6 @@ public class LoginController {
|
|||||||
return ResponseEntity.status(HttpStatus.FOUND).location(safeNext(next)).build();
|
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")
|
@GetMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(@RequestParam(name = "next", required = false) String next, HttpServletResponse response) {
|
public ResponseEntity<Void> logout(@RequestParam(name = "next", required = false) String next, HttpServletResponse response) {
|
||||||
clearCookie(response);
|
clearCookie(response);
|
||||||
@ -337,28 +238,31 @@ public class LoginController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private URI safeNext(String next) {
|
private URI safeNext(String next) {
|
||||||
String apex = apex();
|
URI defaultNext = URI.create("https://svc.knoe.dev/");
|
||||||
URI defaultNext = URI.create("https://svc." + apex + "/");
|
if (next == null || next.isBlank()) {
|
||||||
if (next == null || next.isBlank()) return defaultNext;
|
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;
|
|
||||||
try {
|
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) {
|
} catch (Exception e) {
|
||||||
return defaultNext;
|
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) {
|
private static String escapeHtmlAttr(String v) {
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
return "";
|
return "";
|
||||||
@ -1,13 +1,13 @@
|
|||||||
package dev.knoe.auth.web;
|
package org.knoe.authority.web;
|
||||||
|
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import dev.knoe.auth.config.AuthProperties;
|
import org.knoe.authority.config.AuthProperties;
|
||||||
import dev.knoe.auth.session.SessionTokenService;
|
import org.knoe.authority.session.SessionTokenService;
|
||||||
import dev.knoe.auth.session.SessionUser;
|
import org.knoe.authority.session.SessionUser;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -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:}
|
|
||||||
@ -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}
|
|
||||||
@ -15,55 +15,13 @@ knoe:
|
|||||||
sessionTtl: 8h
|
sessionTtl: 8h
|
||||||
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
|
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
|
||||||
sessionSecret: ""
|
sessionSecret: ""
|
||||||
emailDomain: knoey.com
|
emailDomain: knoe.dev
|
||||||
formEnabled: false
|
formEnabled: false
|
||||||
# Comma-separated list of bare usernames granted admin group membership.
|
# Comma-separated list of bare usernames granted admin group membership.
|
||||||
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
|
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
|
||||||
adminPrincipals: []
|
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:
|
kerberos:
|
||||||
# REQUIRED for SPNEGO when enabled. Provide via env.
|
# REQUIRED for SPNEGO when enabled. Provide via env.
|
||||||
servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:}
|
servicePrincipal: ""
|
||||||
keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:}
|
keytabPath: ""
|
||||||
realm: ${KNOE_KERBEROS_REALM:}
|
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
|
|
||||||
|
|||||||
@ -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";
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package dev.knoe.auth.session;
|
package org.knoe.authority.session;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
package dev.knoe.auth.web;
|
package org.knoe.authority.web;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import dev.knoe.auth.session.SessionTokenService;
|
import org.knoe.authority.session.SessionTokenService;
|
||||||
import dev.knoe.auth.session.SessionUser;
|
import org.knoe.authority.session.SessionUser;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@ -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",
|
"cluster_name": "knoe-db",
|
||||||
"desired_instances": 3,
|
"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": {
|
"metadata": {
|
||||||
"prior_plan_present": true,
|
"prior_plan_present": true,
|
||||||
"reason": "new_cluster",
|
"reason": "reused",
|
||||||
"regenerated": true,
|
"regenerated": false,
|
||||||
"reused": false
|
"reused": true
|
||||||
},
|
},
|
||||||
"plan_hash": "965d2fde4035059f",
|
"plan_hash": "a44825aeb5ed4e13",
|
||||||
"plan_id": "cnpg-placement-965d2fde4035059f",
|
"plan_id": "cnpg-placement-a44825aeb5ed4e13",
|
||||||
"schema_version": "v1"
|
"schema_version": "v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
22
conf/gke.cfg
22
conf/gke.cfg
@ -124,9 +124,9 @@ GITLAB_DOMAIN = git.knoe.dev
|
|||||||
GITLAB_GITALY_STORAGE_CLASS = standard
|
GITLAB_GITALY_STORAGE_CLASS = standard
|
||||||
GITLAB_GLOBAL_STATIC_IP_NAME =
|
GITLAB_GLOBAL_STATIC_IP_NAME =
|
||||||
GITLAB_INGRESS_CLASS = gce
|
GITLAB_INGRESS_CLASS = gce
|
||||||
GITLAB_OIDC_CLIENT_ID = secretref://gitlab-oidc-client-id
|
GITLAB_OIDC_CLIENT_ID = secretref://google-oidc-client-id
|
||||||
GITLAB_OIDC_CLIENT_SECRET = secretref://gitlab-oidc-client-secret
|
GITLAB_OIDC_CLIENT_SECRET = secretref://google-oidc-client-secret
|
||||||
GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth
|
GITLAB_OIDC_ISSUER = https://accounts.google.com
|
||||||
GITLAB_PUBLIC_HOSTS = git.knoe.dev
|
GITLAB_PUBLIC_HOSTS = git.knoe.dev
|
||||||
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
|
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
|
||||||
GITLAB_SHELL_LOADBALANCER_IP = 34.106.243.154
|
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_CPU = 500m
|
||||||
GITLAB_WEBSERVICE_REQUESTS_MEMORY = 2Gi
|
GITLAB_WEBSERVICE_REQUESTS_MEMORY = 2Gi
|
||||||
GITLAB_WEBSERVICE_WORKER_PROCESSES = 1
|
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_HOSTNAME = svc.knoe.dev
|
||||||
GRAFANA_UPSTREAM_URL = http://prometheus-grafana.monitoring.svc.cluster.local:80
|
|
||||||
K3S_SERVER =
|
K3S_SERVER =
|
||||||
K3S_TOKEN =
|
K3S_TOKEN =
|
||||||
KNOE_DB_USER = chrisfu
|
KNOE_DB_USER = chrisfu
|
||||||
KNOE_USER_GITLAB_API_BASE = https://git.knoe.dev/api/v4
|
KNOE_USER_GITLAB_API_BASE = https://git.knoe.dev/api/v4
|
||||||
KNOE_USER_GITLAB_AUTH_PROVIDER = openid_connect
|
KNOE_USER_GITLAB_AUTH_PROVIDER = openid_connect
|
||||||
KNOE_USER_GITLAB_JIT_AUTO_CREATE_USERS = true
|
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_OIDC_REDIRECT_URI = https://git.knoe.dev/users/auth/openid_connect/callback
|
||||||
KNOE_USER_GITLAB_PROVISIONING_READY = true
|
KNOE_USER_GITLAB_PROVISIONING_READY = true
|
||||||
OPENTOFU_URL = http://127.0.0.1:8080
|
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_GLOBAL_STATIC_IP_NAME = supabase-studio
|
||||||
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
||||||
SVC_KNOE_GLOBAL_STATIC_IP_NAME = svc-knoe
|
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]
|
[Welcome]
|
||||||
; No configuration values captured yet for this section.
|
; 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_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_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_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_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/prometheus-grafana;address=0.0.0.0;hostPort=3000;servicePort=80;protocol=TCP;description=Grafana 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)
|
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]
|
[System Environment]
|
||||||
|
|||||||
181
conf/k3d.cfg
181
conf/k3d.cfg
@ -1,21 +1,17 @@
|
|||||||
; Knoe Master Configuration File
|
; 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.
|
; This file is used as input for Ansible deployment and k8s cluster creation.
|
||||||
|
|
||||||
[User]
|
[User]
|
||||||
; User-editable values; derived values below reference these by default.
|
; User-editable values; derived values below reference these by default.
|
||||||
KNOE_CONF = mock_val
|
; No user values captured yet for this section.
|
||||||
KNOE_HOME = mock_val
|
|
||||||
KNOE_SERVICE = mock_val
|
|
||||||
PROLE_DATA = mock_val
|
|
||||||
PROLE_LOGS = mock_val
|
|
||||||
|
|
||||||
[Inputs]
|
[Inputs]
|
||||||
; Screen-scoped inputs used for unattended replays (-S)
|
; Screen-scoped inputs used for unattended replays (-S)
|
||||||
argocd.node_selector =
|
argocd.node_selector =
|
||||||
build.deploy_env = Dev
|
build.deploy_env = Dev
|
||||||
build.run_build = false
|
build.run_build = false
|
||||||
database_options.distribution =
|
database_options.distribution = percona
|
||||||
database_options.ext.dblink = true
|
database_options.ext.dblink = true
|
||||||
database_options.ext.pg_buffercache = true
|
database_options.ext.pg_buffercache = true
|
||||||
database_options.ext.pg_cron = true
|
database_options.ext.pg_cron = true
|
||||||
@ -31,155 +27,173 @@ database_options.ext.pgrowlocks = true
|
|||||||
database_options.ext.pgvector = true
|
database_options.ext.pgvector = true
|
||||||
database_options.ext.postgis = true
|
database_options.ext.postgis = true
|
||||||
database_options.ext.postgres_fdw = 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.auto_install_missing = true
|
||||||
dependencies.brew.install = true
|
dependencies.brew.install = true
|
||||||
dependencies.containerd.install = true
|
|
||||||
dependencies.docker-buildx.install = true
|
|
||||||
dependencies.docker.install = true
|
dependencies.docker.install = true
|
||||||
dependencies.gcloud.install = true
|
dependencies.gcloud.install = true
|
||||||
dependencies.k3d.install = true
|
dependencies.k3d.install = true
|
||||||
dependencies.kubectl.install = true
|
dependencies.kubectl.install = true
|
||||||
dependencies.kubectx.install = true
|
dependencies.kubectx.install = true
|
||||||
dependencies.op.install = true
|
|
||||||
dependencies.opentofu.install = true
|
dependencies.opentofu.install = true
|
||||||
dependencies.python.install = true
|
dependencies.python.install = true
|
||||||
dependencies.verify_all = true
|
dependencies.verify_all = false
|
||||||
disk_selection.disk_type =
|
disk_selection.disk_type = local
|
||||||
disk_selection.local_path =
|
disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist
|
||||||
disk_selection.removable_mount =
|
disk_selection.removable_mount =
|
||||||
env_setup.CLUSTER_NAME =
|
env_setup.CLUSTER_NAME = ${CLUSTER_NAME}
|
||||||
env_setup.DATABASE_NAMESPACE =
|
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
|
||||||
env_setup.KNOE_CONF = /Users/chrisfu/dev/prole/conf
|
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf
|
||||||
env_setup.KNOE_HOME = mock_val
|
env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data
|
||||||
env_setup.KNOE_SERVICE = mock_val/etc
|
env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe
|
||||||
env_setup.PROLE_DATA = mock_val/data
|
env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
|
||||||
env_setup.PROLE_LOGS = mock_val/logs
|
env_setup.KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
|
||||||
gitops.git_provider =
|
gitops.git_provider = Gitea
|
||||||
gitops.node_selector =
|
gitops.node_selector =
|
||||||
init_cluster.argocd_enabled = true
|
init_cluster.argocd_enabled = false
|
||||||
init_cluster.at_rest_encryption_enabled = true
|
init_cluster.at_rest_encryption_enabled = true
|
||||||
init_cluster.cluster_env = dev
|
init_cluster.cluster_env = dev
|
||||||
init_cluster.deployment_target =
|
init_cluster.deployment_target = knoe-dev-cluster
|
||||||
init_cluster.gitops_enabled = true
|
init_cluster.gitops_enabled = true
|
||||||
init_cluster.k3s_server_url =
|
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443
|
||||||
init_cluster.k3s_token =
|
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
init_cluster.kerberos_enabled = true
|
init_cluster.kerberos_enabled = true
|
||||||
init_cluster.mode =
|
init_cluster.mode = k3d
|
||||||
init_cluster.start_cluster = true
|
init_cluster.start_cluster = true
|
||||||
init_cluster.supabase_enabled = true
|
init_cluster.supabase_enabled = false
|
||||||
init_cnpg_deploy.force_rollout = false
|
init_cnpg_deploy.force_rollout = false
|
||||||
init_cnpg_deploy.run_deploy = true
|
init_cnpg_deploy.run_deploy = true
|
||||||
init_db_build.run_build = 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_host_port = 5432
|
||||||
init_password.db_namespace = knoe-system
|
init_password.db_namespace = ${DATABASE_NAMESPACE}
|
||||||
init_password.db_password =
|
init_password.db_password =
|
||||||
init_password.db_password_confirm =
|
init_password.db_password_confirm =
|
||||||
init_password.db_username = knoe-db
|
init_password.db_username = root
|
||||||
init_password.generate_ssh_key = true
|
init_password.generate_ssh_key = true
|
||||||
init_scripts.run_scripts = true
|
init_scripts.run_scripts = true
|
||||||
kerberos_config.enabled = true
|
kerberos_config.enabled = true
|
||||||
kerberos_config.kdc =
|
kerberos_config.kdc = 10.0.0.3
|
||||||
kerberos_config.password =
|
kerberos_config.password =
|
||||||
kerberos_config.realm =
|
kerberos_config.realm = PROLE.ORG
|
||||||
kerberos_config.test_connection = false
|
kerberos_config.test_connection = false
|
||||||
kerberos_config.user =
|
kerberos_config.user = administrator
|
||||||
network_scan.run = true
|
network_scan.run = true
|
||||||
ollama_config.model =
|
ollama_config.model =
|
||||||
ollama_config.server_host =
|
ollama_config.server_host =
|
||||||
ollama_config.server_port =
|
ollama_config.server_port = 11434
|
||||||
supabase_config.pv_base_dir =
|
supabase_config.pv_base_dir = /synology/d005
|
||||||
supabase_config.pv_node =
|
supabase_config.pv_node = gandalf.knoe.org
|
||||||
|
|
||||||
[Global]
|
[Global]
|
||||||
; Variables used by name in more than one place or assumed global scope
|
; 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_ENV = dev
|
||||||
CLUSTER_NAME = mock_val
|
CLUSTER_NAME = knoe-db
|
||||||
DATABASE_NAMESPACE = mock_val
|
CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json
|
||||||
DB_HOST_PORT = mock_val
|
CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f
|
||||||
DB_PASSWORD =
|
CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f
|
||||||
DEPLOYMENT_TARGET =
|
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
|
DOCKER_PRELOAD = false
|
||||||
GITEA_NODE_SELECTOR = mock_val
|
GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.knoe.org
|
||||||
KNOE_DB_USER = knoe-db
|
K3S_SERVER = https://myrddin.knoe.org:6443
|
||||||
KNOE_HOME = mock_val
|
K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==}
|
||||||
KUBECONTEXT = mock_val
|
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
|
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
|
||||||
PROLE_K3S_SERVER = mock_val
|
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443
|
||||||
PROLE_K3S_TOKEN =
|
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
|
PROLE_OPENTOFU_URL = http://127.0.0.1:8080
|
||||||
SERVICE_NAMESPACE = default
|
PROTECTED_DB_HOSTS = db.0.knoe.dev,db.knoe.org
|
||||||
SUPABASE_PV_BASE = mock_val
|
PROTECTED_GIT_HOSTS = git.knoe.dev,git.knoe.org
|
||||||
SUPABASE_PV_BASE_DIR = mock_val
|
REGISTRY_NAMESPACE = knoe-system
|
||||||
SUPABASE_PV_NODE = mock_val
|
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]
|
[Welcome]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Dependencies]
|
[Dependencies]
|
||||||
; No configuration values captured yet for this section.
|
STATUS = All installed
|
||||||
|
|
||||||
[Network]
|
[Network]
|
||||||
AD_DC_HOST = myrddin.prole.org
|
AD_DC_HOST = myrddin.knoe.org
|
||||||
AD_DC_IP = 10.0.0.3
|
AD_DC_IP = 10.0.0.3
|
||||||
ANSIBLE_INFRASTRUCTURE = $HOME/dev/prole/infrastructure
|
ANSIBLE_DOMAIN = knoe.org
|
||||||
ANSIBLE_INVENTORY = $HOME/dev/prole/infrastructure/inventory
|
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
|
||||||
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_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_ANSIBLE_DETECTED = 10.0.0.3
|
||||||
KDC_AUTO_DETECTED =
|
KDC_AUTO_DETECTED = 10.0.0.3
|
||||||
KERBEROS_AUTO_ENABLED = false
|
KERBEROS_AUTO_ENABLED = True
|
||||||
|
|
||||||
[Port Forwards]
|
[Port Forwards]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[System Environment]
|
[System Environment]
|
||||||
KNOE_CONF = mock_val
|
KNOE_CONF = $HOME/dev/knoe/conf
|
||||||
KNOE_HOME = mock_val
|
PROLE_DATA = $HOME/dev/knoe/data
|
||||||
KNOE_SERVICE = mock_val
|
KNOE_HOME = $HOME/dev/knoe
|
||||||
PROLE_DATA = mock_val
|
PROLE_LOGS = $HOME/dev/knoe/logs
|
||||||
PROLE_LOGS = mock_val
|
KNOE_SERVICE = $HOME/dev/knoe/etc
|
||||||
|
|
||||||
[Monitoring]
|
[Monitoring]
|
||||||
; No configuration values captured yet for this section.
|
MONITORING_STORAGE_CLASS = local-path
|
||||||
|
|
||||||
[Kerberos Authentication]
|
[Kerberos Authentication]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Ollama]
|
[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]
|
[Optional Features]
|
||||||
AT_REST_ENCRYPTION_ENABLED = false
|
AT_REST_ENCRYPTION_ENABLED = true
|
||||||
GITOPS_ENABLED = False
|
GITOPS_ENABLED = True
|
||||||
KERBEROS_ENABLED = false
|
GITOPS_PROVIDER = GitLab
|
||||||
|
KERBEROS_ENABLED = true
|
||||||
SUPABASE_ENABLED = false
|
SUPABASE_ENABLED = false
|
||||||
|
|
||||||
[GitOps]
|
[GitOps]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Database Creation]
|
[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]
|
[Initialize Cluster]
|
||||||
ENVIRONMENT = mock_val
|
ENVIRONMENT = dev
|
||||||
K3S_SERVER_URL = mock_val
|
K3S_SERVER_URL = https://myrddin.knoe.org:6443
|
||||||
K3S_TOKEN =
|
K3S_TOKEN = ${KNOE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==}
|
||||||
|
|
||||||
[Dev Cluster (k3d)]
|
[Dev Cluster (k3d)]
|
||||||
CLUSTER_ENV = dev
|
CLUSTER_ENV = dev
|
||||||
DISPLAY_NAME = knoe-dev-cluster
|
DISPLAY_NAME = knoe-dev-cluster
|
||||||
KUBECTL_CONTEXT = mock_val
|
KUBECTL_CONTEXT = dev
|
||||||
MODE = k3d
|
MODE = k3d
|
||||||
|
|
||||||
[Service Cluster (k3s)]
|
[Service Cluster (k3s)]
|
||||||
CLUSTER_ENV = knoe-service-cluster
|
CLUSTER_ENV = knoe-service-cluster
|
||||||
DISPLAY_NAME = knoe-service-cluster
|
DISPLAY_NAME = knoe-service-cluster
|
||||||
K3S_SERVER_URL = mock_val
|
K3S_SERVER_URL = https://myrddin.knoe.org:6443
|
||||||
K3S_TOKEN =
|
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
MODE = k3s
|
MODE = k3s
|
||||||
PIPELINE_URL = http://127.0.0.1:8080
|
|
||||||
|
|
||||||
[GCP]
|
[GCP]
|
||||||
BILLING_ACCOUNT = 01193C-25783B-3211AD
|
BILLING_ACCOUNT = 01193C-25783B-3211AD
|
||||||
@ -188,20 +202,21 @@ ORG_ID = 584001916389
|
|||||||
PROJECT_ID = plenary-truck-485623-p7
|
PROJECT_ID = plenary-truck-485623-p7
|
||||||
|
|
||||||
[Prod Cluster (k8s)]
|
[Prod Cluster (k8s)]
|
||||||
ARTIFACTS_DIR = mock_val
|
ARTIFACTS_DIR = $HOME/dev/knoe/data/staging
|
||||||
CLUSTER_ENV = knoe-prod-cluster
|
CLUSTER_ENV = knoe-prod-cluster
|
||||||
DISPLAY_NAME = knoe-prod-cluster
|
DISPLAY_NAME = knoe-prod-cluster
|
||||||
MODE = k8s
|
MODE = k8s
|
||||||
PIPELINE_URL = http://127.0.0.1:8080
|
|
||||||
|
|
||||||
[Docker Build]
|
[Docker Build]
|
||||||
; No configuration values captured yet for this section.
|
LOCAL_REGISTRY = localhost:5000
|
||||||
|
LOCAL_REGISTRY_INTERNAL = k3d-knoe-registry.localhost:5000
|
||||||
|
|
||||||
[Initialization Scripts]
|
[Initialization Scripts]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Deployment]
|
[Deployment]
|
||||||
TARGET =
|
MODE = k3d
|
||||||
|
TARGET = knoe-dev-cluster
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
STATUS = Finished
|
STATUS = Failed
|
||||||
|
|||||||
244
conf/k3s.cfg
244
conf/k3s.cfg
@ -1,21 +1,17 @@
|
|||||||
; Knoe Master Configuration File
|
; 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.
|
; This file is used as input for Ansible deployment and k8s cluster creation.
|
||||||
|
|
||||||
[User]
|
[User]
|
||||||
; User-editable values; derived values below reference these by default.
|
; User-editable values; derived values below reference these by default.
|
||||||
KNOE_CONF = mock_val
|
; No user values captured yet for this section.
|
||||||
KNOE_HOME = mock_val
|
|
||||||
KNOE_SERVICE = mock_val
|
|
||||||
PROLE_DATA = mock_val
|
|
||||||
PROLE_LOGS = mock_val
|
|
||||||
|
|
||||||
[Inputs]
|
[Inputs]
|
||||||
; Screen-scoped inputs used for unattended replays (-S)
|
; Screen-scoped inputs used for unattended replays (-S)
|
||||||
argocd.node_selector = knoe-service-cluster
|
argocd.node_selector =
|
||||||
build.deploy_env = Dev
|
build.deploy_env = Dev
|
||||||
build.run_build = false
|
build.run_build = false
|
||||||
database_options.distribution = knoe-service-cluster
|
database_options.distribution = percona
|
||||||
database_options.ext.dblink = true
|
database_options.ext.dblink = true
|
||||||
database_options.ext.pg_buffercache = true
|
database_options.ext.pg_buffercache = true
|
||||||
database_options.ext.pg_cron = true
|
database_options.ext.pg_cron = true
|
||||||
@ -31,118 +27,182 @@ database_options.ext.pgrowlocks = true
|
|||||||
database_options.ext.pgvector = true
|
database_options.ext.pgvector = true
|
||||||
database_options.ext.postgis = true
|
database_options.ext.postgis = true
|
||||||
database_options.ext.postgres_fdw = 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.auto_install_missing = true
|
||||||
dependencies.brew.install = true
|
dependencies.brew.install = true
|
||||||
dependencies.containerd.install = true
|
|
||||||
dependencies.docker-buildx.install = true
|
|
||||||
dependencies.docker.install = true
|
dependencies.docker.install = true
|
||||||
dependencies.gcloud.install = true
|
dependencies.gcloud.install = true
|
||||||
dependencies.k3d.install = true
|
dependencies.k3d.install = true
|
||||||
dependencies.kubectl.install = true
|
dependencies.kubectl.install = true
|
||||||
dependencies.kubectx.install = true
|
dependencies.kubectx.install = true
|
||||||
dependencies.op.install = true
|
|
||||||
dependencies.opentofu.install = true
|
dependencies.opentofu.install = true
|
||||||
dependencies.python.install = true
|
dependencies.python.install = true
|
||||||
dependencies.verify_all = true
|
dependencies.verify_all = false
|
||||||
disk_selection.disk_type = knoe-service-cluster
|
disk_selection.disk_type = local
|
||||||
disk_selection.local_path = knoe-service-cluster
|
disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist
|
||||||
disk_selection.removable_mount = knoe-service-cluster
|
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.CLUSTER_NAME = ${CLUSTER_NAME}
|
||||||
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
|
env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE}
|
||||||
env_setup.KNOE_CONF = /Users/chrisfu/dev/prole/conf
|
env_setup.DB_CLUSTER_KUBECONTEXT =
|
||||||
env_setup.KNOE_HOME = mock_val
|
env_setup.DB_CLUSTER_MODE = standard
|
||||||
env_setup.KNOE_SERVICE = mock_val/etc
|
env_setup.DB_CLUSTER_NAME = knoe-cnpg-0
|
||||||
env_setup.PROLE_DATA = mock_val/data
|
env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf
|
||||||
env_setup.PROLE_LOGS = mock_val/logs
|
env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data
|
||||||
gitops.git_provider = knoe-service-cluster
|
env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe
|
||||||
gitops.node_selector = knoe-service-cluster
|
env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
|
||||||
init_cluster.argocd_enabled = true
|
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.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.deployment_target = knoe-service-cluster
|
||||||
init_cluster.gitops_enabled = true
|
init_cluster.gitops_enabled = true
|
||||||
init_cluster.k3s_server_url = knoe-service-cluster
|
init_cluster.k3s_server_url = https://myrddin.knoe.org:6443
|
||||||
init_cluster.k3s_token = ${KNOE_SECRET:v1:78Q345eo8NLP-0eB:zmZSit0eedhrDsp0TOpeeGKJTCYZhKW3Ypr3UaLDbL942Lbt}
|
init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
init_cluster.kerberos_enabled = true
|
init_cluster.kerberos_enabled = true
|
||||||
init_cluster.mode = k3s
|
init_cluster.mode = k3s
|
||||||
init_cluster.start_cluster = true
|
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.force_rollout = false
|
||||||
init_cnpg_deploy.run_deploy = true
|
init_cnpg_deploy.run_deploy = true
|
||||||
init_db_build.run_build = true
|
init_db_build.run_build = true
|
||||||
|
init_password.app_cluster_name = knoe-dev-0
|
||||||
init_password.cluster_name = ${CLUSTER_NAME}
|
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_namespace = ${DATABASE_NAMESPACE}
|
||||||
init_password.db_password = ${KNOE_SECRET:v1:IUf_1zjEhFT6AOzB:XKjfqr9oCDReIdU3W-oH--g6eQTl-D9X58wq6pFXbmovahzz}
|
init_password.db_password = ${KNOE_SECRET:v1:CsdBnE_l6fe36We6:QO1ZEnnsfovmzFHFfEtPwvi2FPKzLxu0pmg0K3X3DSTIt-Fx}
|
||||||
init_password.db_password_confirm = ${KNOE_SECRET:v1:IUf_1zjEhFT6AOzB:XKjfqr9oCDReIdU3W-oH--g6eQTl-D9X58wq6pFXbmovahzz}
|
init_password.db_password_confirm = ${KNOE_SECRET:v1:CsdBnE_l6fe36We6:QO1ZEnnsfovmzFHFfEtPwvi2FPKzLxu0pmg0K3X3DSTIt-Fx}
|
||||||
init_password.db_username = knoe-service-cluster
|
init_password.db_username = root
|
||||||
init_password.generate_ssh_key = true
|
init_password.generate_ssh_key = true
|
||||||
init_scripts.run_scripts = true
|
init_scripts.run_scripts = true
|
||||||
kerberos_config.enabled = true
|
kerberos_config.enabled = true
|
||||||
kerberos_config.kdc = knoe-service-cluster
|
kerberos_config.init_authority = true
|
||||||
kerberos_config.password = ${KNOE_SECRET:v1:X9uYEH0PrmGsWeV2:-K_WV52_930vuAcPEHevsLIokTXN7sstc8t_gGK5rT_YAbd8}
|
kerberos_config.kdc = 10.0.0.3
|
||||||
kerberos_config.realm = knoe-service-cluster
|
kerberos_config.password =
|
||||||
|
kerberos_config.realm = PROLE.ORG
|
||||||
kerberos_config.test_connection = false
|
kerberos_config.test_connection = false
|
||||||
kerberos_config.user = knoe-service-cluster
|
kerberos_config.user = administrator
|
||||||
network_scan.run = true
|
network_scan.run = true
|
||||||
ollama_config.model = knoe-service-cluster
|
ollama_config.model =
|
||||||
ollama_config.server_host = knoe-service-cluster
|
ollama_config.server_host =
|
||||||
ollama_config.server_port = knoe-service-cluster
|
ollama_config.server_port = 11434
|
||||||
supabase_config.pv_base_dir = knoe-service-cluster
|
supabase_config.pv_base_dir = /synology/d005
|
||||||
supabase_config.pv_node = knoe-service-cluster
|
supabase_config.pv_node = gandalf.knoe.org
|
||||||
|
|
||||||
[Global]
|
[Global]
|
||||||
; Variables used by name in more than one place or assumed global scope
|
; Variables used by name in more than one place or assumed global scope
|
||||||
ARGOCD_NODE_SELECTOR = knoe-service-cluster
|
ARGOCD_NAMESPACE = argocd
|
||||||
CLUSTER_ENV = knoe-service-cluster
|
ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system
|
||||||
CLUSTER_NAME = knoe-service-cluster
|
AUTHORITY_ENABLED = true
|
||||||
DATABASE_NAMESPACE = knoe-service-cluster
|
AUTH_HOSTNAME = api.knoe.org
|
||||||
DB_HOST_PORT = knoe-service-cluster
|
AUTH_LOGIN_PATH = /auth/login
|
||||||
DB_PASSWORD = ${KNOE_SECRET:v1:hZgLhrzDo1mWQbyV:IDy89i7N8kOivFmMesHzLqJBth3I0-WtLC8lNmoc1yMFx5uS}
|
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_MODE = k3s
|
||||||
DEPLOYMENT_TARGET = knoe-service-cluster
|
DEPLOYMENT_TARGET = knoe-service-cluster
|
||||||
|
DOCKER_IMPORT_DIR =
|
||||||
DOCKER_PRELOAD = false
|
DOCKER_PRELOAD = false
|
||||||
GITEA_NODE_SELECTOR = knoe-service-cluster
|
GITEA_HOSTNAME = git-internal.knoe.org
|
||||||
KNOE_DB_USER = knoe-service-cluster
|
GITLAB_PUBLIC_HOSTS = git.knoe.org
|
||||||
KNOE_HOME = mock_val
|
GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1
|
||||||
KUBECONTEXT = knoe-service-cluster
|
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
|
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2
|
||||||
PROLE_K3S_SERVER = knoe-service-cluster
|
KNOE_HOME = $HOME/dev/knoe
|
||||||
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:GDtMiPCotXoKy84s:fuE9UNyI-lXrIYAQqS3JjJXpImTg9TKSvbVmfTzgLimZV4nR}
|
PROLE_K3S_SERVER = https://myrddin.knoe.org:6443
|
||||||
SERVICE_NAMESPACE = knoe-service-cluster
|
PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
SUPABASE_PV_BASE = knoe-service-cluster
|
PROTECTED_DB_HOSTS = db.knoe.org
|
||||||
SUPABASE_PV_BASE_DIR = knoe-service-cluster
|
PROTECTED_GIT_HOSTS = git.knoe.org
|
||||||
SUPABASE_PV_NODE = knoe-service-cluster
|
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]
|
[Welcome]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Dependencies]
|
[Dependencies]
|
||||||
; No configuration values captured yet for this section.
|
STATUS = All installed
|
||||||
|
|
||||||
[Network]
|
[Network]
|
||||||
AD_DC_HOST = myrddin.prole.org
|
AD_DC_HOST = myrddin.knoe.org
|
||||||
AD_DC_IP = 10.0.0.3
|
AD_DC_IP = 10.0.0.3
|
||||||
ANSIBLE_INFRASTRUCTURE = $HOME/dev/prole/infrastructure
|
ANSIBLE_DOMAIN = knoe.org
|
||||||
ANSIBLE_INVENTORY = $HOME/dev/prole/infrastructure/inventory
|
ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure
|
||||||
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_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_ANSIBLE_DETECTED = 10.0.0.3
|
||||||
KDC_AUTO_DETECTED =
|
KDC_AUTO_DETECTED = 10.0.0.3
|
||||||
KERBEROS_AUTO_ENABLED = false
|
KERBEROS_AUTO_ENABLED = True
|
||||||
|
|
||||||
[Port Forwards]
|
[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]
|
[System Environment]
|
||||||
KNOE_CONF = mock_val
|
KNOE_CONF = $HOME/dev/knoe/conf
|
||||||
KNOE_HOME = mock_val
|
PROLE_DATA = $HOME/dev/knoe/data
|
||||||
KNOE_SERVICE = mock_val
|
KNOE_HOME = $HOME/dev/knoe
|
||||||
PROLE_DATA = mock_val
|
PROLE_LOGS = $HOME/dev/knoe/logs
|
||||||
PROLE_LOGS = mock_val
|
KNOE_SERVICE = $HOME/dev/knoe/etc
|
||||||
|
|
||||||
[Monitoring]
|
[Monitoring]
|
||||||
; No configuration values captured yet for this section.
|
MONITORING_STORAGE_CLASS = local-path
|
||||||
|
|
||||||
[Kerberos Authentication]
|
[Kerberos Authentication]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
@ -151,46 +211,48 @@ PROLE_LOGS = mock_val
|
|||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Optional Features]
|
[Optional Features]
|
||||||
AT_REST_ENCRYPTION_ENABLED = false
|
AT_REST_ENCRYPTION_ENABLED = true
|
||||||
GITOPS_ENABLED = False
|
GITOPS_ENABLED = true
|
||||||
KERBEROS_ENABLED = false
|
GITOPS_PROVIDER = GitLab
|
||||||
|
KERBEROS_ENABLED = true
|
||||||
SUPABASE_ENABLED = false
|
SUPABASE_ENABLED = false
|
||||||
|
|
||||||
[GitOps]
|
[GitOps]
|
||||||
; No configuration values captured yet for this section.
|
|
||||||
|
|
||||||
[Database Creation]
|
[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]
|
[Initialize Cluster]
|
||||||
ENVIRONMENT = mock_val
|
ENVIRONMENT = service
|
||||||
K3S_SERVER_URL = mock_val
|
K3S_SERVER_URL = https://myrddin.knoe.org:6443
|
||||||
K3S_TOKEN =
|
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
|
|
||||||
[Dev Cluster (k3d)]
|
[Dev Cluster (k3d)]
|
||||||
CLUSTER_ENV = dev
|
CLUSTER_ENV = k3d-knoe-dev-cluster
|
||||||
DISPLAY_NAME = knoe-dev-cluster
|
DISPLAY_NAME = knoe-dev-cluster
|
||||||
KUBECTL_CONTEXT = knoe-service-cluster
|
KUBECTL_CONTEXT = service
|
||||||
MODE = k3d
|
MODE = k3d
|
||||||
|
|
||||||
[Service Cluster (k3s)]
|
[Service Cluster (k3s)]
|
||||||
CLUSTER_ENV = knoe-service-cluster
|
CLUSTER_ENV = knoe-service-cluster
|
||||||
DISPLAY_NAME = knoe-service-cluster
|
DISPLAY_NAME = knoe-service-cluster
|
||||||
K3S_SERVER_URL = knoe-service-cluster
|
K3S_SERVER_URL = https://myrddin.knoe.org:6443
|
||||||
K3S_TOKEN = ${KNOE_SECRET:v1:ugYbCQ3VG5jU52u2:C1ZYWLqL-0aDalk3vSup6bNzTmWfr_2rHlOf1vqjI45cCX2V}
|
K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==}
|
||||||
|
KUBECTL_CONTEXT = knoe-service-cluster
|
||||||
MODE = k3s
|
MODE = k3s
|
||||||
|
PIPELINE_URL = http://127.0.0.1:8080
|
||||||
|
|
||||||
[GCP]
|
[GCP]
|
||||||
BILLING_ACCOUNT = 01193C-25783B-3211AD
|
; No configuration values captured yet for this section.
|
||||||
BILLING_PROJECT = plenary-truck-485623-p7
|
|
||||||
ORG_ID = 584001916389
|
|
||||||
PROJECT_ID = plenary-truck-485623-p7
|
|
||||||
|
|
||||||
[Prod Cluster (k8s)]
|
[Prod Cluster (k8s)]
|
||||||
ARTIFACTS_DIR = knoe-service-cluster
|
ARTIFACTS_DIR =
|
||||||
CLUSTER_ENV = knoe-prod-cluster
|
CLUSTER_ENV = knoe-prod-cluster
|
||||||
DISPLAY_NAME = knoe-prod-cluster
|
DISPLAY_NAME = knoe-prod-cluster
|
||||||
MODE = k8s
|
MODE = k8s
|
||||||
|
PIPELINE_URL = http://127.0.0.1:8080
|
||||||
|
|
||||||
[Docker Build]
|
[Docker Build]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
@ -199,8 +261,8 @@ MODE = k8s
|
|||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|
||||||
[Deployment]
|
[Deployment]
|
||||||
MODE = k3s
|
MODE = k3d
|
||||||
TARGET =
|
TARGET = knoe-dev-cluster
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
STATUS = Finished
|
STATUS = Failed
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
/Users/chrisfu/dev/prole/conf/dev/knoe.cfg
|
|
||||||
@ -1,11 +1,18 @@
|
|||||||
# Port mappings for Knoe Tools (generated).
|
# Port mappings for Knoe Tools (generated).
|
||||||
# Format: key: local=... remote=... ns=... svc=... address=...
|
# 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
|
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
|
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
|
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
|
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
|
dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1
|
||||||
prometheus: local=9090 remote=9090 ns=monitoring svc=prometheus-kube-prometheus-stack-prometheus address=127.0.0.1
|
postgres: local=5432 remote=5432 ns=knoe-db-0 svc=knoe-db-rw address=0.0.0.0
|
||||||
grafana: local=3000 remote=80 ns=monitoring svc=prometheus-grafana address=0.0.0.0
|
prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1
|
||||||
gitea-http: local=13000 remote=3000 ns=gitea svc=gitea-http address=0.0.0.0
|
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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -79,12 +79,11 @@ init_password.db_password_confirm= vzx.wC4Akd4x-Dj6Wguh
|
|||||||
init_password.db_username = chrisfu
|
init_password.db_username = chrisfu
|
||||||
init_password.generate_ssh_key = true
|
init_password.generate_ssh_key = true
|
||||||
init_scripts.run_scripts = true
|
init_scripts.run_scripts = true
|
||||||
kerberos_config.enabled = true
|
kerberos_config.enabled = False
|
||||||
kerberos_config.init_authority = false
|
kerberos_config.init_authority = false
|
||||||
kerberos_config.kdc = 10.0.0.3
|
kerberos_config.kdc = 10.0.0.205
|
||||||
kerberos_config.admin_server = 10.0.0.3
|
|
||||||
kerberos_config.password =
|
kerberos_config.password =
|
||||||
kerberos_config.realm = PROLE.ORG
|
kerberos_config.realm =
|
||||||
kerberos_config.test_connection = false
|
kerberos_config.test_connection = false
|
||||||
kerberos_config.user = administrator
|
kerberos_config.user = administrator
|
||||||
network_scan.run = true
|
network_scan.run = true
|
||||||
@ -117,22 +116,6 @@ KNOE_HOME = /Users/chrisfu/dev/knoe
|
|||||||
REGISTRY_NAMESPACE = knoe-system
|
REGISTRY_NAMESPACE = knoe-system
|
||||||
SERVICE_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]
|
[Welcome]
|
||||||
; No configuration values captured yet for this section.
|
; 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)
|
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]
|
[System Environment]
|
||||||
KNOE_CONF = /home/chrisfu/dev/prole/conf
|
KNOE_CONF = /Users/chrisfu/dev/knoe/conf
|
||||||
PROLE_DATA = /home/chrisfu/dev/prole/data
|
PROLE_DATA = /Users/chrisfu/dev/knoe/data
|
||||||
KNOE_HOME = /home/chrisfu/dev/prole
|
KNOE_HOME = /Users/chrisfu/dev/knoe
|
||||||
PROLE_LOGS = /home/chrisfu/dev/prole/logs
|
PROLE_LOGS = /Users/chrisfu/dev/knoe/logs
|
||||||
KNOE_SERVICE = /home/chrisfu/dev/prole/etc
|
KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc
|
||||||
|
|
||||||
[Monitoring]
|
[Monitoring]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|||||||
@ -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.'
|
|
||||||
@ -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: 3–6 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;
|
|
||||||
@ -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;
|
|
||||||
@ -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.'
|
|
||||||
@ -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';
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
@ -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
|
|
||||||
16
deploy.sh
16
deploy.sh
@ -4,16 +4,6 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
CONFIG_PATH="${PROLE_DEPLOY_CFG:-${ROOT_DIR}/conf/gke.cfg}"
|
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
|
if [[ -x "${ROOT_DIR}/.venv/bin/python3" ]]; then
|
||||||
PYTHON_BIN="${ROOT_DIR}/.venv/bin/python3"
|
PYTHON_BIN="${ROOT_DIR}/.venv/bin/python3"
|
||||||
elif [[ -x "${ROOT_DIR}/bin/python3" ]]; then
|
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_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")
|
gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname")
|
||||||
if not gitlab_domain:
|
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()]
|
gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()]
|
||||||
if not gitlab_public_hosts:
|
if not gitlab_public_hosts:
|
||||||
gitlab_public_hosts = [gitlab_domain]
|
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")
|
auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname")
|
||||||
if not auth_host:
|
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")
|
service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname")
|
||||||
if not service_host:
|
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"
|
service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -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}"
|
|
||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -9,3 +9,19 @@ metadata:
|
|||||||
annotations:
|
annotations:
|
||||||
# Set by etc/init_cnpg_gke.sh — substituted at deploy time
|
# 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"
|
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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -4,11 +4,6 @@ metadata:
|
|||||||
name: knoe-db
|
name: knoe-db
|
||||||
namespace: knoe-db-0
|
namespace: knoe-db-0
|
||||||
spec:
|
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
|
instances: 3
|
||||||
enablePDB: false
|
enablePDB: false
|
||||||
# Image pulled from GCP Artifact Registry — set ARTIFACT_REGISTRY in conf/prod/gcp.cfg
|
# Image pulled from GCP Artifact Registry — set ARTIFACT_REGISTRY in conf/prod/gcp.cfg
|
||||||
@ -45,36 +40,12 @@ spec:
|
|||||||
- pg_stat_statements
|
- pg_stat_statements
|
||||||
- pg_tde
|
- pg_tde
|
||||||
pg_hba:
|
pg_hba:
|
||||||
# Local Unix-socket connections (CNPG default + knoe role)
|
|
||||||
- local all postgres trust
|
- local all postgres trust
|
||||||
- local all knoe scram-sha-256
|
- local all knoe scram-sha-256
|
||||||
|
- host all postgres all scram-sha-256
|
||||||
# postgres / knoe-db / knoe roles: cluster-internal (RFC1918) only.
|
- host knoe knoe-db all scram-sha-256
|
||||||
# Cluster pod CIDRs: db cluster 10.24.0.0/14, app cluster 10.84.0.0/14;
|
- host all all all scram-sha-256
|
||||||
# node subnet 10.180.0.0/16. 10.0.0.0/8 covers all of those.
|
- hostssl knoe knoe-db all scram-sha-256
|
||||||
- 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
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
initdb:
|
initdb:
|
||||||
@ -85,14 +56,7 @@ spec:
|
|||||||
secret:
|
secret:
|
||||||
name: knoe-db-user
|
name: knoe-db-user
|
||||||
postInitTemplateSQL:
|
postInitTemplateSQL:
|
||||||
# Supabase convention: relocatable extensions live in `extensions`, not
|
- CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||||
# `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;
|
|
||||||
postInitSQL:
|
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$ 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$;
|
- 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 TO knoe;
|
||||||
- GRANT SELECT, INSERT, UPDATE ON knoe.user_role TO knoe;
|
- GRANT SELECT, INSERT, UPDATE ON knoe.user_role TO knoe;
|
||||||
- GRANT USAGE, SELECT ON SEQUENCE knoe.user_id_seq 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:
|
managed:
|
||||||
roles:
|
roles:
|
||||||
@ -163,34 +119,13 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: "100m"
|
cpu: "100m"
|
||||||
# 512Mi (was 128Mi) — postgres baseline working set is ~290Mi on the
|
memory: "128Mi"
|
||||||
# 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"
|
|
||||||
limits:
|
limits:
|
||||||
cpu: "500m"
|
cpu: "500m"
|
||||||
# 2Gi (was 512Mi) — barman-cloud-backup is single-threaded gzip + GCS
|
memory: "512Mi"
|
||||||
# upload buffering and the throughput tops out at the memory ceiling.
|
|
||||||
# 2Gi cuts a 9 GB DB backup from 30–90 min down to 5–10 min.
|
|
||||||
memory: "2Gi"
|
|
||||||
|
|
||||||
enableSuperuserAccess: true
|
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:
|
storage:
|
||||||
size: 50Gi
|
size: 50Gi
|
||||||
pvcTemplate:
|
pvcTemplate:
|
||||||
@ -212,7 +147,4 @@ spec:
|
|||||||
storageClassName: premium-rwo # pd-ssd; restore to premium-rwo after quota increase (matches PGDATA above)
|
storageClassName: premium-rwo # pd-ssd; restore to premium-rwo after quota increase (matches PGDATA above)
|
||||||
|
|
||||||
monitoring:
|
monitoring:
|
||||||
# enablePodMonitor and podMonitorRelabelings removed — both fields are
|
enablePodMonitor: true # kube-prometheus-stack (Prometheus Operator) is installed before CNPG — PodMonitor CRD is present
|
||||||
# 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).
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -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: {}
|
|
||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -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}"
|
|
||||||
@ -16,7 +16,7 @@ spec:
|
|||||||
app: garage
|
app: garage
|
||||||
spec:
|
spec:
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
kubernetes.io/hostname: myrddin.prole.org
|
kubernetes.io/hostname: myrddin.knoe.org
|
||||||
containers:
|
containers:
|
||||||
- name: garage
|
- name: garage
|
||||||
image: dxflrs/garage:v1.3.1
|
image: dxflrs/garage:v1.3.1
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: knoe-grafana-proxy-nginx
|
name: knoe-grafana-proxy-nginx
|
||||||
namespace: monitoring
|
|
||||||
data:
|
data:
|
||||||
nginx.conf: |
|
nginx.conf: |
|
||||||
worker_processes 1;
|
worker_processes 1;
|
||||||
@ -17,20 +16,43 @@ data:
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Strip any inbound auth headers — Grafana auth.google handles
|
# Never trust inbound auth headers from clients.
|
||||||
# Google users directly. Kerberos auth_request (X-WEBAUTH-USER
|
|
||||||
# injection) re-enabled once knoe-auth /auth/verify is ready.
|
|
||||||
proxy_set_header X-WEBAUTH-USER "";
|
proxy_set_header X-WEBAUTH-USER "";
|
||||||
proxy_set_header X-Knoe-Groups "";
|
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 / {
|
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 Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ apiVersion: apps/v1
|
|||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: knoe-grafana-proxy
|
name: knoe-grafana-proxy
|
||||||
namespace: monitoring
|
|
||||||
labels:
|
labels:
|
||||||
app: knoe-grafana-proxy
|
app: knoe-grafana-proxy
|
||||||
spec:
|
spec:
|
||||||
|
|||||||
@ -2,7 +2,6 @@ apiVersion: v1
|
|||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: knoe-grafana-proxy
|
name: knoe-grafana-proxy
|
||||||
namespace: monitoring
|
|
||||||
labels:
|
labels:
|
||||||
app: knoe-grafana-proxy
|
app: knoe-grafana-proxy
|
||||||
spec:
|
spec:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user