#!/usr/bin/env bash # init_knoe_auth.sh # Provision knoe-auth (Kerberos KDC + Spring Boot enrollment service). # # Usage: # ./etc/init_knoe_auth.sh [--context KUBECONTEXT] [--namespace NAMESPACE] [--project PROJECT_ID] [--mode MODE] # ./etc/init_knoe_auth.sh initialize # full setup # ./etc/init_knoe_auth.sh schema # schema only (idempotent) # ./etc/init_knoe_auth.sh invite EMAIL # create first admin invite # ./etc/init_knoe_auth.sh status # check pod + principal state # # Modes: # (default / gke) GKE deploy — uses 1Password for secrets, GCP Workload Identity # k3d Local k3d dev loop — uses hardcoded dev passwords, skips GCP steps # # Prerequisites: # kubectl, op (1Password CLI — GKE mode only), psql (or kubectl exec fallback) # # Env vars (override args): # APP_CLUSTER_KUBECONTEXT, KNOE_NAMESPACE, GCP_PROJECT_ID, # KNOE_DB_HOST, KNOE_DB_PORT, KNOE_DB_NAME, KNOE_DB_SUPERUSER, # KNOE_MODE (set to 'k3d' as alternative to --mode k3d) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" GKE_DIR="$REPO_ROOT/deploy/gcp/gke" # ── Defaults ──────────────────────────────────────────────────────────────── MODE="${KNOE_MODE:-gke}" # gke (default) or k3d APP_CTX="${APP_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-dev-0}" NAMESPACE="${KNOE_NAMESPACE:-knoe-system}" GCP_PROJECT="${GCP_PROJECT_ID:-plenary-truck-485623-p7}" DB_HOST="${KNOE_DB_HOST:-}" # resolved from CNPG svc if blank DB_PORT="${KNOE_DB_PORT:-5432}" DB_NAME="${KNOE_DB_NAME:-knoe}" DB_SUPERUSER="${KNOE_DB_SUPERUSER:-postgres}" REALM="KNOE.DEV" AUTH_HOST="${KNOE_AUTH_HOST:-https://auth.knoe.dev}" # k3d-specific defaults (overridden when --mode k3d is active) K3D_CLUSTER_NAME="${K3D_CLUSTER_NAME:-k3d-knoe}" K3D_CTX="k3d-${K3D_CLUSTER_NAME}" K3D_DB_NS="${K3D_DB_NS:-knoe-db-0}" # Dev-only passwords — NOT used in GKE mode; safe to commit K3D_KDC_MASTER_PASSWORD="${K3D_KDC_MASTER_PASSWORD:-knoe-local-master-dev}" K3D_KDC_ADMIN_PASSWORD="${K3D_KDC_ADMIN_PASSWORD:-knoe-local-admin-dev}" log() { echo "[init_knoe_auth] $*"; } info() { log "INFO $*"; } warn() { log "WARN $*" >&2; } die() { log "ERROR $*" >&2; exit 1; } kube() { kubectl --context="$APP_CTX" "$@"; } # ── Argument parsing ───────────────────────────────────────────────────────── COMMAND="${1:-initialize}" shift || true while [[ $# -gt 0 ]]; do case "$1" in --context) APP_CTX="$2"; shift 2 ;; --namespace) NAMESPACE="$2"; shift 2 ;; --project) GCP_PROJECT="$2"; shift 2 ;; --db-host) DB_HOST="$2"; shift 2 ;; --mode) MODE="$2"; shift 2 ;; *) break ;; esac done # Apply k3d mode overrides after argument parsing if [[ "$MODE" == "k3d" ]]; then APP_CTX="${APP_CLUSTER_KUBECONTEXT:-${K3D_CTX}}" REALM="KNOE.LOCAL" AUTH_HOST="${KNOE_AUTH_HOST:-http://localhost:8080}" # DB is accessed via port-forward (localhost:5432) in k3d mode DB_HOST="${KNOE_DB_HOST:-localhost}" fi # ── Helpers ────────────────────────────────────────────────────────────────── require_tool() { command -v "$1" >/dev/null 2>&1 || die "Required tool not found: $1 — install it and retry." } wait_for_pods() { local label="$1" local timeout="${2:-180}" info "Waiting up to ${timeout}s for pods with label ${label} in ${NAMESPACE}..." kube -n "$NAMESPACE" wait pod \ -l "$label" \ --for=condition=Ready \ --timeout="${timeout}s" } op_secret() { # Retrieve a 1Password secret; fall back to prompting if op isn't authed. local item="$1" field="${2:-password}" if command -v op >/dev/null 2>&1; then op item get "$item" --fields "$field" 2>/dev/null || { warn "1Password: could not read $item/$field — prompting." read -rsp "Enter value for $item/$field: " val; echo printf '%s' "$val" } else read -rsp "Enter value for $item/$field: " val; echo printf '%s' "$val" fi } resolve_db_host() { if [[ -n "$DB_HOST" ]]; then return; fi if [[ "$MODE" == "k3d" ]]; then # In k3d mode the engineer runs make k3d-knoe-pf first; DB is at localhost:5432 DB_HOST="localhost" info "k3d mode: using DB host localhost (port-forward expected on :5432)" return fi # Try to resolve CNPG primary service from the DB cluster context DB_CTX="${DB_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0}" DB_HOST=$(kubectl --context="$DB_CTX" -n knoe-db-0 \ get svc knoe-db-rw -o jsonpath='{.spec.clusterIP}' 2>/dev/null || echo "") [[ -z "$DB_HOST" ]] && die "Cannot resolve KNOE DB host. Set KNOE_DB_HOST or ensure knoe-db-rw svc exists." info "Resolved DB host: $DB_HOST" } psql_file() { local file="$1" if [[ "$MODE" == "k3d" ]]; then # In k3d mode: exec into the CNPG primary pod directly (no port-forward needed for schema) local pod pod=$(kubectl --context="$APP_CTX" -n "$K3D_DB_NS" \ get pod -l cnpg.io/cluster=knoe-db,role=primary \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) [[ -z "$pod" ]] && die "Cannot find CNPG primary pod in k3d. Is 'make k3d-knoe-up' complete?" kubectl --context="$APP_CTX" -n "$K3D_DB_NS" cp "$file" "${pod}:/tmp/knoe_auth_schema.sql" kubectl --context="$APP_CTX" -n "$K3D_DB_NS" exec "$pod" -- \ psql -U "$DB_SUPERUSER" -d "$DB_NAME" -f /tmp/knoe_auth_schema.sql return fi if command -v psql >/dev/null 2>&1 && [[ -n "${PGPASSWORD:-}" ]]; then psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_SUPERUSER" -d "$DB_NAME" -f "$file" else # Fallback: exec into CNPG primary pod DB_CTX="${DB_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0}" local pod pod=$(kubectl --context="$DB_CTX" -n knoe-db-0 \ get pod -l cnpg.io/cluster=knoe-db,role=primary \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) [[ -z "$pod" ]] && die "Cannot find CNPG primary pod. Set PGPASSWORD and KNOE_DB_HOST for direct psql." kubectl --context="$DB_CTX" -n knoe-db-0 cp "$file" "${pod}:/tmp/knoe_auth_schema.sql" kubectl --context="$DB_CTX" -n knoe-db-0 exec "$pod" -- \ psql -U "$DB_SUPERUSER" -d "$DB_NAME" -f /tmp/knoe_auth_schema.sql fi } # ── Schema ─────────────────────────────────────────────────────────────────── run_schema() { info "Applying knoe-auth schema additions..." resolve_db_host local tmpfile tmpfile=$(mktemp /tmp/knoe_auth_schema_XXXX.sql) cat > "$tmpfile" <<'ENDSQL' -- ── knoe-auth Round 1 schema additions ─────────────────────────────────────── -- Idempotent: all CREATE TABLE ... IF NOT EXISTS -- Invite tokens (admin creates, single-use) -- contact is the email/phone the invite was sent to — the OTP trust anchor. -- knoe.dev starts with ZERO pre-knowledge of the developer's home org. CREATE TABLE IF NOT EXISTS knoe.invitation ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), token TEXT NOT NULL UNIQUE, contact TEXT NOT NULL, contact_type TEXT NOT NULL DEFAULT 'email', name_hint TEXT, otp_hash TEXT NOT NULL, otp_expires_at TIMESTAMPTZ NOT NULL, otp_attempts INT NOT NULL DEFAULT 0, otp_verified_at TIMESTAMPTZ, created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, used_by TEXT ); CREATE INDEX IF NOT EXISTS idx_invitation_token ON knoe.invitation(token); CREATE INDEX IF NOT EXISTS idx_invitation_contact ON knoe.invitation(contact); -- External identity corroborations (Google sub → knoe user) -- provider_hd records the developer's home domain (prole.org, gmail.com, etc.) -- for audit purposes only — it is NOT used for access control. CREATE TABLE IF NOT EXISTS knoe.identity ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES knoe.user(id) ON DELETE CASCADE, provider TEXT NOT NULL, provider_sub TEXT NOT NULL, provider_email TEXT, provider_hd TEXT, verified_at TIMESTAMPTZ NOT NULL, UNIQUE(provider, provider_sub) ); CREATE INDEX IF NOT EXISTS idx_identity_user ON knoe.identity(user_id); -- TOTP 2FA credentials (encrypted secret, backup codes) CREATE TABLE IF NOT EXISTS knoe.totp_credential ( user_id INT PRIMARY KEY REFERENCES knoe.user(id) ON DELETE CASCADE, secret TEXT NOT NULL, verified_at TIMESTAMPTZ, backup_codes TEXT[], created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Platform-managed resources (repos, db roles, policies, etc.) CREATE TABLE IF NOT EXISTS knoe.knobject ( id SERIAL PRIMARY KEY, type TEXT NOT NULL, name TEXT NOT NULL, platform_id TEXT, metadata JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(type, name) ); -- Access grants (user → knobject with role) CREATE TABLE IF NOT EXISTS knoe.access_grant ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES knoe.user(id), knobject_id INT NOT NULL REFERENCES knoe.knobject(id), role TEXT NOT NULL, granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, UNIQUE(user_id, knobject_id) ); -- Async provisioning job queue (GitLab user, Gitea user, CNPG role, etc.) CREATE TABLE IF NOT EXISTS knoe.provisioning_job ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES knoe.user(id), job_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', payload JSONB, result JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_provisioning_job_status ON knoe.provisioning_job(status, created_at); -- Seed well-known knobjects INSERT INTO knoe.knobject (type, name, metadata) VALUES ('gitea_org', 'knoey.com', '{"description": "Knoey.com Gitea organisation"}'), ('gitlab_group','knoey.com', '{"description": "Knoey.com GitLab group"}') ON CONFLICT (type, name) DO NOTHING; -- Grant table-level DML to the knoe role. Tables were created by the -- postgres superuser, so schema ownership alone is insufficient. GRANT SELECT, INSERT, UPDATE, DELETE ON knoe.invitation, knoe.identity, knoe.totp_credential, knoe.knobject, knoe.access_grant, knoe.provisioning_job TO knoe; GRANT USAGE, SELECT ON knoe.identity_id_seq, knoe.knobject_id_seq, knoe.access_grant_id_seq, knoe.provisioning_job_id_seq TO knoe; -- Cover any tables/sequences added to the schema in future runs. ALTER DEFAULT PRIVILEGES IN SCHEMA knoe GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO knoe; ALTER DEFAULT PRIVILEGES IN SCHEMA knoe GRANT USAGE, SELECT ON SEQUENCES TO knoe; SELECT 'knoe-auth schema v1 applied.' AS status; ENDSQL psql_file "$tmpfile" rm -f "$tmpfile" info "Schema applied." } # ── Dev-user seed (k3d only) ───────────────────────────────────────────────── # # Creates the `knoe_developer` group role (the production GKE deploy uses it # too via pg_hba.conf `+knoe_developer` rules; on GKE it was hand-rolled per # 2026-04-30 onboarding work, never baked into postInitTemplateSQL — see # docs/db-access.md). Then creates a `chrisfu` LOGIN role with a dev # password and grants `knoe_developer` to it, so the engineer can connect # from the host as chrisfu@knoey.com via the port-forward. # # Idempotent: re-runs on every `make k3d-knoe-up` and either creates or # updates the role's password. This makes the rebuild loop deterministic — # after `down && up`, chrisfu's password is always `chrisfu-dev`. seed_dev_users_k3d() { info "Seeding dev users (knoe_developer + chrisfu) for k3d ..." local tmpfile tmpfile=$(mktemp /tmp/knoe_auth_seed_XXXX.sql) cat > "$tmpfile" <<'ENDSQL' -- knoe_developer group role: R/W on knoe + public, R/O on auth/storage/extensions. -- Mirrors the GKE production layout (docs/db-access.md). NOLOGIN — group only. DO $do$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'knoe_developer') THEN CREATE ROLE knoe_developer NOLOGIN; END IF; END $do$; -- knoe + public — full R/W GRANT USAGE ON SCHEMA knoe TO knoe_developer; GRANT USAGE ON SCHEMA public TO knoe_developer; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA knoe TO knoe_developer; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO knoe_developer; GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA knoe TO knoe_developer; GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO knoe_developer; ALTER DEFAULT PRIVILEGES IN SCHEMA knoe GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO knoe_developer; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO knoe_developer; ALTER DEFAULT PRIVILEGES IN SCHEMA knoe GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO knoe_developer; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO knoe_developer; -- Per-engineer LOGIN role for chrisfu (local-dev only; password is fixed dev value). -- Creates if missing, otherwise resets the password — guarantees the rebuild loop -- always produces the same credentials. DO $do$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'chrisfu') THEN CREATE ROLE chrisfu LOGIN INHERIT PASSWORD 'chrisfu-dev'; ELSE ALTER ROLE chrisfu WITH LOGIN INHERIT PASSWORD 'chrisfu-dev'; END IF; END $do$; GRANT knoe_developer TO chrisfu; -- Sanity check (visible in psql_file output). SELECT 'chrisfu' AS role, pg_has_role('chrisfu', 'knoe_developer', 'MEMBER') AS is_developer; ENDSQL psql_file "$tmpfile" rm -f "$tmpfile" info "Dev-user seed applied (chrisfu / chrisfu-dev, member of knoe_developer)." } # ── KDC secrets ────────────────────────────────────────────────────────────── create_kdc_secrets() { if kube -n "$NAMESPACE" get secret knoe-kdc-secrets >/dev/null 2>&1; then info "knoe-kdc-secrets already exists — skipping." return fi if [[ "$MODE" == "k3d" ]]; then info "k3d mode: creating knoe-kdc-secrets with dev passwords (no 1Password)..." kube -n "$NAMESPACE" create secret generic knoe-kdc-secrets \ --from-literal=master_password="$K3D_KDC_MASTER_PASSWORD" \ --from-literal=admin_password="$K3D_KDC_ADMIN_PASSWORD" info "knoe-kdc-secrets created (dev passwords)." return fi info "Creating knoe-kdc-secrets from 1Password..." local master admin master=$(op_secret "knoe-kdc-master" "password") admin=$(op_secret "knoe-kdc-admin" "password") kube -n "$NAMESPACE" create secret generic knoe-kdc-secrets \ --from-literal=master_password="$master" \ --from-literal=admin_password="$admin" info "knoe-kdc-secrets created." } create_google_oidc_secret() { if kube -n "$NAMESPACE" get secret knoe-auth-google-oidc >/dev/null 2>&1; then info "knoe-auth-google-oidc already exists — skipping." return fi info "Creating knoe-auth-google-oidc secret..." local client_id client_secret client_id=$(op_secret "knoe-google-oidc" "client_id") client_secret=$(op_secret "knoe-google-oidc" "client_secret") kube -n "$NAMESPACE" create secret generic knoe-auth-google-oidc \ --from-literal=client_id="$client_id" \ --from-literal=client_secret="$client_secret" info "knoe-auth-google-oidc created." } create_session_secret() { if kube -n "$NAMESPACE" get secret knoe-auth-secrets >/dev/null 2>&1; then info "knoe-auth-secrets already exists — skipping." return fi info "Creating knoe-auth-secrets (session HMAC key)..." local session_secret session_secret=$(op_secret "knoe-auth-session" "password") kube -n "$NAMESPACE" create secret generic knoe-auth-secrets \ --from-literal=sessionSecret="$session_secret" info "knoe-auth-secrets created." } create_oidc_path_b_secret() { if kube -n "$NAMESPACE" get secret knoe-auth-oidc >/dev/null 2>&1; then info "knoe-auth-oidc already exists — skipping." return fi info "Creating knoe-auth-oidc secret for Path B..." local client_id client_secret signing_key client_id=$(op_secret "knoe-auth-oidc-gitlab" "client_id") client_secret=$(op_secret "knoe-auth-oidc-gitlab" "client_secret") signing_key=$(op_secret "knoe-auth-oidc-signing" "private_key") kube -n "$NAMESPACE" create secret generic knoe-auth-oidc \ --from-literal=client-id="$client_id" \ --from-literal=client-secret="$client_secret" \ --from-literal=signing-key="$signing_key" info "knoe-auth-oidc created." } # ── Manifests ──────────────────────────────────────────────────────────────── apply_manifests() { if [[ "$MODE" == "k3d" ]]; then local k3d_dir="$REPO_ROOT/k8s/knoe" info "k3d mode: applying KDC manifests from $k3d_dir..." kube apply -f "$k3d_dir/knoe-kdc-configmap.yaml" kube apply -f "$k3d_dir/knoe-kdc-pvc.yaml" kube apply -f "$k3d_dir/knoe-kdc-deployment.yaml" kube apply -f "$k3d_dir/knoe-kdc-service.yaml" info "k3d mode: skipping knoe-auth Deployment (runs on host via mvn spring-boot:run)." return fi info "Applying KDC ConfigMap..." kube apply -f "$GKE_DIR/knoe-kdc-configmap.yaml" info "Applying knoe-auth Deployment..." kube apply -f "$GKE_DIR/knoe-auth-deployment.yaml" } # ── Invite helper ───────────────────────────────────────────────────────────── create_first_invite() { local contact="${1:-}" [[ -z "$contact" ]] && { read -rp "Invite contact (email or phone): " contact; } local name_hint="" read -rp "Display name hint (optional, press Enter to skip): " name_hint || true info "Creating invite for: $contact" local admin_token admin_token=$(op_secret "knoe-admin-token" "credential" 2>/dev/null || \ { read -rsp "Admin token: " t; echo; printf '%s' "$t"; }) local response response=$(curl -sf -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $admin_token" \ -d "{\"contact\":\"$contact\",\"contactType\":\"email\",\"nameHint\":\"$name_hint\"}" \ "${AUTH_HOST}/auth/admin/invites") || { warn "Admin API call failed. knoe-auth may not be ready yet." warn "Retry: POST ${AUTH_HOST}/auth/admin/invites" return 1 } local invite_url invite_url=$(printf '%s' "$response" | grep -o '"enrollUrl":"[^"]*"' | sed 's/"enrollUrl":"//;s/"//') printf '\n\033[1;32mInvite URL:\033[0m %s\n\n' "$invite_url" info "Send the above URL to: $contact" } # ── Status ──────────────────────────────────────────────────────────────────── show_status() { if [[ "$MODE" == "k3d" ]]; then info "=== KDC pod status ===" kube -n "$NAMESPACE" get pods -l app=knoe-kdc 2>/dev/null || true info "=== KDC secret ===" kube -n "$NAMESPACE" get secret knoe-kdc-secrets 2>/dev/null || true info "=== KDC service ===" kube -n "$NAMESPACE" get svc knoe-kdc 2>/dev/null || true info "=== knoe-auth runs on host ===" info " export KRB5_CONFIG=\$PWD/etc/krb5.local.conf" info " mvn -pl authority spring-boot:run" return fi info "=== Pod status ===" kube -n "$NAMESPACE" get pods -l app=knoe-auth 2>/dev/null || true info "=== Secrets ===" kube -n "$NAMESPACE" get secret \ knoe-kdc-secrets knoe-auth-google-oidc knoe-auth-secrets 2>/dev/null || true info "=== Services ===" kube -n "$NAMESPACE" get svc knoe-auth 2>/dev/null || true info "=== Enrollment endpoint ===" info "${AUTH_HOST}/auth/enroll" } # ── Full initialization ─────────────────────────────────────────────────────── cmd_initialize() { require_tool kubectl info "=== knoe-auth initialization ===" info " Mode: $MODE" info " Realm: $REALM" info " Cluster: $APP_CTX" info " NS: $NAMESPACE" info "" if [[ "$MODE" == "k3d" ]]; then cmd_initialize_k3d return fi # 1. Ensure namespace exists kube get namespace "$NAMESPACE" >/dev/null 2>&1 || \ kube create namespace "$NAMESPACE" # 2. Secrets create_kdc_secrets create_google_oidc_secret create_session_secret create_oidc_path_b_secret # 3. Apply ConfigMap + Deployment apply_manifests # 4. Wait for pods wait_for_pods "app=knoe-auth" 240 # 5. Schema run_schema # 6. Done info "" info "=== knoe-auth is ready ===" info "Enrollment URL: ${AUTH_HOST}/auth/enroll?token=" info "" info "Next: create first admin invite:" info " $0 invite chrisfu@knoey.com" info "" show_status } cmd_initialize_k3d() { info "=== k3d mode: provisioning KDC + schema ===" # 1. Ensure namespace exists kube get namespace "$NAMESPACE" >/dev/null 2>&1 || \ kube create namespace "$NAMESPACE" # 2. KDC secret (dev passwords, no 1Password) create_kdc_secrets # 3. Apply KDC manifests apply_manifests # 4. Wait for KDC pod info "Waiting for KDC deployment to be ready..." kube -n "$NAMESPACE" rollout status deployment/knoe-kdc --timeout=120s # 5. Schema (via kubectl exec into CNPG primary) run_schema # 6. Dev-user seed: knoe_developer group + chrisfu role (k3d-only). seed_dev_users_k3d info "" info "=== k3d knoe-auth stack is ready ===" info "Run: make k3d-knoe-pf" info "Then in another terminal:" info " export KRB5_CONFIG=\$PWD/etc/krb5.local.conf" info " export KNOE_AUTH_OIDC_SIGNING_KEY=\$(cat etc/secrets/knoe-auth-oidc-key.b64)" info " mvn -pl authority spring-boot:run -Dspring-boot.run.profiles=k3d" info "" info "Dev DB access (from host, with port-forward up):" info " PGPASSWORD=chrisfu-dev psql -h localhost -U chrisfu -d knoe-db" info "" show_status } # ── Dispatch ────────────────────────────────────────────────────────────────── case "$COMMAND" in initialize) cmd_initialize ;; schema) run_schema ;; invite) create_first_invite "${1:-}" ;; status) show_status ;; *) echo "Usage: $0 {initialize|schema|invite EMAIL|status} [--context CTX] [--namespace NS]" >&2 exit 1 ;; esac