Skip to content

Commit c65cc77

Browse files
committed
Fix Helm authentication issuer detection
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent ed4d023 commit c65cc77

8 files changed

Lines changed: 160 additions & 2 deletions

File tree

.github/workflows/helm-e2e.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
go-version-file: go.mod
3232
- name: Setup Helm
3333
uses: azure/setup-helm@v4
34+
- name: Verify authentication chart render
35+
run: hack/verify-helm-authentication.sh
3436
- name: Cache micro-VM assets
3537
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
3638
with:
@@ -74,13 +76,18 @@ jobs:
7476
hack/install-ate-kind.sh --create-jwt-authority-pool-secret
7577
hack/install-ate-kind.sh --create-actor-id-ca-pool-secret
7678
hack/install-ate-kind.sh --create-actor-id-ca-certs-secret
77-
hack/install-ate-kind.sh --create-api-authentication-config
7879
- name: Wait for Helm install
7980
run: |
8081
helm upgrade substrate charts/substrate \
8182
--namespace ate-system \
8283
--reuse-values \
8384
--wait --timeout=10m
85+
- name: Verify ServiceAccount bearer authentication
86+
run: |
87+
make build-atectl
88+
export PATH="${PWD}/bin:${PATH}"
89+
kubectl -n ate-system create token ate-client --audience=api.ate-system.svc \
90+
| kubectl-ate --token-file - get actors -A
8491
- name: Deploy micro-VM counter demo
8592
run: hack/run-microvm-demo-kind.sh --skip-control-plane
8693
- name: Deploy gVisor counter demo

