diff --git a/authority/src/main/java/dev/knoe/auth/web/JwksController.java b/authority/src/main/java/dev/knoe/auth/web/JwksController.java index afa0f77..0d2943c 100644 --- a/authority/src/main/java/dev/knoe/auth/web/JwksController.java +++ b/authority/src/main/java/dev/knoe/auth/web/JwksController.java @@ -4,11 +4,13 @@ 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; diff --git a/authority/src/main/java/dev/knoe/auth/web/LoginController.java b/authority/src/main/java/dev/knoe/auth/web/LoginController.java index 3c7db01..27cbfb8 100644 --- a/authority/src/main/java/dev/knoe/auth/web/LoginController.java +++ b/authority/src/main/java/dev/knoe/auth/web/LoginController.java @@ -321,7 +321,8 @@ public class LoginController { } private URI safeNext(String next) { - URI defaultNext = URI.create("https://svc.knoe.dev/"); + String apex = apex(); + URI defaultNext = URI.create("https://svc." + apex + "/"); if (next == null || next.isBlank()) { return defaultNext; } @@ -332,13 +333,13 @@ public class LoginController { return defaultNext; } String host = u.getHost(); - if (host == null || !host.endsWith(".knoe.dev")) { + if (host == null || (!host.equals(apex) && !host.endsWith("." + apex))) { return defaultNext; } return u; } if (next.startsWith("/")) { - return URI.create("https://svc.knoe.dev" + next); + return URI.create("https://svc." + apex + next); } return defaultNext; } catch (Exception e) { @@ -346,6 +347,11 @@ public class LoginController { } } + private String apex() { + String cd = auth.getCookieDomain(); + return (cd != null && cd.startsWith(".")) ? cd.substring(1) : "knoe.dev"; + } + private static String escapeHtmlAttr(String v) { if (v == null) { return ""; diff --git a/authority/src/main/java/dev/knoe/auth/web/OidcAuthorizeController.java b/authority/src/main/java/dev/knoe/auth/web/OidcAuthorizeController.java index 155815c..d6ecce7 100644 --- a/authority/src/main/java/dev/knoe/auth/web/OidcAuthorizeController.java +++ b/authority/src/main/java/dev/knoe/auth/web/OidcAuthorizeController.java @@ -6,6 +6,7 @@ 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.UriComponentsBuilder; @@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; @Controller +@RequestMapping("/auth") public class OidcAuthorizeController { private final AuthProperties authProperties; @@ -51,7 +53,7 @@ public class OidcAuthorizeController { Optional userOpt = sessionService.getSessionUser(request); if (userOpt.isEmpty()) { // No session, redirect to login with this URL as 'next' - String currentUrl = UriComponentsBuilder.fromPath("/authorize") + String currentUrl = UriComponentsBuilder.fromPath("/auth/authorize") .queryParam("client_id", clientId) .queryParam("redirect_uri", redirectUri) .queryParam("state", state) @@ -59,8 +61,8 @@ public class OidcAuthorizeController { .queryParam("response_type", responseType) .queryParam("scope", scope) .build().toUriString(); - - return "redirect:/login?next=" + UriComponentsBuilder.fromPath(currentUrl).build().encode().toUriString(); + + return "redirect:/auth/login?next=" + UriComponentsBuilder.fromPath(currentUrl).build().encode().toUriString(); } // User is authenticated, generate code diff --git a/authority/src/main/java/dev/knoe/auth/web/OidcDiscoveryController.java b/authority/src/main/java/dev/knoe/auth/web/OidcDiscoveryController.java index 73e6eea..2b7f48c 100644 --- a/authority/src/main/java/dev/knoe/auth/web/OidcDiscoveryController.java +++ b/authority/src/main/java/dev/knoe/auth/web/OidcDiscoveryController.java @@ -3,12 +3,14 @@ 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; diff --git a/authority/src/main/java/dev/knoe/auth/web/OidcTokenController.java b/authority/src/main/java/dev/knoe/auth/web/OidcTokenController.java index 1f0e4a6..cf1e92b 100644 --- a/authority/src/main/java/dev/knoe/auth/web/OidcTokenController.java +++ b/authority/src/main/java/dev/knoe/auth/web/OidcTokenController.java @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Map; @RestController +@RequestMapping("/auth") public class OidcTokenController { private final AuthProperties authProperties; diff --git a/authority/src/main/java/dev/knoe/auth/web/OidcUserInfoController.java b/authority/src/main/java/dev/knoe/auth/web/OidcUserInfoController.java index 853b6a9..d3fd71c 100644 --- a/authority/src/main/java/dev/knoe/auth/web/OidcUserInfoController.java +++ b/authority/src/main/java/dev/knoe/auth/web/OidcUserInfoController.java @@ -5,6 +5,7 @@ import dev.knoe.auth.session.SessionService; import dev.knoe.auth.session.SessionUser; 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; @@ -12,6 +13,7 @@ import java.util.Map; import java.util.Optional; @RestController +@RequestMapping("/auth") public class OidcUserInfoController { private final AuthProperties authProperties; diff --git a/authority/src/main/resources/application-prole.yml b/authority/src/main/resources/application-prole.yml index cfb6f54..d62e443 100644 --- a/authority/src/main/resources/application-prole.yml +++ b/authority/src/main/resources/application-prole.yml @@ -11,6 +11,10 @@ knoe: 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:} google: clientId: ${GOOGLE_PROLE_CLIENT_ID:} diff --git a/deploy/opentofu/k3s/manifests/knoe/iscsi-pvs.yaml b/deploy/opentofu/k3s/manifests/knoe/iscsi-pvs.yaml index 5329f46..b7abc87 100644 --- a/deploy/opentofu/k3s/manifests/knoe/iscsi-pvs.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/iscsi-pvs.yaml @@ -152,7 +152,7 @@ spec: - key: kubernetes.io/hostname operator: In values: - - pi.prole.org + - gandalf.prole.org --- apiVersion: v1 kind: PersistentVolume @@ -178,7 +178,7 @@ spec: - key: kubernetes.io/hostname operator: In values: - - pi.prole.org + - gandalf.prole.org --- apiVersion: v1 kind: PersistentVolume @@ -204,7 +204,7 @@ spec: - key: kubernetes.io/hostname operator: In values: - - pi.prole.org + - gandalf.prole.org --- apiVersion: v1 kind: PersistentVolume diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-deployment.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-deployment.yaml index 15d731a..a760949 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-deployment.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-deployment.yaml @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: knoe-auth + namespace: knoe-system labels: app: knoe-auth spec: @@ -18,6 +19,8 @@ spec: - name: keytab-bootstrap image: myrddin.prole.org:5000/knoe-auth:latest imagePullPolicy: IfNotPresent + securityContext: + runAsUser: 0 command: - /bin/bash - -lc @@ -129,24 +132,35 @@ spec: mountPath: /etc/krb5kdc containers: - name: knoe-auth - image: knoe-auth:latest + image: myrddin.prole.org:5000/knoe-auth:latest ports: - containerPort: 8080 name: http env: - - name: PROLE_AUTH_ENABLED - value: "true" - - name: PROLE_AUTH_COOKIE_DOMAIN - valueFrom: - configMapKeyRef: - name: knoe-platform-config - key: authCookieDomain - optional: true - - name: PROLE_AUTH_SESSION_SECRET + - name: SPRING_PROFILES_ACTIVE + value: "prole" + - name: SPRING_DATASOURCE_HIKARI_INITIALIZATION_FAIL_TIMEOUT + value: "-1" + - name: KNOE_AUTH_SESSION_SECRET valueFrom: secretKeyRef: name: knoe-auth-secrets key: sessionSecret + - name: KNOE_DB_URL + valueFrom: + secretKeyRef: + name: knoe-auth-db + key: db-url + - name: KNOE_DB_USER + valueFrom: + secretKeyRef: + name: knoe-auth-db + key: db-user + - name: KNOE_DB_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-auth-db + key: db-password - name: PROLE_KERBEROS_SERVICE_PRINCIPAL valueFrom: configMapKeyRef: @@ -197,6 +211,17 @@ spec: name: knoe-auth-google-prole key: client_secret optional: true + # ── knoe-auth OIDC provider (Phase 2) ──────────────────────────── + - name: KNOE_AUTH_OIDC_ENABLED + value: "true" + - name: KNOE_AUTH_OIDC_ISSUER + value: "https://api.prole.org/auth" + - name: KNOE_AUTH_OIDC_SIGNING_KEY + valueFrom: + secretKeyRef: + name: knoe-auth-oidc-signing + key: signing-key + optional: true # pod starts without it; OIDC endpoints 503 until key is present - name: OIDC_ISSUER_URL value: "https://knoe-auth.knoe-system.svc.cluster.local:8080" - name: OIDC_BASE_URL @@ -206,7 +231,7 @@ spec: key: frontdoorHost optional: true # Comma-separated list of bare usernames granted admin group in OIDC tokens - - name: PROLE_AUTH_ADMIN_PRINCIPALS + - name: KNOE_AUTH_ADMIN_PRINCIPALS value: "admin" volumeMounts: - name: keytab @@ -223,57 +248,10 @@ spec: mountPath: /etc/krb5.conf subPath: krb5.conf readOnly: true - - name: kdc - image: myrddin.prole.org:5000/knoe-auth:latest - imagePullPolicy: IfNotPresent - command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"] - env: - - name: PROLE_KDC_REALM - valueFrom: - configMapKeyRef: - name: knoe-auth-kerberos - key: realm - - name: PROLE_KDC_ADMIN_PRINCIPAL - value: "admin/admin" - - name: PROLE_KDC_MASTER_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-kdc-secrets - key: master_password - - name: PROLE_KDC_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-kdc-secrets - key: admin_password - - name: PROLE_KDC_GUEST_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-kdc-secrets - key: guest_password - optional: true # auto-generated by init_knoe_users.sh if absent - 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-data - mountPath: /etc/krb5kdc + # KDC sidecar intentionally omitted: prole.org uses Samba AD (PROLE.ORG) + # as the external KDC. SPNEGO auth uses the keytab from knoe-auth-http-keytab + # secret; the in-cluster KDC is not needed. Re-add when cross-realm trust + # (KNOE.LOCAL ↔ PROLE.ORG) is implemented per docs/plans/prole-auth-samba-ad-cross-realm.md. volumes: - name: keytab emptyDir: {} diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-google-prole-secret.example.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-google-prole-secret.example.yaml new file mode 100644 index 0000000..c555703 --- /dev/null +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-google-prole-secret.example.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: Secret +metadata: + name: knoe-auth-google-prole + namespace: knoe-system + labels: + app.kubernetes.io/managed-by: knoe-installer +# Google OAuth2 credentials for the prole.org Workspace login path (Path A). +# +# 1. Create an OAuth 2.0 Client ID at: +# https://console.cloud.google.com/apis/credentials +# Application type: Web application +# Authorized redirect URIs: +# https://api.prole.org/auth/auth/callback/google +# https://api.prole.org/auth/auth/enroll/google-callback +# +# 2. Store real values in OpenBao: +# bao kv put knoe/oauth2/google-prole-org \ +# client_id="" client_secret="" +# +# 3. Create the secret from OpenBao: +# kubectl create secret generic knoe-auth-google-prole \ +# --namespace knoe-system \ +# --from-literal=client_id="$(bao kv get -field=client_id knoe/oauth2/google-prole-org)" \ +# --from-literal=client_secret="$(bao kv get -field=client_secret knoe/oauth2/google-prole-org)" +type: Opaque +stringData: + client_id: "" + client_secret: "" diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-kerberos-configmap.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-kerberos-configmap.yaml index f1fa755..ac00dd9 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-kerberos-configmap.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-kerberos-configmap.yaml @@ -3,6 +3,7 @@ kind: ConfigMap metadata: name: knoe-auth-kerberos data: - # Kerberos HTTP service principal for SPNEGO (must match keytab) - servicePrincipal: "HTTP/api.knoe.org@KNOE.LOCAL" - realm: "KNOE.LOCAL" + # Kerberos HTTP service principal for SPNEGO (must match keytab). + # prole.org: principal lives in PROLE.ORG Samba AD realm on myrddin.prole.org. + servicePrincipal: "HTTP/api.prole.org@PROLE.ORG" + realm: "PROLE.ORG" diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-oidc-signing-secret.example.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-oidc-signing-secret.example.yaml new file mode 100644 index 0000000..13fef60 --- /dev/null +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-oidc-signing-secret.example.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Secret +metadata: + name: knoe-auth-oidc-signing + namespace: knoe-system + labels: + app.kubernetes.io/managed-by: knoe-installer +# OIDC RS256 signing key for knoe-auth acting as OIDC provider (Phase 2). +# The signing key is a base64-encoded PKCS#8 RSA private key (2048-bit minimum). +# +# Generate a fresh key: +# openssl genrsa -out oidc-signing.pem 2048 +# openssl pkcs8 -topk8 -nocrypt -in oidc-signing.pem -out oidc-signing-pkcs8.pem +# SIGNING_KEY=$(base64 -w0 < oidc-signing-pkcs8.pem) +# +# Store the real value in OpenBao: +# bao kv put knoe/oauth2/knoe-auth-oidc-signing signing-key="$SIGNING_KEY" +# +# Then create the secret from OpenBao: +# kubectl create secret generic knoe-auth-oidc-signing \ +# --namespace knoe-system \ +# --from-literal=signing-key="$(bao kv get -field=signing-key knoe/oauth2/knoe-auth-oidc-signing)" +type: Opaque +stringData: + signing-key: "CHANGE_ME_BASE64_PKCS8_RSA_PRIVATE_KEY" diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-service.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-service.yaml index 523cde4..3797128 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-auth-service.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-auth-service.yaml @@ -5,7 +5,7 @@ metadata: namespace: knoe-system spec: selector: - app: authority-prole-auth + app: knoe-auth ports: - name: http port: 8080 diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-db-barman-objectstore.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-db-barman-objectstore.yaml index 032e5e2..dc41fde 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-db-barman-objectstore.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-db-barman-objectstore.yaml @@ -1,3 +1,14 @@ +# knoe-db barman ObjectStore — k3s/prole.org +# +# Backup pipeline: +# CNPG (WAL archive + daily basebackup) +# → Garage S3 (local, knoe-system namespace, s3://knoe-db-backups/) +# → [TODO] rclone CronJob syncs Garage → AWS S3 bucket (off-site durability) +# +# The rclone sync job needs: +# - AWS S3 bucket + IAM credentials (secret: knoe-db-barman-s3-aws) +# - CronJob in knoe-db namespace running rclone sync on the same schedule +# - Once in place, 30d retention here + AWS lifecycle policy for long-term cold storage apiVersion: barmancloud.cnpg.io/v1 kind: ObjectStore metadata: diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-db-scheduled-backup.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-db-scheduled-backup.yaml index 631c595..a5bcec0 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-db-scheduled-backup.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-db-scheduled-backup.yaml @@ -6,13 +6,13 @@ metadata: spec: cluster: name: knoe-db - # Six-field cron: seconds minutes hours day month weekday - # Fires daily at 03:00:00 UTC + # CNPG uses 6-field cron: sec min hour dom month dow + # Fires daily at 03:00 UTC schedule: "0 0 3 * * *" method: plugin pluginConfiguration: name: barman-cloud.cloudnative-pg.io parameters: barmanObjectName: knoe-db-barman-objectstore - immediate: true + immediate: false backupOwnerReference: self diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-db-user.example.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-db-user.example.yaml new file mode 100644 index 0000000..f7c57a4 --- /dev/null +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-db-user.example.yaml @@ -0,0 +1,23 @@ +# Example only (do not commit real secrets). +# +# knoe-db-user — CNPG bootstrap secret for the knoe database owner. +# Referenced by spec.bootstrap.initdb.secret in knoe-db.yaml. +# +# IMPORTANT: username MUST match spec.bootstrap.initdb.owner (knoe). +# CNPG sets the password on the role named here; if this says "root" +# the knoe role gets no password and all knoe-auth DB connections fail. +# +# Create with knoe-db-passwwd.sh, which defaults DB_USER=knoe. +# If creating manually: +# kubectl create secret generic knoe-db-user -n knoe-db \ +# --from-literal=username=knoe \ +# --from-literal=password= +apiVersion: v1 +kind: Secret +metadata: + name: knoe-db-user + namespace: knoe-db +type: Opaque +stringData: + username: knoe + password: "CHANGE_ME" diff --git a/deploy/opentofu/k3s/manifests/knoe/knoe-db.yaml b/deploy/opentofu/k3s/manifests/knoe/knoe-db.yaml index 189dce0..d7e1480 100644 --- a/deploy/opentofu/k3s/manifests/knoe/knoe-db.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/knoe-db.yaml @@ -118,10 +118,18 @@ spec: comment: "Developer group role — granted to knoe-system user accounts" certificates: - serverTLSSecret: knoe-db-tls - serverCASecret: knoe-db-ca + serverAltDNSNames: + - pg.prole.org + - knoe-db-rw.knoe-db.svc.cluster.local enableSuperuserAccess: true + plugins: + - name: barman-cloud.cloudnative-pg.io + enabled: true + isWALArchiver: true + parameters: + barmanObjectName: knoe-db-barman-objectstore + env: - name: AWS_REGION value: garage diff --git a/deploy/opentofu/k3s/manifests/knoe/kong-configmap.yaml b/deploy/opentofu/k3s/manifests/knoe/kong-configmap.yaml index 2ff969f..ae4aed6 100644 --- a/deploy/opentofu/k3s/manifests/knoe/kong-configmap.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/kong-configmap.yaml @@ -62,7 +62,7 @@ data: hosts: - api.prole.org paths: - - / + - /auth strip_path: false # Supabase Studio: gated by oauth2-proxy (Google Workspace prole.org). diff --git a/docs/TODO.md b/docs/TODO.md index 930aab9..7edb84a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -50,6 +50,8 @@ The Kanban "Now" section at top is the only place this doc imposes structure. Ev 12. **Fork the Supabase Studio image to wire in-app help / support / feedback buttons to `mailto:support@knoe.dev` (or `https://db.0.knoe.dev/support`)** — upstream Studio (`supabase/studio:2026.02.16-sha-26c615c`) hardcodes those URLs to Supabase-cloud endpoints (`supabase.com/dashboard/api/feedback` etc.) which are unreachable from self-hosted, so the in-app "Report a problem" / "Send feedback" / "Get help" flows error out. No env-var hook exists in upstream — verified by Explore search. Fork the image, patch the relevant TSX (`apps/studio/components/layouts/AppLayout/AppLayout.tsx`, support-dialog component, settings/help links — handful of files), publish to our registry, bump `image.studio.repository` in [`supabase/helm/knoe-supabase/values.yaml`](../supabase/helm/knoe-supabase/values.yaml). Same fork is the natural place to fix the OpenAI key panel, telemetry endpoints, and any other in-Studio assumptions about Supabase cloud as we encounter them. Stop-gap until then: `https://db.0.knoe.dev/support` 302s to `mailto:support@knoe.dev` (Kong route `support`); just need to tell users to bookmark or remember it. +13. **Ekosystem UUID system — CNPG wiring and follow-on work** — Schema files written (`knoe-db/schema/ekosystem.sql`, `knoe-db/schema/ekosystem_objects.sql`); `init_prole_app.sql` wired for direct psql runs. Remaining: (a) ConfigMap + `postInitApplicationSQLRefs` for all three CNPG manifests (k3s prod, GKE, OpenTofu); (b) register `prole` as tenant 1; (c) Python counterpart utility; (d) align `knoe.user` with ekosystem UUIDs; (e) LDAP/Samba AD group sync from `cross_grants`. Brief: [`docs/plans/junie/ekosystem-uuid-cnpg-wire.md`](plans/junie/ekosystem-uuid-cnpg-wire.md). + 14. **Migrate cnpg-prometheus datasource UID to the stable `cnpg-prometheus` name** — currently the live datasource has the auto-generated UID `P5531627C358300FE` from the original kps install. We pinned `uid: cnpg-prometheus` in [`monitoring/kps-values-gke.yaml`](../monitoring/kps-values-gke.yaml) so any FRESH kps install lands on the stable name, but Grafana refuses to change the UID of an already-provisioned datasource (read-only via API; rollout-restart doesn't migrate it). The dashboard transform [`monitoring/cnpg-dashboard-transforms.yaml`](../monitoring/cnpg-dashboard-transforms.yaml) `ds_prometheus_default_to_cnpg` carries the auto-uid as a workaround. Migration path on the next clean kps re-install (or after a maintenance window where we can wipe the Grafana sqlite DB to drop datasources): swap `value: P5531627C358300FE` → `value: cnpg-prometheus` in the transform and re-run the sync tool. No client-visible change either way. diff --git a/docs/plans/prole-auth-samba-ad-cross-realm.md b/docs/plans/prole-auth-samba-ad-cross-realm.md new file mode 100644 index 0000000..07aed48 --- /dev/null +++ b/docs/plans/prole-auth-samba-ad-cross-realm.md @@ -0,0 +1,147 @@ +# prole.org auth — Samba AD cross-realm trust & keytab provisioning + +**Status:** Infrastructure coded; operational steps pending. +**Owner:** chrisfu + +Closes Path B: Samba AD users on `myrddin.prole.org` → Kerberos SPNEGO → knoe-auth session. + +--- + +## Architecture recap + +The in-cluster KDC runs the `KNOE.LOCAL` realm. `myrddin.prole.org` (10.0.0.3) runs Samba AD with realm `PROLE.ORG`. A bidirectional cross-realm trust lets a `PROLE.ORG` ticket-holder authenticate to any kerberized in-cluster service (`HTTP/api.prole.org@KNOE.LOCAL` etc.) without needing a second Kerberos account. + +``` +AD user on myrddin → TGT from PROLE.ORG KDC (myrddin:88) + → cross-realm referral → KNOE.LOCAL KDC (in-cluster) + → service ticket for HTTP/api.prole.org@KNOE.LOCAL + → SPNEGO negotiation with knoe-auth + → knoe_session cookie issued +``` + +The in-cluster ExternalName service `prole-kerberos-ad-dc.knoe-system.svc.cluster.local:88` +routes to `myrddin.prole.org` so the KDC container can find the PROLE.ORG KDC. + +--- + +## Step 1 — Create the inter-realm keys on the in-cluster KDC + +`init_kdc.sh` provisions the MIT KDC side when `PROLE_KDC_TRUST_REALM` is set. Run this +from within the cluster (or via `kubectl exec` into the KDC container): + +```bash +# The shared trust password is in knoe-kdc-secrets/trust_shared_password +TRUST_SHARED_PW=$(kubectl -n knoe-system get secret knoe-kdc-secrets \ + -o jsonpath='{.data.trust_shared_password}' | base64 -d) + +kubectl exec -n knoe-system deploy/knoe-auth -c kdc -- kadmin.local -q \ + "addprinc -pw ${TRUST_SHARED_PW} krbtgt/PROLE.ORG@KNOE.LOCAL" + +kubectl exec -n knoe-system deploy/knoe-auth -c kdc -- kadmin.local -q \ + "addprinc -pw ${TRUST_SHARED_PW} krbtgt/KNOE.LOCAL@PROLE.ORG" +``` + +Both directions must exist. The KNOE.LOCAL → PROLE.ORG key is used when a PROLE.ORG principal +requests a service ticket in KNOE.LOCAL (referral chain). + +--- + +## Step 2 — Create the reciprocal trust account on myrddin (Samba side) + +SSH to `myrddin.prole.org` as Administrator and run: + +```bash +# Create the outbound trust principal that KNOE.LOCAL will use +sudo samba-tool user create krbtgt_KNOEDOTLOCAL --random-password + +# Set the inter-realm key to the SAME shared password used in Step 1 +sudo samba-tool user setpassword krbtgt_KNOEDOTLOCAL --newpassword="${TRUST_SHARED_PW}" + +# Disable password expiry for the trust account +sudo samba-tool user setexpiry krbtgt_KNOEDOTLOCAL --noexpiry + +# Create the one-way trust entry (PROLE.ORG trusts KNOE.LOCAL) +sudo samba-tool domain trust create KNOE.LOCAL \ + --type=external \ + --direction=incoming \ + --password="${TRUST_SHARED_PW}" +``` + +Note: `KNOE.LOCAL` must be resolvable from `myrddin`. Either add a DNS forwarder for the +`KNOE.LOCAL` domain pointing at the in-cluster KDC service IP, or add a hosts entry. + +--- + +## Step 3 — Extract the HTTP service keytab + +The knoe-auth pod needs `HTTP/api.prole.org@PROLE.ORG` to accept SPNEGO from PROLE.ORG browsers. + +```bash +# Option A — generate keytab on myrddin (if the principal lives in PROLE.ORG) +ssh myrddin.prole.org "sudo samba-tool user create HTTP-api-prole-org --random-password && \ + sudo samba-tool spn add HTTP/api.prole.org HTTP-api-prole-org && \ + sudo samba-tool domain exportkeytab /tmp/http-api.keytab --principal=HTTP/api.prole.org" +scp myrddin.prole.org:/tmp/http-api.keytab ./http.keytab + +# Option B — generate on the in-cluster KDC (principal in KNOE.LOCAL) +kubectl exec -n knoe-system deploy/knoe-auth -c kdc -- kadmin.local -q \ + "addprinc -randkey HTTP/api.prole.org@KNOE.LOCAL" +kubectl exec -n knoe-system deploy/knoe-auth -c kdc -- kadmin.local -q \ + "ktadd -k /tmp/http.keytab HTTP/api.prole.org@KNOE.LOCAL" +kubectl cp knoe-system/$(kubectl get pod -n knoe-system -l app=knoe-auth -o name | head -1 | cut -d/ -f2):/tmp/http.keytab ./http.keytab +``` + +Choose Option A if clients authenticate as `user@PROLE.ORG` — the service principal must match +the realm clients target. Use Option B if you want KNOE.LOCAL to be authoritative. + +Store the keytab in the k8s secret: + +```bash +kubectl create secret generic knoe-auth-http-keytab \ + --namespace knoe-system \ + --from-file=http.keytab=./http.keytab \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +The deployment mounts this at `/etc/knoe-auth/http.keytab` (already wired in `knoe-auth-deployment.yaml`). + +--- + +## Step 4 — Verify trust end-to-end + +From a domain-joined Windows or Linux machine in `PROLE.ORG`: + +```bash +# Linux (kinit from PROLE.ORG) +kinit user@PROLE.ORG +kvno HTTP/api.prole.org@KNOE.LOCAL # should succeed via cross-realm referral + +# Test SPNEGO login +curl -v --negotiate -u : https://api.prole.org/auth/spnego +# Expect: 302 redirect with knoe_session cookie +``` + +--- + +## Step 5 — AD group sync (future work) + +AD group membership → `knoe.access_grant` rows is not yet implemented. Current behavior: +- Samba AD users get a baseline `knoe_session` with no extra groups. +- Admin rights are granted only to usernames listed in `PROLE_AUTH_ADMIN_PRINCIPALS`. + +To grant admin access to an AD user before group sync is built: +```bash +kubectl -n knoe-system set env deploy/knoe-auth \ + PROLE_AUTH_ADMIN_PRINCIPALS="admin," +``` + +--- + +## Related files + +| File | Purpose | +|---|---| +| [`deploy/opentofu/k3s/manifests/knoe/prole-kerberos-ad-dc-svc.yaml`](../opentofu/k3s/manifests/knoe/prole-kerberos-ad-dc-svc.yaml) | ExternalName service → myrddin.prole.org:88 | +| [`deploy/opentofu/k3s/manifests/knoe/knoe-kdc-secrets.example.yaml`](../opentofu/k3s/manifests/knoe/knoe-kdc-secrets.example.yaml) | trust_shared_password lives here | +| [`etc/init_kdc.sh`](../../etc/init_kdc.sh) | Automates Step 1 when `PROLE_KDC_TRUST_*` env vars are set | +| [`deploy/opentofu/k3s/manifests/knoe/knoe-auth-http-keytab-secret.example.yaml`](../opentofu/k3s/manifests/knoe/knoe-auth-http-keytab-secret.example.yaml) | HTTP service keytab secret template | diff --git a/etc/init_knoe_auth.sh b/etc/init_knoe_auth.sh index e8b430e..8e252ac 100755 --- a/etc/init_knoe_auth.sh +++ b/etc/init_knoe_auth.sh @@ -257,6 +257,25 @@ INSERT INTO knoe.knobject (type, name, metadata) VALUES ('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 diff --git a/etc/onboard_tenant.sh b/etc/onboard_tenant.sh new file mode 100755 index 0000000..49212a6 --- /dev/null +++ b/etc/onboard_tenant.sh @@ -0,0 +1,778 @@ +#!/usr/bin/env bash +# etc/onboard_tenant.sh — Tenant secret provisioning and Kubernetes wiring +# +# Usage: +# ./etc/onboard_tenant.sh provision [OPTIONS] +# ./etc/onboard_tenant.sh apply [OPTIONS] +# ./etc/onboard_tenant.sh rotate [OPTIONS] +# ./etc/onboard_tenant.sh status +# +# Subcommands: +# provision Create 1Password vault, generate all auto-generatable secrets, +# store PLACEHOLDERs for items that need external input (Google OAuth, +# Samba keytab). For 'tenant' type: fully automated, no PLACEHOLDERs. +# +# apply Read all secrets from the 1P vault and create/update Kubernetes +# secrets in the tenant namespace. Fails if any PLACEHOLDERs remain. +# +# rotate Post-install password rotation. For each user-rotatable secret, +# offers keep / enter custom / regenerate. Updates 1P + k8s + DB. +# +# status Print a table of what is provisioned vs. pending in the 1P vault. +# +# Options: +# --domain DOMAIN Tenant's primary domain (e.g. prole.org). Defaults to tenant-id. +# --type tenant|enterprise Tenant type. Default: enterprise. +# tenant Hosted tenant: uses knoe-auth at api.knoe.dev/auth, has data but no +# infrastructure. Keytab bootstrapped by initContainer. Fully automated. +# enterprise Brings own Kerberos infrastructure (Samba AD / MIT KDC) and a domain. +# knoe-auth integrates with their KDC. Requires Google OAuth + keytab +# from external AD. PLACEHOLDERs remain until human steps complete. +# --context CTX kubectl context to use. Default: current context. +# --namespace NS Kubernetes namespace for knoe-auth. Default: knoe-system. +# --db-host HOST PostgreSQL host for ALTER ROLE during rotate. Default: pg.. +# --db-context CTX kubectl context of the DB cluster. Default: same as --context. +# --db-namespace NS Namespace where the CNPG cluster lives. Default: knoe-db. +# --kdc-host HOST SSH host for Samba AD / KDC keytab extraction (enterprise only). +# Default: myrddin.. +# --ci-google-client ID Shared CI Google OAuth client ID (tenant type only). +# --ci-google-secret SEC Shared CI Google OAuth client secret (tenant type only). +# --yes Non-interactive: accept suggested values without prompting. +# +# 1Password vault layout: "Knoe Tenant - " +# knoe-db Login — DB role 'knoe' password + superuser password + URL +# knoe-auth Password — session secret +# knoe-oidc Password — RS256 signing key (base64 PEM) +# knoe-google-tenant Login — per-tenant Google OAuth client (enrollment flow) +# knoe-kerberos Note — KDC master/admin passwords + HTTP keytab (base64) +# +# Kubernetes secrets created (namespace: knoe-system): +# knoe-db-user CNPG bootstrap secret (username + password for 'knoe' role) +# knoe-auth-db jdbc URL + user + password +# knoe-auth-secrets sessionSecret +# knoe-auth-oidc-signing signing-key +# knoe-auth-google- client_id + client_secret (enrollment Google OAuth) +# knoe-auth-http-keytab http.keytab file +# knoe-kdc-secrets master_password + admin_password (in-cluster KDC, tenant type) +# +# For enterprise tenants: after 'provision', the status command will show which items +# need external completion (Google Cloud Console, Samba AD keytab) before 'apply'. +# +# Rotate targets (prompted in order): +# 1. knoe DB role password — ALTER ROLE knoe + k8s secret + 1P +# 2. session secret — k8s secret + 1P (no DB op needed) +# 3. KDC master password — k8s secret + 1P (in-cluster KDC restart required) +# 4. KDC admin password — k8s secret + 1P + +set -euo pipefail + +# ── Constants ────────────────────────────────────────────────────────────────── + +PLACEHOLDER="__PLACEHOLDER__" +VAULT_PREFIX="Knoe Tenant - " +ITEM_DB="knoe-db" +ITEM_AUTH="knoe-auth" +ITEM_OIDC="knoe-oidc" +ITEM_GOOGLE="knoe-google-tenant" +ITEM_KERBEROS="knoe-kerberos" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +_info() { echo " [onboard] $*"; } +_ok() { echo " [onboard] ✓ $*"; } +_warn() { echo " [onboard] ⚠ $*" >&2; } +_err() { echo " [onboard] ✗ $*" >&2; } +_die() { _err "$*"; exit 1; } +_sep() { echo " ──────────────────────────────────────────────────────────"; } +_head() { echo; echo " ══ $* ══"; } + +# ── Argument parsing ────────────────────────────────────────────────────────── + +SUBCOMMAND="${1:-}" +[[ -z "$SUBCOMMAND" ]] && { echo "Usage: $0 {provision|apply|rotate|status} [OPTIONS]" >&2; exit 1; } +shift + +TENANT_ID="${1:-}" +[[ -z "$TENANT_ID" ]] && _die "tenant-id required as second argument" +shift + +# Defaults +TENANT_DOMAIN="" +TENANT_TYPE="enterprise" +KUBE_CONTEXT="" +KUBE_NAMESPACE="knoe-system" +DB_CONTEXT="" +DB_NAMESPACE="knoe-db" +DB_HOST="" +KDC_HOST="" +CI_GOOGLE_CLIENT_ID="" +CI_GOOGLE_CLIENT_SECRET="" +YES=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) TENANT_DOMAIN="$2"; shift 2 ;; + --type) TENANT_TYPE="$2"; shift 2 ;; + --context) KUBE_CONTEXT="$2"; shift 2 ;; + --namespace) KUBE_NAMESPACE="$2"; shift 2 ;; + --db-context) DB_CONTEXT="$2"; shift 2 ;; + --db-namespace) DB_NAMESPACE="$2"; shift 2 ;; + --db-host) DB_HOST="$2"; shift 2 ;; + --kdc-host) KDC_HOST="$2"; shift 2 ;; + --ci-google-client) CI_GOOGLE_CLIENT_ID="$2"; shift 2 ;; + --ci-google-secret) CI_GOOGLE_CLIENT_SECRET="$2"; shift 2 ;; + --yes) YES=true; shift ;; + *) _die "Unknown option: $1" ;; + esac +done + +# Apply defaults +[[ -z "$TENANT_DOMAIN" ]] && TENANT_DOMAIN="$TENANT_ID" +[[ -z "$DB_HOST" ]] && DB_HOST="pg.${TENANT_DOMAIN}" +[[ -z "$KDC_HOST" ]] && KDC_HOST="myrddin.${TENANT_DOMAIN}" +[[ -z "$DB_CONTEXT" ]] && DB_CONTEXT="${KUBE_CONTEXT}" + +VAULT="${VAULT_PREFIX}${TENANT_ID}" +K8S_GOOGLE_SECRET="knoe-auth-google-${TENANT_ID//[^a-z0-9-]/-}" + +# kubectl wrapper respecting --context +_kubectl() { + if [[ -n "$KUBE_CONTEXT" ]]; then + kubectl --context="$KUBE_CONTEXT" "$@" + else + kubectl "$@" + fi +} + +_kubectl_db() { + if [[ -n "$DB_CONTEXT" ]]; then + kubectl --context="$DB_CONTEXT" "$@" + else + kubectl "$@" + fi +} + +# ── Preflight ────────────────────────────────────────────────────────────────── + +require_op() { + command -v op >/dev/null 2>&1 || _die "1Password CLI (op) not found. Install: brew install 1password-cli" + op whoami >/dev/null 2>&1 || _die "Not signed in to 1Password. Run: op signin" +} + +require_openssl() { + command -v openssl >/dev/null 2>&1 || _die "openssl not found" +} + +# ── Secret generation ────────────────────────────────────────────────────────── + +gen_password() { + # 32-char alphanumeric+symbol password, URL-safe + openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#%^&*_+=' | head -c 32 +} + +gen_secret() { + # 48-byte random secret, base64-encoded (for session secrets etc.) + openssl rand -base64 48 | tr -d '\n' +} + +gen_rsa_key_b64() { + # RSA-2048 private key in PKCS#8 DER format, base64-encoded (no newlines). + # Must be raw DER (not PEM): OidcTokenService does Base64.decode() → PKCS8EncodedKeySpec, + # which requires raw DER bytes. PEM headers cause a DER parse error ("extra data at the end"). + openssl genrsa 2048 2>/dev/null \ + | openssl pkcs8 -topk8 -nocrypt -outform DER 2>/dev/null \ + | base64 | tr -d '\n' +} + +# ── 1Password helpers ────────────────────────────────────────────────────────── + +op_vault_exists() { + op vault get "$VAULT" >/dev/null 2>&1 +} + +op_item_exists() { + local title="$1" + op item get "$title" --vault "$VAULT" >/dev/null 2>&1 +} + +op_get_field() { + local title="$1" field="$2" + op item get "$title" --vault "$VAULT" --fields "label=$field" --reveal 2>/dev/null \ + | tr -d '\n' +} + +op_update_field() { + local title="$1" field="$2" value="$3" + op item edit "$title" --vault "$VAULT" "${field}[concealed]=${value}" >/dev/null +} + +is_placeholder() { + [[ "${1:-}" == "$PLACEHOLDER" ]] +} + +# ── Provision ───────────────────────────────────────────────────────────────── + +cmd_provision() { + require_op + require_openssl + + _head "Provisioning tenant: ${TENANT_ID} (type: ${TENANT_TYPE}, domain: ${TENANT_DOMAIN})" + + # ── Create vault ────────────────────────────────────────────────────────── + if op_vault_exists; then + _warn "Vault '${VAULT}' already exists — skipping vault creation." + else + _info "Creating 1Password vault: ${VAULT}" + op vault create "$VAULT" >/dev/null + _ok "Vault created." + fi + + # ── Item: knoe-db ────────────────────────────────────────────────────────── + _sep + _info "Item: knoe-db (database credentials)" + if op_item_exists "$ITEM_DB"; then + _warn "Item '${ITEM_DB}' already exists — skipping." + else + local knoe_pw superuser_pw + knoe_pw="$(gen_password)" + superuser_pw="$(gen_password)" + local db_url="jdbc:postgresql://knoe-db-rw.knoe-db.svc.cluster.local:5432/knoe-db" + + op item create \ + --vault "$VAULT" \ + --category=Login \ + --title="$ITEM_DB" \ + --url "$db_url" \ + "username[text]=knoe" \ + "password[concealed]=${knoe_pw}" \ + "superuser_password[concealed]=${superuser_pw}" \ + "db_url[text]=${db_url}" \ + "bootstrap_username[text]=knoe" \ + >/dev/null + _ok "knoe-db: DB role password and superuser password generated." + fi + + # ── Item: knoe-auth ──────────────────────────────────────────────────────── + _sep + _info "Item: knoe-auth (session secret)" + if op_item_exists "$ITEM_AUTH"; then + _warn "Item '${ITEM_AUTH}' already exists — skipping." + else + local session_secret + session_secret="$(gen_secret)" + op item create \ + --vault "$VAULT" \ + --category=Password \ + --title="$ITEM_AUTH" \ + "password[concealed]=${session_secret}" \ + >/dev/null + _ok "knoe-auth: session secret generated (${#session_secret} chars)." + fi + + # ── Item: knoe-oidc ──────────────────────────────────────────────────────── + _sep + _info "Item: knoe-oidc (OIDC RS256 signing key)" + if op_item_exists "$ITEM_OIDC"; then + _warn "Item '${ITEM_OIDC}' already exists — skipping." + else + _info " Generating RSA-2048 key (this takes a moment)..." + local signing_key + signing_key="$(gen_rsa_key_b64)" + op item create \ + --vault "$VAULT" \ + --category=Password \ + --title="$ITEM_OIDC" \ + "password[concealed]=${signing_key}" \ + >/dev/null + _ok "knoe-oidc: RS256 signing key generated." + fi + + # ── Item: knoe-google-tenant ─────────────────────────────────────────────── + _sep + _info "Item: knoe-google-tenant (Google OAuth for enrollment flow)" + if op_item_exists "$ITEM_GOOGLE"; then + _warn "Item '${ITEM_GOOGLE}' already exists — skipping." + else + local g_client_id g_client_secret + if [[ "$TENANT_TYPE" == "tenant" ]]; then + # Hosted tenant: use shared CI client (points at api.knoe.dev/auth) + g_client_id="${CI_GOOGLE_CLIENT_ID:-$PLACEHOLDER}" + g_client_secret="${CI_GOOGLE_CLIENT_SECRET:-$PLACEHOLDER}" + if [[ "$g_client_id" == "$PLACEHOLDER" ]]; then + _warn "tenant: No CI Google client provided (--ci-google-client / --ci-google-secret)." + _warn " Set PLACEHOLDER now and fill before 'apply', OR pass flags to skip." + else + _ok "tenant: Using provided CI Google client." + fi + else + # Enterprise tenant: placeholder — customer must create OAuth app in their Cloud Console + g_client_id="$PLACEHOLDER" + g_client_secret="$PLACEHOLDER" + _warn "PLACEHOLDER set for Google OAuth client." + _warn " Complete in Google Cloud Console (enterprise's GCP project):" + _warn " App type: Web application" + _warn " Redirect URIs:" + _warn " https://api.${TENANT_DOMAIN}/auth/auth/enroll/google-callback" + _warn " http://localhost:8080/auth/auth/enroll/google-callback (k3d)" + _warn " Then run:" + _warn " op item edit '${ITEM_GOOGLE}' --vault '${VAULT}' \\" + _warn " 'client_id[text]=' 'password[concealed]='" + fi + op item create \ + --vault "$VAULT" \ + --category=Login \ + --title="$ITEM_GOOGLE" \ + "username[text]=knoe-${TENANT_DOMAIN}" \ + "password[concealed]=${g_client_secret}" \ + "client_id[text]=${g_client_id}" \ + "hosted_domain[text]=${TENANT_DOMAIN}" \ + >/dev/null + _ok "knoe-google-tenant: item created." + fi + + # ── Item: knoe-kerberos ──────────────────────────────────────────────────── + _sep + _info "Item: knoe-kerberos (KDC passwords + HTTP service keytab)" + if op_item_exists "$ITEM_KERBEROS"; then + _warn "Item '${ITEM_KERBEROS}' already exists — skipping." + else + local master_pw admin_pw http_keytab_b64 + master_pw="$(gen_password)" + admin_pw="$(gen_password)" + + if [[ "$TENANT_TYPE" == "tenant" ]]; then + # Hosted tenant: uses an in-cluster KDC — keytab is bootstrapped at deploy time + # by the keytab-bootstrap initContainer; store a sentinel so apply knows + # to skip the keytab secret (initContainer handles it). + http_keytab_b64="__INITCONTAINER__" + _ok "tenant: Keytab will be bootstrapped by initContainer at deploy time." + else + # Enterprise tenant: keytab must be extracted from the enterprise's Samba AD / MIT KDC + http_keytab_b64="$PLACEHOLDER" + _warn "PLACEHOLDER set for HTTP service keytab (enterprise Kerberos infrastructure required)." + _warn " On ${KDC_HOST} (enterprise AD/KDC), run:" + _warn " sudo samba-tool user create knoe-auth-http --random-password" + _warn " sudo samba-tool spn add HTTP/api.${TENANT_DOMAIN} knoe-auth-http" + _warn " sudo samba-tool domain exportkeytab /tmp/http.keytab \\" + _warn " --principal=HTTP/api.${TENANT_DOMAIN}" + _warn " klist -k /tmp/http.keytab # verify" + _warn " Then store the keytab in 1Password:" + _warn " KEYTAB_B64=\$(ssh ${KDC_HOST} 'base64 -w0 /tmp/http.keytab')" + _warn " op item edit '${ITEM_KERBEROS}' --vault '${VAULT}' \\" + _warn " 'http_keytab_b64[concealed]='\"\${KEYTAB_B64}\"" + fi + + op item create \ + --vault "$VAULT" \ + --category=Password \ + --title="$ITEM_KERBEROS" \ + "password[concealed]=${master_pw}" \ + "master_password[concealed]=${master_pw}" \ + "admin_password[concealed]=${admin_pw}" \ + "http_keytab_b64[concealed]=${http_keytab_b64}" \ + "realm[text]=${TENANT_DOMAIN^^}" \ + "service_principal[text]=HTTP/api.${TENANT_DOMAIN}@${TENANT_DOMAIN^^}" \ + >/dev/null + _ok "knoe-kerberos: master + admin passwords generated." + fi + + _sep + _head "Provision complete" + echo + echo " Vault: ${VAULT}" + echo + echo " Next steps:" + echo " 1. Run 'status ${TENANT_ID}' to see what still needs external input." + if [[ "$TENANT_TYPE" == "enterprise" ]]; then + echo " 2. Complete PLACEHOLDERs (Google OAuth + enterprise Kerberos keytab — see warnings above)." + echo " 3. Run 'apply ${TENANT_ID} --context ' to create Kubernetes secrets." + else + echo " 2. Run 'apply ${TENANT_ID} --context ' to create Kubernetes secrets." + fi + echo " 4. After install: run 'rotate ${TENANT_ID}' to set user-chosen passwords." + echo +} + +# ── Status ──────────────────────────────────────────────────────────────────── + +cmd_status() { + require_op + + _head "Secret status: ${TENANT_ID}" + echo " Vault: ${VAULT}" + echo + + if ! op_vault_exists; then + _err "Vault '${VAULT}' not found. Run: $0 provision ${TENANT_ID}" + return 1 + fi + + _check_item() { + local item="$1" field="$2" label="$3" + local val + val="$(op_get_field "$item" "$field" 2>/dev/null || echo "$PLACEHOLDER")" + if [[ -z "$val" || "$val" == "$PLACEHOLDER" ]]; then + printf " %-40s %s\n" "$label" "⚠ PLACEHOLDER — needs external input" + elif [[ "$val" == "__INITCONTAINER__" ]]; then + printf " %-40s %s\n" "$label" "✓ (initContainer)" + else + printf " %-40s %s\n" "$label" "✓ set (${#val} chars)" + fi + } + + printf " %-40s %s\n" "SECRET" "STATUS" + printf " %-40s %s\n" "──────────────────────────────────────" "──────────────────────────────────" + + _check_item "$ITEM_DB" "password" "DB role 'knoe' password" + _check_item "$ITEM_DB" "superuser_password" "DB superuser password" + _check_item "$ITEM_AUTH" "password" "Session secret" + _check_item "$ITEM_OIDC" "password" "OIDC RS256 signing key" + _check_item "$ITEM_GOOGLE" "client_id" "Google OAuth client_id" + _check_item "$ITEM_GOOGLE" "password" "Google OAuth client_secret" + _check_item "$ITEM_KERBEROS" "master_password" "KDC master password" + _check_item "$ITEM_KERBEROS" "admin_password" "KDC admin password" + _check_item "$ITEM_KERBEROS" "http_keytab_b64" "HTTP service keytab" + echo +} + +# ── Apply ───────────────────────────────────────────────────────────────────── + +cmd_apply() { + require_op + + _head "Applying secrets to Kubernetes: ${TENANT_ID}" + _info "Context: ${KUBE_CONTEXT:-}" + _info "Namespace: ${KUBE_NAMESPACE}" + echo + + if ! op_vault_exists; then + _die "Vault '${VAULT}' not found. Run: $0 provision ${TENANT_ID}" + fi + + # ── Read all secrets ─────────────────────────────────────────────────────── + local knoe_pw superuser_pw db_url session_secret signing_key + local g_client_id g_client_secret hosted_domain + local kdc_master kdc_admin http_keytab_b64 + + _info "Reading secrets from vault..." + knoe_pw=$(op_get_field "$ITEM_DB" "password") + superuser_pw=$(op_get_field "$ITEM_DB" "superuser_password") + db_url=$(op_get_field "$ITEM_DB" "db_url") + session_secret=$(op_get_field "$ITEM_AUTH" "password") + signing_key=$(op_get_field "$ITEM_OIDC" "password") + g_client_id=$(op_get_field "$ITEM_GOOGLE" "client_id") + g_client_secret=$(op_get_field "$ITEM_GOOGLE" "password") + hosted_domain=$(op_get_field "$ITEM_GOOGLE" "hosted_domain") + kdc_master=$(op_get_field "$ITEM_KERBEROS" "master_password") + kdc_admin=$(op_get_field "$ITEM_KERBEROS" "admin_password") + http_keytab_b64=$(op_get_field "$ITEM_KERBEROS" "http_keytab_b64") + + # ── Validate: no PLACEHOLDERs ───────────────────────────────────────────── + local has_placeholder=false + _check_placeholder() { + local name="$1" val="$2" + if is_placeholder "$val"; then + _err "PLACEHOLDERs remain: ${name} — complete before running apply." + has_placeholder=true + fi + } + _check_placeholder "Google OAuth client_id" "$g_client_id" + _check_placeholder "Google OAuth client_secret" "$g_client_secret" + _check_placeholder "HTTP service keytab" "$http_keytab_b64" + if $has_placeholder; then + _die "Resolve all PLACEHOLDERs first. Use 'status ${TENANT_ID}' to check." + fi + + _ok "All secrets validated — no PLACEHOLDERs." + _sep + + # ── Create namespace if needed ───────────────────────────────────────────── + if ! _kubectl get namespace "$KUBE_NAMESPACE" >/dev/null 2>&1; then + _info "Creating namespace: ${KUBE_NAMESPACE}" + _kubectl create namespace "$KUBE_NAMESPACE" + fi + + _apply_secret() { + local name="$1"; shift + if _kubectl -n "$KUBE_NAMESPACE" get secret "$name" >/dev/null 2>&1; then + _kubectl -n "$KUBE_NAMESPACE" delete secret "$name" >/dev/null + fi + _kubectl -n "$KUBE_NAMESPACE" create secret generic "$name" "$@" >/dev/null + _ok "Secret applied: ${name}" + } + + # knoe-db-user (CNPG bootstrap — must match spec.bootstrap.initdb.owner) + _apply_secret knoe-db-user \ + --from-literal=username="knoe" \ + --from-literal=password="$knoe_pw" + + # knoe-auth-db + _apply_secret knoe-auth-db \ + --from-literal=db-url="$db_url" \ + --from-literal=db-user="knoe" \ + --from-literal=db-password="$knoe_pw" + + # knoe-auth-secrets + _apply_secret knoe-auth-secrets \ + --from-literal=sessionSecret="$session_secret" + + # knoe-auth-oidc-signing + _apply_secret knoe-auth-oidc-signing \ + --from-literal=signing-key="$signing_key" + + # knoe-auth-google- (enrollment flow) + _apply_secret "$K8S_GOOGLE_SECRET" \ + --from-literal=client_id="$g_client_id" \ + --from-literal=client_secret="$g_client_secret" + + # knoe-auth-http-keytab (skip if initContainer-managed) + if [[ "$http_keytab_b64" == "__INITCONTAINER__" ]]; then + _info "Keytab: managed by initContainer — skipping knoe-auth-http-keytab secret." + else + local tmpkeytab + tmpkeytab="$(mktemp /tmp/http_keytab_XXXX)" + echo "$http_keytab_b64" | base64 -d > "$tmpkeytab" + _apply_secret knoe-auth-http-keytab \ + --from-file=http.keytab="$tmpkeytab" + rm -f "$tmpkeytab" + fi + + # knoe-kdc-secrets (always — initContainer uses these even when also generating keytab) + _apply_secret knoe-kdc-secrets \ + --from-literal=master_password="$kdc_master" \ + --from-literal=admin_password="$kdc_admin" + + _sep + _head "Apply complete" + echo + echo " All Kubernetes secrets created in ${KUBE_NAMESPACE}." + echo + _warn "DB role sync required for existing clusters:" + _warn " The k8s secrets now hold the 1Password-generated password, but if the" + _warn " PostgreSQL 'knoe' role already exists with a different password, knoe-auth" + _warn " will fail to connect. Sync now:" + echo + echo " NEW_PW=\$(op item get 'knoe-db' --vault '${VAULT}' --fields 'label=password' --reveal)" + echo " kubectl --context=${KUBE_CONTEXT:-} -n ${DB_NAMESPACE} \\" + echo " exec \$(kubectl --context=${KUBE_CONTEXT:-} -n ${DB_NAMESPACE} get pod \\" + echo " -l 'cnpg.io/cluster=knoe-db,role=primary' -o jsonpath='{.items[0].metadata.name}') \\" + echo " -c postgres -- psql -U postgres -c \"ALTER ROLE knoe WITH PASSWORD '\${NEW_PW}';\"" + echo + echo " Or use 'rotate' which handles this automatically:" + echo " $0 rotate ${TENANT_ID} --context ${KUBE_CONTEXT:-}" + echo + echo " Then restart knoe-auth to pick up new secrets:" + echo " kubectl --context=${KUBE_CONTEXT:-} -n ${KUBE_NAMESPACE} \\" + echo " rollout restart deployment/knoe-auth" + echo +} + +# ── Rotate ──────────────────────────────────────────────────────────────────── + +cmd_rotate() { + require_op + require_openssl + + _head "Post-install password rotation: ${TENANT_ID}" + _info "Cluster: ${KUBE_CONTEXT:-}" + _info "Namespace: ${KUBE_NAMESPACE}" + _info "DB host: ${DB_HOST}" + echo + + if ! op_vault_exists; then + _die "Vault '${VAULT}' not found." + fi + + # Helper: prompt for new value or generate + _rotate_secret() { + local label="$1" current="$2" + local masked="${current:0:4}****" + + if $YES; then + # Non-interactive: regenerate + gen_password + return + fi + + echo + echo " ┌─ ${label}" + echo " │ Current: ${masked} (${#current} chars)" + echo " │" + echo " │ [1] Keep current" + echo " │ [2] Enter custom" + echo " │ [3] Regenerate (strong random)" + printf " └─ Choice [1/2/3]: " + read -r choice + case "$choice" in + 2) + printf " Enter new value: " + read -rs new_val; echo + echo "$new_val" + ;; + 3) + local new; new="$(gen_password)" + echo " Generated: ${new:0:4}****" >&2 + echo "$new" + ;; + *) + echo "$current" # keep + ;; + esac + } + + # ── Rotate: DB role 'knoe' password ─────────────────────────────────────── + _sep + _info "Rotating: DB role 'knoe' password" + local current_knoe_pw new_knoe_pw + current_knoe_pw="$(op_get_field "$ITEM_DB" "password")" + new_knoe_pw="$(_rotate_secret "DB role 'knoe' password" "$current_knoe_pw")" + + if [[ "$new_knoe_pw" != "$current_knoe_pw" ]]; then + _info " Updating DB role 'knoe' via ALTER ROLE..." + local superuser_pw + superuser_pw="$(op_get_field "$ITEM_DB" "superuser_password")" + + # Find CNPG primary pod + local primary_pod + primary_pod="$(_kubectl_db -n "$DB_NAMESPACE" get pod \ + -l "cnpg.io/cluster=knoe-db,role=primary" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)" + [[ -z "$primary_pod" ]] && _die "Cannot find CNPG primary pod in ${DB_NAMESPACE}." + + _kubectl_db -n "$DB_NAMESPACE" exec "$primary_pod" -c postgres -- \ + psql -U postgres -c "ALTER ROLE knoe WITH PASSWORD '${new_knoe_pw}';" >/dev/null + _ok " DB role updated." + + # Update 1P + op item edit "$ITEM_DB" --vault "$VAULT" \ + "password[concealed]=${new_knoe_pw}" >/dev/null + _ok " 1Password updated." + + # Update k8s secrets + local db_url + db_url="$(op_get_field "$ITEM_DB" "db_url")" + _kubectl -n "$KUBE_NAMESPACE" delete secret knoe-auth-db >/dev/null 2>&1 || true + _kubectl -n "$KUBE_NAMESPACE" create secret generic knoe-auth-db \ + --from-literal=db-url="$db_url" \ + --from-literal=db-user="knoe" \ + --from-literal=db-password="$new_knoe_pw" >/dev/null + _kubectl -n "$KUBE_NAMESPACE" delete secret knoe-db-user >/dev/null 2>&1 || true + _kubectl -n "$KUBE_NAMESPACE" create secret generic knoe-db-user \ + --from-literal=username="knoe" \ + --from-literal=password="$new_knoe_pw" >/dev/null + _ok " Kubernetes secrets updated." + else + _ok " Keeping current DB password." + fi + + # ── Rotate: session secret ───────────────────────────────────────────────── + _sep + _info "Rotating: session secret" + local current_session new_session + current_session="$(op_get_field "$ITEM_AUTH" "password")" + new_session="$(_rotate_secret "Session secret" "$current_session")" + + if [[ "$new_session" != "$current_session" ]]; then + op item edit "$ITEM_AUTH" --vault "$VAULT" \ + "password[concealed]=${new_session}" >/dev/null + _ok " 1Password updated." + _kubectl -n "$KUBE_NAMESPACE" delete secret knoe-auth-secrets >/dev/null 2>&1 || true + _kubectl -n "$KUBE_NAMESPACE" create secret generic knoe-auth-secrets \ + --from-literal=sessionSecret="$new_session" >/dev/null + _ok " Kubernetes secret updated." + else + _ok " Keeping current session secret." + fi + + # ── Rotate: KDC master password ──────────────────────────────────────────── + _sep + _info "Rotating: KDC master password" + local current_master new_master + current_master="$(op_get_field "$ITEM_KERBEROS" "master_password")" + new_master="$(_rotate_secret "KDC master password" "$current_master")" + + if [[ "$new_master" != "$current_master" ]]; then + op item edit "$ITEM_KERBEROS" --vault "$VAULT" \ + "master_password[concealed]=${new_master}" \ + "password[concealed]=${new_master}" >/dev/null + _ok " 1Password updated." + # Update the composite kdc-secrets secret + local current_admin + current_admin="$(op_get_field "$ITEM_KERBEROS" "admin_password")" + _kubectl -n "$KUBE_NAMESPACE" delete secret knoe-kdc-secrets >/dev/null 2>&1 || true + _kubectl -n "$KUBE_NAMESPACE" create secret generic knoe-kdc-secrets \ + --from-literal=master_password="$new_master" \ + --from-literal=admin_password="$current_admin" >/dev/null + _ok " Kubernetes secret updated." + _warn " KDC master password changed — the in-cluster KDC pod must be restarted." + _warn " Rolling restart will re-run the keytab-bootstrap initContainer." + else + _ok " Keeping current KDC master password." + fi + + # ── Rotate: KDC admin password ───────────────────────────────────────────── + _sep + _info "Rotating: KDC admin password" + local current_kadmin new_kadmin + current_kadmin="$(op_get_field "$ITEM_KERBEROS" "admin_password")" + new_kadmin="$(_rotate_secret "KDC admin password" "$current_kadmin")" + + if [[ "$new_kadmin" != "$current_kadmin" ]]; then + op item edit "$ITEM_KERBEROS" --vault "$VAULT" \ + "admin_password[concealed]=${new_kadmin}" >/dev/null + _ok " 1Password updated." + local current_master_now + current_master_now="$(op_get_field "$ITEM_KERBEROS" "master_password")" + _kubectl -n "$KUBE_NAMESPACE" delete secret knoe-kdc-secrets >/dev/null 2>&1 || true + _kubectl -n "$KUBE_NAMESPACE" create secret generic knoe-kdc-secrets \ + --from-literal=master_password="$current_master_now" \ + --from-literal=admin_password="$new_kadmin" >/dev/null + _ok " Kubernetes secret updated." + else + _ok " Keeping current KDC admin password." + fi + + # ── Roll deployment to pick up new secrets ───────────────────────────────── + _sep + _info "Rolling knoe-auth deployment to pick up updated secrets..." + _kubectl -n "$KUBE_NAMESPACE" rollout restart deployment/knoe-auth >/dev/null 2>&1 || \ + _warn "Could not restart deployment — do it manually: kubectl rollout restart deployment/knoe-auth" + _ok "Rollout triggered." + + _sep + _head "Rotation complete" + echo + echo " All selected secrets have been:" + echo " • Updated in 1Password vault '${VAULT}'" + echo " • Re-applied as Kubernetes secrets in ${KUBE_NAMESPACE}" + echo " • DB role updated via ALTER ROLE (if changed)" + echo + echo " knoe-auth deployment is rolling — run:" + echo " kubectl -n ${KUBE_NAMESPACE} rollout status deployment/knoe-auth" + echo +} + +# ── Dispatch ────────────────────────────────────────────────────────────────── + +case "$SUBCOMMAND" in + provision) cmd_provision ;; + apply) cmd_apply ;; + rotate) cmd_rotate ;; + status) cmd_status ;; + *) + echo "Usage: $0 {provision|apply|rotate|status} [OPTIONS]" >&2 + echo + echo " provision Create 1Password vault and pre-generate all secrets" + echo " apply Create Kubernetes secrets from 1Password vault" + echo " rotate Post-install: update passwords, re-apply secrets, roll deployment" + echo " status Show what is provisioned vs. pending in the vault" + echo + echo "Examples:" + echo " $0 provision prole.org --domain prole.org --type enterprise" + echo " $0 provision acme --type tenant --ci-google-client --ci-google-secret " + echo " $0 status prole.org" + echo " $0 apply prole.org --context prole-service-cluster" + echo " $0 rotate prole.org --context prole-service-cluster --yes" + exit 1 + ;; +esac diff --git a/infrastructure/inventory/host_vars/gandalf.prole.org.yml b/infrastructure/inventory/host_vars/gandalf.prole.org.yml index 18f7637..13a2d8b 100644 --- a/infrastructure/inventory/host_vars/gandalf.prole.org.yml +++ b/infrastructure/inventory/host_vars/gandalf.prole.org.yml @@ -21,11 +21,25 @@ k3s_rancher_mount_passno: 0 k3s_required_mounts: - /var/lib/rancher + - /synology/d003 - /synology/d005 iscsi_portal: 10.0.0.203:3260 iscsi_targets: + # PROLE-DATA-3 — knoe-db replica storage (CNPG knoe-db-3 data + WAL) + # Moved from pi.prole.org 2026-05-24 during gandalf full integration. + # Synology ACL must include gandalf initiator: iqn.1993-08.org.debian:01:5f78ddfd77a + - iqn: "iqn.2000-01.com.synology:synology.Target-13.292d45194a1" + chap_user: "prole" + chap_password: "{{ vault_iscsi_prole_password }}" + mounts: + - name: d003 + path: /synology/d003 + src: "UUID=757f1ee4-dc23-414b-b595-e3058c0744f0" + fstype: xfs + opts: "_netdev,noatime" + # PROLE-DATA-5 - iqn: "iqn.2000-01.com.synology:synology.Target-14.292d45194a1" chap_user: "prole" diff --git a/infrastructure/inventory/host_vars/pi.prole.org.yml b/infrastructure/inventory/host_vars/pi.prole.org.yml index ac75b4e..aaebeaa 100644 --- a/infrastructure/inventory/host_vars/pi.prole.org.yml +++ b/infrastructure/inventory/host_vars/pi.prole.org.yml @@ -1,22 +1,11 @@ iscsi_portal: 10.0.0.203 -k3s_required_mounts: - - /synology/d003 +# pi.prole.org is a dedicated pihole node — k3s disabled, insufficient RAM. +# PROLE-DATA-3 (d003) moved to gandalf.prole.org 2026-05-24. +k3s_required_mounts: [] iscsi_targets: - # `/var/lib/rancher` is local host storage (do not manage it via iSCSI). - - # PROLE-DATA-3 - - iqn: "iqn.2000-01.com.synology:synology.Target-13.292d45194a1" - chap_user: "prole" - chap_password: "{{ vault_iscsi_prole_password }}" - mounts: - - path: /synology/d003 - fstype: xfs - opts: "_netdev,noatime" - src: "UUID=757f1ee4-dc23-414b-b595-e3058c0744f0" - - # PROLE-PI-2 + # PROLE-PI-2 — pihole log storage - iqn: "iqn.2000-01.com.synology:synology.Target-19.292d45194a1" chap_user: "prole" chap_password: "{{ vault_iscsi_prole_password }}" @@ -30,12 +19,7 @@ iscsi_targets: # insufficient RAM for k3s workloads; agent manually stopped 2026-04-01. k3s_enabled: false k3s_state: absent - -k3s_rancher_mount_required: true -k3s_rancher_mount_src: /synology/d003/rancher -k3s_rancher_mount_fstype: none -k3s_rancher_mount_opts: bind -k3s_rancher_mount_passno: 0 +k3s_rancher_mount_required: false k3s_role: agent k3s_cluster_init: false diff --git a/k8s/knoe/knoe-db.yaml b/k8s/knoe/knoe-db.yaml index 749b410..e3cae3b 100644 --- a/k8s/knoe/knoe-db.yaml +++ b/k8s/knoe/knoe-db.yaml @@ -35,9 +35,15 @@ spec: shared_buffers: 256MB pg_stat_statements.max: '10000' pg_stat_statements.track: all + # pg_knoe_auth GUCs — active when oauthbearer pg_hba entries are enabled (requires PG18) + pg_knoe_auth.issuer: https://api.prole.org/auth + pg_knoe_auth.audience: knoe-db + pg_knoe_auth.role_claim: preferred_username + pg_knoe_auth.usermap_required: 'true' shared_preload_libraries: - pg_stat_statements - pg_tde + - pg_knoe_auth pg_hba: # allow password access from remote hosts if environment is "#dev" - local all postgres trust @@ -46,6 +52,9 @@ spec: - host knoe knoe-db all scram-sha-256 - host all all all scram-sha-256 - hostssl knoe knoe-db all scram-sha-256 + # OAUTHBEARER via pg_knoe_auth — requires PG18 + knoe-db image with pg_knoe_auth installed. + # Activate by uncommenting. JWT issued by api.prole.org/auth; preferred_username → pg_ident. + # - hostssl knoe-db knoe all oauthbearer issuer="https://api.prole.org/auth" # - host all all all gss include_realm=1 krb_realm=EXAMPLE.COM bootstrap: diff --git a/knoe-db/init_prole_app.sql b/knoe-db/init_prole_app.sql index 2845934..984c087 100644 --- a/knoe-db/init_prole_app.sql +++ b/knoe-db/init_prole_app.sql @@ -1,3 +1,11 @@ +-- Reference init script for direct psql execution. +-- Run from the knoe-db/ directory: +-- psql -v ON_ERROR_STOP=1 -U knoe -d knoe-db -f init_prole_app.sql +-- +-- CNPG path: SQL is wired via ConfigMap + postInitApplicationSQLRefs. +-- See docs/plans/junie/ekosystem-uuid-cnpg-wire.md for pending CNPG work. + +-- Extensions CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA knoe; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA knoe TO knoe; CREATE EXTENSION IF NOT EXISTS postgis SCHEMA knoe; @@ -8,3 +16,11 @@ CREATE EXTENSION IF NOT EXISTS vector SCHEMA knoe; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA knoe TO knoe; CREATE EXTENSION IF NOT EXISTS tds_fdw SCHEMA knoe; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA knoe TO knoe; + +-- Ekosystem UUID system (depends on extensions above) +\i schema/ekosystem.sql +\i schema/ekosystem_objects.sql + +-- Seed: knoe-db is tenant 0 (inserted idempotently in ekosystem.sql) +-- Register prole as tenant 1 when deploying the prole ekosystem: +-- SELECT * FROM knoe.register_tenant('prole'); diff --git a/knoe-db/schema/ekosystem.sql b/knoe-db/schema/ekosystem.sql new file mode 100644 index 0000000..7665f0c --- /dev/null +++ b/knoe-db/schema/ekosystem.sql @@ -0,0 +1,214 @@ +-- ============================================================================= +-- knoe.ekosystem — Stable routable base36 UUID system for federated clusters +-- +-- Bit layout (82 bits → 16 base36 chars): +-- [49: timestamp_ms][12: tenant_id][10: shard_id][11: seq] +-- +-- Epoch: 2026-01-01 00:00:00 UTC +-- Range: ~17,839 years (to ~19,865 AD) +-- Tenants: 4,096 (tenant_id 0 = knoe-db root authority) +-- Shards: 1,024 per tenant +-- Seq: 2,048 IDs/ms/shard (~2M IDs/sec/shard) +-- Alphabet: 0-9 a-z (base36 lowercase — safe for DNS labels and PG identifiers) +-- +-- Object naming pattern: p_{16-char-uuid}_{table_name} +-- Prefix is 19 chars. PostgreSQL limit 63 bytes → table_name budget: 44 chars. +-- SQLite and ClickHouse have no meaningful limit. +-- +-- LDAP integration point: knoe.tenants is the authority for access control. +-- Routing: knoe.ekosystem_tenant(table_name) extracts tenant_id from any +-- project-namespaced object name without a registry lookup. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Tenant registry — root of trust for all ekosystems +-- knoe-db is tenant 0 and the sole issuer of tenant UUIDs. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS knoe.tenants ( + tenant_id smallint PRIMARY KEY CHECK (tenant_id BETWEEN 0 AND 4095), + uuid text UNIQUE, -- 16-char ekosystem UUID; NULL for root + name text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO knoe.tenants (tenant_id, uuid, name) +VALUES (0, NULL, 'knoe-db') +ON CONFLICT DO NOTHING; + +-- Monotonic counter for the seq field; nextval() is O(1) with no row locking. +-- Wraps modulo 2048 (11 bits) inside the function. +CREATE SEQUENCE IF NOT EXISTS knoe.ekosystem_seq + AS bigint MINVALUE 0 NO CYCLE; + +-- --------------------------------------------------------------------------- +-- knoe.ekosystem_id(tenant_id, shard_id) → 16-char base36 +-- +-- Primary generator. numeric arithmetic handles the 82-bit value safely +-- since PostgreSQL bigint is capped at 63 bits. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.ekosystem_id( + p_tenant_id integer DEFAULT 0, + p_shard_id integer DEFAULT 0 +) RETURNS text +LANGUAGE plpgsql +AS $$ +DECLARE + c_epoch_ms constant bigint := 1767225600000; -- 2026-01-01 00:00:00 UTC + c_alphabet constant text := '0123456789abcdefghijklmnopqrstuvwxyz'; + v_ts_ms bigint; + v_seq integer; + v_packed numeric; + v_result text := ''; + v_rem integer; + i integer; +BEGIN + IF p_tenant_id NOT BETWEEN 0 AND 4095 THEN + RAISE EXCEPTION 'tenant_id must be 0–4095, got %', p_tenant_id; + END IF; + IF p_shard_id NOT BETWEEN 0 AND 1023 THEN + RAISE EXCEPTION 'shard_id must be 0–1023, got %', p_shard_id; + END IF; + + v_ts_ms := (extract(epoch from clock_timestamp()) * 1000)::bigint - c_epoch_ms; + v_seq := nextval('knoe.ekosystem_seq')::integer % 2048; + + -- [49: ts_ms | 12: tenant | 10: shard | 11: seq] packed as numeric + -- Power-of-2 multipliers avoid floating-point error in numeric division. + v_packed := v_ts_ms::numeric * 8589934592::numeric -- 2^33 + + p_tenant_id::numeric * 2097152::numeric -- 2^21 + + p_shard_id::numeric * 2048::numeric -- 2^11 + + v_seq::numeric; + + FOR i IN 1..16 LOOP + v_rem := mod(v_packed, 36)::integer; + v_result := substr(c_alphabet, v_rem + 1, 1) || v_result; + v_packed := floor(v_packed / 36); + END LOOP; + + RETURN v_result; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.ekosystem_decode(id) → (tenant_id, shard_id, ts_ms, seq, issued_at) +-- +-- Unpack any UUID back to its constituent fields. IMMUTABLE — safe to index. +-- Primary use: routing queries to the correct ekosystem shard without a +-- registry lookup, and LDAP principal resolution. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.ekosystem_decode(p_id text) +RETURNS TABLE( + tenant_id integer, + shard_id integer, + ts_ms bigint, + seq integer, + issued_at timestamptz +) +LANGUAGE plpgsql IMMUTABLE STRICT +AS $$ +DECLARE + c_epoch_ms constant bigint := 1767225600000; + c_alphabet constant text := '0123456789abcdefghijklmnopqrstuvwxyz'; + v_n numeric := 0; + v_pos integer; + i integer; +BEGIN + IF length(p_id) != 16 THEN + RAISE EXCEPTION 'ekosystem UUID must be exactly 16 chars, got %', length(p_id); + END IF; + + FOR i IN 1..16 LOOP + v_pos := position(substr(p_id, i, 1) IN c_alphabet) - 1; + IF v_pos < 0 THEN + RAISE EXCEPTION 'Invalid base36 character at position %: ''%''', + i, substr(p_id, i, 1); + END IF; + v_n := v_n * 36 + v_pos; + END LOOP; + + seq := mod(v_n, 2048)::integer; v_n := floor(v_n / 2048); + shard_id := mod(v_n, 1024)::integer; v_n := floor(v_n / 1024); + tenant_id := mod(v_n, 4096)::integer; v_n := floor(v_n / 4096); + ts_ms := v_n::bigint; + issued_at := to_timestamp((ts_ms + c_epoch_ms) / 1000.0); + + RETURN NEXT; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.ekosystem_tenant(table_name) → tenant_id +-- +-- Extract the owning tenant from a project-namespaced object name without any +-- registry join. Extracts the UUID from position 3 (past 'p_') for 16 chars. +-- +-- Usage in triggers: SELECT knoe.ekosystem_tenant(TG_TABLE_NAME) +-- Usage in routing: SELECT knoe.ekosystem_tenant('p_3k9mxqt2f8vn0r7b_accounts') +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.ekosystem_tenant(p_object_name text) +RETURNS integer +LANGUAGE sql IMMUTABLE STRICT +AS $$ + SELECT tenant_id + FROM knoe.ekosystem_decode(substring(p_object_name FROM 3 FOR 16)); +$$; + +-- --------------------------------------------------------------------------- +-- knoe.register_tenant(name) → (tenant_id, uuid, name) +-- +-- Register a new ekosystem tenant. The UUID is minted by the root authority +-- (tenant 0, shard 0) and becomes the tenant's stable external identifier — +-- used in DNS (db.{uuid}.prole.org) and service-account prefixes. +-- Subsequent IDs generated by the tenant embed their assigned tenant_id. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.register_tenant(p_name text) +RETURNS TABLE(tenant_id integer, uuid text, name text) +LANGUAGE plpgsql +AS $$ +DECLARE + v_tenant_id integer; + v_uuid text; +BEGIN + SELECT COALESCE(MAX(t.tenant_id), 0) + 1 + INTO v_tenant_id + FROM knoe.tenants t; + + IF v_tenant_id > 4095 THEN + RAISE EXCEPTION 'Tenant capacity exhausted (max 4096 ekosystems)'; + END IF; + + v_uuid := knoe.ekosystem_id(0, 0); -- root authority mints the UUID + + INSERT INTO knoe.tenants (tenant_id, uuid, name) + VALUES (v_tenant_id, v_uuid, p_name) + RETURNING tenants.tenant_id, tenants.uuid, tenants.name; + + RETURN NEXT; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.project_prefix(tenant_id, shard_id) → 'p_{uuid}_' +-- +-- Convenience: generate the full 19-char prefix for a new project namespace. +-- Caller appends the table name: prefix || 'accounts' +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.project_prefix( + p_tenant_id integer DEFAULT 0, + p_shard_id integer DEFAULT 0 +) RETURNS text +LANGUAGE sql +AS $$ + SELECT 'p_' || knoe.ekosystem_id(p_tenant_id, p_shard_id) || '_'; +$$; + +-- --------------------------------------------------------------------------- +-- Grants (mirrors existing knoe schema pattern) +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION knoe.ekosystem_id(integer, integer) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.ekosystem_decode(text) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.ekosystem_tenant(text) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.register_tenant(text) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.project_prefix(integer, integer) TO knoe; +GRANT SELECT, INSERT ON TABLE knoe.tenants TO knoe; +GRANT USAGE ON SEQUENCE knoe.ekosystem_seq TO knoe; diff --git a/knoe-db/schema/ekosystem_objects.sql b/knoe-db/schema/ekosystem_objects.sql new file mode 100644 index 0000000..b5952e3 --- /dev/null +++ b/knoe-db/schema/ekosystem_objects.sql @@ -0,0 +1,278 @@ +-- ============================================================================= +-- knoe.ekosystem_objects — Modular object kind registry, naming, and +-- cross-ekosystem sharing / grant model +-- +-- Depends on: ekosystem.sql (knoe.tenants, knoe.ekosystem_decode) +-- +-- Object naming convention: +-- {prefix}_{uuid} — standalone objects (project, user, embedding) +-- {prefix}_{uuid}_{name} — compound objects (table, index, view) +-- +-- All prefixes are {letter}_ so ekosystem_tenant() works uniformly: +-- substring(object_name FROM 3 FOR 16) always extracts the UUID. +-- +-- Cross-ekosystem sharing: +-- UUIDs embed the originating tenant_id in bits — ownership is always +-- decodable without a registry join, so imported objects cannot collide +-- even when mixed across ekosystems. knoe.cross_grants records the +-- explicit sharing approvals that LDAP/Samba AD will eventually enforce. +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- knoe.object_kinds — Modular kind registry +-- Add new kinds here; ekosystem_object() picks them up automatically. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS knoe.object_kinds ( + kind text PRIMARY KEY, + prefix char(1) NOT NULL UNIQUE CHECK (prefix ~ '^[a-z]$'), + description text NOT NULL +); + +INSERT INTO knoe.object_kinds (kind, prefix, description) VALUES + ('project', 'p', 'Project namespace — schema or resource root'), + ('user', 'u', 'Service account / PostgreSQL role'), + ('embedding', 'e', 'Vector embedding store'), + ('table', 't', 'Data table'), + ('index', 'x', 'Index or search structure'), + ('view', 'v', 'Logical view or materialised view'), + ('sequence', 's', 'Auto-increment sequence'), + ('function', 'f', 'Stored function or procedure') +ON CONFLICT DO NOTHING; + +GRANT SELECT ON TABLE knoe.object_kinds TO knoe; + +-- --------------------------------------------------------------------------- +-- knoe.ekosystem_object(uuid, kind) → '{prefix}_{uuid}' +-- +-- Returns the canonical identifier for any typed object. +-- Unknown kind raises — register it in knoe.object_kinds first. +-- +-- Compound names (tables, indexes): +-- knoe.ekosystem_object(uuid, 'table') || '_accounts' +-- → 't_3k9mxqt2f8vn0r7b_accounts' +-- +-- The 2-char prefix is the same width for every kind, so +-- ekosystem_tenant() always finds the UUID at position 3. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.ekosystem_object(p_uuid text, p_kind text) +RETURNS text +LANGUAGE plpgsql STABLE +AS $$ +DECLARE + v_prefix char(1); +BEGIN + SELECT prefix INTO v_prefix FROM knoe.object_kinds WHERE kind = p_kind; + IF NOT FOUND THEN + RAISE EXCEPTION + 'Unknown object kind: %. Register it in knoe.object_kinds first.', p_kind; + END IF; + RETURN v_prefix || '_' || p_uuid; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.ekosystem_kind(object_name) → kind +-- +-- Reverse lookup: given any prefixed object name, return its kind. +-- Returns NULL if the prefix is unregistered (not an error — enables routing +-- filters that skip non-ekosystem objects). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.ekosystem_kind(p_object_name text) +RETURNS text +LANGUAGE sql STABLE +AS $$ + SELECT kind + FROM knoe.object_kinds + WHERE prefix = substring(p_object_name FROM 1 FOR 1); +$$; + +-- --------------------------------------------------------------------------- +-- knoe.cross_grants — Cross-ekosystem sharing policy +-- +-- Records that grantor_tenant has shared an object with grantee_tenant. +-- This table is the policy source of truth; actual PG GRANT statements +-- and future LDAP/Samba AD group memberships are derived from it. +-- +-- Ownership is always decodable from the UUID itself via ekosystem_decode(), +-- so this table only needs to record the *exceptions* (sharing across +-- tenant boundaries). Within a single ekosystem, use standard PG GRANT. +-- +-- privileges: PostgreSQL privilege names, e.g. '{SELECT}', '{SELECT,INSERT}' +-- expires_at: NULL = permanent until explicitly revoked +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS knoe.cross_grants ( + id text PRIMARY KEY + DEFAULT knoe.ekosystem_id(0, 0), + object_uuid text NOT NULL, + object_kind text NOT NULL REFERENCES knoe.object_kinds(kind), + grantor_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id), + grantee_tenant smallint NOT NULL REFERENCES knoe.tenants(tenant_id), + privileges text[] NOT NULL CHECK (array_length(privileges, 1) > 0), + granted_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + CONSTRAINT cross_grants_no_self + CHECK (grantor_tenant != grantee_tenant), + CONSTRAINT cross_grants_unique + UNIQUE (object_uuid, object_kind, grantee_tenant) +); + +CREATE INDEX IF NOT EXISTS cross_grants_grantee_idx + ON knoe.cross_grants (grantee_tenant, object_kind); + +CREATE INDEX IF NOT EXISTS cross_grants_object_idx + ON knoe.cross_grants (object_uuid); + +GRANT SELECT, INSERT, DELETE ON TABLE knoe.cross_grants TO knoe; + +-- --------------------------------------------------------------------------- +-- knoe.grant_object(object_uuid, kind, grantee_tenant, privileges, expires_at) +-- +-- Record that the owning tenant shares an object with another ekosystem. +-- Validates that the caller's tenant actually owns the object (via UUID bits). +-- On conflict: refreshes privileges and expiry (upsert semantics). +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.grant_object( + p_object_uuid text, + p_kind text, + p_grantee_tenant smallint, + p_privileges text[] DEFAULT '{SELECT}', + p_expires_at timestamptz DEFAULT NULL +) RETURNS knoe.cross_grants +LANGUAGE plpgsql +AS $$ +DECLARE + v_grantor smallint; + v_row knoe.cross_grants; +BEGIN + -- Derive owner from UUID bits — no registry join needed + SELECT tenant_id INTO v_grantor FROM knoe.ekosystem_decode(p_object_uuid); + + IF NOT EXISTS (SELECT 1 FROM knoe.tenants WHERE tenant_id = p_grantee_tenant) THEN + RAISE EXCEPTION 'Grantee tenant % is not a registered ekosystem', p_grantee_tenant; + END IF; + + IF v_grantor = p_grantee_tenant THEN + RAISE EXCEPTION + 'Grant target is the object owner (tenant %). Use standard PG GRANT within an ekosystem.', + v_grantor; + END IF; + + INSERT INTO knoe.cross_grants + (object_uuid, object_kind, grantor_tenant, grantee_tenant, privileges, expires_at) + VALUES + (p_object_uuid, p_kind, v_grantor, p_grantee_tenant, p_privileges, p_expires_at) + ON CONFLICT (object_uuid, object_kind, grantee_tenant) DO UPDATE + SET privileges = EXCLUDED.privileges, + expires_at = EXCLUDED.expires_at, + granted_at = now() + RETURNING * INTO v_row; + + RETURN v_row; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.revoke_object(object_uuid, kind, grantee_tenant) +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.revoke_object( + p_object_uuid text, + p_kind text, + p_grantee_tenant smallint +) RETURNS boolean +LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM knoe.cross_grants + WHERE object_uuid = p_object_uuid + AND object_kind = p_kind + AND grantee_tenant = p_grantee_tenant; + RETURN FOUND; +END; +$$; + +-- --------------------------------------------------------------------------- +-- knoe.can_access(object_uuid, kind, tenant_id) → boolean +-- +-- True when: +-- (a) the tenant owns the object (tenant_id embedded in UUID), OR +-- (b) a non-expired cross-grant exists, OR +-- (c) tenant 0 (knoe-db root) — root has universal read access +-- +-- STABLE: safe in WHERE clauses and on replicas. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.can_access( + p_object_uuid text, + p_kind text, + p_tenant_id smallint +) RETURNS boolean +LANGUAGE sql STABLE +AS $$ + SELECT + p_tenant_id = 0 -- root authority + OR + (SELECT tenant_id + FROM knoe.ekosystem_decode(p_object_uuid)) = p_tenant_id -- owner + OR + EXISTS ( + SELECT 1 FROM knoe.cross_grants + WHERE object_uuid = p_object_uuid + AND object_kind = p_kind + AND grantee_tenant = p_tenant_id + AND (expires_at IS NULL OR expires_at > now()) + ); +$$; + +-- --------------------------------------------------------------------------- +-- knoe.my_grants(tenant_id) → active cross-grants visible to this ekosystem +-- Includes both grants received and grants issued by this tenant. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION knoe.my_grants(p_tenant_id smallint) +RETURNS TABLE( + direction text, -- 'received' | 'issued' + object_uuid text, + object_kind text, + object_name text, -- prefixed identifier + other_tenant smallint, + privileges text[], + granted_at timestamptz, + expires_at timestamptz +) +LANGUAGE sql STABLE +AS $$ + SELECT 'received'::text, + g.object_uuid, + g.object_kind, + knoe.ekosystem_object(g.object_uuid, g.object_kind), + g.grantor_tenant, + g.privileges, + g.granted_at, + g.expires_at + FROM knoe.cross_grants g + WHERE g.grantee_tenant = p_tenant_id + AND (g.expires_at IS NULL OR g.expires_at > now()) + + UNION ALL + + SELECT 'issued'::text, + g.object_uuid, + g.object_kind, + knoe.ekosystem_object(g.object_uuid, g.object_kind), + g.grantee_tenant, + g.privileges, + g.granted_at, + g.expires_at + FROM knoe.cross_grants g + WHERE g.grantor_tenant = p_tenant_id + AND (g.expires_at IS NULL OR g.expires_at > now()); +$$; + +-- --------------------------------------------------------------------------- +-- Grants (mirrors existing knoe schema pattern) +-- --------------------------------------------------------------------------- +GRANT EXECUTE ON FUNCTION knoe.ekosystem_object(text, text) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.ekosystem_kind(text) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.grant_object(text, text, smallint, text[], timestamptz) + TO knoe; +GRANT EXECUTE ON FUNCTION knoe.revoke_object(text, text, smallint) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.can_access(text, text, smallint) TO knoe; +GRANT EXECUTE ON FUNCTION knoe.my_grants(smallint) TO knoe;