diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f91c5..43f9365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and the chart uses SemVer (`version`) plus the upstream Forail CalVer ## [Unreleased] +### Security +- **No working secret defaults**: `postgresPassword`, `forailSecretKey` and + `forailBroadcastWebsocketSecret` are auto-generated on first install and + reused across upgrades (via a Secret `lookup`). `forailAdminPassword` is now + **required** — `helm install` fails unless you provide one. +- **`forail-task` defaults to non-privileged** with no host cgroup mount. Opt in + for the podman-in-pod execution path: `--set task.privileged=true --set + task.hostCgroup=true`. +- **Secure cookies on by default** (`forail.cookieSecure: "true"`) and + `forail.allowedHosts` defaults to the ingress host instead of `"*"`. +- **Opt-in NetworkPolicy** (`networkPolicy.enabled`, default false): default-deny + ingress with scoped allows so Postgres/Redis aren't reachable cluster-wide. +- **Per-workload `securityContext` / `podSecurityContext`** values wired into + web / frontend / assistant (empty by default, pending per-image validation). + ## [2026.06.0] - 2026-06-14 ### Changed diff --git a/Chart.yaml b/Chart.yaml index 138857d..6cf9aa0 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: forail description: Forail Platform — automation, RBAC, EDA, observability (k8s deployment) type: application -version: 2026.6.0 -appVersion: "2026.06.0" +version: 2026.7.0 +appVersion: "2026.07.0" # forail-operator lives in its own repo (forail-platform/forail-operator) and # is installed separately via its own helm chart: # helm install forail-operator -n forail --set forail.token= diff --git a/README.md b/README.md index 6be47a6..9ef8173 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,40 @@ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -addext 'subjectAltName=DNS:forail.local,DNS:*.forail.local' kubectl -n forail create secret tls forail-tls --cert=tls.crt --key=tls.key -# Install: -helm install forail . -n forail --create-namespace -f values.yaml +# Install (admin password is required — install fails without it): +helm install forail . -n forail --create-namespace -f values.yaml \ + --set secrets.forailAdminPassword="$(openssl rand -base64 24)" ``` If you mirror the images to a private registry, override `images.*.repository` and set `imagePullSecrets` in your values file. +## Secure defaults & breaking changes + +This chart ships **no working secret defaults**: + +- `secrets.postgresPassword`, `secrets.forailSecretKey` and + `secrets.forailBroadcastWebsocketSecret` are **auto-generated** on first + install and reused across upgrades — leave them empty unless you want to pin + explicit values. +- `secrets.forailAdminPassword` is **required**; `helm install` fails if unset. +- `forail-task` runs **non-privileged by default**. The podman-in-pod job + execution path needs privileges — enable it explicitly and, ideally, isolate + such workers onto dedicated tainted nodes: + + ```sh + --set task.privileged=true --set task.hostCgroup=true + ``` + +- Session cookies are `Secure` by default (`forail.cookieSecure: "true"`) and + `forail.allowedHosts` defaults to the ingress host (not `"*"`). +- `networkPolicy.enabled` (default false) adds a default-deny ingress policy with + scoped allows so Postgres/Redis aren't reachable cluster-wide. Enable it on a + policy-enforcing CNI (Calico/Cilium) — k3s' default flannel does not enforce it. +- `podSecurityContext` and per-workload `securityContext.{web,frontend,assistant}` + are available for pod hardening (empty by default; validate per image — the + frontend binds `:80` and needs `NET_BIND_SERVICE` or a non-root port). + ## Layout ``` diff --git a/files/receptor/receptor.conf b/files/receptor/receptor.conf index 3a0c095..7a57c8b 100644 --- a/files/receptor/receptor.conf +++ b/files/receptor/receptor.conf @@ -14,3 +14,16 @@ command: /var/lib/awx/venv/awx/bin/ansible-runner params: worker allowruntimeparams: true + +# Container-group job execution. The "default" instance group is a container +# group, so the scheduler submits each job to receptor as the +# "kubernetes-incluster-auth" work type; without this worktype receptor rejects +# the launch ("unknown work type kubernetes-incluster-auth") and the job errors +# at 0s. authmethod incluster uses the task pod's own ServiceAccount token, which +# has the pod RBAC granted in rbac.yaml. +- work-kubernetes: + worktype: kubernetes-incluster-auth + authmethod: incluster + allowruntimeauth: true + allowruntimepod: true + allowruntimeparams: true diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl index dfbe14b..3a7a798 100644 --- a/templates/_helpers.tpl +++ b/templates/_helpers.tpl @@ -98,6 +98,26 @@ Wraps DB, Redis, secrets, OTel, admin into one block to avoid drift. value: {{ .Values.forail.otel.tracesSampler | quote }} - name: OTEL_TRACES_SAMPLER_ARG value: {{ .Values.forail.otel.tracesSamplerArg | quote }} +# Namespace the container-group scheduler launches automation job pods into +# (AWX_CONTAINER_GROUP_DEFAULT_NAMESPACE reads MY_POD_NAMESPACE). Bind it to +# the release namespace via the downward API so jobs run where the pod RBAC +# in rbac.yaml is granted, not the cluster "default" namespace. +- name: MY_POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +{{- end }} + +{{/* +Name of the ServiceAccount the web/task/init pods run as. When +serviceAccount.create is false, fall back to the namespace "default" account. +*/}} +{{- define "forail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- .Values.serviceAccount.name -}} +{{- else -}} +default +{{- end -}} {{- end }} {{/* diff --git a/templates/forail-assistant.yaml b/templates/forail-assistant.yaml index c1e64ab..ec4e0a6 100644 --- a/templates/forail-assistant.yaml +++ b/templates/forail-assistant.yaml @@ -56,10 +56,16 @@ spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-assistant image: "{{ .Values.images.assistant.repository }}:{{ .Values.images.assistant.tag }}" imagePullPolicy: {{ .Values.images.assistant.pullPolicy }} + {{- with .Values.securityContext.assistant }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: http, containerPort: 8100 } env: diff --git a/templates/forail-frontend.yaml b/templates/forail-frontend.yaml index fb2005b..c9d342d 100644 --- a/templates/forail-frontend.yaml +++ b/templates/forail-frontend.yaml @@ -34,10 +34,16 @@ spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-frontend image: "{{ .Values.images.frontend.repository }}:{{ .Values.images.frontend.tag }}" imagePullPolicy: {{ .Values.images.frontend.pullPolicy }} + {{- with .Values.securityContext.frontend }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} ports: - { name: http, containerPort: 80 } readinessProbe: diff --git a/templates/forail-init-job.yaml b/templates/forail-init-job.yaml index dbfb10f..75c3fcc 100644 --- a/templates/forail-init-job.yaml +++ b/templates/forail-init-job.yaml @@ -16,6 +16,7 @@ spec: {{- include "forail.componentLabels" (dict "root" . "component" "init") | nindent 8 }} spec: restartPolicy: OnFailure + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/templates/forail-task.yaml b/templates/forail-task.yaml index b84480f..e805f9c 100644 --- a/templates/forail-task.yaml +++ b/templates/forail-task.yaml @@ -15,6 +15,7 @@ spec: labels: {{- include "forail.componentLabels" (dict "root" . "component" "task") | nindent 8 }} spec: + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/templates/forail-web.yaml b/templates/forail-web.yaml index 13b647f..4191786 100644 --- a/templates/forail-web.yaml +++ b/templates/forail-web.yaml @@ -34,15 +34,22 @@ spec: labels: {{- include "forail.componentLabels" (dict "root" . "component" "web") | nindent 8 }} spec: + serviceAccountName: {{ include "forail.serviceAccountName" . }} {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} # No explicit dep on forail-init: web crashloops until DB schema is ready, # which is k8s-native and avoids extra RBAC for a kubectl initContainer. + {{- with .Values.podSecurityContext }} + securityContext: {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: forail-web image: "{{ .Values.images.backend.repository }}:{{ .Values.images.backend.tag }}" imagePullPolicy: {{ .Values.images.backend.pullPolicy }} + {{- with .Values.securityContext.web }} + securityContext: {{- toYaml . | nindent 12 }} + {{- end }} command: ["launch_awx_web.sh"] ports: - { name: http, containerPort: 8013 } diff --git a/templates/networkpolicy.yaml b/templates/networkpolicy.yaml new file mode 100644 index 0000000..fe33e12 --- /dev/null +++ b/templates/networkpolicy.yaml @@ -0,0 +1,93 @@ +{{- /* +needtofix M11: tiered NetworkPolicy. Off by default (see values.networkPolicy); +enable only on a policy-enforcing CNI after validation. The intent: + + - default-deny all ingress in the namespace, then + - Postgres accepts 5432 only from web + task, + - Redis accepts 6379 only from web + task, + - web/frontend accept their HTTP/WS ports from anywhere (the ingress). + +Datastore ports are thereby unreachable from arbitrary pods on a flat network. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $ns := include "forail.namespace" . -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-default-deny-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: {} + policyTypes: ["Ingress"] + # No ingress rules → deny all; the allow policies below add exceptions. +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-postgres-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: postgres + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: web + - podSelector: + matchLabels: + app.kubernetes.io/component: task + ports: + - { protocol: TCP, port: 5432 } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-redis-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/component: redis + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/component: web + - podSelector: + matchLabels: + app.kubernetes.io/component: task + ports: + - { protocol: TCP, port: 6379 } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: forail-http-ingress + namespace: {{ $ns }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "netpol") | nindent 4 }} +spec: + podSelector: + matchExpressions: + - key: app.kubernetes.io/component + operator: In + values: ["web", "frontend"] + policyTypes: ["Ingress"] + ingress: + # Allow the HTTP/WS ports from any source (ingress controller terminates + # external traffic). Tighten to the ingress namespace/pod once known. + - ports: + - { protocol: TCP, port: 8013 } + - { protocol: TCP, port: 8015 } + - { protocol: TCP, port: 80 } +{{- end }} diff --git a/templates/rbac.yaml b/templates/rbac.yaml new file mode 100644 index 0000000..0e0fcad --- /dev/null +++ b/templates/rbac.yaml @@ -0,0 +1,53 @@ +{{/* +Job-execution RBAC. + +The forail-task scheduler runs each automation job in a Kubernetes "container +group": receptor lists, creates and deletes those job pods through the task +pod's ServiceAccount. Without pod RBAC in this namespace every launch fails +with "pods is forbidden: User \"system:serviceaccount::default\" cannot +list resource \"pods\"", and the job never leaves pending. The Role below is +scoped to this namespace only (no cluster-wide pod access) and paired with the +MY_POD_NAMESPACE downward-API env so pods land here, where the grant applies. +*/}} +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "forail.serviceAccountName" . }} + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +{{- end }} +{{- if .Values.rbac.create }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: forail-job-runner + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods/log", "pods/attach", "pods/exec"] + verbs: ["get", "list", "watch", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: forail-job-runner + namespace: {{ include "forail.namespace" . }} + labels: + {{- include "forail.componentLabels" (dict "root" . "component" "rbac") | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: forail-job-runner +subjects: + - kind: ServiceAccount + name: {{ include "forail.serviceAccountName" . }} + namespace: {{ include "forail.namespace" . }} +{{- end }} diff --git a/templates/secret.yaml b/templates/secret.yaml index 71f3dbe..4a6f031 100644 --- a/templates/secret.yaml +++ b/templates/secret.yaml @@ -1,13 +1,32 @@ +{{- /* +needtofix H3: never ship working secret defaults. Infrastructure secrets +(postgres password, Django SECRET_KEY, broadcast websocket secret) are taken +from an explicit override when set, otherwise reused from the existing +forail-secrets Secret (so upgrades keep the same value), otherwise generated +randomly on first install. forailAdminPassword has no safe auto-value and is +required. +*/ -}} +{{- $ns := include "forail.namespace" . -}} +{{- $existing := (lookup "v1" "Secret" $ns "forail-secrets") -}} +{{- $existingData := dict -}} +{{- if $existing -}}{{- $existingData = $existing.data -}}{{- end -}} +{{- $pgPass := .Values.secrets.postgresPassword | default (index $existingData "postgresPassword" | default "" | b64dec) | default (randAlphaNum 32) -}} +{{- $secretKey := .Values.secrets.forailSecretKey | default (index $existingData "forailSecretKey" | default "" | b64dec) | default (randAlphaNum 50) -}} +{{- $bcastSecret := .Values.secrets.forailBroadcastWebsocketSecret | default (index $existingData "forailBroadcastWebsocketSecret" | default "" | b64dec) | default (randAlphaNum 50) -}} +{{- $adminPass := .Values.secrets.forailAdminPassword | default (index $existingData "forailAdminPassword" | default "" | b64dec) -}} +{{- if not $adminPass -}} +{{- fail "secrets.forailAdminPassword is required — set it, e.g. --set secrets.forailAdminPassword=\"$(openssl rand -base64 24)\" (see needtofix H3)" -}} +{{- end -}} apiVersion: v1 kind: Secret metadata: name: forail-secrets - namespace: {{ include "forail.namespace" . }} + namespace: {{ $ns }} labels: {{- include "forail.componentLabels" (dict "root" . "component" "secrets") | nindent 4 }} type: Opaque stringData: - postgresPassword: {{ .Values.secrets.postgresPassword | quote }} - forailSecretKey: {{ .Values.secrets.forailSecretKey | quote }} - forailBroadcastWebsocketSecret: {{ .Values.secrets.forailBroadcastWebsocketSecret | quote }} - forailAdminPassword: {{ .Values.secrets.forailAdminPassword | quote }} + postgresPassword: {{ $pgPass | quote }} + forailSecretKey: {{ $secretKey | quote }} + forailBroadcastWebsocketSecret: {{ $bcastSecret | quote }} + forailAdminPassword: {{ $adminPass | quote }} diff --git a/values.yaml b/values.yaml index 40b8e09..1e881e5 100644 --- a/values.yaml +++ b/values.yaml @@ -13,15 +13,15 @@ namespace: images: backend: repository: ghcr.io/forail-platform/forail-backend - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent frontend: repository: ghcr.io/forail-platform/forail-frontend - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent assistant: repository: ghcr.io/forail-platform/forail-assistant - tag: "2026.06.0" + tag: "2026.07.0" pullPolicy: IfNotPresent postgres: repository: postgres @@ -31,12 +31,15 @@ images: tag: 7-alpine opa: repository: openpolicyagent/opa + # needtofix L15: pin to an immutable version tag (e.g. 0.70.0-rootless) + # before shipping — a floating tag drifts and undermines supply-chain trust. tag: latest-rootless nginx: repository: nginx tag: 1.27-alpine otelCollector: repository: otel/opentelemetry-collector-contrib + # needtofix L15: pin to an immutable version tag (e.g. 0.110.0) before shipping. tag: latest # Public ghcr.io images do not require pull secrets. @@ -44,28 +47,44 @@ images: imagePullSecrets: [] # ── Secrets ─────────────────────────────────────────────── -# In production, manage via external secret operator. These are dev defaults. +# The chart ships NO working secret defaults (see needtofix H3). Leave these +# empty and the chart will auto-generate the infrastructure secrets on first +# install and reuse them across upgrades (via a Secret lookup). In production, +# set them explicitly or manage via an external secret operator. +# +# forailAdminPassword has no safe auto-value (the operator must know it), so it +# is REQUIRED: `helm install` fails unless you provide one, e.g. +# --set secrets.forailAdminPassword="$(openssl rand -base64 24)" secrets: - postgresPassword: "changeme-postgres" - forailSecretKey: "changeme-replace-with-openssl-rand-hex-32-output-aaaaaaa" - forailBroadcastWebsocketSecret: "changeme-replace-with-openssl-rand-hex-32-output-bbbb" - forailAdminPassword: "changeme-admin" + # Auto-generated when empty (override to pin an explicit value): + postgresPassword: "" + forailSecretKey: "" + forailBroadcastWebsocketSecret: "" + # Required — no default; install fails if unset: + forailAdminPassword: "" # ── Forail configuration ─────────────────────────────────── forail: admin: user: admin email: admin@forail.local - allowedHosts: "*" + # needtofix M9: do NOT default to "*" (Host-header injection → cache + # poisoning / poisoned password-reset links). Keep the ingress host in sync + # here; 127.0.0.1 and localhost MUST stay in the list because the in-cluster + # liveness/readiness probes reach the app over the loopback address (Django + # rejects any Host not listed with HTTP 400, which would crash-loop the pod). + allowedHosts: "forail.lan,127.0.0.1,localhost" # NOTE: avoid the .local TLD — Avahi/mDNS (`mdns_minimal [NOTFOUND=return]` # in nsswitch.conf on most desktop Linux distros) intercepts every # `.local` lookup and bypasses /etc/hosts, which makes browser access # to a manually-mapped forail.local fail with "Server Not Found" even # though curl from the same host works. `.lan` has no such hijack. csrfTrustedOrigins: "http://forail.lan:30080,https://forail.lan:30443,https://forail.lan" - # Set to false when serving over plain HTTP (test cluster). Forail's - # custom_settings.py reads FORAIL_COOKIE_SECURE for SESSION/CSRF cookies. - cookieSecure: "false" + # needtofix M8: default Secure cookies on (ingress TLS is on by default). + # Set to "false" only when serving over plain HTTP (e.g. a local dev run + # without TLS). Forail's custom_settings.py reads FORAIL_COOKIE_SECURE for + # SESSION/CSRF cookies. + cookieSecure: "true" node: name: forail-node type: hybrid @@ -122,11 +141,14 @@ web: task: replicas: 1 - # forail-task runs ansible-runner with podman, which needs a privileged - # container and host cgroup namespace. This is k8s test-cluster style; - # in prod consider a dedicated worker daemonset on isolated nodes. - privileged: true - hostCgroup: true + # needtofix H4: forail-task runs ansible-runner with podman, which CAN want + # a privileged container + host cgroup namespace. Both default OFF now — a + # privileged pod with a host cgroup mount is a trivial container escape to + # node-root. Opt in explicitly for the podman-in-pod execution path, and + # isolate such workers onto dedicated tainted nodes: + # --set task.privileged=true --set task.hostCgroup=true + privileged: false + hostCgroup: false resources: # 2Gi was OOM-killed during a single Demo Project sync + hello_world # run on a fresh cluster — receptor work units in /tmp die with the @@ -136,6 +158,19 @@ task: requests: { memory: 1Gi, cpu: 200m } limits: { memory: 4Gi, cpu: 2000m } +# ── Job-execution RBAC ──────────────────────────────────── +# The forail-task scheduler launches every automation job as a pod in a +# Kubernetes container group; receptor manages those pods with the task pod's +# ServiceAccount. That account therefore needs namespaced pod RBAC — without it +# each launch fails ("pods is forbidden ... cannot list resource pods") and the +# job stays pending. See templates/rbac.yaml. Disable both only when you wire an +# external execution node and want no in-cluster job pods. +serviceAccount: + create: true + name: forail +rbac: + create: true + # ── Frontend ────────────────────────────────────────────── frontend: replicas: 1 @@ -163,6 +198,28 @@ assistant: requests: { memory: 1Gi, cpu: 250m } limits: { memory: 4Gi, cpu: 2000m } +# ── Network policy (needtofix M11) ──────────────────────── +# Off by default: k3s' default flannel CNI does not enforce NetworkPolicy, and +# a default-deny would silently do nothing there while breaking traffic on a +# CNI that DOES enforce it. Enable on a policy-capable cluster (Calico/Cilium) +# after validating: default-deny ingress + scoped allows between tiers so that +# Postgres:5432 / Redis:6379 are not reachable cluster-wide. +networkPolicy: + enabled: false + +# ── Pod hardening (needtofix M10) ───────────────────────── +# Per-workload securityContext. Empty by default (no behaviour change) so the +# chart stays deployable while each context is validated per image — note the +# frontend binds :80 and needs NET_BIND_SERVICE or a non-root port before caps +# can be dropped. The operator chart is the reference model to copy: +# runAsNonRoot: true, allowPrivilegeEscalation: false, +# capabilities: { drop: ["ALL"] }, readOnlyRootFilesystem: true +podSecurityContext: {} +securityContext: + web: {} + frontend: {} + assistant: {} + # ── Ingress ─────────────────────────────────────────────── ingress: enabled: true