diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index 24a1d7c..0000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,79 +0,0 @@ -name: Build Docker Images -on: - push: - branches: - - main - tags: - - "images/v*.*.*" - -env: - REGISTRY: ghcr.io - REPOSITORY: pelotech/images - -jobs: - # Determine which image build contexts changed so we only rebuild those on - # pushes to main. Release tags (images/v*.*.*) build everything (see docker.if). - changes: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - images: ${{ steps.filter.outputs.changes }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 - id: filter - with: - filters: | - goose: images/goose/** - keyserver: images/keyserver/** - - docker: - needs: changes - # Build on a release tag (all images) or when an image's context changed on main. - if: startsWith(github.ref, 'refs/tags/') || needs.changes.outputs.images != '[]' - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - id-token: write - strategy: - matrix: - image: ${{ fromJSON(startsWith(github.ref, 'refs/tags/') && '["goose","keyserver"]' || needs.changes.outputs.images) }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - with: - cosign-release: 'v2.2.4' - - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - id: metadata - with: - images: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}/${{ matrix.image }} - tags: | - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - type=raw,value=${{ github.sha }},enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{version}} - - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - id: push - with: - context: ./images/${{ matrix.image }} - file: ./images/${{ matrix.image }}/Dockerfile - push: 'true' - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 - - name: sign the published image - env: - TAGS: ${{ steps.metadata.outputs.tags }} - DIGEST: ${{ steps.push.outputs.digest }} - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/charts/postgrest/Chart.lock b/charts/postgrest/Chart.lock deleted file mode 100644 index 69848f4..0000000 --- a/charts/postgrest/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: cluster - repository: https://cloudnative-pg.io/charts - version: 0.7.0 -digest: sha256:2085dcb4903893b9b3ccd2ece82b1faeb90663ac5a85d9f9abce34ebb798267d -generated: "2026-07-07T15:19:43.595725387Z" diff --git a/charts/postgrest/Chart.yaml b/charts/postgrest/Chart.yaml index a5bb645..f3821ed 100644 --- a/charts/postgrest/Chart.yaml +++ b/charts/postgrest/Chart.yaml @@ -8,9 +8,4 @@ maintainers: - name: apkatsikas email: andrew.katsikas@pelo.tech description: Helm chart for a PostgREST data api. - -dependencies: - - name: cluster - version: 0.7.0 - repository: https://cloudnative-pg.io/charts - condition: cluster.enabled +appVersion: "v14.16" diff --git a/charts/postgrest/README.md b/charts/postgrest/README.md index 65e8faa..b41c167 100644 --- a/charts/postgrest/README.md +++ b/charts/postgrest/README.md @@ -2,13 +2,9 @@ ### version: 0.7.0 -Helm chart for a PostgREST data api. - -## Requirements +![AppVersion: v14.16](https://img.shields.io/badge/AppVersion-v14.16-informational?style=flat-square) -| Repository | Name | Version | -|------------|------|---------| -| https://cloudnative-pg.io/charts | cluster | 0.7.0 | +Helm chart for a PostgREST data api. ## Values @@ -18,81 +14,60 @@ Helm chart for a PostgREST data api. | application.jwt.claim.selector | string | `".postgrest"` | | | application.preRequest | string | `""` | | | application.schemas | string | `"public"` | | -| cluster.cluster.instances | int | `3` | | -| cluster.cluster.roles[0].ensure | string | `"present"` | | -| cluster.cluster.roles[0].login | bool | `true` | | -| cluster.cluster.roles[0].name | string | `"goose"` | | -| cluster.cluster.roles[0].passwordSecret.name | string | `"migrations"` | | -| cluster.cluster.roles[0].superuser | bool | `true` | | -| cluster.cluster.roles[1].ensure | string | `"present"` | | -| cluster.cluster.roles[1].login | bool | `false` | | -| cluster.cluster.roles[1].name | string | `"anon"` | | -| cluster.cluster.roles[2].ensure | string | `"present"` | | -| cluster.cluster.roles[2].login | bool | `false` | | -| cluster.cluster.roles[2].name | string | `"peek"` | | -| cluster.cluster.roles[3].ensure | string | `"present"` | | -| cluster.cluster.roles[3].login | bool | `false` | | -| cluster.cluster.roles[3].name | string | `"view"` | | -| cluster.cluster.roles[4].ensure | string | `"present"` | | -| cluster.cluster.roles[4].login | bool | `false` | | -| cluster.cluster.roles[4].name | string | `"edit"` | | -| cluster.cluster.roles[5].ensure | string | `"present"` | | -| cluster.cluster.roles[5].inRoles[0] | string | `"anon"` | | -| cluster.cluster.roles[5].inRoles[1] | string | `"peek"` | | -| cluster.cluster.roles[5].inRoles[2] | string | `"view"` | | -| cluster.cluster.roles[5].inRoles[3] | string | `"edit"` | | -| cluster.cluster.roles[5].login | bool | `true` | | -| cluster.cluster.roles[5].name | string | `"postgrest"` | | -| cluster.cluster.roles[5].passwordSecret.name | string | `"connection"` | | -| cluster.cluster.roles[5].superuser | bool | `false` | | -| cluster.cluster.storage.size | string | `"1Gi"` | | -| cluster.databases[0].name | string | `"postgrest"` | | -| cluster.databases[0].owner | string | `"goose"` | | -| cluster.databases[0].schemas[0].ensure | string | `"present"` | | -| cluster.databases[0].schemas[0].name | string | `"public"` | | -| cluster.databases[0].schemas[0].owner | string | `"goose"` | | -| cluster.enabled | bool | `true` | | -| cluster.mode | string | `"standalone"` | | -| cluster.type | string | `"postgresql"` | | +| containerPorts.admin | int | `3001` | | +| containerPorts.http | int | `3000` | | | database.connection.database | string | `"postgrest"` | | | database.connection.hostname | string | `"postgrest-cluster-rw"` | | | database.connection.password | string | `"postgrest"` | | | database.connection.username | string | `"postgrest"` | | -| database.migrations.command[0] | string | `"/bin/goose"` | | -| database.migrations.command[1] | string | `"up"` | | -| database.migrations.database | string | `"postgrest"` | | -| database.migrations.enabled | bool | `false` | | -| database.migrations.hostname | string | `"postgrest-cluster-rw"` | | -| database.migrations.image | string | `"ghcr.io/pelotech/images/goose@sha256:afee303f0172bf7ed4db97180ce1561d0f1db09222f8e87d3313b2cd1fb184c9"` | | -| database.migrations.loadFromConfigMap | bool | `false` | | -| database.migrations.password | string | `"goose"` | | -| database.migrations.tag | string | `"latest"` | | -| database.migrations.username | string | `"goose"` | | -| imagePullSecrets | string | `nil` | | +| image.digest | string | `"sha256:bea1c76a856fa39d1e542d25911cf95d02fe2bf971992d033044ff209f1504b8"` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.registry | string | `"docker.io"` | | +| image.repository | string | `"postgrest/postgrest"` | | +| image.tag | string | `""` | | +| imagePullSecrets | list | `[]` | | | ingress.enabled | bool | `false` | | -| ingress.rules[0].host | string | `"auth.app.localhost"` | | +| ingress.rules[0].host | string | `"data.app.localhost"` | | | ingress.rules[0].http.paths[0].backend.service.name | string | `"postgrest"` | | -| ingress.rules[0].http.paths[0].backend.service.port.name | string | `"keyserver"` | | +| ingress.rules[0].http.paths[0].backend.service.port.name | string | `"postgrest"` | | | ingress.rules[0].http.paths[0].path | string | `"/"` | | | ingress.rules[0].http.paths[0].pathType | string | `"Prefix"` | | -| ingress.rules[1].host | string | `"data.app.localhost"` | | -| ingress.rules[1].http.paths[0].backend.service.name | string | `"postgrest"` | | -| ingress.rules[1].http.paths[0].backend.service.port.name | string | `"postgrest"` | | -| ingress.rules[1].http.paths[0].path | string | `"/"` | | -| ingress.rules[1].http.paths[0].pathType | string | `"Prefix"` | | -| ingress.tls[0].hosts[0] | string | `"auth.app.localhost"` | | -| ingress.tls[0].hosts[1] | string | `"data.app.localhost"` | | +| ingress.tls[0].hosts[0] | string | `"data.app.localhost"` | | | ingress.tls[0].secretName | string | `"app.localhost-tls"` | | -| keyserver.api.key | string | `"a-string-secret-at-least-256-bits-long"` | | -| keyserver.image | string | `"ghcr.io/pelotech/images/keyserver@sha256:a128a2b37ccf00ab6bbbcd9ed0321bc81b1875aff51d0eb3aa412f07e50804d8"` | | -| keyserver.jwt.alg | string | `"RS256"` | | -| keyserver.jwt.aud | string | `"postgrest"` | | -| keyserver.jwt.claims.postgrest | string | `"peek"` | | -| keyserver.jwt.exp | string | `"5 minutes"` | | -| keyserver.jwt.iss | string | `"https://auth.app.localhost"` | | -| keyserver.jwt.jwks_uri | string | `"https://auth.app.localhost/jwks"` | | -| keyserver.jwt.origin | string | `"https://jwt.io,https://app.localhost"` | | -| keyserver.jwt.sub | string | `""` | | -| keyserver.jwt.trust | string | `"https://sso.localhost/auth/realms/example/protocol/openid-connect/certs"` | | -| keyserver.tag | string | `"latest"` | | +| jwks.enabled | bool | `true` | | +| jwks.filename | string | `"jwks.json"` | | +| jwks.image.digest | string | `"sha256:e318141552c5e6923abff064bd2b8d338c2722bda46cf48d84404b9957bcd814"` | | +| jwks.image.pullPolicy | string | `"IfNotPresent"` | | +| jwks.image.registry | string | `"reg.mini.dev"` | | +| jwks.image.repository | string | `"curl-fips"` | | +| jwks.image.tag | string | `"8.21.0"` | | +| jwks.maxAttempts | int | `40` | | +| jwks.mountPath | string | `"/etc/opt/postgrest/certificates"` | | +| jwks.refresh.enabled | bool | `false` | | +| jwks.refresh.interval | int | `300` | | +| jwks.refresh.reload.enabled | bool | `true` | | +| jwks.refresh.reload.processName | string | `"postgrest"` | | +| jwks.refresh.reload.signal | string | `"USR2"` | | +| jwks.require.kid | string | `""` | | +| jwks.require.kty | string | `""` | | +| jwks.require.use | string | `""` | | +| jwks.resources | object | `{}` | | +| jwks.retryInterval | int | `3` | | +| jwks.securityContext.runAsUser | int | `1000` | | +| jwks.sizeLimit | string | `"1Mi"` | | +| jwks.timeout | int | `10` | | +| jwks.url | string | `"https://sso.localhost/auth/realms/example/protocol/openid-connect/certs"` | | +| podAnnotations | object | `{}` | | +| podSecurityContext.fsGroup | int | `1000` | | +| probes.liveness.enabled | bool | `true` | | +| probes.liveness.failureThreshold | int | `3` | | +| probes.liveness.periodSeconds | int | `10` | | +| probes.startup.enabled | bool | `true` | | +| probes.startup.failureThreshold | int | `60` | | +| probes.startup.periodSeconds | int | `2` | | +| replicaCount | int | `1` | | +| resources | object | `{}` | | +| securityContext | object | `{}` | | +| service.port | int | `3000` | | +| service.type | string | `"ClusterIP"` | | diff --git a/charts/postgrest/files/fetch-jwks.sh b/charts/postgrest/files/fetch-jwks.sh new file mode 100644 index 0000000..e846190 --- /dev/null +++ b/charts/postgrest/files/fetch-jwks.sh @@ -0,0 +1,139 @@ +#!/bin/sh +# Fetch a JWKS to disk for PostgREST, which takes jwt-secret as a literal or an @file only. +# +# once (default) fetch and exit. 0 if a usable key set ends up on disk, 1 otherwise. +# refresh then re-check every JWKS_REFRESH_INTERVAL seconds. Failures are not fatal. +# +# Responses are validated in a temp file and renamed into place, so a bad fetch never +# replaces working keys. +set -eu + +MODE="${1:-once}" +case "${MODE}" in + once | refresh) ;; + *) echo "jwks :: unknown role '${MODE}', expected 'once' or 'refresh'" >&2; exit 2 ;; +esac + +URL="${JWKS_URL:?JWKS_URL is required}" +TARGET="${JWKS_TARGET:-/etc/opt/postgrest/certificates/jwks.json}" +TIMEOUT="${JWKS_TIMEOUT:-10}" +RETRY_INTERVAL="${JWKS_RETRY_INTERVAL:-3}" +MAX_ATTEMPTS="${JWKS_MAX_ATTEMPTS:-40}" +REFRESH_INTERVAL="${JWKS_REFRESH_INTERVAL:-0}" +RELOAD_SIGNAL="${JWKS_RELOAD_SIGNAL:-}" +RELOAD_PROCESS="${JWKS_RELOAD_PROCESS:-postgrest}" +REQUIRE_KTY="${JWKS_REQUIRE_KTY:-}" +REQUIRE_USE="${JWKS_REQUIRE_USE:-}" +REQUIRE_KID="${JWKS_REQUIRE_KID:-}" + +TMP="${TARGET}.tmp" + +log() { echo "jwks :: $*"; } +err() { echo "jwks :: $*" >&2; } + +# Tests each key on its own. A document can carry the required kty, use and kid across +# different keys and satisfy a naive substring check without holding a usable key. +valid_jwks() { + [ -s "$1" ] || return 1 + JWKS_REQUIRE_KTY="${REQUIRE_KTY}" JWKS_REQUIRE_USE="${REQUIRE_USE}" \ + JWKS_REQUIRE_KID="${REQUIRE_KID}" awk ' + BEGIN { + want_kty = ENVIRON["JWKS_REQUIRE_KTY"] + want_use = ENVIRON["JWKS_REQUIRE_USE"] + want_kid = ENVIRON["JWKS_REQUIRE_KID"] + } + { doc = doc $0 } + END { + gsub(/[ \t\r\n]/, "", doc) + if (doc !~ /^\{/ || doc !~ /\}$/) exit 1 + if (doc !~ /"keys":\[/) exit 1 + + n = split(doc, key, /\},?\{/) + for (i = 1; i <= n; i++) { + if (key[i] !~ /"kty":"[^"]+"/) continue + if (want_kty != "" && key[i] !~ "\"kty\":\"" want_kty "\"") continue + if (want_use != "" && key[i] !~ "\"use\":\"" want_use "\"") continue + if (want_kid != "" && key[i] !~ /"kid":"[^"]+"/) continue + exit 0 + } + exit 1 + } + ' "$1" +} + +# Whitespace goes first: a pretty-printed document writes "kid": "x", not "kid":"x". +key_ids() { + tr -d '[:space:]' < "$1" \ + | grep -o '"kid":"[^"]*"' \ + | cut -d'"' -f4 \ + | sort \ + | paste -sd, - \ + | sed 's/,/, /g' +} + +# Key ids rather than bytes. Some providers reorder the keys per request, which would look +# like a rotation every refresh and flush PostgREST's JWT cache for nothing. +fingerprint() { + ids=$(key_ids "$1") + if [ -n "${ids}" ]; then + echo "${ids}" + else + cat "$1" + fi +} + +# SIGUSR2 makes PostgREST re-read the @file jwt-secret. Needs a matching uid: a non-root +# sender has no effective CAP_KILL. +reload() { + [ "${MODE}" = refresh ] || return 0 + [ -n "${RELOAD_SIGNAL}" ] || return 0 + + pid=$(pgrep -x "${RELOAD_PROCESS}" | head -1) + if [ -n "${pid}" ] && kill -"${RELOAD_SIGNAL}" "${pid}" 2>/dev/null; then + log "signalled ${RELOAD_PROCESS} (pid ${pid}) with SIG${RELOAD_SIGNAL}" + else + err "could not signal ${RELOAD_PROCESS}: not running, or not permitted" + fi +} + +fetch_once() { + attempt=1 + while [ "${attempt}" -le "${MAX_ATTEMPTS}" ]; do + if curl --fail --silent --show-error --location --max-time "${TIMEOUT}" \ + --output "${TMP}" "${URL}" && valid_jwks "${TMP}"; then + if [ -s "${TARGET}" ] && [ "$(fingerprint "${TMP}")" = "$(fingerprint "${TARGET}")" ]; then + rm -f "${TMP}" + log "unchanged" + else + mv -f "${TMP}" "${TARGET}" + ids=$(key_ids "${TARGET}") + log "wrote ${TARGET} from ${URL}${ids:+ (key ids: ${ids})}" + reload + fi + return 0 + fi + + rm -f "${TMP}" + err "no usable jwks from ${URL} (attempt ${attempt}/${MAX_ATTEMPTS})" + attempt=$((attempt + 1)) + [ "${attempt}" -le "${MAX_ATTEMPTS}" ] && sleep "${RETRY_INTERVAL}" + done + + if valid_jwks "${TARGET}"; then + err "giving up on the fetch, keeping the jwks already at ${TARGET}" + return 0 + fi + + err "no usable jwks fetched and none on disk at ${TARGET}" + return 1 +} + +fetch_once || exit 1 + +[ "${MODE}" = refresh ] || exit 0 + +log "refreshing every ${REFRESH_INTERVAL}s" +while true; do + sleep "${REFRESH_INTERVAL}" + fetch_once || true +done diff --git a/charts/postgrest/templates/_helpers.tpl b/charts/postgrest/templates/_helpers.tpl index 9674142..080fcab 100644 --- a/charts/postgrest/templates/_helpers.tpl +++ b/charts/postgrest/templates/_helpers.tpl @@ -6,14 +6,27 @@ {{- printf "user=%s password=%s host=%s dbname=%s sslmode=disable" $username $password $hostname $database }} {{- end -}} -{{- define "database.migrations" -}} -{{- $username := .Values.database.migrations.username }} -{{- $password := .Values.database.migrations.password }} -{{- $database := .Values.database.migrations.database }} -{{- $hostname := .Values.database.migrations.hostname }} -{{- printf "user=%s password=%s host=%s dbname=%s sslmode=disable" $username $password $hostname $database }} +{{- define "postgrest.image" -}} +{{- $registry := .Values.image.registry }} +{{- $repository := .Values.image.repository }} +{{- $tag := default .Chart.AppVersion .Values.image.tag }} +{{- if .Values.image.digest }} +{{- printf "%s/%s@%s" $registry $repository .Values.image.digest }} +{{- else }} +{{- printf "%s/%s:%s" $registry $repository $tag }} +{{- end }} +{{- end -}} + +{{- define "postgrest.jwks.image" -}} +{{- $registry := .Values.jwks.image.registry }} +{{- $repository := .Values.jwks.image.repository }} +{{- if .Values.jwks.image.digest }} +{{- printf "%s/%s@%s" $registry $repository .Values.jwks.image.digest }} +{{- else }} +{{- printf "%s/%s:%s" $registry $repository .Values.jwks.image.tag }} +{{- end }} {{- end -}} -{{- define "postgrest.jwt.claims" }} -{{- printf "{%s:%s}" (.Values.application.jwt.claim | quote ) (.Values.application.anon | quote )}} -{{- end }} \ No newline at end of file +{{- define "postgrest.jwks.path" -}} +{{- printf "%s/%s" (trimSuffix "/" .Values.jwks.mountPath) .Values.jwks.filename }} +{{- end -}} diff --git a/charts/postgrest/templates/configurations.yaml b/charts/postgrest/templates/configurations.yaml index 62fabeb..1e30b7b 100644 --- a/charts/postgrest/templates/configurations.yaml +++ b/charts/postgrest/templates/configurations.yaml @@ -1,31 +1,3 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: goose - namespace: {{ .Release.Namespace }} -data: - GOOSE_DRIVER: "postgres" - ---- - -apiVersion: v1 -kind: ConfigMap -metadata: - name: keyserver - namespace: {{ .Release.Namespace }} -data: - PGRST_JWT_ALG: {{ .Values.keyserver.jwt.alg | quote }} - PGRST_JWT_ISS: {{ .Values.keyserver.jwt.iss | quote }} - PGRST_JWT_EXP: {{ .Values.keyserver.jwt.exp | quote }} - {{- if .Values.keyserver.jwt.sub }} - PGRST_JWT_SUB: {{ .Values.keyserver.jwt.sub | quote }} - {{- end }} - PGRST_CLIENT_ORIGIN: {{ .Values.keyserver.jwt.origin | quote }} - PGRST_JWK_TRUST: {{ .Values.keyserver.jwt.trust | quote }} - PGRST_JWT_CLAIMS: {{ .Values.keyserver.jwt.claims | toJson | quote }} - ---- - apiVersion: v1 kind: ConfigMap metadata: @@ -38,6 +10,21 @@ data: PGRST_DB_PRE_REQUEST: {{ .Values.application.preRequest | quote }} {{- end }} PGRST_JWT_ROLE_CLAIM_KEY: {{ .Values.application.jwt.claim.selector | quote }} - PGRST_JWT_SECRET: "@/etc/opt/postgrest/certificates/jwks.json" + {{- if or .Values.probes.startup.enabled .Values.probes.liveness.enabled }} + PGRST_ADMIN_SERVER_PORT: {{ .Values.containerPorts.admin | quote }} + {{- end }} + {{- if .Values.jwks.enabled }} + PGRST_JWT_SECRET: {{ printf "@%s" (include "postgrest.jwks.path" .) | quote }} + {{- end }} +{{- if .Values.jwks.enabled }} --- +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgrest-jwks + namespace: {{ .Release.Namespace }} +data: + fetch-jwks.sh: | +{{ .Files.Get "files/fetch-jwks.sh" | indent 4 }} +{{- end }} diff --git a/charts/postgrest/templates/database.yaml b/charts/postgrest/templates/database.yaml deleted file mode 100644 index 5099506..0000000 --- a/charts/postgrest/templates/database.yaml +++ /dev/null @@ -1,13 +0,0 @@ -{{ $namespace := .Release.Namespace }} -{{- if (not .Values.cluster.enabled) }} -{{- range $database := .Values.cluster.databases }} -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: postgrest - namespace: "{{ $namespace }}" -spec: - {{ $database | toYaml | nindent 2 }} ---- -{{- end }} -{{- end }} diff --git a/charts/postgrest/templates/deployment.yaml b/charts/postgrest/templates/deployment.yaml index f430995..4edc96e 100644 --- a/charts/postgrest/templates/deployment.yaml +++ b/charts/postgrest/templates/deployment.yaml @@ -4,7 +4,7 @@ metadata: name: postgrest namespace: "{{ .Release.Namespace }}" spec: - replicas: 1 + replicas: {{ .Values.replicaCount }} selector: matchLabels: app.kubernetes.io/component: postgrest @@ -16,87 +16,119 @@ spec: app.kubernetes.io/component: postgrest app.kubernetes.io/instance: "{{ .Release.Namespace }}" app.kubernetes.io/name: postgrest + annotations: + # Without these a config or credential change never reaches a running pod. + checksum/config: {{ include (print $.Template.BasePath "/configurations.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secrets.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} spec: imagePullSecrets: {{ default ("[]" | toJson) (.Values.imagePullSecrets | toJson) }} + {{- if and .Values.jwks.enabled .Values.jwks.refresh.enabled .Values.jwks.refresh.reload.enabled }} + # So the refresh run can signal PostgREST. + shareProcessNamespace: true + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.jwks.enabled }} initContainers: - - name: keyserver - image: {{ .Values.keyserver.image }}:{{ .Values.keyserver.tag }} - imagePullPolicy: IfNotPresent - restartPolicy: Always - ports: - - name: keyserver - containerPort: 8000 - protocol: TCP - volumeMounts: - - mountPath: /etc/opt/postgrest/certificates - name: certificates - envFrom: - - configMapRef: - name: keyserver - env: - - name: PGRST_JWT_SECRET - valueFrom: - configMapKeyRef: - name: postgrest - key: PGRST_JWT_SECRET - - name: PGRST_CLIENT_KEY - valueFrom: - secretKeyRef: - name: keyserver - key: PGRST_CLIENT_KEY - - name: wait - image: alpine:3.23.2 - imagePullPolicy: IfNotPresent - command: - - /bin/sh - - -c - - | - apk add --no-cache postgresql-client - - while ! psql "$CONNECTION" -c "SELECT 1;" 2>/dev/null; do - echo "awaiting connection - retrying in 5 seconds" - sleep 5 - done - echo "connection succeeded" - env: - - name: CONNECTION - valueFrom: - secretKeyRef: - name: migrations - key: connection - {{- if .Values.database.migrations.enabled }} - - name: goose - image: {{ .Values.database.migrations.image }}:{{ .Values.database.migrations.tag }} - imagePullPolicy: IfNotPresent + # Aliased below for the refresh run, which differs only in name, restart + # policy and role. + - &jwks + name: jwks-fetch + image: {{ include "postgrest.jwks.image" . }} + imagePullPolicy: {{ .Values.jwks.image.pullPolicy }} command: - {{- toYaml .Values.database.migrations.command | nindent 12 }} - {{- if .Values.database.migrations.loadFromConfigMap }} - volumeMounts: - - name: migrations - mountPath: /migrations - {{- end}} - envFrom: - - configMapRef: - name: goose + - /usr/bin/sh + - /scripts/fetch-jwks.sh + args: + - once env: - - name: GOOSE_DBSTRING - valueFrom: - secretKeyRef: - key: connection - name: migrations - {{- if .Values.database.migrations.loadFromConfigMap }} - - name: GOOSE_MIGRATION_DIR - value: /migrations + - name: JWKS_URL + value: {{ .Values.jwks.url | required "jwks.url is required when jwks.enabled is true" | quote }} + - name: JWKS_TARGET + value: {{ include "postgrest.jwks.path" . | quote }} + - name: JWKS_TIMEOUT + value: {{ .Values.jwks.timeout | quote }} + - name: JWKS_RETRY_INTERVAL + value: {{ .Values.jwks.retryInterval | quote }} + - name: JWKS_MAX_ATTEMPTS + value: {{ .Values.jwks.maxAttempts | quote }} + - name: JWKS_REQUIRE_KTY + value: {{ .Values.jwks.require.kty | quote }} + - name: JWKS_REQUIRE_USE + value: {{ .Values.jwks.require.use | quote }} + - name: JWKS_REQUIRE_KID + value: {{ .Values.jwks.require.kid | quote }} + - name: JWKS_REFRESH_INTERVAL + value: {{ .Values.jwks.refresh.interval | quote }} + {{- if .Values.jwks.refresh.reload.enabled }} + - name: JWKS_RELOAD_SIGNAL + value: {{ .Values.jwks.refresh.reload.signal | quote }} + - name: JWKS_RELOAD_PROCESS + value: {{ .Values.jwks.refresh.reload.processName | quote }} {{- end }} + {{- with .Values.jwks.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: certificates + mountPath: {{ .Values.jwks.mountPath }} + - name: jwks-script + mountPath: /scripts + readOnly: true + {{- with .Values.jwks.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.jwks.refresh.enabled }} + - <<: *jwks + name: jwks-refresh + restartPolicy: Always + args: + - refresh {{- end }} + {{- end }} containers: - name: postgrest - image: postgrest/postgrest - imagePullPolicy: IfNotPresent + image: {{ include "postgrest.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} ports: - name: postgrest - containerPort: 3000 + containerPort: {{ .Values.containerPorts.http }} + protocol: TCP + {{- if or .Values.probes.startup.enabled .Values.probes.liveness.enabled }} + - name: admin + containerPort: {{ .Values.containerPorts.admin }} protocol: TCP + {{- end }} + {{- if .Values.probes.startup.enabled }} + # Gates the rollout. Not re-evaluated once it passes, so a later outage + # leaves the pod serving 503 rather than dropping out of the Service. + startupProbe: + httpGet: + path: /ready + port: admin + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + {{- end }} + {{- if .Values.probes.liveness.enabled }} + # /live stays 200 with the database down, so an outage cannot restart-loop. + livenessProbe: + httpGet: + path: /live + port: admin + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + {{- end }} envFrom: - configMapRef: name: postgrest @@ -106,15 +138,22 @@ spec: secretKeyRef: key: PGRST_DB_URI name: connection + {{- if .Values.jwks.enabled }} volumeMounts: - - mountPath: /etc/opt/postgrest/certificates - name: certificates + - name: certificates + mountPath: {{ .Values.jwks.mountPath }} + readOnly: true + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.jwks.enabled }} volumes: - name: certificates emptyDir: - sizeLimit: 1Mi - {{- if .Values.database.migrations.loadFromConfigMap }} - - name: migrations + sizeLimit: {{ .Values.jwks.sizeLimit }} + - name: jwks-script configMap: - name: migrations - {{- end}} \ No newline at end of file + name: postgrest-jwks + {{- end }} diff --git a/charts/postgrest/templates/migrations.yaml b/charts/postgrest/templates/migrations.yaml deleted file mode 100644 index 57310a6..0000000 --- a/charts/postgrest/templates/migrations.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: migrations - namespace: {{ .Release.Namespace }} -data: - 00001_noop.sql: | - -- +goose up - SELECT 1; - ---- \ No newline at end of file diff --git a/charts/postgrest/templates/secrets.yaml b/charts/postgrest/templates/secrets.yaml index 89a6cc3..04bf19b 100644 --- a/charts/postgrest/templates/secrets.yaml +++ b/charts/postgrest/templates/secrets.yaml @@ -1,22 +1,7 @@ apiVersion: v1 kind: Secret -type: kubebernetes.io/basic-auth -metadata: - name: migrations - namespace: {{ .Release.Namespace }} - labels: - cnpg.io/reload: "true" -data: - username: {{ .Values.database.migrations.username | b64enc | quote }} - password: {{ .Values.database.migrations.password | b64enc | quote }} - hostname: {{ .Values.database.migrations.hostname | b64enc | quote }} - connection: {{ include "database.migrations" . | b64enc | quote }} - ---- - -apiVersion: v1 -kind: Secret -type: kubebernetes.io/basic-auth +# Not kubernetes.io/basic-auth: that type rejects keys beyond username/password. +type: Opaque metadata: name: connection namespace: {{ .Release.Namespace }} @@ -27,16 +12,3 @@ data: password: {{ .Values.database.connection.password | b64enc | quote }} hostname: {{ .Values.database.connection.hostname | b64enc | quote }} PGRST_DB_URI: {{ include "database.connection" . | b64enc | quote }} - ---- - -apiVersion: v1 -kind: Secret -metadata: - name: keyserver - namespace: {{ .Release.Namespace }} -data: - PGRST_CLIENT_KEY: {{ .Values.keyserver.api.key | b64enc | quote }} - ---- - diff --git a/charts/postgrest/templates/service.yaml b/charts/postgrest/templates/service.yaml index 198b0bc..7b34a1a 100644 --- a/charts/postgrest/templates/service.yaml +++ b/charts/postgrest/templates/service.yaml @@ -4,17 +4,13 @@ metadata: name: postgrest namespace: "{{ .Release.Namespace }}" spec: - type: ClusterIP + type: {{ .Values.service.type }} selector: app.kubernetes.io/component: postgrest app.kubernetes.io/instance: "{{ .Release.Namespace }}" app.kubernetes.io/name: postgrest ports: - - name: keyserver - port: 8000 - protocol: TCP - targetPort: keyserver - name: postgrest - port: 3000 + port: {{ .Values.service.port }} protocol: TCP targetPort: postgrest diff --git a/charts/postgrest/values.yaml b/charts/postgrest/values.yaml index 8b5dbab..fac29f4 100644 --- a/charts/postgrest/values.yaml +++ b/charts/postgrest/values.yaml @@ -1,124 +1,238 @@ -imagePullSecrets: +## @param imagePullSecrets Image pull secrets applied to every container in the pod +## e.g: +## imagePullSecrets: +## - name: my-registry-creds +## +imagePullSecrets: [] + +## @param replicaCount Number of PostgREST replicas +## +replicaCount: 1 + +image: + ## @param image.registry PostgREST image registry + ## + registry: docker.io + ## @param image.repository PostgREST image repository + ## + repository: postgrest/postgrest + ## @param image.tag Image tag. Defaults to the chart appVersion when empty. + ## + tag: "" + ## @param image.digest Image digest (sha256:...). When set, pins the pull and takes precedence over the tag. + ## + digest: "sha256:bea1c76a856fa39d1e542d25911cf95d02fe2bf971992d033044ff209f1504b8" + ## @param image.pullPolicy PostgREST image pull policy + ## + pullPolicy: IfNotPresent application: - schemas: public # PSQL schemas (comma separated) for api usage - anon: anon # PSQL role to grant anonymous api usage - preRequest: "" # PSQL to run at beginning of each request + ## @param application.schemas PSQL schemas (comma separated) exposed by the api + ## + schemas: public + ## @param application.anon PSQL role granted anonymous api usage + ## + anon: anon + ## @param application.preRequest PSQL function to run at the beginning of each request + ## + preRequest: "" jwt: claim: + ## @param application.jwt.claim.selector JSPath selector for the JWT claim carrying the PSQL role + ## selector: ".postgrest" database: connection: - username: postgrest # application username for PSQL connection - password: postgrest # application password for PSQL connection - database: postgrest # application database for PSQL connection - hostname: postgrest-cluster-rw # application hostname for PSQL connection - - migrations: - username: goose # superuser username for PSQL connection - password: goose # superuser password for PSQL connection - database: postgrest # superuser database for PSQL connection - hostname: postgrest-cluster-rw # superuser hostname for PSQL connection - image: ghcr.io/pelotech/images/goose@sha256:afee303f0172bf7ed4db97180ce1561d0f1db09222f8e87d3313b2cd1fb184c9 - tag: latest - enabled: false - loadFromConfigMap: false - command: - - /bin/goose - - up + ## @param database.connection.username Application username for the PSQL connection + ## + username: postgrest + ## @param database.connection.password Application password for the PSQL connection + ## + password: postgrest + ## @param database.connection.database Application database for the PSQL connection + ## + database: postgrest + ## @param database.connection.hostname Application hostname for the PSQL connection + ## + hostname: postgrest-cluster-rw -cluster: +## Fetches a JWKS to a volume shared with PostgREST, which reads it via PGRST_JWT_SECRET. +## Runs files/fetch-jwks.sh as an init container that fetches once and exits, so PostgREST +## does not start until a usable key set is on disk. +## +jwks: + ## @param jwks.enabled Fetch a JWKS and point PGRST_JWT_SECRET at it. + ## When false, no JWT secret is configured and you must supply one yourself. + ## enabled: true - type: postgresql - mode: standalone - cluster: - instances: 3 - storage: - size: 1Gi - roles: - - name: goose - ensure: present - login: true - superuser: true - passwordSecret: - name: migrations + ## @param jwks.url JWKS endpoint to fetch. Required when jwks.enabled is true. + ## + url: https://sso.localhost/auth/realms/example/protocol/openid-connect/certs + ## @param jwks.mountPath Directory shared with PostgREST + ## + mountPath: /etc/opt/postgrest/certificates + ## @param jwks.filename Filename written into jwks.mountPath + ## + filename: jwks.json + ## @param jwks.timeout Per-attempt curl timeout in seconds + ## + timeout: 10 + ## @param jwks.retryInterval Seconds between attempts + ## + retryInterval: 3 + ## @param jwks.maxAttempts Attempts before giving up. With the default retryInterval this + ## is two minutes of tolerance for an identity provider that is still coming up. + ## + maxAttempts: 40 + ## @param jwks.sizeLimit Size limit of the shared emptyDir volume + ## + sizeLimit: 1Mi + ## Extra constraints on a usable key, on top of the structural checks. Empty accepts any, + ## since use and kid are optional in RFC 7517. A Keycloak realm can take RSA / sig / kid. + ## + require: + ## @param jwks.require.kty Required key type, e.g. RSA. Empty accepts any. + ## + kty: "" + ## @param jwks.require.use Required key use, e.g. sig. Empty accepts any. + ## + use: "" + ## @param jwks.require.kid Require a non-empty kid. PostgREST matches an incoming + ## token's kid header against the set. + ## + kid: "" + ## Re-fetch on an interval, in a resident sidecar. Off by default: only useful where + ## signing keys rotate, and a resident container that crashes drops the pod from its + ## Service endpoints. Otherwise rotate with `kubectl rollout restart`. + ## + refresh: + ## @param jwks.refresh.enabled Run the script resident, re-checking on an interval + ## + enabled: false + ## @param jwks.refresh.interval Seconds between refresh checks + ## + interval: 300 + ## PostgREST does not watch the jwt-secret file, so signal it after a rotation. + ## Enables shareProcessNamespace, which also exposes container filesystems to each + ## other via /proc//root. + ## + reload: + ## @param jwks.refresh.reload.enabled Signal PostgREST after the key set changes + ## + enabled: true + ## @param jwks.refresh.reload.signal SIGUSR2 reloads config, which re-reads the + ## jwt-secret file. SIGUSR1 only reloads the schema cache. + ## + signal: USR2 + ## @param jwks.refresh.reload.processName Process name to signal, matched exactly + ## + processName: postgrest + image: + ## @param jwks.image.registry JWKS image registry + ## + registry: reg.mini.dev + ## @param jwks.image.repository JWKS image repository. Minimus hardened FIPS curl. + ## + repository: curl-fips + ## @param jwks.image.tag JWKS image tag + ## + tag: "8.21.0" + ## @param jwks.image.digest Image digest (sha256:...). When set, pins the pull and takes precedence over the tag. + ## + digest: "sha256:e318141552c5e6923abff064bd2b8d338c2722bda46cf48d84404b9957bcd814" + ## @param jwks.image.pullPolicy JWKS image pull policy + ## + pullPolicy: IfNotPresent + ## @param jwks.securityContext runAsUser must match the PostgREST container's uid (1000 + ## in postgrest/postgrest) or the refresh run's signal is rejected with EPERM. Change it + ## alongside image.repository. + ## + securityContext: + runAsUser: 1000 + ## @param jwks.resources Resource requests and limits for both JWKS runs + ## + resources: {} - # default transaction scoped role with valid JWT from OIDC - - name: anon - ensure: present - login: false +## @param podSecurityContext fsGroup matches the uid both containers run as, so the shared +## volume is writable by one and readable by the other. +## +podSecurityContext: + fsGroup: 1000 - # short-lived JWT issued by keyserver - - name: peek - ensure: present - login: false +## @param podAnnotations Annotations to add to the pod +## +podAnnotations: {} - # transaction scoped role from realm.access() mapping function - - name: view - ensure: present - login: false +## @param securityContext Container-level security context for the PostgREST container +## +securityContext: {} - # transaction scoped role from realm.access() mapping function - - name: edit - ensure: present - login: false +## @param resources Resource requests and limits for the PostgREST container +## +resources: {} - - name: postgrest - ensure: present - login: true - superuser: false - passwordSecret: - name: connection - inRoles: - - anon - - peek - - view - - edit +service: + ## @param service.type Kubernetes service type + ## + type: ClusterIP + ## @param service.port Service port for the PostgREST api + ## + port: 3000 - databases: - - name: postgrest - owner: goose - schemas: - - name: public - owner: goose - ensure: present +containerPorts: + ## @param containerPorts.http Container port PostgREST listens on + ## + http: 3000 + ## @param containerPorts.admin PostgREST's admin server, serving /live and /ready for the + ## probes below. Not exposed on the Service. + ## + admin: 3001 -keyserver: - image: ghcr.io/pelotech/images/keyserver@sha256:a128a2b37ccf00ab6bbbcd9ed0321bc81b1875aff51d0eb3aa412f07e50804d8 - tag: latest - api: - key: a-string-secret-at-least-256-bits-long - jwt: - alg: RS256 - iss: https://auth.app.localhost - aud: postgrest - exp: 5 minutes - claims: - postgrest: peek - origin: https://jwt.io,https://app.localhost - trust: https://sso.localhost/auth/realms/example/protocol/openid-connect/certs - jwks_uri: https://auth.app.localhost/jwks - sub: '' +## Probes against PostgREST's admin server, which reports on the connection pool and schema +## cache. There is no readiness probe: it would drop the pod from its Service during a +## database outage, so callers would get a connection refusal instead of PostgREST's 503. +## The startup probe covers the case that matters, since it gates the rollout. Revisit if +## you run more than one replica, where readiness would let you shed a single bad pod. +## +probes: + startup: + ## @param probes.startup.enabled Gate rollout on PostgREST reporting ready + ## + enabled: true + ## @param probes.startup.periodSeconds How often to poll /ready during startup + ## + periodSeconds: 2 + ## @param probes.startup.failureThreshold Polls before the pod is failed. Two minutes + ## at the default period. + ## + failureThreshold: 60 + liveness: + ## @param probes.liveness.enabled Restart PostgREST if its listener stops answering. + ## Targets /live, which stays 200 with the database down, so an outage is not a restart + ## loop. + ## + enabled: true + ## @param probes.liveness.periodSeconds How often to poll /live + ## + periodSeconds: 10 + ## @param probes.liveness.failureThreshold Failed polls before the container is restarted + ## + failureThreshold: 3 ingress: + ## @param ingress.enabled Set to true to enable ingress record generation + ## enabled: false + ## @param ingress.tls TLS configuration for the ingress + ## tls: - hosts: - - 'auth.app.localhost' - 'data.app.localhost' secretName: 'app.localhost-tls' + ## @param ingress.rules Ingress rules + ## rules: - - host: 'auth.app.localhost' - http: - paths: - - pathType: Prefix - path: / - backend: - service: - name: postgrest - port: - name: keyserver - host: 'data.app.localhost' http: paths: diff --git a/images/goose/Dockerfile b/images/goose/Dockerfile deleted file mode 100644 index 2120099..0000000 --- a/images/goose/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder - -LABEL authors="Jared Davis " - -RUN addgroup --system goose --gid 10001 && \ - adduser --system -G goose goose - -WORKDIR /github.com/pressly - -RUN apk add git - -RUN git clone -b v3.26.0 https://github.com/pressly/goose - -WORKDIR /github.com/pressly/goose - -RUN go mod tidy && go build -ldflags="-s -w" -tags='no_sqlite no_sqlite3 no_clickhouse no_mssql no_mysql no_vertica no_ydb' -o /bin/goose ./cmd/goose - - -FROM scratch - -COPY --from=builder /etc/group /etc/group -COPY --from=builder /etc/passwd /etc/passwd - -USER goose - -COPY --chown=goose:goose --from=builder /bin/goose /bin/goose - -# https://pressly.github.io/goose/documentation/environment-variables/ -ENV GOOSE_DBSTRING="user=postgres dbname=postgres sslmode=disable" - -ENTRYPOINT [ "/bin/goose", "postgres" ] - -CMD [ "status" ] diff --git a/images/keyserver/.dockerignore b/images/keyserver/.dockerignore deleted file mode 100644 index 77b5378..0000000 --- a/images/keyserver/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -test.env -test.jwks.json diff --git a/images/keyserver/Dockerfile b/images/keyserver/Dockerfile deleted file mode 100644 index 3e2736b..0000000 --- a/images/keyserver/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM denoland/deno:alpine@sha256:437386888b8b5168e5b51a118e06d663c64a274ce1928ebce8c8079cd8a3b303 - -LABEL authors="Jared Davis " - -WORKDIR /keyserver - -COPY deno.json deno.json -COPY deno.lock deno.lock -COPY main.ts main.ts - -RUN deno install --entrypoint main.ts - -CMD [ "deno", "run", "--allow-net", "--allow-env", "--allow-read", "--allow-write", "main.ts" ] diff --git a/images/keyserver/deno.json b/images/keyserver/deno.json deleted file mode 100644 index 24cd745..0000000 --- a/images/keyserver/deno.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "tasks": { - "dev": "deno run --env-file=test.env --watch main.ts" - }, - "imports": { - "@std/assert": "jsr:@std/assert@1", - "hono": "npm:hono@^4.11.5", - "jose": "npm:jose@^6.1.3" - } -} diff --git a/images/keyserver/deno.lock b/images/keyserver/deno.lock deleted file mode 100644 index 41f6287..0000000 --- a/images/keyserver/deno.lock +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.17", - "jsr:@std/internal@^1.0.12": "1.0.12", - "npm:hono@^4.11.5": "4.11.5", - "npm:jose@6.1.3": "6.1.3", - "npm:jose@^6.1.3": "6.1.3" - }, - "jsr": { - "@std/assert@1.0.17": { - "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.12": { - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" - } - }, - "npm": { - "hono@4.11.5": { - "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==" - }, - "jose@6.1.3": { - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==" - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@1", - "npm:hono@^4.11.5", - "npm:jose@^6.1.3" - ] - } -} diff --git a/images/keyserver/main.ts b/images/keyserver/main.ts deleted file mode 100644 index 4a9a657..0000000 --- a/images/keyserver/main.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Hono } from 'hono'; -import { generateKeyPair, JSONWebKeySet, JWK, calculateJwkThumbprintUri } from 'jose'; -import { GenerateKeyPairResult } from 'jose/key/generate/keypair'; -import { exportJWK } from "jose/key/export"; -import { SignJWT } from "jose/jwt/sign"; -import { cors } from 'hono/cors'; - -const path = Deno.env.get('PGRST_JWT_SECRET')?.replace('@', '') ?? '/tmp/jwks.json'; -const claims = JSON.parse(Deno.env.get('PGRST_JWT_CLAIMS') ?? '{}'); -const origin = Deno.env.get('PGRST_CLIENT_ORIGIN')?.split(',') ?? []; -const trusted = Deno.env.get('PGRST_JWK_TRUST')?.split(',') ?? []; -const cert = Deno.env.get('PGRST_JWK_CERT') ?? 'cert.pem'; -const alg = Deno.env.get('PGRST_JWT_ALG') ?? 'RS256'; -const iss = Deno.env.get('PGRST_JWT_ISS') ?? 'http://localhost:8000' -const aud = Deno.env.get('PGRST_JWT_AUD') ?? 'postgrest'; -const exp = Deno.env.get('PGRST_JWT_EXP') ?? '5 minutes'; -const sub = Deno.env.get('PGRST_JWT_SUB') ?? 'anon'; -const api = Deno.env.get('PGRST_CLIENT_KEY') ?? ''; -const typ = 'JWT'; -const jwks_uri = Deno.env.get('PGRST_JWKS_URI') ?? 'http://localhost:8000/jwks' - -let keypair: GenerateKeyPairResult; -const initialize = async () => { - keypair = await generateKeyPair(alg, { extractable: true }); - const keysets = await jwk(keypair.publicKey, ...await upstream()); - - keysets.keys[0].kid = await calculateJwkThumbprintUri(keysets.keys[0]) - - await write(keysets); - localStorage.setItem('jwk:kid', keysets.keys[0].kid); - localStorage.setItem('jwk:set', JSON.stringify(keysets)) -} - -const upstream = async (): Promise> => { - const certificate = await Deno.readTextFile(cert).catch((error) => { - console.warn(error) - return undefined - }) - const caCerts = certificate ? [certificate] : []; - - const client = Deno.createHttpClient({ caCerts }) - - const keyset = new Array(); - - for await (const address of trusted) { - try { - const response = await fetch(address, { client }) - const data = await response.json() as JSONWebKeySet; - keyset.push(...data.keys) - } catch (error) { - console.warn(error) - } - } - - return keyset; -} - -const jwk = async (key: CryptoKey, ...jwks: Array): Promise => { - const jwk = await exportJWK(key); - - const keys = [ jwk, ...jwks].filter(value => !!value); - - return { keys } -} - -const jwt = async (key: CryptoKey, kid?: string): Promise => await new SignJWT(claims) - .setProtectedHeader({ alg, kid, typ }) - .setAudience(aud) - .setIssuedAt() - .setExpirationTime(exp) - .setSubject(sub) - .setIssuer(iss) - .sign(key); - - -const write = async (keys: JSONWebKeySet) => { - const file = await Deno.create(path); - const encoder = new TextEncoder(); - const data = encoder.encode(JSON.stringify(keys)) - - await file.write(data) -} - -const app = new Hono(); - -app.use('/', cors({ origin })) -app.get('/', (context) => context.redirect('/.well-known/openid-configuration', 301)); - -app.use('/.well-known/openid-configuration', cors({ origin })) -app.get('/.well-known/openid-configuration', (context) => context.json({jwks_uri})) - -app.use('/jwks', cors({ origin })) -app.get('/jwks', (context) => { - const keyset = localStorage.getItem('jwk:set'); - - if (keyset) return context.json(JSON.parse(keyset)); - - return context.json({message:'not found'}, 404); -}); - -app.use('/peek', cors({ origin })) -app.get('/peek', async (context) => { - const auth = context.req.header('Authorization')?.replace('Bearer ', ''); - - if (api && api != auth) return context.json({message:'not authorized'}, 401) - - const kid = localStorage.getItem('jwk:kid') ?? ''; - - return context.json({ access_token: await jwt(keypair.privateKey, kid) }) -}); - -initialize().finally(() => console.log(`initialized application with kid ${localStorage.getItem('kid')}`)); - -Deno.serve(app.fetch); diff --git a/images/keyserver/test.env b/images/keyserver/test.env deleted file mode 100644 index 7dbec82..0000000 --- a/images/keyserver/test.env +++ /dev/null @@ -1,10 +0,0 @@ -PGRST_JWT_SECRET="test.jwks.json" -PGRST_JWT_CLAIMS="{\"mode\":\"test\"}" -PGRST_CLIENT_ORIGIN="https://jwt.io" -PGRST_JWK_TRUST="https://sso.localhost/auth/realms/od360-kind/protocol/openid-connect/certs" -PGRST_JWT_ALG="RS256" -PGRST_JWT_ISS="http://localhost:8000/jwks" -PGRST_JWT_AUD="postgrest" -PGRST_JWT_EXP="5 minutes" -PGRST_JWT_SUB="test" -PGRST_CLIENT_KEY="a-string-secret-at-least-256-bits-long"