For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /product/operations/adr-0025-validation.md.

Themelio compatibility reference. This page describes the retained Themelio implementation or its historical design. Check the parallel-operation guide before applying it to the current platform.

ADR-0025 validation

This document records the commands and manual checks used to validate ADR-0025 in staging. Execute it with two people or with an operator and an agent: run one numbered validation, inspect its evidence, record the result, and only then continue. Do not execute the whole document as an unattended script.

The validation covers only behavior required by ADR-0025: native Istio security controls, proof that the protected application flows still work, and regression checks for the gateways and applications affected by those controls. A failed required validation keeps ADR-0025 in Draft.

Safety rules

  • Run against staging only. Confirm the context before every mutating test.
  • Never paste PATs, passwords, cookies, or Kubernetes Secret values into this document or an evidence file.
  • Commands marked mutating create disposable test resources. Stop and obtain operator confirmation before running them.
  • Preserve relevant logs before deleting a failed test pod.
  • Treat the commands in this document as the executable source of truth. Run them verbatim after replacing documented placeholders only. If a command is wrong or the environment requires a different command, stop, update this document first, and then run the corrected command.
  • Treat an ingress-host rejection and a backend identity rejection differently: an unknown ingress host must return HTTP 403; a sidecar authorization denial can appear as HTTP 403 or as a refused/reset connection depending on whether Envoy classified the destination listener as HTTP or TCP.

Result values

Use one of these values in the result tables:

  • NOT RUN
  • PASS
  • FAIL
  • BLOCKED
  • NOT APPLICABLE

Validation checklist

IDTestRequiredResultEvidence or notes
PRE-01Staging context and component healthYesPASSCluster components are healthy and staging-apps, themelio, and waldur-site-agent-cloud reconciled to Synced/Healthy after the sidecar rollback was committed and pushed; 20260723T135550Z-sidecar/argocd-applications.txt
PRE-02Enforced policy, sidecar, and identity inventoryYesPASSDedicated identities, selected sidecars, STRICT policies, and non-empty ingress rules verified; 20260723T135550Z-sidecar/{security-resources,themelio-identities,waldur-identities,no-empty-gateway-rules}.txt
GW-01Configured gateway hosts and host-with-port reach expected handlingYesPASSAll configured hosts and host-with-port requests reached normal application/authentication handling, including the Space route on the Themelio service gateway; 20260723T135550Z-sidecar/gateway-host-results.tsv
GW-02Unknown gateway hosts return 403YesPASSEvery enforced gateway returned 403 for rejected.invalid; 20260723T135550Z-sidecar/gateway-host-results.tsv
FLOW-01Web gateway to WebYesPASSGateway-to-Web mTLS principal and successful Web handling observed; 20260723T135550Z-sidecar/sidecar-mtls-requests.json
FLOW-02Web to k8s-root-actorYesPASSReal Web sidecar returned 200; 20260723T135550Z-sidecar/web-to-actors.txt
FLOW-03Web to gitlab-root-actorYesPASSReal Web sidecar returned 200; 20260723T135550Z-sidecar/web-to-actors.txt
FLOW-04Waldur order process to gitlab-root-actorYesPASSReal order-process sidecar returned 200; 20260723T135550Z-sidecar/waldur-sync-to-gitlab-root-actor.txt
FLOW-05Waldur membership sync to gitlab-root-actorYesPASSReal membership-sync sidecar returned 200; 20260723T135550Z-sidecar/waldur-sync-to-gitlab-root-actor.txt
DENY-01Waldur report to gitlab-root-actor is deniedYesPASSNon-injected report identity received a connection reset; 20260723T135550Z-sidecar/waldur-report-denial.txt
DENY-02Arbitrary sidecar-injected Themelio identity is deniedYesPASSDisposable identity received 403 from both actors; 20260723T135550Z-sidecar/arbitrary-identity-denials.txt
DENY-03Plaintext/direct pod-IP access is rejectedYesPASSAll three direct calls were reset with no HTTP response; 20260723T135550Z-sidecar/plaintext-pod-ip-denials.txt
FLOW-06Seeder to gitlab-root-actorConditionalPASSDisposable seeder sidecar returned 200; 20260723T135550Z-sidecar/seeder-to-gitlab-root-actor.txt
AUTH-01Application authentication remains requiredYesPASSActor returned 401 without a bearer token and Web redirected unauthenticated access; 20260723T135550Z-sidecar/{waldur-application-auth,web-session-auth}.txt
OBS-01Expected SPIFFE identities appear in Envoy sidecarsYesPASSAll selected proxies are subscribed with valid certificates; 20260723T135550Z-sidecar/{sidecar-proxy-status,themelio-sidecar-state,waldur-sidecar-state}.txt
OBS-02Sidecar mutual TLS is observedYesPASSPrometheus contains every expected mTLS principal pair; 20260723T135550Z-sidecar/sidecar-mtls-requests.json
OBS-03Denials appear in sidecar logs and monitoringYesPASSEnvoy, Prometheus, and Loki contain the generated 403 denials and principals; 20260723T135550Z-sidecar/sidecar-denials.txt, sidecar-denial-metrics.json, and loki-istio-denials.json
APP-01Browser and OIDC loginYesPASSOperator-confirmed staging OIDC login retained as valid regression evidence
APP-02Normal Themelio navigationYesPASSOperator-confirmed authenticated Themelio navigation retained as valid regression evidence
APP-03Git clone, push, and Git LFSYesPASSDisposable repository, push, LFS upload/download, and cleanup completed successfully
APP-04Kubernetes deployment actionYesPASSThe Space deployment created its Deployment, Service, and routed VirtualService successfully
APP-05Waldur order and membership reconciliationYesPASSBoth live reconciliation processes are healthy and their protected actor calls returned 200
APP-06Kubeflow and external authorizationYesPASSKubeflow gateway reached the expected authentication flow and its sidecar workloads remained healthy
APP-07Registry pull and pushYesPASSDisposable registry pull/push/re-pull and digest verification completed successfully
APP-08Grafana and Prometheus accessYesPASSOperator confirmed both UIs; sidecar, gateway, and istiod telemetry is available
APP-09Readiness probes and alert rulesYesPASSSelected workloads and all 11 CNI pods are ready; sidecar alerting is loaded and evaluating; 20260723T135550Z-sidecar/istio-pods.txt, themelio-pods.txt, waldur-agent-pods.txt, and istio-alerts.json
TENANT-01Per-workload scoped policies exist with the correct selector/principal/port and no namespace-wide rules: [{}]YesPASSStaging, 2026-07-24: real Space spaces-…-service-gateway generated with the deployment-runner selector, istio-ingressgateway-themelio-service-account principal, and the workload port; allow-all absent from pharos. Inference selector shape confirmed via a GPU-free mock predictor; generation on a live InferenceService is pending a GPU environment
TENANT-02Access from outside the tenant namespace to a tenant deployment/predictor is denied (predictor is native-sidecar injected so the policy is enforced); same-namespace access remains allowed by ns-owner-access-istioYesPASSStaging, 2026-07-24: foreign-namespace pod (themelio) denied 403 to the real Space Service and to a mock KServe predictor; same-namespace caller allowed (404, reached app) as expected via ns-owner-access-istio
TENANT-03Authorized access through the Themelio service gateway reaches the tenant deployment and predictor (policy admits the gateway identity)YesPASSStaging, 2026-07-24: through the service gateway the real deployment returned 404 (app response, not an RBAC 403) and the mock predictor 200; predictor confirmed native-sidecar injected so the policy is enforced
TENANT-04Deleting a workload from the UI removes its <name>-service-gateway policy with no orphanYesPASSStaging, 2026-07-24: deleting the Space from the UI removed its Deployment/Service/VirtualService/PVC and …-service-gateway policy (NotFound); no orphan; tenant namespace left with only the Kubeflow ns-owner-access-istio and per-user policies

