mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
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:
parent
03d1c3d9e8
commit
cf33342500
@ -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;
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {}
|
||||
|
||||
@ -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: ""
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
@ -5,7 +5,7 @@ metadata:
|
||||
namespace: knoe-system
|
||||
spec:
|
||||
selector:
|
||||
app: authority-prole-auth
|
||||
app: knoe-auth
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
23
deploy/opentofu/k3s/manifests/knoe/knoe-db-user.example.yaml
Normal file
23
deploy/opentofu/k3s/manifests/knoe/knoe-db-user.example.yaml
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -62,7 +62,7 @@ data:
|
||||
hosts:
|
||||
- api.prole.org
|
||||
paths:
|
||||
- /
|
||||
- /auth
|
||||
strip_path: false
|
||||
|
||||
# Supabase Studio: gated by oauth2-proxy (Google Workspace prole.org).
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
147
docs/plans/prole-auth-samba-ad-cross-realm.md
Normal file
147
docs/plans/prole-auth-samba-ad-cross-realm.md
Normal 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 |
|
||||
@ -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
778
etc/onboard_tenant.sh
Executable 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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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');
|
||||
|
||||
214
knoe-db/schema/ekosystem.sql
Normal file
214
knoe-db/schema/ekosystem.sql
Normal 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 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;
|
||||
278
knoe-db/schema/ekosystem_objects.sql
Normal file
278
knoe-db/schema/ekosystem_objects.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user