charts/substrate/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ helm upgrade --install substrate-crds ./charts/substrate-crds
1515
helm upgrade --install substrate ./charts/substrate
1616
```
1717

18+
The chart derives the ServiceAccount token issuer from ateapi's projected
19+
token. To override it explicitly:
20+
21+
```bash
22+
helm upgrade --install substrate ./charts/substrate \
23+
--set-string auth.jwt.issuer="$(kubectl get --raw /.well-known/openid-configuration | jq -r .issuer)"
24+
```
25+
1826
By default, component images are pulled from `ghcr.io/kagent-dev/substrate`
1927
using the chart `appVersion` as the tag. Override `image.registry` and
2028
`image.tag` to install from a different image repository or tag.
@@ -35,6 +43,8 @@ See `values.yaml` for the full set; the important keys:
3543

3644
| Key | Default | Notes |
3745
|-----|---------|-------|
46+
| `auth.jwt.issuer` | `""` (detected) | Explicit ServiceAccount token issuer override |
47+
| `auth.jwt.audience` | `api.ate-system.svc` | Expected ServiceAccount token audience |
3848
| `postgres.connectionString` | `""` (in-cluster) | Override to use external PostgreSQL |
3949
| `postgres.storageSize` | `1Gi` | In-cluster PostgreSQL PVC size |
4050
| `rustfs.enabled` | `true` | Deploy an in-cluster S3-compatible RustFS bucket for snapshots |
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{{/*
2+
Copyright 2026 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/}}
16+
17+
apiVersion: v1
18+
kind: ConfigMap
19+
metadata:
20+
name: ate-api-authentication
21+
namespace: {{ .Release.Namespace }}
22+
data:
23+
authentication.yaml: |
24+
actorIdentityJWTProvider: kubernetes
25+
jwtProviders:
26+
- name: kubernetes
27+
{{- if .Values.auth.jwt.issuer }}
28+
issuer: {{ .Values.auth.jwt.issuer | quote }}
29+
{{- end }}
30+
audiences: [{{ .Values.auth.jwt.audience | quote }}]
31+
{{- if or (not .Values.auth.jwt.issuer) (has .Values.auth.jwt.issuer (list "https://kubernetes.default.svc" "https://kubernetes.default.svc.cluster.local")) }}
32+
certificateAuthorityFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
33+
discoveryTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
34+
{{- end }}

charts/substrate/values.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
# manifests/ate-install/ install path (kubectl apply).
2424
createNamespace: false
2525

26+
auth:
27+
jwt:
28+
# ServiceAccount token issuer. Empty derives it from the projected token.
29+
issuer: ""
30+
audience: api.ate-system.svc
31+
2632
postgres:
2733
storageSize: 1Gi
2834
connectionString: ""

docs/authentication.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ configured with the file passed to `--authentication-config`:
77
actorIdentityJWTProvider: kubernetes
88
jwtProviders:
99
- name: kubernetes
10-
issuer: https://kubernetes.default.svc.cluster.local
1110
audiences:
1211
- api.ate-system.svc
1312
certificateAuthorityFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
@@ -22,6 +21,8 @@ Provider names and issuers must be unique. `issuer` must be an HTTPS URL and
2221
`audiences` must be non-empty; a token is accepted when any configured audience
2322
matches. `certificateAuthorityFile` and `discoveryTokenFile` are optional and
2423
are needed for OIDC discovery against some private Kubernetes API servers.
24+
When `issuer` is omitted, ate-api derives it from the trusted JWT at
25+
`discoveryTokenFile`.
2526

2627
`actorIdentityJWTProvider` identifies the provider allowed to call
2728
`ActorIdentity.MintJWT`. Other authenticated providers can call every RPC.

hack/verify-helm-authentication.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
3+
# Copyright 2026 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
set -o errexit -o nounset -o pipefail
18+
19+
ROOT="$(git rev-parse --show-toplevel)"
20+
cd "${ROOT}"
21+
22+
rendered="$(helm template substrate charts/substrate \
23+
--namespace ate-system \
24+
--show-only templates/ate-api-authentication.yaml \
25+
--set-string auth.jwt.issuer=https://issuer.example)"
26+
27+
rg -q '^ issuer: "https://issuer\.example"$' <<<"${rendered}"
28+
if rg -q 'certificateAuthorityFile|discoveryTokenFile' <<<"${rendered}"; then
29+
echo "external issuers must not use in-cluster discovery credentials" >&2
30+
exit 1
31+
fi
32+
33+
rendered="$(helm template substrate charts/substrate \
34+
--namespace ate-system \
35+
--show-only templates/ate-api-authentication.yaml)"
36+
if rg -q '^ issuer:' <<<"${rendered}"; then
37+
echo "default authentication config must omit the issuer" >&2
38+
exit 1
39+
fi
40+
rg -q '^ discoveryTokenFile: /var/run/secrets/kubernetes\.io/serviceaccount/token$' <<<"${rendered}"

internal/ateapiauth/config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ func LoadAuthenticationConfig(path string) (*AuthenticationConfig, error) {
4747
if err := yaml.UnmarshalStrict(b, &cfg); err != nil {
4848
return nil, fmt.Errorf("parse authentication config: %w", err)
4949
}
50+
for i := range cfg.JWTProviders {
51+
provider := &cfg.JWTProviders[i]
52+
if provider.Issuer == "" && provider.DiscoveryTokenFile != "" {
53+
// discoveryTokenFile is trusted local configuration; client tokens are
54+
// still verified against the issuer derived from it.
55+
token, readErr := os.ReadFile(provider.DiscoveryTokenFile)
56+
if readErr != nil {
57+
return nil, fmt.Errorf("derive jwtProviders[%d].issuer: read discovery token file: %w", i, readErr)
58+
}
59+
provider.Issuer, err = unverifiedIssuer(string(token))
60+
if err != nil {
61+
return nil, fmt.Errorf("derive jwtProviders[%d].issuer: %w", i, err)
62+
}
63+
}
64+
}
5065
if err := ValidateAuthenticationConfig(&cfg); err != nil {
5166
return nil, err
5267
}

internal/ateapiauth/config_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,57 @@
1515
package ateapiauth
1616

1717
import (
18+
"encoding/base64"
1819
"os"
1920
"path/filepath"
2021
"strings"
2122
"testing"
2223
)
2324

25+
func TestLoadAuthenticationConfigDerivesIssuer(t *testing.T) {
26+
dir := t.TempDir()
27+
tokenPath := filepath.Join(dir, "token")
28+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"https://kubernetes.default.svc.cluster.local"}`))
29+
if err := os.WriteFile(tokenPath, []byte("header."+payload+".signature"), 0o600); err != nil {
30+
t.Fatal(err)
31+
}
32+
configPath := filepath.Join(dir, "authentication.yaml")
33+
config := "actorIdentityJWTProvider: kubernetes\njwtProviders:\n- name: kubernetes\n audiences: [api.ate-system.svc]\n discoveryTokenFile: " + tokenPath + "\n"
34+
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
cfg, err := LoadAuthenticationConfig(configPath)
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
if got, want := cfg.JWTProviders[0].Issuer, "https://kubernetes.default.svc.cluster.local"; got != want {
43+
t.Fatalf("Issuer = %q, want %q", got, want)
44+
}
45+
}
46+
47+
func TestLoadAuthenticationConfigKeepsExplicitIssuer(t *testing.T) {
48+
path := filepath.Join(t.TempDir(), "authentication.yaml")
49+
if err := os.WriteFile(path, []byte(`
50+
actorIdentityJWTProvider: explicit
51+
jwtProviders:
52+
- name: explicit
53+
issuer: https://issuer.example
54+
audiences: [audience]
55+
certificateAuthorityFile: /does/not/exist
56+
discoveryTokenFile: /does/not/exist
57+
`), 0o600); err != nil {
58+
t.Fatal(err)
59+
}
60+
cfg, err := LoadAuthenticationConfig(path)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
if got := cfg.JWTProviders[0].Issuer; got != "https://issuer.example" {
65+
t.Fatalf("Issuer = %q, want explicit issuer", got)
66+
}
67+
}
68+
2469
func TestLoadAuthenticationConfig(t *testing.T) {
2570
path := filepath.Join(t.TempDir(), "authentication.yaml")
2671
if err := os.WriteFile(path, []byte(`

0 commit comments

Comments
 (0)