# Nic Add Policy

> 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.

- Skill: `nginx/nic-add-policy` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nginx/nic-add-policy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nginx/nic-add-policy/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: nginx (https://skillmd.com/u/nginx)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nginx/nic-add-policy

---


# 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)

1. 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.
2. Run `make test-update-snaps`.
3. `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.
4. 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:

```go
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

```nginx
{{- with $s.MyPolicy }}
my_directive {{ .Value }};
{{- if .OptionalField }}
my_optional_directive {{ .OptionalField }};
{{- end }}
{{- end }}
```