0. Connect and initialize evidence

Run the following from the repository workstation:

cd /home/dkats/themelio/devops/ansible
make show-env

Expected output: staging. Stop if any other environment is selected.

eval "$(ssh-agent -s)" \
  && ssh-add ~/.ssh/pharos-admin-key \
  && ssh -J admin@18.195.179.229 admin@10.0.100.44

Run the remaining cluster commands on the staging master unless a section says otherwise. Create a directory for non-secret evidence:

export ADR0025_RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
export ADR0025_EVIDENCE="$HOME/adr-0025-validation/$ADR0025_RUN_ID"
mkdir -p "$ADR0025_EVIDENCE"
printf 'run_id=%s\nstarted_utc=%s\n' \
  "$ADR0025_RUN_ID" "$(date -u +%FT%TZ)" \
  | tee "$ADR0025_EVIDENCE/run.txt"

To resume an existing validation run in a new shell, replace the documented placeholder with its run ID and execute:

export ADR0025_RUN_ID='<existing-run-id>'
export ADR0025_EVIDENCE="$HOME/adr-0025-validation/$ADR0025_RUN_ID"
test -d "$ADR0025_EVIDENCE"

1. Preconditions

PRE-01: context and component health

kubectl config current-context | tee "$ADR0025_EVIDENCE/context.txt"
kubectl get nodes -o wide | tee "$ADR0025_EVIDENCE/nodes.txt"
kubectl get pods -n istio-system -o wide \
  | tee "$ADR0025_EVIDENCE/istio-pods.txt"
kubectl get pods -n themelio -o wide \
  | tee "$ADR0025_EVIDENCE/themelio-pods.txt"
kubectl get pods -n waldur-site-agent-cloud -o wide \
  | tee "$ADR0025_EVIDENCE/waldur-agent-pods.txt"
kubectl get applications.argoproj.io -n argocd \
  -o custom-columns='NAME:.metadata.name,REVISION:.spec.source.targetRevision,SYNC:.status.sync.status,HEALTH:.status.health.status' \
  | tee "$ADR0025_EVIDENCE/argocd-applications.txt"

Pass when:

  • the context and node names are staging;
  • every schedulable node has a ready Istio CNI pod and no ztunnel remains;
  • istiod and all ingress gateways are ready;
  • the selected Themelio and Waldur workloads have ready istio-proxy sidecars;
  • the relevant Argo CD applications are healthy and at the intended revision.

PRE-02: enforced policy and identity inventory

kubectl get authorizationpolicy,peerauthentication -A -o wide \
  | tee "$ADR0025_EVIDENCE/security-resources.txt"

