Adding a New Policy Type
Follow these steps IN ORDER. Each step depends on the previous.
Step 1: Define the CRD type
File: pkg/apis/configuration/v1/types.go
- Add a new struct (e.g.,
type MyPolicy struct { ... })
- Add a
*MyPolicy pointer field to PolicySpec
- Use kubebuilder markers for validation
- JSON tags: kebab-case for NGINX-proxy fields, camelCase for K8s fields
*bool/*int = optional/nullable. Plain bool/int = required or zero-default
- Booleans defaulting to
false must be non-pointer value types
Step 2: Regenerate deep copy
Run make update-codegen to update zz_generated.deepcopy.go.
Step 3: Regenerate CRDs
Run make update-crds to regenerate config/crd/bases/, deploy/crds.yaml, and chart CRDs.
Step 4: Add validation
File: pkg/apis/configuration/validation/policy.go
- Add
validate<MyPolicy>(spec *v1.MyPolicy, fieldPath *field.Path) field.ErrorList
- Wire into
validatePolicySpec() with field count increment and feature gate check
- Add tests in
policy_test.go with valid and invalid cases
Step 5: Add template structs
File: internal/configs/version2/http.go
- Add struct (e.g.,
type MyPolicyConfig struct { ... })
- Add
*MyPolicyConfig or fields to Server, Location, or both
- If the policy needs HTTP-level directives (zones, maps), add fields to
VirtualServerConfig
Step 6: Add config generation
File: internal/configs/policy.go
- Add field(s) to
policiesCfg
- Add
add<MyPolicy>Config() method following the pattern below
- Wire into the
switch in generatePolicies()
- Add tests in
policy_test.go
Step 7: Wire into VirtualServer generation
File: internal/configs/virtualserver.go
- In
GenerateVirtualServerConfig(), extract from policiesCfg and assign to version2 fields
- Use
addPoliciesCfgToLocation() for location-level assignment
Step 8: Wire into Ingress generation (if applicable)
File: internal/configs/ingress.go
- In
generateNginxCfg(), extract from policiesCfg and assign to version1 fields
- Handle mergeable ingress in
generateNginxCfgForMergeableIngresses()
Step 9: Add NGINX template directives
- Version 2:
internal/configs/version2/nginx.virtualserver.tmpl and internal/configs/version2/nginx-plus.virtualserver.tmpl
- Version 1:
internal/configs/version1/nginx.ingress.tmpl and internal/configs/version1/nginx-plus.ingress.tmpl
- Use
{{- if }} / {{- with }} guards around directive blocks
- Template helpers go in
internal/configs/version2/template_helper.go and/or internal/configs/version1/template_helper.go, matching the template version you are updating
- HTTP-level directives (zones, maps) go BEFORE
server{}
- Server-level inside
server{}, location-level inside each location{}
Step 10: Update snapshot tests
Files: internal/configs/version2/templates_test.go (VS/VSR/TS), internal/configs/version1/template_test.go (Ingress)
- Add the new policy fields to the fixture structs used by the snapshot tests -- a regeneration with no fixture change produces no diff and leaves the policy untested.
- Run
make test-update-snaps.
git diff -- '**/__snapshots__/**' and confirm your directives render in the golden files for every edition the policy supports. Plus-only policies (OIDC, WAF) must appear in the Plus golden files only; policies available to both editions must appear in both.
- Run
make test to confirm green, and commit the regenerated golden files with the template change.
If you wired the policy into Ingress (Step 8), version1 snapshots must change too.
Step 11: Update the Helm chart (if policy needs CLI flag or ConfigMap entry)
charts/nginx-ingress/values.yaml -- add value with ## doc
charts/nginx-ingress/values.schema.json -- add schema entry
charts/nginx-ingress/templates/_helpers.tpl -- add CLI arg or ConfigMap key
charts/tests/testdata/ -- add test values file
charts/tests/helmunit_test.go -- add test case
Step 12: Add controller support
File: internal/k8s/
- In
syncPolicy(), ensure the new type is handled for VS/VSR/Ingress
- Check if it needs feature-gate guarding (isPlus, enableOIDC, etc.)
Step 13: Write integration tests
Directory: tests/suite/
- Create test data YAMLs in
tests/data/<feature>/
- Create
test_<feature>_policies_vs.py, _vsr.py, _ingress.py
- Use
@pytest.mark.policies and @pytest.mark.policies_<feature> markers
- Register the new marker in
pyproject.toml -- pytest runs with --strict-markers
Gotchas
- Never skip
make update-codegen after changing types.go -- the build will fail with missing DeepCopy methods
- Never use raw user strings in NGINX config without
containsDangerousChars() validation
- Both OSS and Plus templates must be updated for policies available to both editions -- they are separate files, each with its own snapshot entries. Plus-only policies (OIDC, WAF) belong in the Plus templates only
- A policy that reaches a template but has no snapshot fixture ships with zero rendered-output coverage
make update-crds also refreshes deploy/crds*.yaml and docs/crd/; charts/nginx-ingress/crds is a symlink to config/crd/bases/
- If the policy adds telemetry counters, run
make telemetry-schema -- CI fails on any diff in internal/telemetry
policiesCfg duplicate check must warn and return, not error (exception: addCORSConfig has no duplicate check -- it overwrites, since CORS is additive via headers)
Policy add*Config() Pattern
Every add*Config() method in internal/configs/policy.go follows this pattern:
func (p *policiesCfg) addMyPolicyConfig(spec *conf_v1.MyPolicy, key, namespace string,
secretRefs map[string]*secrets.SecretReference) *validationResults {
res := newValidationResults()
// 1. Duplicate check
if p.MyPolicy != nil {
res.addWarningf("MyPolicy policy already configured, ignoring")
return res
}
// 2. Secret resolution (if applicable)
secretKey := namespace + "/" + spec.Secret
secretRef := secretRefs[secretKey]
if secretRef.Error != nil {
res.isError = true
res.addWarningf("secret %s has error: %v", secretKey, secretRef.Error)
return res
}
if secretRef.Type != secrets.SecretTypeExpected {
res.isError = true
res.addWarningf("secret %s has wrong type", secretKey)
return res
}
// 3. Build template struct and assign
p.MyPolicy = &version2.MyPolicyConfig{
Field1: spec.Field1,
Field2: spec.Field2,
Secret: secretRef.Path,
}
return res
}
NGINX Template Pattern
{{- with $s.MyPolicy }}
my_directive {{ .Value }};
{{- if .OptionalField }}
my_optional_directive {{ .OptionalField }};
{{- end }}
{{- end }}
1---2name: nic-add-policy3description: Step-by-step checklist for adding a new Policy CRD type to NIC. Use when implementing a new policy like AccessControl, RateLimit, JWTAuth, ExternalAuth, BasicAuth, IngressMTLS, EgressMTLS, OIDC, WAF, APIKey, Cache, or CORS, or extending the policy system with a new policy type.4---56# Adding a New Policy Type78Follow these steps IN ORDER. Each step depends on the previous.910## Step 1: Define the CRD type1112File: `pkg/apis/configuration/v1/types.go`1314- Add a new struct (e.g., `type MyPolicy struct { ... }`)15- Add a `*MyPolicy` pointer field to `PolicySpec`16- Use kubebuilder markers for validation17- JSON tags: **kebab-case** for NGINX-proxy fields, **camelCase** for K8s fields18- `*bool`/`*int` = optional/nullable. Plain `bool`/`int` = required or zero-default19- Booleans defaulting to `false` must be non-pointer value types2021## Step 2: Regenerate deep copy2223Run `make update-codegen` to update `zz_generated.deepcopy.go`.2425## Step 3: Regenerate CRDs2627Run `make update-crds` to regenerate `config/crd/bases/`, `deploy/crds.yaml`, and chart CRDs.2829## Step 4: Add validation3031File: `pkg/apis/configuration/validation/policy.go`3233- Add `validate<MyPolicy>(spec *v1.MyPolicy, fieldPath *field.Path) field.ErrorList`34- Wire into `validatePolicySpec()` with field count increment and feature gate check35- Add tests in `policy_test.go` with valid and invalid cases3637## Step 5: Add template structs3839File: `internal/configs/version2/http.go`4041- Add struct (e.g., `type MyPolicyConfig struct { ... }`)42- Add `*MyPolicyConfig` or fields to `Server`, `Location`, or both43- If the policy needs HTTP-level directives (zones, maps), add fields to `VirtualServerConfig`4445## Step 6: Add config generation4647File: `internal/configs/policy.go`4849- Add field(s) to `policiesCfg`50- Add `add<MyPolicy>Config()` method following the pattern below51- Wire into the `switch` in `generatePolicies()`52- Add tests in `policy_test.go`5354## Step 7: Wire into VirtualServer generation5556File: `internal/configs/virtualserver.go`5758- In `GenerateVirtualServerConfig()`, extract from `policiesCfg` and assign to `version2` fields59- Use `addPoliciesCfgToLocation()` for location-level assignment6061## Step 8: Wire into Ingress generation (if applicable)6263File: `internal/configs/ingress.go`6465- In `generateNginxCfg()`, extract from `policiesCfg` and assign to `version1` fields66- Handle mergeable ingress in `generateNginxCfgForMergeableIngresses()`6768## Step 9: Add NGINX template directives6970- Version 2: `internal/configs/version2/nginx.virtualserver.tmpl` and `internal/configs/version2/nginx-plus.virtualserver.tmpl`71- Version 1: `internal/configs/version1/nginx.ingress.tmpl` and `internal/configs/version1/nginx-plus.ingress.tmpl`72- Use `{{- if }}` / `{{- with }}` guards around directive blocks73- Template helpers go in `internal/configs/version2/template_helper.go` and/or `internal/configs/version1/template_helper.go`, matching the template version you are updating74- HTTP-level directives (zones, maps) go BEFORE `server{}`75- Server-level inside `server{}`, location-level inside each `location{}`7677## Step 10: Update snapshot tests7879Files: `internal/configs/version2/templates_test.go` (VS/VSR/TS), `internal/configs/version1/template_test.go` (Ingress)80811. Add the new policy fields to the fixture structs used by the snapshot tests -- a regeneration with no fixture change produces no diff and leaves the policy untested.822. Run `make test-update-snaps`.833. `git diff -- '**/__snapshots__/**'` and confirm your directives render in the golden files for every edition the policy supports. Plus-only policies (OIDC, WAF) must appear in the Plus golden files **only**; policies available to both editions must appear in both.844. Run `make test` to confirm green, and commit the regenerated golden files with the template change.8586If you wired the policy into Ingress (Step 8), version1 snapshots must change too.8788## Step 11: Update the Helm chart (if policy needs CLI flag or ConfigMap entry)8990- `charts/nginx-ingress/values.yaml` -- add value with `##` doc91- `charts/nginx-ingress/values.schema.json` -- add schema entry92- `charts/nginx-ingress/templates/_helpers.tpl` -- add CLI arg or ConfigMap key93- `charts/tests/testdata/` -- add test values file94- `charts/tests/helmunit_test.go` -- add test case9596## Step 12: Add controller support9798File: `internal/k8s/`99100- In `syncPolicy()`, ensure the new type is handled for VS/VSR/Ingress101- Check if it needs feature-gate guarding (isPlus, enableOIDC, etc.)102103## Step 13: Write integration tests104105Directory: `tests/suite/`106107- Create test data YAMLs in `tests/data/<feature>/`108- Create `test_<feature>_policies_vs.py`, `_vsr.py`, `_ingress.py`109- Use `@pytest.mark.policies` and `@pytest.mark.policies_<feature>` markers110- Register the new marker in `pyproject.toml` -- pytest runs with `--strict-markers`111112---113114## Gotchas115116- **Never** skip `make update-codegen` after changing `types.go` -- the build will fail with missing DeepCopy methods117- **Never** use raw user strings in NGINX config without `containsDangerousChars()` validation118- Both OSS and Plus templates must be updated for policies available to both editions -- they are separate files, each with its own snapshot entries. Plus-only policies (OIDC, WAF) belong in the Plus templates only119- A policy that reaches a template but has no snapshot fixture ships with zero rendered-output coverage120- `make update-crds` also refreshes `deploy/crds*.yaml` and `docs/crd/`; `charts/nginx-ingress/crds` is a symlink to `config/crd/bases/`121- If the policy adds telemetry counters, run `make telemetry-schema` -- CI fails on any diff in `internal/telemetry`122- `policiesCfg` duplicate check must warn and return, not error (exception: `addCORSConfig` has no duplicate check -- it overwrites, since CORS is additive via headers)123124---125126## Policy add*Config() Pattern127128Every `add*Config()` method in `internal/configs/policy.go` follows this pattern:129130```go131func (p *policiesCfg) addMyPolicyConfig(spec *conf_v1.MyPolicy, key, namespace string,132 secretRefs map[string]*secrets.SecretReference) *validationResults {133 res := newValidationResults()134135 // 1. Duplicate check136 if p.MyPolicy != nil {137 res.addWarningf("MyPolicy policy already configured, ignoring")138 return res139 }140141 // 2. Secret resolution (if applicable)142 secretKey := namespace + "/" + spec.Secret143 secretRef := secretRefs[secretKey]144 if secretRef.Error != nil {145 res.isError = true146 res.addWarningf("secret %s has error: %v", secretKey, secretRef.Error)147 return res148 }149 if secretRef.Type != secrets.SecretTypeExpected {150 res.isError = true151 res.addWarningf("secret %s has wrong type", secretKey)152 return res153 }154155 // 3. Build template struct and assign156 p.MyPolicy = &version2.MyPolicyConfig{157 Field1: spec.Field1,158 Field2: spec.Field2,159 Secret: secretRef.Path,160 }161162 return res163}164```165166## NGINX Template Pattern167168```nginx169{{- with $s.MyPolicy }}170my_directive {{ .Value }};171{{- if .OptionalField }}172my_optional_directive {{ .OptionalField }};173{{- end }}174{{- end }}175```