feat(prole): bootstrap knoe-auth on k3s; tenant onboarding; cluster stabilisation

knoe-auth (prole.org k3s):
- Fix CNPG manifest drift: remove spec.backup.pluginConfiguration (CNPG 1.28 only),
  switch spec.certificates from serverTLSSecret to serverAltDNSNames
- Apply knoe-auth Round 1 schema + GRANTs manually (postInitSQL had never run on live cluster)
- Fix OIDC signing key generator: base64(DER) not base64(PEM) — OidcTokenService
  does Base64.decode() → PKCS8EncodedKeySpec which requires raw DER bytes
- Add OIDC controllers: authorize, token, userinfo, jwks, discovery
- Add prole Spring profile: cookieDomain, emailDomain, Kerberos config
- Add secret example templates: knoe-db-user, knoe-auth-oidc-signing, knoe-auth-google-prole
- Kong configmap: scope knoe-auth route to /auth prefix only

Tenant onboarding:
- Add etc/onboard_tenant.sh: provision/apply/rotate/status workflow backed by 1Password
  vaults; types: 'enterprise' (own Kerberos + domain) and 'tenant' (hosted, initContainer KDC)
- Provision 'Knoe Tenant - prole.org' vault; apply all 7 k8s secrets to knoe-system
- init_knoe_auth.sh: add explicit GRANT + ALTER DEFAULT PRIVILEGES for knoe role

Cluster stabilisation:
- gitea: roll back 14-day stuck rollout (RWO PVC + maxSurge=100% deadlock);
  patch deployment strategy to Recreate
- supabase: create supabase_admin role, _supabase db, _analytics schema, _realtime schema
  in CNPG — analytics and realtime had never connected since Helm install day 1
- knoe-db barman ObjectStore: add GCS-backed objectstore manifest + scheduled backup

Infrastructure:
- gandalf host_vars: k3s registry config
- pi host_vars: clean up stale entries
- knoe-db schemas: ekosystem.sql, ekosystem_objects.sql
- init_prole_app.sql: prole app DB initialisation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chrisfu 2026-05-26 00:50:37 -07:00
parent 03d1c3d9e8
commit cf33342500
28 changed files with 1658 additions and 102 deletions

View File

@ -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;

View File

@ -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 "";

View File

@ -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<SessionUser> 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)
@ -60,7 +62,7 @@ public class OidcAuthorizeController {
.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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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:}

View File

@ -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

View File

@ -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: {}

View File

@ -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_ID>" client_secret="<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: ""

View File

@ -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"

View File

@ -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"

View File

@ -5,7 +5,7 @@ metadata:
namespace: knoe-system
spec:
selector:
app: authority-prole-auth
app: knoe-auth
ports:
- name: http
port: 8080

View File

@ -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:

View File

@ -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

View File

@ -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=<strong-random-password>
apiVersion: v1
kind: Secret
metadata:
name: knoe-db-user
namespace: knoe-db
type: Opaque
stringData:
username: knoe
password: "CHANGE_ME"

View File

@ -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

View File

@ -62,7 +62,7 @@ data:
hosts:
- api.prole.org
paths:
- /
- /auth
strip_path: false
# Supabase Studio: gated by oauth2-proxy (Google Workspace prole.org).

View File

@ -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.

View File

@ -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,<samba-username>"
```
---
## 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 |

View File

@ -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

778
etc/onboard_tenant.sh Executable file
View File

@ -0,0 +1,778 @@
#!/usr/bin/env bash
# etc/onboard_tenant.sh — Tenant secret provisioning and Kubernetes wiring
#
# Usage:
# ./etc/onboard_tenant.sh provision <tenant-id> [OPTIONS]
# ./etc/onboard_tenant.sh apply <tenant-id> [OPTIONS]
# ./etc/onboard_tenant.sh rotate <tenant-id> [OPTIONS]
# ./etc/onboard_tenant.sh status <tenant-id>
#
# 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.<domain>.
# --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.<domain>.
# --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 - <tenant-id>"
# 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-<tid> 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} <tenant-id> [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]=<ID>' 'password[concealed]=<SECRET>'"
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 <CTX>' to create Kubernetes secrets."
else
echo " 2. Run 'apply ${TENANT_ID} --context <CTX>' 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:-<current>}"
_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-<tenant> (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:-<CTX>} -n ${DB_NAMESPACE} \\"
echo " exec \$(kubectl --context=${KUBE_CONTEXT:-<CTX>} -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:-<CTX>}"
echo
echo " Then restart knoe-auth to pick up new secrets:"
echo " kubectl --context=${KUBE_CONTEXT:-<CTX>} -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:-<current>}"
_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} <tenant-id> [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 <ID> --ci-google-secret <S>"
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

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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');

View File

@ -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 04095, got %', p_tenant_id;
END IF;
IF p_shard_id NOT BETWEEN 0 AND 1023 THEN
RAISE EXCEPTION 'shard_id must be 01023, 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;

View File

@ -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;