kubectl get deploy -n themelio -o json \
  | jq -r '.items[] | [.metadata.name,
      (.spec.template.spec.serviceAccountName // "default"),
      (.spec.template.metadata.labels["sidecar.istio.io/inject"] // "false")]
      | @tsv' \
  | tee "$ADR0025_EVIDENCE/themelio-identities.txt"

kubectl get deploy -n waldur-site-agent-cloud -o json \
  | jq -r '.items[] | [.metadata.name,
      (.spec.template.spec.serviceAccountName // "default"),
      (.spec.template.metadata.labels["sidecar.istio.io/inject"] // "false")]
      | @tsv' \
  | tee "$ADR0025_EVIDENCE/waldur-identities.txt"

kubectl get authorizationpolicy -n istio-system -o json \
  | jq -e '[.items[]
      | select((.spec.selector.matchLabels.app // "")
          | test("ingressgateway"))
      | .spec.rules[]?
      | select(. == {})]
      | length == 0' \
  | tee "$ADR0025_EVIDENCE/no-empty-gateway-rules.txt"

Expected protected identities:

WorkloadServiceAccountSidecar
webwebYes
k8s-root-actork8s-root-actor-saYes
gitlab-root-actorgitlab-root-actorYes
Waldur order processwaldur-site-agent-cloud-gitlab-syncYes
Waldur membership syncwaldur-site-agent-cloud-gitlab-syncYes
Waldur reportwaldur-site-agent-cloudNo

Pass when the policies are present, selected workloads have the expected unique ServiceAccounts, and no ingress ALLOW policy contains rules: [{}].

2. Gateway host enforcement

GW-01 and GW-02: test every rendered host policy

This command discovers the policies, gateway Services, configured hosts, and ClusterIPs from the live cluster. It does not hard-code a second hostname inventory. Calling the gateway Service directly avoids public DNS, the bastion, and NodePort traffic-policy differences.

kubectl get authorizationpolicy -n istio-system -o json \
  | jq -r '.items[]
      | select([.spec.rules[]?.to[]?.operation.hosts[]?] | length > 0)
      | [.metadata.name,
         .spec.selector.matchLabels.app,
         .spec.rules[0].to[0].operation.hosts[0]]
      | @tsv' \
  | while IFS=$'\t' read -r policy app configured_host; do
      service="$(kubectl get service -n istio-system -l "app=$app" -o json \
        | jq -r '.items[] | select(.spec.type=="NodePort") | .metadata.name' \
        | head -n1)"
      service_ip="$(kubectl get service -n istio-system "$service" \
        -o jsonpath='{.spec.clusterIP}')"

      configured_code="$(curl -sS -o /dev/null -w '%{http_code}' \
        -H "Host: $configured_host" \
        --connect-timeout 5 --max-time 10 "http://$service_ip/" || true)"
      with_port_code="$(curl -sS -o /dev/null -w '%{http_code}' \
        -H "Host: $configured_host:80" \
        --connect-timeout 5 --max-time 10 "http://$service_ip/" || true)"
      rejected_code="$(curl -sS -o /dev/null -w '%{http_code}' \
        -H 'Host: rejected.invalid' \
        --connect-timeout 5 --max-time 10 "http://$service_ip/" || true)"

      printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
        "$policy" "$service@$service_ip" "$configured_host" \
        "$configured_code" "$with_port_code" "$rejected_code"
    done \
  | tee "$ADR0025_EVIDENCE/gateway-host-results.tsv"

Columns are policy, Service, configured host, configured-host response, host-with-port response, and rejected-host response.

Pass when:

  • each configured-host request reaches the normal application or authentication behavior;
  • each host-with-port request behaves the same way;
  • every rejected.invalid request returns 403.

A configured host can legitimately produce an authentication redirect or an application 401/403. Record headers for any ambiguous response:

curl -si -H 'Host: <configured-host>' \
  --connect-timeout 5 --max-time 10 \
  "http://<gateway-service-cluster-ip>/" \
  | tee "$ADR0025_EVIDENCE/<gateway>-configured-response.txt"

GW-DIAG: diagnose an unreachable gateway

Run this read-only block when GW-01 or GW-02 cannot establish a connection. It records the Kubernetes traffic chain, attached Istio routing resources, Envoy listeners, and relevant control-plane logs.

export ADR0025_FAILED_GATEWAY=istio-ingressgateway-service
export ADR0025_FAILED_GATEWAY_POD="$(kubectl get pod -n istio-system \
  -l "app=$ADR0025_FAILED_GATEWAY" \
  -o jsonpath='{.items[0].metadata.name}')"
export ADR0025_FAILED_GATEWAY_POD_IP="$(kubectl get pod -n istio-system \
  "$ADR0025_FAILED_GATEWAY_POD" \
  -o jsonpath='{.status.podIP}')"

{
  kubectl get service -n istio-system "$ADR0025_FAILED_GATEWAY" -o yaml
  kubectl get endpointslice -n istio-system \
    -l "kubernetes.io/service-name=$ADR0025_FAILED_GATEWAY" -o yaml
  kubectl get deployment -n istio-system "$ADR0025_FAILED_GATEWAY" -o yaml
  kubectl get pod -n istio-system "$ADR0025_FAILED_GATEWAY_POD" \
    -o jsonpath='{.metadata.labels}{"\n"}{.spec.serviceAccountName}{"\n"}{.status.conditions}{"\n"}'
  kubectl get authorizationpolicy -n istio-system \
    "$ADR0025_FAILED_GATEWAY" -o yaml
} | tee "$ADR0025_EVIDENCE/gateway-failure-kubernetes.txt"

kubectl get gateway.networking.istio.io -A -o json \
  | jq -c --arg app "$ADR0025_FAILED_GATEWAY" \
      '.items[]
       | select(.spec.selector.app == $app
           or .spec.selector.istio == "ingressgateway-service")
       | {namespace:.metadata.namespace,name:.metadata.name,
          selector:.spec.selector,servers:.spec.servers}' \
  | tee "$ADR0025_EVIDENCE/gateway-failure-attached-gateways.jsonl"

kubectl get virtualservice.networking.istio.io -A -o json \
  | jq -c '.items[]
      | select(any(.spec.gateways[]?;
          . == "service-gateway" or endswith("/service-gateway")))
      | {namespace:.metadata.namespace,name:.metadata.name,
         gateways:.spec.gateways,hosts:.spec.hosts}' \
  | tee "$ADR0025_EVIDENCE/gateway-failure-virtualservices.jsonl"

export ADR0025_ISTIO_VERSION="$(kubectl get deployment -n istio-system istiod \
  -o jsonpath='{.spec.template.spec.containers[0].image}' \
  | sed -E 's/.*:([0-9]+\.[0-9]+\.[0-9]+).*/\1/')"
export ADR0025_ISTIOCTL="$HOME/.local/bin/istioctl-$ADR0025_ISTIO_VERSION"
if [ ! -x "$ADR0025_ISTIOCTL" ]; then
  mkdir -p "$HOME/.local/bin"
  curl -fsSL --max-time 120 \
    "https://github.com/istio/istio/releases/download/$ADR0025_ISTIO_VERSION/istioctl-$ADR0025_ISTIO_VERSION-linux-amd64.tar.gz" \
    | tar -xz -C "$HOME/.local/bin"
  mv "$HOME/.local/bin/istioctl" "$ADR0025_ISTIOCTL"
fi

"$ADR0025_ISTIOCTL" proxy-status "$ADR0025_FAILED_GATEWAY_POD.istio-system" \
  | tee "$ADR0025_EVIDENCE/gateway-failure-proxy-status.txt"
"$ADR0025_ISTIOCTL" proxy-config listeners \
  "$ADR0025_FAILED_GATEWAY_POD.istio-system" \
  | tee "$ADR0025_EVIDENCE/gateway-failure-listeners.txt"
"$ADR0025_ISTIOCTL" proxy-config routes \
  "$ADR0025_FAILED_GATEWAY_POD.istio-system" \
  | tee "$ADR0025_EVIDENCE/gateway-failure-routes.txt"

{
  kubectl logs -n istio-system "$ADR0025_FAILED_GATEWAY_POD" --since=30m
  kubectl logs -n istio-system -l app=istiod --prefix --since=30m \
    | grep -Ei "$ADR0025_FAILED_GATEWAY|service-gateway|reject|error|warn" \
    || true
} | tee "$ADR0025_EVIDENCE/gateway-failure-control-plane-logs.txt"

set +e
{
  curl -sS -o /dev/null -w 'service_http_code=%{http_code}\n' \
    --connect-timeout 5 --max-time 10 \
    "http://$(kubectl get service -n istio-system \
      "$ADR0025_FAILED_GATEWAY" -o jsonpath='{.spec.clusterIP}')/"
  echo "service_curl_rc=$?"
  curl -sS -o /dev/null -w 'pod_http_code=%{http_code}\n' \
    --connect-timeout 5 --max-time 10 \
    "http://$ADR0025_FAILED_GATEWAY_POD_IP:8080/"
  echo "pod_curl_rc=$?"
} |& tee "$ADR0025_EVIDENCE/gateway-failure-connectivity.txt"
set -e

An empty listener/route result together with no matching Gateway means Envoy is healthy but intentionally has no traffic listener. A matching Gateway with no listener indicates an Istio attachment or configuration problem. A listener with failed connectivity indicates a Service, endpoint, NetworkPolicy, or process-level problem.

3. Positive protected flows

FLOW-01: Web gateway to Web

export ADR0025_WEB_HOST="$(kubectl get gateway -n themelio web-gateway \
  -o jsonpath='{.spec.servers[0].hosts[0]}')"
export ADR0025_WEB_GATEWAY_IP="$(kubectl get service -n istio-system \
  -l app=istio-ingressgateway-web \
  -o jsonpath='{.items[0].spec.clusterIP}')"

curl -si -H "Host: $ADR0025_WEB_HOST" \
  --connect-timeout 5 --max-time 10 \
  "http://$ADR0025_WEB_GATEWAY_IP/" \
  | tee "$ADR0025_EVIDENCE/web-gateway-to-web.txt"

Pass when the response is the normal Web response or OIDC redirect, rather than an Istio authorization denial. The expected source principal is:

cluster.local/ns/istio-system/sa/istio-ingressgateway-web-service-account

FLOW-02 and FLOW-03: Web to both root actors

The Web image contains Node.js, so these calls originate from the real Web pod and therefore use its sidecar-issued workload identity.

kubectl exec -i -n themelio deploy/web -- node <<'NODE' \
  | tee "$ADR0025_EVIDENCE/web-to-actors.txt"
const urls = [
  'http://k8s-root-actor.themelio.svc.cluster.local/-/health',
  'http://gitlab-root-actor.themelio.svc.cluster.local/-/health',
];
for (const url of urls) {
  const response = await fetch(url);
  console.log(url, response.status, await response.text());
  if (response.status !== 200) process.exitCode = 1;
}
NODE

Pass when both calls return 200. The source principal must be:

cluster.local/ns/themelio/sa/web

FLOW-04 and FLOW-05: Waldur GitLab-sync workloads

for deployment in \
  waldur-agent-cloud-order-process \
  waldur-agent-cloud-membership-sync; do
  kubectl exec -n waldur-site-agent-cloud "deploy/$deployment" -- \
    python -c "import urllib.request; r=urllib.request.urlopen('http://gitlab-root-actor.themelio.svc.cluster.local/-/health', timeout=10); print('$deployment', r.status, r.read().decode())"
done | tee "$ADR0025_EVIDENCE/waldur-sync-to-gitlab-root-actor.txt"

Pass when both return 200. Both must use this source principal:

cluster.local/ns/waldur-site-agent-cloud/sa/waldur-site-agent-cloud-gitlab-sync

4. Negative protected flows

Run these calls one at a time. After each denial, immediately perform the observability checks in section 7 so the evidence remains inside the metric and log query windows.

DENY-01: Waldur report is denied

set +e
kubectl exec -n waldur-site-agent-cloud deploy/waldur-agent-cloud-report -- \
  python -c "import urllib.request; urllib.request.urlopen('http://gitlab-root-actor.themelio.svc.cluster.local/-/health', timeout=10)" \
  >"$ADR0025_EVIDENCE/waldur-report-denial.txt" 2>&1
rc=$?
set -e
cat "$ADR0025_EVIDENCE/waldur-report-denial.txt"
test "$rc" -ne 0

Pass when the connection fails. An HTTP 200 is a failure. This workload uses waldur-site-agent-cloud, which is intentionally absent from the actor's allowed-principal list.

DENY-02: arbitrary sidecar-injected Themelio identity is denied

Mutating: creates one ServiceAccount and one disposable pod.

kubectl apply -f - <<'YAML'
apiVersion: v1
kind: ServiceAccount
metadata:
  name: adr0025-denied
  namespace: themelio
---
apiVersion: v1
kind: Pod
metadata:
  name: adr0025-denied
  namespace: themelio
  labels:
    app: adr0025-denied
    sidecar.istio.io/inject: "true"
spec:
  serviceAccountName: adr0025-denied
  automountServiceAccountToken: false
  restartPolicy: Never
  containers:
    - name: curl
      image: curlimages/curl:8.12.1
      command: ["sleep", "3600"]
YAML
kubectl wait -n themelio --for=condition=Ready pod/adr0025-denied --timeout=180s
for destination in k8s-root-actor gitlab-root-actor; do
  set +e
  kubectl exec -n themelio adr0025-denied -- \
    curl -fsS --connect-timeout 5 --max-time 10 \
    "http://$destination.themelio.svc.cluster.local/-/health"
  rc=$?
  set -e
  printf '%s rc=%s\n' "$destination" "$rc"
  test "$rc" -ne 0
done |& tee "$ADR0025_EVIDENCE/arbitrary-identity-denials.txt"

Preserve logs, then clean up:

kubectl delete pod,serviceaccount -n themelio adr0025-denied --wait=true

DENY-03: direct plaintext pod-IP access is rejected

Obtain both protected destination pod IPs and call them directly from the staging master, which has no workload SPIFFE identity:

for app in web k8s-root-actor gitlab-root-actor; do
  pod_ip="$(kubectl get pod -n themelio -l "app=$app" \
    -o jsonpath='{.items[0].status.podIP}')"
  path='/-/health'
  if [ "$app" = web ]; then path='/'; fi
  set +e
  code="$(curl -sS -o /dev/null -w '%{http_code}' \
    --connect-timeout 5 --max-time 10 \
    "http://$pod_ip:3000$path")"
  rc=$?
  set -e
  printf '%s pod_ip=%s http_code=%s rc=%s\n' \
    "$app" "$pod_ip" "$code" "$rc"
  test "$rc" -ne 0
  test "$code" = 000
done |& tee "$ADR0025_EVIDENCE/plaintext-pod-ip-denials.txt"

Pass only when no HTTP response is received (http_code=000). An application 401, 403, or 404 proves that plaintext reached the application and is a failure for this test.

FLOW-06: conditional seeder identity

Run only while the seeder still calls gitlab-root-actor. This does not run the full repository mirror.

Mutating: creates one disposable pod using the existing seeder ServiceAccount.

kubectl run adr0025-seeder-call -n themelio \
  --image=curlimages/curl:8.12.1 \
  --restart=Never \
  --labels='app=adr0025-seeder-call,sidecar.istio.io/inject=true' \
  --overrides='{"spec":{"serviceAccountName":"themelio-seeder","automountServiceAccountToken":false}}' \
  --command -- sleep 3600
kubectl wait -n themelio --for=condition=Ready pod/adr0025-seeder-call --timeout=180s
kubectl exec -n themelio adr0025-seeder-call -- \
  curl -fsS --max-time 10 \
  http://gitlab-root-actor.themelio.svc.cluster.local/-/health \
  | tee "$ADR0025_EVIDENCE/seeder-to-gitlab-root-actor.txt"
kubectl delete pod -n themelio adr0025-seeder-call --wait=true

Pass when the call returns 200 with principal cluster.local/ns/themelio/sa/themelio-seeder.

5. Application authentication remains required

AUTH-01A: Waldur token is still required after mesh authorization

This request originates from an allowed sidecar identity but deliberately omits the application bearer token. The empty body is safe because authentication is checked before request validation and mutation.

kubectl exec -i -n waldur-site-agent-cloud \
  deploy/waldur-agent-cloud-order-process -- python - <<'PY' \
  | tee "$ADR0025_EVIDENCE/waldur-application-auth.txt"
import urllib.error
import urllib.request

request = urllib.request.Request(
    'http://gitlab-root-actor.themelio.svc.cluster.local/waldur/project_create',
    data=b'{}',
    headers={'Content-Type': 'application/json'},
    method='POST',
)
try:
    urllib.request.urlopen(request, timeout=10)
    raise SystemExit('FAIL: request without bearer token succeeded')
except urllib.error.HTTPError as error:
    print('status', error.code)
    if error.code != 401:
        raise
PY

Pass when the mesh permits the connection but the application returns 401.

AUTH-01B: Web session authentication is still required

From a workstation allowed to reach staging, run:

curl -skI https://staging.themelio.pharos.grnet.gr/settings/tokens \
  | sed -E 's/^(set-cookie:).*/\1 [REDACTED]/I' \
  | tee "$ADR0025_EVIDENCE/web-session-auth.txt"

Pass when an unauthenticated request redirects to the login flow or returns the normal application authentication response. It must not expose the protected page.

6. Sidecar identity and mutual TLS

OBS-01: sidecar proxy and certificate state

export ADR0025_ISTIO_VERSION="$(kubectl get deployment -n istio-system istiod \
  -o jsonpath='{.spec.template.spec.containers[0].image}' \
  | sed -E 's/.*:([0-9]+\.[0-9]+\.[0-9]+).*/\1/')"
export ADR0025_ISTIOCTL="$HOME/.local/bin/istioctl-$ADR0025_ISTIO_VERSION"

if [ ! -x "$ADR0025_ISTIOCTL" ]; then
  mkdir -p "$HOME/.local/bin"
  curl -fsSL --max-time 120 \
    "https://github.com/istio/istio/releases/download/$ADR0025_ISTIO_VERSION/istioctl-$ADR0025_ISTIO_VERSION-linux-amd64.tar.gz" \
    | tar -xz -C "$HOME/.local/bin"
  mv "$HOME/.local/bin/istioctl" "$ADR0025_ISTIOCTL"
fi

"$ADR0025_ISTIOCTL" version --remote=false \
  | tee "$ADR0025_EVIDENCE/istioctl-version.txt"
"$ADR0025_ISTIOCTL" proxy-status \
  | tee "$ADR0025_EVIDENCE/sidecar-proxy-status.txt"

for workload in web k8s-root-actor gitlab-root-actor; do
  pod="$(kubectl get pod -n themelio -l "app=$workload" \
    -o jsonpath='{.items[0].metadata.name}')"
  printf '=== themelio/%s ===\n' "$pod"
  kubectl get pod -n themelio "$pod" \
    -o jsonpath='{.spec.initContainers[*].name}{" "}{.spec.containers[*].name}{"\n"}'
  grep -F "$pod.themelio" "$ADR0025_EVIDENCE/sidecar-proxy-status.txt"
  "$ADR0025_ISTIOCTL" proxy-config secret "$pod.themelio"
done | tee "$ADR0025_EVIDENCE/themelio-sidecar-state.txt"

for workload in \
  waldur-agent-cloud-order-process \
  waldur-agent-cloud-membership-sync; do
  pod="$(kubectl get pod -n waldur-site-agent-cloud -l "app=$workload" \
    -o jsonpath='{.items[0].metadata.name}')"
  printf '=== waldur-site-agent-cloud/%s ===\n' "$pod"
  kubectl get pod -n waldur-site-agent-cloud "$pod" \
    -o jsonpath='{.spec.initContainers[*].name}{" "}{.spec.containers[*].name}{"\n"}'
  grep -F "$pod.waldur-site-agent-cloud" \
    "$ADR0025_EVIDENCE/sidecar-proxy-status.txt"
  "$ADR0025_ISTIOCTL" proxy-config secret \
    "$pod.waldur-site-agent-cloud"
done | tee "$ADR0025_EVIDENCE/waldur-sidecar-state.txt"

Pass when every selected workload contains istio-proxy, is synchronized in proxy-status, and its proxy has a valid cluster.local workload certificate.

OBS-02: Prometheus confirms sidecar mTLS traffic

export ADR0025_PROM_IP="$(kubectl get service -n monitoring \
  kube-prometheus-stack-prometheus -o jsonpath='{.spec.clusterIP}')"

curl -fsSG "http://$ADR0025_PROM_IP:9090/api/v1/query" \
  --data-urlencode 'query=sum by (source_principal,destination_principal) (istio_requests_total{reporter="destination",connection_security_policy="mutual_tls"})' \
  | jq -c '.data.result' \
  | tee "$ADR0025_EVIDENCE/sidecar-mtls-requests.json"

curl -fsSG "http://$ADR0025_PROM_IP:9090/api/v1/query" \
  --data-urlencode 'query=sum by (destination_workload) (istio_requests_total{reporter="destination",connection_security_policy="mutual_tls"})' \
  | jq -c '.data.result' \
  | tee "$ADR0025_EVIDENCE/sidecar-mtls-workloads.json"

Pass when the positive tests appear with connection_security_policy="mutual_tls" and the expected source/destination principals.

7. Denial observability

Run this section within 30 minutes of each negative test.

OBS-03A: destination sidecar logs

for workload in web k8s-root-actor gitlab-root-actor; do
  kubectl logs -n themelio -l "app=$workload" \
    -c istio-proxy --prefix --since=30m
done | grep -Ei 'rbac|deny|denied|authorization|tls|identity| 403 ' \
  | tee "$ADR0025_EVIDENCE/sidecar-denials.txt"

OBS-03B: Prometheus failure metrics and alerts

curl -fsSG "http://$ADR0025_PROM_IP:9090/api/v1/query" \
  --data-urlencode 'query=sum by (source_principal,destination_principal,response_code) (istio_requests_total{reporter="destination",response_code="403"})' \
  | jq -c '.data.result' \
  | tee "$ADR0025_EVIDENCE/sidecar-denial-metrics.json"

curl -fsS "http://$ADR0025_PROM_IP:9090/api/v1/alerts" \
  | jq '.data.alerts[] | select(.labels.alertname | startswith("Istio"))' \
  | tee "$ADR0025_EVIDENCE/istio-alerts.json"

OBS-03C: Loki evidence

export ADR0025_LOKI_IP="$(kubectl get service -n monitoring loki-gateway \
  -o jsonpath='{.spec.clusterIP}')"
export ADR0025_LOKI_START="$(date -u -d '30 minutes ago' +%s)000000000"

curl -fsSG "http://$ADR0025_LOKI_IP/loki/api/v1/query_range" \
  --data-urlencode 'query={container="istio-proxy"} |~ "RBAC|DENY|denied|policy rejection|TLS_FAILURE| 403 "' \
  --data-urlencode "start=$ADR0025_LOKI_START" \
  --data-urlencode 'limit=500' \
  | jq -c '.data.result' \
  | tee "$ADR0025_EVIDENCE/loki-istio-denials.json"

Pass when the deliberately generated denials can be attributed to the expected source and destination in sidecar logs or metrics, and the monitoring pipeline contains corresponding evidence. Record any missing label or scrape problem as a failure rather than inferring success from the client error alone.

8. Broader application smoke tests

APP-01 and APP-02: browser/OIDC and normal navigation

From a browser with access to staging:

  1. Open https://staging.themelio.pharos.grnet.gr in a private window.
  2. Confirm redirect to the configured OIDC provider.
  3. Log in with a staging test user.
  4. Confirm the catalogue, project selector, model, dataset, repo, space, recipe, upskilling, settings, and token pages load without unexpected 401, 403, or 5xx responses.
  5. In browser developer tools, export a sanitized HAR with cookies and authorization headers removed.

Correlate browser actions with application health:

kubectl logs -n themelio deploy/web --since=15m \
  | tee "$ADR0025_EVIDENCE/web-navigation.log"
kubectl get pods -n themelio \
  | tee "$ADR0025_EVIDENCE/themelio-after-navigation.txt"

APP-03: Git clone, push, and LFS

Mutating: creates a short-lived GitLab PAT for the existing pharos-admin staging user, a small disposable repository, and a disposable sidecar-injected test pod. The pod uses the existing themelio-seeder identity to request the token from gitlab-root-actor. It revokes the PAT and impersonation token and deletes the repository on exit. The PAT is never printed or written to evidence.

kubectl delete pod -n themelio adr0025-git-smoke \
  --ignore-not-found --wait=true

cat <<YAML | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: adr0025-git-smoke
  namespace: themelio
  labels:
    app: adr0025-git-smoke
    sidecar.istio.io/inject: "true"
spec:
  serviceAccountName: themelio-seeder
  automountServiceAccountToken: false
  restartPolicy: Never
  containers:
    - name: git-smoke
      image: alpine:3.22
      command: ["/bin/sh", "-c"]
      env:
        - name: ADR0025_RUN_ID
          value: "$ADR0025_RUN_ID"
      args:
        - |
          set -eu
          apk add --no-cache ca-certificates coreutils curl git git-lfs jq \
            >/dev/null

          gitlab=https://staging.gitlab.pharos.grnet.gr
          actor=http://gitlab-root-actor.themelio.svc.cluster.local
          username=pharos-admin
          project_id=''
          impersonation_id=''
          impersonation_token=''
          pat=''

          cleanup() {
            command_status=\$?
            set +e
            cleanup_failed=0
            if [ -n "\$project_id" ] && [ -n "\$pat" ]; then
              curl -fsS -X DELETE -H "PRIVATE-TOKEN: \$pat" \
                "\$gitlab/api/v4/projects/\$project_id" >/dev/null \
                && echo 'repository_cleanup=ok' \
                || cleanup_failed=1
            fi
            if [ -n "\$pat" ]; then
              curl -fsS -X DELETE -H "PRIVATE-TOKEN: \$pat" \
                "\$gitlab/api/v4/personal_access_tokens/self" >/dev/null \
                && echo 'temporary_pat_revoked=ok' \
                || cleanup_failed=1
            fi
            if [ -n "\$impersonation_id" ]; then
              curl -fsS -X DELETE \
                "\$actor/users/\$user_id/impersonation_tokens/\$impersonation_id" \
                >/dev/null \
                && echo 'impersonation_token_revoked=ok' \
                || cleanup_failed=1
            fi
            trap - EXIT
            if [ "\$command_status" -ne 0 ] || [ "\$cleanup_failed" -ne 0 ]; then
              exit 1
            fi
          }
          trap cleanup EXIT

          user_id="\$(curl -fsSG "\$gitlab/api/v4/users" \
            --data-urlencode "username=\$username" \
            | jq -er '.[0].id')"

          impersonation_response="\$(curl -fsS -X POST \
            "\$actor/users/\$user_id/impersonation_tokens")"
          impersonation_id="\$(printf '%s' "\$impersonation_response" \
            | jq -er '.id')"
          impersonation_token="\$(printf '%s' "\$impersonation_response" \
            | jq -er '.token')"

          expires_at="\$(date -u -d '+1 day' +%F)"
          pat_response="\$(jq -nc \
            --arg name "adr0025-\$ADR0025_RUN_ID" \
            --arg expires_at "\$expires_at" \
            '{name:\$name,expires_at:\$expires_at,scopes:["api","read_repository","write_repository"]}' \
            | curl -fsS -X POST \
                -H 'Content-Type: application/json' \
                -H "Authorization: Bearer \$impersonation_token" \
                --data-binary @- \
                "\$actor/api/v4/user/personal_access_tokens")"
          pat="\$(printf '%s' "\$pat_response" | jq -er '.token')"
          unset impersonation_token impersonation_response pat_response
          echo 'temporary_pat=created'

          project_name="adr0025-smoke-\$(printf '%s' "\$ADR0025_RUN_ID" \
            | tr '[:upper:]' '[:lower:]')"
          project_response="\$(curl -fsS -X POST \
            -H "PRIVATE-TOKEN: \$pat" \
            --data-urlencode "name=\$project_name" \
            --data-urlencode 'visibility=private' \
            --data-urlencode 'initialize_with_readme=true' \
            "\$gitlab/api/v4/projects")"
          project_id="\$(printf '%s' "\$project_response" | jq -er '.id')"
          project_path="\$(printf '%s' "\$project_response" \
            | jq -er '.path_with_namespace')"
          unset project_response
          echo "repository=created path=\$project_path"

          cat >/tmp/askpass <<'SH'
          #!/bin/sh
          case "\$1" in
            *Username*) printf '%s\n' oauth2 ;;
            *Password*) printf '%s\n' "\$ADR0025_GITLAB_PAT" ;;
          esac
          SH
          chmod 700 /tmp/askpass
          export ADR0025_GITLAB_PAT="\$pat"
          export GIT_ASKPASS=/tmp/askpass GIT_TERMINAL_PROMPT=0

          repository_url="\$gitlab/\$project_path.git"
          git clone "\$repository_url" /tmp/repository >/dev/null
          echo 'clone=ok'
          cd /tmp/repository
          git config user.name 'ADR-0025 validation'
          git config user.email 'adr-0025-validation@example.invalid'
          git switch -c adr0025-smoke >/dev/null
          git lfs install --local >/dev/null
          git lfs track '*.adr0025.bin' >/dev/null
          dd if=/dev/urandom of=smoke.adr0025.bin \
            bs=1024 count=64 status=none
          sha256sum smoke.adr0025.bin | cut -d' ' -f1 >/tmp/source.sha256
          git add .gitattributes smoke.adr0025.bin
          git commit -m 'test: ADR-0025 Git and LFS smoke test' >/dev/null
          git push origin adr0025-smoke >/dev/null
          echo 'push_and_lfs_upload=ok'

          GIT_LFS_SKIP_SMUDGE=1 git clone --branch adr0025-smoke \
            "\$repository_url" /tmp/verification >/dev/null
          git -C /tmp/verification lfs pull >/dev/null
          sha256sum /tmp/verification/smoke.adr0025.bin \
            | cut -d' ' -f1 >/tmp/verification.sha256
          cmp -s /tmp/source.sha256 /tmp/verification.sha256
          echo 'fresh_clone_and_lfs_download=ok'

          git push origin --delete adr0025-smoke >/dev/null
          echo 'branch_cleanup=ok'
YAML

kubectl wait -n themelio --for=condition=Ready \
  pod/adr0025-git-smoke --timeout=180s
kubectl wait -n themelio --for=jsonpath='{.status.phase}'=Succeeded \
  pod/adr0025-git-smoke --timeout=600s

While the completion wait is active, the following documented diagnostic may be run from a second staging-master shell. It does not expose token values:

kubectl get pod -n themelio adr0025-git-smoke -o wide
kubectl logs -n themelio adr0025-git-smoke --tail=100

If the diagnostic reports Error, interrupt the completion wait with Ctrl-C, preserve the failed log, and remove the failed pod before correcting this document and retrying:

kubectl logs -n themelio adr0025-git-smoke \
  | tee "$ADR0025_EVIDENCE/git-clone-push-lfs-failed.txt"
kubectl delete pod -n themelio adr0025-git-smoke --wait=true

After the completion wait succeeds, continue in its original shell:

kubectl logs -n themelio adr0025-git-smoke \
  | tee "$ADR0025_EVIDENCE/git-clone-push-lfs.txt"
kubectl delete pod -n themelio adr0025-git-smoke --wait=true

Pass when the log records temporary_pat=created, clone=ok, push_and_lfs_upload=ok, fresh_clone_and_lfs_download=ok, and branch_cleanup=ok, followed by repository_cleanup=ok, temporary_pat_revoked=ok, and impersonation_token_revoked=ok. Preserve pod logs before deletion if the test fails, then verify that the PAT, impersonation token, and disposable repository were removed before retrying.

APP-04: Kubernetes deployment action

Mutating: create and then remove a disposable deployment/job through the Themelio UI, not directly with kubectl. In parallel, observe:

export ADR0025_TENANT_NAMESPACE='<disposable-staging-project-namespace>'
kubectl get pods,jobs,deployments,inferenceservices -n "$ADR0025_TENANT_NAMESPACE" -w

In another terminal:

kubectl logs -n themelio deploy/k8s-root-actor --since=20m \
  | tee "$ADR0025_EVIDENCE/k8s-root-actor-action.log"

Pass when create, status updates, logs, and deletion work from the UI and the actor remains ready.

APP-05: Waldur reconciliation

Mutating: approve a disposable staging order and add/remove a disposable member in Waldur. Observe both real processors:

kubectl logs -n waldur-site-agent-cloud \
  deploy/waldur-agent-cloud-order-process -f --since=5m
kubectl logs -n waldur-site-agent-cloud \
  deploy/waldur-agent-cloud-membership-sync -f --since=5m

After reconciliation:

kubectl get namespace "$ADR0025_TENANT_NAMESPACE" --show-labels
kubectl get resourcequota,serviceaccount,rolebinding \
  -n "$ADR0025_TENANT_NAMESPACE"
kubectl logs -n themelio deploy/gitlab-root-actor --since=20m \
  | tee "$ADR0025_EVIDENCE/waldur-gitlab-reconciliation.log"

Pass when the namespace/quota and GitLab groups/membership match the Waldur state. Remove the disposable order/project using the supported workflow.

APP-06: Kubeflow and external authorization

curl -skI https://staging.kubeflow.pharos.grnet.gr/ \
  | tee "$ADR0025_EVIDENCE/kubeflow-auth-response.txt"
kubectl logs -n oauth2-proxy -l app.kubernetes.io/name=oauth2-proxy \
  --all-containers --prefix --since=10m \
  | tee "$ADR0025_EVIDENCE/kubeflow-oauth2-proxy.log"

Then log in through a browser and open an authorized Kubeflow page. Pass when unauthenticated access enters the external-authorization/login flow, authorized access works, and the existing CUSTOM policy continues to operate alongside the scoped host ALLOW policy.

APP-07: registry pull and push

Mutating: creates one disposable in-cluster pod and pushes one disposable tag. The pod reads the existing registry credentials through Secret references; the commands never decode or print them. The pod and its temporary credential file are deleted after the test. The pushed tag is left for the normal registry retention workflow.

kubectl delete pod -n registry adr0025-registry-smoke \
  --ignore-not-found --wait=true

cat <<YAML | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: adr0025-registry-smoke
  namespace: registry
spec:
  restartPolicy: Never
  automountServiceAccountToken: false
  containers:
    - name: crane
      image: gcr.io/go-containerregistry/crane:debug
      command: ["/busybox/sh", "-c"]
      args:
        - |
          set -eu
          registry=staging.registry.pharos.grnet.gr
          source="\$registry/themelio/web:staging"
          target="\$registry/themelio/web:adr0025-$ADR0025_RUN_ID"
          crane auth login "\$registry" \
            -u "\$REGISTRY_USER" -p "\$REGISTRY_PASSWORD" >/dev/null
          echo "source_digest=\$(crane digest "\$source")"
          crane pull "\$source" /tmp/source.tar
          echo "pull_source=ok bytes=\$(wc -c </tmp/source.tar)"
          crane push /tmp/source.tar "\$target"
          echo "push_test=ok"
          crane pull "\$target" /tmp/test.tar
          echo "pull_test=ok bytes=\$(wc -c </tmp/test.tar)"
          test "\$(crane digest "\$source")" = \
            "\$(crane digest "\$target")"
          echo "digest_match=ok"
      env:
        - name: REGISTRY_USER
          valueFrom:
            secretKeyRef:
              name: registry-auth
              key: k8s_user
        - name: REGISTRY_PASSWORD
          valueFrom:
            secretKeyRef:
              name: registry-auth
              key: k8s_pass
YAML

kubectl wait -n registry --for=condition=Ready \
  pod/adr0025-registry-smoke --timeout=180s
kubectl wait -n registry --for=jsonpath='{.status.phase}'=Succeeded \
  pod/adr0025-registry-smoke --timeout=600s
kubectl logs -n registry adr0025-registry-smoke \
  | tee "$ADR0025_EVIDENCE/registry-pull-push.txt"
kubectl delete pod -n registry adr0025-registry-smoke --wait=true

Pass when the log records pull_source=ok, push_test=ok, pull_test=ok, and digest_match=ok. Preserve logs before deleting the pod if it fails.

APP-08: Grafana and Prometheus

The staging reverse proxy restricts both UIs by source CIDR. Test from an allowed network:

curl -skI https://staging.grafana.pharos.grnet.gr/ \
  | tee "$ADR0025_EVIDENCE/grafana-public-response.txt"
curl -skI https://staging.prometheus.pharos.grnet.gr/ \
  | tee "$ADR0025_EVIDENCE/prometheus-public-response.txt"

Open both UIs in a browser. Confirm the ADR-0025 sidecar dashboard loads and Prometheus has ready sidecar, istiod, and gateway targets. A public 403 from a non-allowed source tests the Nginx CIDR restriction, not the Istio policy; use the direct gateway Service ClusterIP test in section 2 for Istio evidence.

APP-09: readiness probes and alert definitions

kubectl get deploy -A \
  -o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,DESIRED:.spec.replicas,READY:.status.readyReplicas,AVAILABLE:.status.availableReplicas' \
  | tee "$ADR0025_EVIDENCE/deployment-readiness.txt"

kubectl get prometheusrule -n monitoring istio-sidecar-security -o yaml \
  | tee "$ADR0025_EVIDENCE/istio-alert-rules.yaml"

kubectl get podmonitor -n monitoring \
  istio-workload-sidecars istio-control-plane istio-gateways -o wide \
  | tee "$ADR0025_EVIDENCE/istio-podmonitors.txt"

Pass when protected workloads and platform components remain ready, all three Istio scrape definitions exist, and the ADR-0025 alert classes are loaded: control-plane availability, gateway host rejection, workload-identity denial, mTLS failure, and application authorization rejection.

9. Tenant workload authorization

Validates the scoped tenant-namespace policies that k8s-root-actor generates per Themelio deployment and inference service (DD-0025 §9). The positive path — the workload is reachable through the Themelio service gateway — is already exercised by APP-04 and the Space route in GW-01; this section verifies the security behavior that a happy-path UI test cannot: that the policy is scoped, enforced, denies access from outside the tenant namespace, and is cleaned up.

Each generated <name>-service-gateway policy admits only the Themelio service-gateway principal on the workload port; ns-owner-access-istio retains same-namespace access, and the former namespace-wide allow-all is removed.

Prerequisites: the mesh runs native sidecars (ENABLE_NATIVE_SIDECARS=true, DD-0025 §2.2), and one disposable deployment and one disposable inference service have been created from the UI (APP-04) in a disposable tenant namespace. Set the placeholders:

export ADR0025_TENANT_NAMESPACE='<disposable-staging-project-namespace>'
export ADR0025_DEPLOYMENT='<deployment-name>'
export ADR0025_INFERENCE='<inferenceservice-name>'
export ADR0025_GATEWAY_PRINCIPAL='cluster.local/ns/istio-system/sa/istio-ingressgateway-themelio-service-account'

TENANT-01: scoped policies exist and carry no namespace-wide rule

kubectl get authorizationpolicy -n "$ADR0025_TENANT_NAMESPACE" \
  -o custom-columns=NAME:.metadata.name,ACTION:.spec.action \
  | tee "$ADR0025_EVIDENCE/tenant-authz-policies.txt"

# Full shape of the two generated policies: selector, admitted principal, port.
kubectl get authorizationpolicy \
  "${ADR0025_DEPLOYMENT}-service-gateway" \
  "${ADR0025_INFERENCE}-service-gateway" \
  -n "$ADR0025_TENANT_NAMESPACE" -o yaml \
  | tee "$ADR0025_EVIDENCE/tenant-scoped-policies.yaml"

# No ALLOW policy in the namespace contains an empty (match-all) rule.
kubectl get authorizationpolicy -n "$ADR0025_TENANT_NAMESPACE" -o json \
  | jq '[.items[]
         | select(.spec.action=="ALLOW")
         | select((.spec.rules // []) | index({}))
         | .metadata.name]' \
  | tee "$ADR0025_EVIDENCE/tenant-namespace-wide-rules.json"

Pass when each <name>-service-gateway policy selects its workload (the deployment-runner labels, or the KServe serving.kserve.io/inferenceservice + component: predictor labels), admits only $ADR0025_GATEWAY_PRINCIPAL on the workload port, and the empty-rule array is [].

TENANT-02: enforcement denies an identity outside the tenant namespace

The retained ns-owner-access-istio policy allows all same-namespace traffic (source.namespace == <tenant>), so an in-namespace caller is admitted by design. The scoped policy's contribution is blocking callers from outside the tenant namespace that are not the service gateway — the access allow-all used to permit. The deny test therefore runs from a foreign namespace.

First confirm the predictor carries the Envoy proxy as a native sidecar — without it the policy is silently inert:

predictor_pod="$(kubectl get pod -n "$ADR0025_TENANT_NAMESPACE" \
  -l "serving.kserve.io/inferenceservice=${ADR0025_INFERENCE},component=predictor" \
  -o jsonpath='{.items[0].metadata.name}')"
kubectl get pod -n "$ADR0025_TENANT_NAMESPACE" "$predictor_pod" \
  -o jsonpath='{.spec.initContainers[*].name}{"\n"}' \
  | tee "$ADR0025_EVIDENCE/tenant-predictor-initcontainers.txt"

istio-proxy must appear among the init containers.

Mutating: create one disposable pod in a foreign (non-tenant) namespace and confirm it is denied on each workload's port:

export ADR0025_FOREIGN_NAMESPACE='themelio'
kubectl -n "$ADR0025_FOREIGN_NAMESPACE" run adr0025-foreign-denied \
  --restart=Never --image=curlimages/curl:8.12.1 --command -- sleep 3600
kubectl -n "$ADR0025_FOREIGN_NAMESPACE" \
  wait --for=condition=Ready pod/adr0025-foreign-denied --timeout=180s
for target in \
  "http://${ADR0025_DEPLOYMENT}.${ADR0025_TENANT_NAMESPACE}.svc.cluster.local/" \
  "http://${ADR0025_INFERENCE}-predictor.${ADR0025_TENANT_NAMESPACE}.svc.cluster.local/"; do
  set +e
  code="$(kubectl exec -n "$ADR0025_FOREIGN_NAMESPACE" adr0025-foreign-denied -- \
    curl -sS -o /dev/null -w '%{http_code}' \
    --connect-timeout 5 --max-time 10 "$target")"
  rc=$?
  set -e
  printf '%s http_code=%s rc=%s\n' "$target" "$code" "$rc"
  [ "$rc" -ne 0 ] || [ "$code" = 403 ]
done |& tee "$ADR0025_EVIDENCE/tenant-unauthorized-denials.txt"

Pass when both targets return 403 (or a refused/reset connection). A same- namespace caller returning 200 is expected and is not a failure.

Pass only when both targets are denied — a refused/reset connection (non-zero rc) or an RBAC 403. A 200 proves the policy is not enforcing and is a failure. Preserve logs, then clean up:

kubectl delete pod -n "$ADR0025_TENANT_NAMESPACE" adr0025-tenant-denied --wait=true

TENANT-03: authorized access through the service gateway succeeds

The counterpart to TENANT-02: prove the policy admits the right identity, not just that it denies the wrong one. Route to each workload through the Themelio service gateway (whose ServiceAccount is the admitted principal) and confirm the application answers rather than the destination Envoy rejecting with an RBAC 403. Discover each workload's host and route path from its VirtualService, and reuse the Themelio service gateway Service ClusterIP that GW-01 already found for the Space route:

export ADR0025_SERVICE_GATEWAY_IP='<Themelio service-gateway Service ClusterIP from GW-01>'

for entry in \
  "${ADR0025_DEPLOYMENT}-vs" \
  "${ADR0025_INFERENCE}-predictor-vs"; do
  host="$(kubectl get virtualservice "$entry" -n "$ADR0025_TENANT_NAMESPACE" \
    -o jsonpath='{.spec.hosts[0]}')"
  path="$(kubectl get virtualservice "$entry" -n "$ADR0025_TENANT_NAMESPACE" \
    -o jsonpath='{.spec.http[0].match[0].uri.exact}')"
  code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Host: $host" \
    --connect-timeout 5 --max-time 15 \
    "http://${ADR0025_SERVICE_GATEWAY_IP}${path}" || true)"
  printf '%s\t%s%s\t%s\n' "$entry" "$host" "$path" "$code"
done |& tee "$ADR0025_EVIDENCE/tenant-authorized-through-gateway.txt"

Pass when each request reaches the normal application or authentication behavior. A gateway/RBAC 403 is a failure — the policy is wrongly denying the gateway identity. Distinguish the two: an Envoy RBAC denial returns the body RBAC: access denied with no application content, so capture headers and body for any 403:

curl -si -H "Host: <workload-host>" \
  --connect-timeout 5 --max-time 15 \
  "http://${ADR0025_SERVICE_GATEWAY_IP}<workload-path>" \
  | tee "$ADR0025_EVIDENCE/tenant-authorized-response.txt"

TENANT-04: teardown removes the scoped policy

Delete the disposable deployment and inference service through the UI, then confirm no <name>-service-gateway policy is orphaned:

kubectl get authorizationpolicy -n "$ADR0025_TENANT_NAMESPACE" \
  | grep -E "${ADR0025_DEPLOYMENT}-service-gateway|${ADR0025_INFERENCE}-service-gateway" \
  | tee "$ADR0025_EVIDENCE/tenant-policy-teardown.txt" \
  || echo "no orphan service-gateway policies" \
  | tee "$ADR0025_EVIDENCE/tenant-policy-teardown.txt"

Pass when neither policy remains after the UI deletion completes.

10. Final review

Do not mark the run accepted solely because the positive calls passed. Review every required row in the top-level checklist and record links or paths to the evidence.

printf 'finished_utc=%s\n' "$(date -u +%FT%TZ)" \
  | tee -a "$ADR0025_EVIDENCE/run.txt"
find "$ADR0025_EVIDENCE" -maxdepth 1 -type f -printf '%f\n' | sort

Validation succeeds only when:

  • every required row is PASS;
  • conditional seeder coverage is either PASS or documented as NOT APPLICABLE;
  • no unexplained regression, missing denial evidence, or unexpected principal remains;
  • temporary pods, branches, and application resources have been removed;
  • credentials used during testing have been unset, and any credential exposed outside an approved secret store has been rotated.
Page source

Source snapshot · Revision f928834c8.