Skip to content

Commit 53f84f1

Browse files
authored
feat: refactor authentication flows inside of the webhook (#225)
* Refactor authentication flow in the webhook. Remove the obsolete long-live token authentication option. Add the possibility to setup multitenant environment using SA keys per namespace. Add the possibility to use WIF auth flow. * Add integration test for the multitenant auth flow. * Update documentation * fix: code review * fix: lint and lower acme record default TTL
1 parent c2036aa commit 53f84f1

24 files changed

Lines changed: 600 additions & 919 deletions

Makefile

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ out/report.json:
6464

6565
.PHONY: test-e2e-conformance
6666
test-e2e-conformance:
67-
@STACKIT_TOKEN=$(STACKIT_TOKEN) TEST_ZONE_NAME=$(TEST_ZONE_NAME) go test -race -tags=e2e ./... -coverprofile out/cover.out
67+
@TEST_ZONE_NAME=$(TEST_ZONE_NAME) go test -race -tags=e2e ./... -coverprofile out/cover.out
6868

6969
run:
7070
go run cmd/webhook/main.go
@@ -134,21 +134,31 @@ e2e-cert-manager:
134134
kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager -n cert-manager
135135
kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager-webhook -n cert-manager
136136

137+
.PHONY: e2e-namespaces
138+
e2e-namespaces:
139+
@echo "=> Creating test namespaces and secrets..."
140+
kubectl create namespace e2e-tenant --dry-run=client -o yaml | kubectl apply -f -
141+
kubectl create secret generic stackit-dynamic-auth -n e2e-tenant \
142+
--from-file=sa.json=$(AUTH_KEY_PATH) \
143+
--dry-run=client -o yaml | kubectl apply -f -
144+
kubectl create namespace e2e-tenant-two --dry-run=client -o yaml | kubectl apply -f -
145+
kubectl create secret generic stackit-dynamic-auth -n e2e-tenant-two \
146+
--from-file=sa.json=$(AUTH_KEY_PATH) \
147+
--dry-run=client -o yaml | kubectl apply -f -
148+
137149
.PHONY: e2e-deploy-webhook
138150
e2e-deploy-webhook:
139151
@echo "=> Deploying stackit-cert-manager-webhook..."
140152
kubectl create secret generic stackit-sa-authentication -n cert-manager \
141153
--from-file=sa.json=$(AUTH_KEY_PATH) \
142154
--dry-run=client -o yaml | kubectl apply -f -
143-
144155
helm upgrade --install stackit-cert-manager-webhook ./deploy/stackit \
145156
--namespace cert-manager \
146157
--set image.repository=stackitcloud/stackit-cert-manager-webhook \
147158
--set image.tag=e2e \
148159
--set image.pullPolicy=Never \
149160
--set stackitSaAuthentication.enabled=true \
150-
--set stackitSaAuthentication.secretName=stackit-sa-authentication
151-
161+
--set stackitSaAuthentication.secretAccessScope=issuer
152162
kubectl wait --for=condition=available --timeout=120s deployment/stackit-cert-manager-webhook -n cert-manager
153163

154164
.PHONY: e2e-run-kuttl
@@ -171,6 +181,6 @@ clean-e2e-local:
171181

172182
# The main target chains the dependencies together
173183
.PHONY: test-e2e-local
174-
test-e2e-local: e2e-check-env e2e-cluster e2e-cert-manager e2e-deploy-webhook
184+
test-e2e-local: e2e-check-env e2e-cluster e2e-cert-manager e2e-namespaces e2e-deploy-webhook
175185
@$(MAKE) e2e-run-kuttl || ( $(MAKE) clean-e2e-local && exit 1 )
176186
@$(MAKE) clean-e2e-local

README.md

Lines changed: 129 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
[![CI](https://github.com/stackitcloud/stackit-cert-manager-webhook/actions/workflows/main.yml/badge.svg)](https://github.com/stackitcloud/stackit-cert-manager-webhook/actions/workflows/main.yml)
1010
[![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/stackit-cert-manager-webhook)](https://goreportcard.com/report/github.com/stackitcloud/stackit-cert-manager-webhook)
1111

12-
Facilitate a webhook integration for leveraging the STACKIT DNS alongside
13-
its [API](https://docs.api.stackit.cloud/documentation/dns/version/v1) to act as a DNS01
14-
ACME Issuer with [cert-manager](https://cert-manager.io/docs/).
12+
Facilitate a webhook integration for leveraging the STACKIT DNS alongside its [API](https://docs.api.stackit.cloud/documentation/dns/version/v1) to act as a DNS01 ACME Issuer with [cert-manager](https://cert-manager.io/docs/).
1513

1614
## Installation
1715

@@ -20,175 +18,136 @@ helm repo add stackit-cert-manager-webhook https://stackitcloud.github.io/stacki
2018
helm install stackit-cert-manager-webhook --namespace cert-manager stackit-cert-manager-webhook/stackit-cert-manager-webhook
2119
```
2220

23-
## Usage
24-
25-
1. ***Initiation of STACKIT Service Account Secret:***
26-
```bash
27-
kubectl create secret generic stackit-sa-authentication \
28-
-n cert-manager \
29-
--from-literal=sa.json='{
30-
"id": "4e1fe486-b463-4bcd-9210-288854268e34",
31-
"publicKey": "-----BEGIN PUBLIC KEY-----\nPUBLIC_KEY\n-----END PUBLIC KEY-----",
32-
"createdAt": "2024-04-02T13:12:17.678+00:00",
33-
"validUntil": "2024-04-15T22:00:00.000+00:00",
34-
"keyType": "USER_MANAGED",
35-
"keyOrigin": "GENERATED",
36-
"keyAlgorithm": "RSA_2048",
37-
"active": true,
38-
"credentials": {
39-
"kid": "kid",
40-
"iss": "iss",
41-
"sub": "sub",
42-
"aud": "aud",
43-
"privateKey": "-----BEGIN PRIVATE KEY-----\nPRIVATE-KEY==\n-----END PRIVATE KEY-----"
44-
}
45-
}'
46-
```
47-
You now need to adjust the deployment via helm to use the secret:
48-
```bash
49-
helm upgrade stackit-cert-manager-webhook \
50-
--namespace cert-manager \
51-
stackit-cert-manager-webhook/stackit-cert-manager-webhook \
21+
## Authentication & Usage
22+
23+
The STACKIT webhook requires authentication against the STACKIT DNS API. Depending on your cluster architecture and security policies, you can authenticate using one of the three methods below.
24+
25+
The webhook will explicitly fail if multiple mutually exclusive authentication methods are configured for a single Issuer.
26+
27+
### Option A: Dynamic Service Account Key (Multi-Tenant)
28+
29+
This method is recommended for multi-tenant clusters where different `Issuer` or `ClusterIssuer` resources manage zones across different STACKIT projects. The webhook fetches the Service Account JSON directly from a Kubernetes Secret per challenge.
30+
31+
1. **Create the Secret containing the SA JSON:**
32+
```bash
33+
kubectl create secret generic stackit-tenant-a-auth \
34+
-n default \
35+
--from-file=sa.json=/path/to/tenant-a-sa.json
36+
```
37+
38+
2. **Configure the Issuer:**
39+
Ensure the `serviceAccountSecretNamespace` matches the namespace of your Secret. If you want the webhook to read secrets outside of its own installation namespace, you must set `stackitSaAuthentication.secretAccessScope=issuer` when installing the Helm chart.
40+
```yaml
41+
apiVersion: cert-manager.io/v1
42+
kind: Issuer
43+
metadata:
44+
name: letsencrypt-prod
45+
namespace: default
46+
spec:
47+
acme:
48+
server: https://acme-v02.api.letsencrypt.org/directory
49+
email: example@example.com
50+
privateKeySecretRef:
51+
name: letsencrypt-prod
52+
solvers:
53+
- dns01:
54+
webhook:
55+
solverName: stackit
56+
groupName: acme.stackit.de
57+
config:
58+
projectId: <STACKIT ID PROJECT>
59+
serviceAccountSecretRef: stackit-tenant-a-auth
60+
serviceAccountSecretKey: sa.json
61+
serviceAccountSecretNamespace: default
62+
```
63+
64+
### Option B: Static Service Account Key (Single Tenant / Global Fallback)
65+
66+
This method mounts a single Service Account key JSON file into the webhook Pod. It is ideal for single-tenant clusters where the webhook manages domains for a single STACKIT project or organization.
67+
68+
1. **Deploy the Webhook with the Key Mounted:**
69+
Create a secret in the `cert-manager` namespace and install the Helm chart with mounting enabled:
70+
```bash
71+
kubectl create secret generic stackit-sa-authentication \
72+
-n cert-manager \
73+
--from-file=sa.json=/path/to/global-sa.json
74+
75+
helm upgrade --install stackit-cert-manager-webhook stackit-cert-manager-webhook/stackit-cert-manager-webhook \
76+
--namespace cert-manager \
5277
--set stackitSaAuthentication.enabled=true
53-
```
54-
55-
2. ***Configuration of ClusterIssuer/Issuer:***
56-
For scenarios wherein zones and record sets are encapsulated within a singular project, utilize a ClusterIssuer:
57-
```yaml
58-
apiVersion: cert-manager.io/v1
59-
kind: ClusterIssuer
60-
metadata:
61-
name: letsencrypt-prod
62-
spec:
63-
acme:
64-
server: https://acme-v02.api.letsencrypt.org/directory
65-
email: example@example.com # Replace this with your email address
66-
privateKeySecretRef:
67-
name: letsencrypt-prod
68-
solvers:
69-
- dns01:
70-
webhook:
71-
solverName: stackit
72-
groupName: acme.stackit.de
73-
config:
74-
projectId: <STACKIT PROJECT ID>
75-
```
76-
77-
For diverse project architectures where zones are spread across varying projects, use an Issuer (namespaces are separate):
78-
```yaml
79-
apiVersion: cert-manager.io/v1
80-
kind: Issuer
81-
metadata:
82-
name: letsencrypt-prod
83-
namespace: default
84-
spec:
85-
acme:
86-
server: https://acme-v02.api.letsencrypt.org/directory
87-
email: example@example.com # Replace this with your email address
88-
privateKeySecretRef:
89-
name: letsencrypt-prod
90-
solvers:
91-
- dns01:
92-
webhook:
93-
solverName: stackit
94-
groupName: acme.stackit.de
95-
config:
96-
projectId: <STACKIT PROJECT ID>
97-
```
98-
*Note on service accounts and namespaces:*
99-
- Issuer-per-namespace (recommended for isolation): create a STACKIT service-account key (sa.json) for each STACKIT project you need to manage and place that key in a Kubernetes Secret in the same namespace as the Issuer. This means one sa.json (one SA key) per Issuer/namespace when the Issuers target different STACKIT projects.
100-
Example (create a secret in the Issuer namespace):
101-
```bash
102-
kubectl create secret generic stackit-sa-authentication \
103-
-n <issuer-namespace> \
104-
--from-literal=sa.json='{"id":"...","credentials":{...}}'
105-
```
106-
Ensure the webhook can read the secret in that namespace (create the secret where the Issuer lives).
107-
- Alternative (single SA key for multiple projects): you can grant the service account broader permissions at folder or organization level so one sa.json can manage zones across multiple projects. This is more convenient but grants wider access — evaluate security and follow least-privilege principles.
108-
- Tradeoffs:
109-
- Per-namespace/per-project SA keys: better isolation and least privilege, easier to rotate keys per project.
110-
- Folder/org-level SA key: lower operational overhead (single key), but larger blast radius if compromised.
111-
112-
3. ***Demonstration of Ingress Integration with Wildcard SSL/TLS Certificate Generation***
113-
Given the preceding configuration, it is possible to exploit the capabilities of the Issuer or ClusterIssuer to
114-
dynamically produce wildcard SSL/TLS certificates in the following manner:
115-
```yaml
116-
apiVersion: cert-manager.io/v1
117-
kind: Certificate
118-
metadata:
119-
name: wildcard-example
120-
namespace: default
121-
spec:
122-
secretName: wildcard-example-tls
123-
issuerRef:
124-
name: letsencrypt-prod
125-
kind: Issuer
126-
commonName: '*.example.runs.onstackit.cloud' # project must be the owner of this zone
127-
duration: 8760h0m0s
128-
dnsNames:
129-
- example.runs.onstackit.cloud
130-
- '*.example.runs.onstackit.cloud'
131-
---
132-
apiVersion: networking.k8s.io/v1
133-
kind: Ingress
134-
metadata:
135-
name: app-ingress
136-
namespace: default
137-
annotations:
138-
ingress.kubernetes.io/rewrite-target: /
139-
kubernetes.io/ingress.class: "nginx"
140-
spec:
141-
rules:
142-
- host: "app.example.runs.onstackit.cloud"
143-
http:
144-
paths:
145-
- path: /
146-
pathType: Prefix
147-
backend:
148-
service:
149-
name: webapp
150-
port:
151-
number: 80
152-
tls:
153-
- hosts:
154-
- "app.example.runs.onstackit.cloud"
155-
secretName: wildcard-example-tls
156-
```
78+
```
79+
80+
2. **Configure the Issuer:**
81+
Reference the mounted file path.
82+
```yaml
83+
apiVersion: cert-manager.io/v1
84+
kind: ClusterIssuer
85+
metadata:
86+
name: letsencrypt-prod
87+
spec:
88+
acme:
89+
server: https://acme-v02.api.letsencrypt.org/directory
90+
email: example@example.com
91+
privateKeySecretRef:
92+
name: letsencrypt-prod
93+
solvers:
94+
- dns01:
95+
webhook:
96+
solverName: stackit
97+
groupName: acme.stackit.de
98+
config:
99+
projectId: <STACKIT ID PROJECT>
100+
serviceAccountKeyPath: /var/run/secrets/stackit/sa.json
101+
```
102+
103+
### Option C: Workload Identity Federation (WIF)
104+
105+
If your cluster supports Workload Identity Federation (e.g., SKE clusters), you can avoid managing long-lived keys entirely by projecting a short-lived token into the webhook container.
106+
107+
1. **Annotate the Webhook ServiceAccount:**
108+
Update your Helm deployment to instruct the identity webhook to inject the federated token.
109+
```yaml
110+
# values.yaml
111+
serviceAccount:
112+
annotations:
113+
workload-identity.stackit.cloud/service-account-email: "your-service-account@sa.stackit.cloud"
114+
```
115+
116+
2. **Configure the Issuer:**
117+
Explicitly instruct the webhook to use the WIF flow.
118+
```yaml
119+
apiVersion: cert-manager.io/v1
120+
kind: ClusterIssuer
121+
metadata:
122+
name: letsencrypt-prod
123+
spec:
124+
acme:
125+
# ...
126+
solvers:
127+
- dns01:
128+
webhook:
129+
solverName: stackit
130+
groupName: acme.stackit.de
131+
config:
132+
projectId: <STACKIT ID PROJECT>
133+
useWorkloadIdentityFederation: true
134+
```
157135

158136
## Config Options
159137

160-
The following table delineates the configuration options available for the STACKIT Cert Manager Webhook:
161-
162-
```yaml
163-
apiVersion: cert-manager.io/v1
164-
kind: Issuer
165-
metadata:
166-
name: letsencrypt-prod
167-
namespace: default
168-
spec:
169-
acme:
170-
server: https://acme-v02.api.letsencrypt.org/directory
171-
email: example@example.com # Replace this with your email address
172-
privateKeySecretRef:
173-
name: letsencrypt-prod
174-
solvers:
175-
- dns01:
176-
webhook:
177-
solverName: stackit
178-
groupName: acme.stackit.de
179-
config:
180-
projectId: string
181-
apiBasePath: string
182-
serviceAccountKeyPath: string
183-
serviceAccountBaseUrl: string
184-
acmeTxtRecordTTL: int64
185-
```
138+
The following table delineates the configuration options available under the `config` block of the STACKIT Cert Manager Webhook solver:
186139

187-
- projectId: The unique identifier for the STACKIT project.
188-
- apiBasePath: The base path for the STACKIT DNS API. (Default: https://dns.api.stackit.cloud)
189-
- serviceAccountKeyPath: The path to the service account key file. The file must be mounted into the container.
190-
- serviceAccountBaseUrl: The base URL for the STACKIT service account API. (Default: https://service-account.api.stackit.cloud/token)
191-
- acmeTxtRecordTTL: The TTL for the ACME TXT record. (Default: 600)
140+
| Key | Type | Default | Description |
141+
|-----|------|--------|-------------|
142+
| `projectId` | string | `""` | **Required.** The unique identifier for the STACKIT project. |
143+
| `apiBasePath` | string | `"https://dns.api.stackit.cloud"` | The base path for the STACKIT DNS API. |
144+
| `serviceAccountSecretRef` | string | `""` | Name of the Kubernetes Secret containing the SA JSON. |
145+
| `serviceAccountSecretKey` | string | `""` | The key within the Secret mapped to the JSON content. |
146+
| `serviceAccountSecretNamespace` | string | `<webhook-namespace>` | The namespace where the Secret is located. |
147+
| `serviceAccountKeyPath` | string | `""` | The absolute file path to a statically mounted SA JSON key inside the webhook container. |
148+
| `useWorkloadIdentityFederation` | bool | `false` | Explicitly enables STACKIT Workload Identity Federation authentication. |
149+
| `serviceAccountBaseUrl` | string | `""` | Custom URL for trading SA keys for access tokens. |
150+
| `acmeTxtRecordTTL` | int32 | `60` | The TTL for the ACME TXT challenge record. |
192151

193152
## Test Procedures
194153

@@ -210,12 +169,13 @@ make lint
210169
### Go Conformance Testing:
211170
Runs the official cert-manager Go solver test suite in memory against the STACKIT API:
212171
```bash
213-
STACKIT_TOKEN="<token>" TEST_ZONE_NAME="example.com" make test-e2e-conformance
172+
TEST_ZONE_NAME="example.com" make test-e2e-conformance
214173
```
215174
Follow the comprehensive guide available [here](e2e_test/README.md).
216175

217176
### Kubernetes Integration (E2E) Testing:
218-
Spins up a local Kind cluster, installs cert-manager, builds and deploys the webhook, and executes Kuttl integration tests (testing single-record lifecycle and wildcard certificates against Let's Encrypt Staging):
177+
Spins up a local Kind cluster, installs cert-manager, builds and deploys the webhook, and executes Kuttl integration tests covering both single-tenant (static fallback) and multi-tenant (dynamic SA fetching) flows against Let's Encrypt Staging:
178+
219179
```bash
220180
make test-e2e-local \
221181
PROJECT_ID="<your-project-id>" \

cmd/webhook/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func main() {
4040
}).DialContext,
4141
TLSHandshakeTimeout: 5 * time.Second,
4242
ResponseHeaderTimeout: 10 * time.Second,
43-
IdleConnTimeout: 30 * time.Second,
43+
IdleConnTimeout: 30 * time.Second,
4444
},
4545
}
4646

0 commit comments

Comments
 (0)