diff --git a/README.md b/README.md index b39658f..1ffba33 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Secrets is inspired by **HashiCorp Vault** โค๏ธ, but it is intentionally **muc The default way to run Secrets is the published Docker image: ```bash -docker pull allisson/secrets:v0.4.0 +docker pull allisson/secrets:v0.4.1 ``` Use pinned tags for reproducible setups. `latest` is also available for fast iteration. @@ -29,13 +29,12 @@ Then follow the Docker setup guide in [docs/getting-started/docker.md](docs/gett 1. ๐Ÿณ **Run with Docker image (recommended)**: [docs/getting-started/docker.md](docs/getting-started/docker.md) 2. ๐Ÿ’ป **Run locally for development**: [docs/getting-started/local-development.md](docs/getting-started/local-development.md) -## ๐Ÿ†• What's New in v0.4.0 +## ๐Ÿ†• What's New in v0.4.1 -- ๐ŸŽซ Tokenization API for format-preserving token workflows (`/v1/tokenization/*`) -- ๐Ÿงฐ New tokenization CLI commands: `create-tokenization-key`, `rotate-tokenization-key`, `clean-expired-tokens` -- ๐Ÿ—„๏ธ Tokenization persistence migrations for PostgreSQL and MySQL (`000002_add_tokenization`) -- ๐Ÿ“ˆ Tokenization business-operation metrics added to observability -- ๐Ÿ“˜ New release notes: [docs/releases/v0.4.0.md](docs/releases/v0.4.0.md) +- ๐Ÿ› Fixed policy path matching for authorization with mid-path wildcards (for example `/v1/transit/keys/*/rotate`) +- โœ… Added stronger policy-matching coverage for wildcard edge cases and common role templates +- ๐Ÿ“˜ Added bugfix release notes: [docs/releases/v0.4.1.md](docs/releases/v0.4.1.md) +- ๐Ÿ“ฆ Updated pinned Docker docs/examples to `allisson/secrets:v0.4.1` ## ๐Ÿ“š Docs Map @@ -46,7 +45,7 @@ Then follow the Docker setup guide in [docs/getting-started/docker.md](docs/gett - ๐Ÿงฐ **Troubleshooting**: [docs/getting-started/troubleshooting.md](docs/getting-started/troubleshooting.md) - โœ… **Smoke test script**: [docs/getting-started/smoke-test.md](docs/getting-started/smoke-test.md) - ๐Ÿงช **CLI commands reference**: [docs/cli/commands.md](docs/cli/commands.md) -- ๐Ÿš€ **v0.4.0 release notes**: [docs/releases/v0.4.0.md](docs/releases/v0.4.0.md) +- ๐Ÿš€ **v0.4.1 release notes**: [docs/releases/v0.4.1.md](docs/releases/v0.4.1.md) - **By Topic** - โš™๏ธ **Environment variables**: [docs/configuration/environment-variables.md](docs/configuration/environment-variables.md) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85fbf92..1371e18 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,32 @@ # ๐Ÿ—’๏ธ Documentation Changelog -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 + +## 2026-02-19 (docs v9 - v0.4.1 bugfix release prep) + +- Added release notes page: `docs/releases/v0.4.1.md` and promoted it as current in docs indexes +- Updated docs metadata source (`docs/metadata.json`) to `current_release: v0.4.1` +- Updated pinned Docker examples from `allisson/secrets:v0.4.0` to `allisson/secrets:v0.4.1` +- Documented policy path-matching behavior with mid-path wildcard support in `docs/api/policies.md` +- Updated troubleshooting and failure playbooks to include exact, trailing wildcard, and mid-path wildcard matching +- Corrected Clients API policy examples to use `decrypt` for `/v1/secrets/*` reads +- Added transit rotate smoke-test step for `/v1/transit/keys/*/rotate` wildcard validation +- Added malformed rotate path-shape smoke check and explicit unsupported wildcard pattern notes +- Added policy matcher quick-reference table to `docs/api/capability-matrix.md` +- Linked `v0.4.1` release notes from production and smoke-test operator guides +- Added route-shape vs policy-shape guidance and cross-links between policies and smoke tests +- Added copy-safe split-role policy snippets for transit rotate-only and secrets read/write separation +- Added operator quick checklist to `docs/releases/v0.4.1.md` and policy matcher FAQ in troubleshooting +- Added pre-deploy policy review checklist to `docs/api/policies.md` +- Added `v0.4.1` documentation migration map with direct section links for operators +- Added strict CI mode snippet for policy smoke checks and 403-vs-404 false-positive guidance +- Added canonical wildcard matcher semantics links in auth, clients, secrets, and transit API docs +- Converted Clients API related references to clickable links for navigation consistency +- Added policy triage cross-links in Audit Logs API and refreshed stale page update stamps +- Added docs metadata guard to require `> Last updated: YYYY-MM-DD` marker on all docs pages +- Added optional strict metadata freshness check via `DOCS_CHANGED_FILES` for changed docs pages +- Added Docs QA checklist and style baseline guidance to `docs/contributing.md` +- Added unified operator runbook hub: `docs/operations/runbook-index.md` and linked it from docs indexes ## 2026-02-18 (docs v8 - docs QA and operations polish) diff --git a/docs/README.md b/docs/README.md index 4e1b76b..e57fbc3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # ๐Ÿ“š Secrets Documentation -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Metadata source for release/API labels: `docs/metadata.json` @@ -19,6 +19,7 @@ Welcome to the full documentation for Secrets. Pick a path and dive in ๐Ÿš€ 1. Start with Docker guide: [getting-started/docker.md](getting-started/docker.md) 2. Validate end-to-end setup: [getting-started/smoke-test.md](getting-started/smoke-test.md) 3. Apply production hardening checklist: [operations/production.md](operations/production.md) +4. Use runbook hub for rollout and incidents: [operations/runbook-index.md](operations/runbook-index.md) ## ๐Ÿ“– Documentation by Topic @@ -31,6 +32,7 @@ Welcome to the full documentation for Secrets. Pick a path and dive in ๐Ÿš€ - ๐Ÿญ [operations/production.md](operations/production.md) - ๐Ÿš‘ [operations/failure-playbooks.md](operations/failure-playbooks.md) - ๐Ÿงช [operations/policy-smoke-tests.md](operations/policy-smoke-tests.md) +- ๐Ÿงญ [operations/runbook-index.md](operations/runbook-index.md) - ๐Ÿ› ๏ธ [development/testing.md](development/testing.md) - ๐Ÿค [contributing.md](contributing.md) - ๐Ÿ—’๏ธ [CHANGELOG.md](CHANGELOG.md) @@ -61,13 +63,14 @@ Welcome to the full documentation for Secrets. Pick a path and dive in ๐Ÿš€ OpenAPI scope note: -- `openapi.yaml` is a baseline subset for common API flows in `v0.4.0` +- `openapi.yaml` is a baseline subset for common API flows in `v0.4.1` - Full endpoint behavior is documented in the endpoint pages under `docs/api/` -- Tokenization endpoints are included in `openapi.yaml` for `v0.4.0` +- Tokenization endpoints are included in `openapi.yaml` for `v0.4.1` ## ๐Ÿš€ Releases -- ๐Ÿ“ฆ [releases/v0.4.0.md](releases/v0.4.0.md) +- ๐Ÿ“ฆ [releases/v0.4.1.md](releases/v0.4.1.md) +- ๐Ÿ“ฆ [releases/v0.4.0.md](releases/v0.4.0.md) (historical) - ๐Ÿ“ฆ [releases/v0.3.0.md](releases/v0.3.0.md) (historical) - ๐Ÿ“ฆ [releases/v0.2.0.md](releases/v0.2.0.md) (historical) - ๐Ÿ“ฆ [releases/v0.1.0.md](releases/v0.1.0.md) (historical) diff --git a/docs/api/audit-logs.md b/docs/api/audit-logs.md index f4f6c7c..43a834b 100644 --- a/docs/api/audit-logs.md +++ b/docs/api/audit-logs.md @@ -1,6 +1,6 @@ # ๐Ÿ“œ Audit Logs API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Audit logs capture capability checks and access attempts for monitoring and compliance. @@ -174,6 +174,8 @@ curl -s "http://localhost:8080/v1/audit-logs?limit=100" \ - [Authentication API](authentication.md) - [Clients API](clients.md) - [Policies cookbook](policies.md) +- [Route shape vs policy shape](policies.md#route-shape-vs-policy-shape) +- [Policy review checklist before deploy](policies.md#policy-review-checklist-before-deploy) - [Capability matrix](capability-matrix.md) - [Response shapes](response-shapes.md) - [API compatibility policy](versioning-policy.md) diff --git a/docs/api/authentication.md b/docs/api/authentication.md index fcaee84..dcccfed 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -1,6 +1,6 @@ # ๐Ÿ” Authentication API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 All protected endpoints require `Authorization: Bearer `. @@ -111,6 +111,8 @@ Representative error payloads (exact messages may vary): - `Bearer` prefix is case-insensitive (`bearer`, `Bearer`, `BEARER`) - Tokens are time-limited and should be renewed before expiration +- For wildcard path matcher semantics used by authorization, see + [Policies cookbook / Path matching behavior](policies.md#path-matching-behavior) ## See also diff --git a/docs/api/capability-matrix.md b/docs/api/capability-matrix.md index 087a86d..b894e41 100644 --- a/docs/api/capability-matrix.md +++ b/docs/api/capability-matrix.md @@ -1,6 +1,6 @@ # ๐Ÿ—‚๏ธ Capability Matrix -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 This page is the canonical capability-to-endpoint reference used by API docs and policy templates. @@ -42,6 +42,17 @@ This page is the canonical capability-to-endpoint reference used by API docs and ## Policy Authoring Notes +Policy matcher quick reference: + +| Pattern type | Example | Matching behavior | +| --- | --- | --- | +| Exact | `/v1/audit-logs` | Only that exact path | +| Full wildcard | `*` | Any request path | +| Trailing wildcard | `/v1/secrets/*` | Prefix + nested paths | +| Mid-path wildcard | `/v1/transit/keys/*/rotate` | `*` matches one segment | + +For complete matcher semantics and unsupported forms, see [Policies cookbook](policies.md#path-matching-behavior). + - Use path scope as narrowly as possible (service + environment prefixes). - Avoid wildcard `*` except temporary break-glass workflows. - Keep encrypt and decrypt separated across clients when operationally possible. diff --git a/docs/api/clients.md b/docs/api/clients.md index 4474baa..0ea2578 100644 --- a/docs/api/clients.md +++ b/docs/api/clients.md @@ -1,6 +1,6 @@ # ๐Ÿ‘ค Clients API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Client APIs manage machine identities and policy documents. @@ -49,7 +49,7 @@ curl -X POST http://localhost:8080/v1/clients \ "name": "payments-api", "is_active": true, "policies": [ - {"path":"/v1/secrets/*","capabilities":["read"]}, + {"path":"/v1/secrets/*","capabilities":["decrypt"]}, {"path":"/v1/transit/keys/payment/encrypt","capabilities":["encrypt"]} ] }' @@ -117,7 +117,7 @@ curl -s -X POST http://localhost:8080/v1/clients \ -d '{ "name": "quickflow-client", "is_active": true, - "policies": [{"path":"/v1/secrets/*","capabilities":["read"]}] + "policies": [{"path":"/v1/secrets/*","capabilities":["decrypt"]}] }' # 2) List clients @@ -129,12 +129,13 @@ Expected result: create returns `201 Created` with one-time `secret`; list retur ## Related -- ๐Ÿ“˜ Policy cookbook: `docs/api/policies.md` -- ๐Ÿงช Curl examples: `docs/examples/curl.md` -- ๐Ÿ Python examples: `docs/examples/python.md` -- ๐ŸŸจ JavaScript examples: `docs/examples/javascript.md` -- ๐Ÿน Go examples: `docs/examples/go.md` -- ๐Ÿงฑ Response shapes: `docs/api/response-shapes.md` +- ๐Ÿ“˜ [Policy cookbook](policies.md) +- ๐Ÿงญ [Wildcard matcher semantics](policies.md#path-matching-behavior) +- ๐Ÿงช [Curl examples](../examples/curl.md) +- ๐Ÿ [Python examples](../examples/python.md) +- ๐ŸŸจ [JavaScript examples](../examples/javascript.md) +- ๐Ÿน [Go examples](../examples/go.md) +- ๐Ÿงฑ [Response shapes](response-shapes.md) ## Use Cases diff --git a/docs/api/policies.md b/docs/api/policies.md index 012377c..a803b63 100644 --- a/docs/api/policies.md +++ b/docs/api/policies.md @@ -1,6 +1,6 @@ # ๐Ÿ“˜ Authorization Policy Cookbook -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Ready-to-use policy templates for common service roles. @@ -8,6 +8,9 @@ Ready-to-use policy templates for common service roles. ## ๐Ÿ“‘ Table of Contents - [Policy structure](#policy-structure) +- [Path matching behavior](#path-matching-behavior) +- [Route shape vs policy shape](#route-shape-vs-policy-shape) +- [Policy review checklist before deploy](#policy-review-checklist-before-deploy) - [1) Read-only service](#1-read-only-service) - [2) CI writer](#2-ci-writer) - [3) Transit encrypt-only service](#3-transit-encrypt-only-service) @@ -16,6 +19,7 @@ Ready-to-use policy templates for common service roles. - [6) Break-glass admin (emergency)](#6-break-glass-admin-emergency) - [7) Key operator](#7-key-operator) - [8) Tokenization operator](#8-tokenization-operator) +- [Copy-safe split-role snippets](#copy-safe-split-role-snippets) - [Policy mismatch example (wrong vs fixed)](#policy-mismatch-example-wrong-vs-fixed) - [Common policy mistakes](#common-policy-mistakes) - [Best practices](#best-practices) @@ -31,7 +35,7 @@ Ready-to-use policy templates for common service roles. ```json [ { - "path": "/v1/secrets/*", + "path": "/v1/audit-logs", "capabilities": ["read"] } ] @@ -39,6 +43,47 @@ Ready-to-use policy templates for common service roles. Capabilities: `read`, `write`, `delete`, `encrypt`, `decrypt`, `rotate`. +## Path matching behavior + +Policies are evaluated with case-sensitive matching rules: + +- Exact path: no wildcard means full exact match (`/v1/audit-logs` matches only `/v1/audit-logs`) +- Full wildcard: `*` matches any request path +- Trailing wildcard: `prefix/*` matches paths starting with `prefix/` (greedy for deeper paths) +- Mid-path wildcard: `*` inside a path matches exactly one segment + +Examples: + +- `/v1/secrets/*` matches `/v1/secrets/app`, `/v1/secrets/app/db`, and `/v1/secrets/app/db/password` +- `/v1/transit/keys/*/rotate` matches `/v1/transit/keys/payment/rotate` +- `/v1/transit/keys/*/rotate` does not match `/v1/transit/keys/rotate` (missing segment) +- `/v1/transit/keys/*/rotate` does not match `/v1/transit/keys/payment/extra/rotate` (extra segment) +- `/v1/*/keys/*/rotate` matches `/v1/transit/keys/payment/rotate` + +Unsupported patterns (not shell globs): + +- Partial-segment wildcard like `/v1/transit/keys/prod-*` +- Suffix/prefix wildcard inside one segment like `*prod` or `prod*` +- Mixed-segment glob forms like `/v1/**/rotate` + +## Route shape vs policy shape + +- Route shape is validated by the HTTP router first (`404` on non-existent endpoint patterns). +- Policy shape is evaluated after route resolution (`403` when capability/path policy denies access). +- Example: `POST /v1/transit/keys/payment/extra/rotate` is a route-shape mismatch (`404`) before policy checks. +- Example: `POST /v1/transit/keys/payment/rotate` can still return `403` if caller lacks `rotate` on + `/v1/transit/keys/*/rotate`. + +Use [Policy smoke tests](../operations/policy-smoke-tests.md) to validate both route shape and policy behavior. + +## Policy review checklist before deploy + +1. Confirm endpoint capability intent for each path (`read`, `write`, `delete`, `encrypt`, `decrypt`, `rotate`). +2. Confirm wildcard type is intentional (exact, full `*`, trailing `/*`, or mid-path segment `*`). +3. Reject unsupported patterns (`prod-*`, `*prod`, `prod*`, `**`) before policy rollout. +4. Run route-shape and allow/deny smoke checks from [Policy smoke tests](../operations/policy-smoke-tests.md). +5. Review denied audit events after rollout and verify mismatches are expected. + Endpoint capability intent (quick map, condensed from [Capability matrix](capability-matrix.md)): | Endpoint family | Typical capability | @@ -166,7 +211,9 @@ Use for teams responsible only for transit key lifecycle. ] ``` -Risk note: scope key names by environment when possible (for example `/v1/transit/keys/prod-*`). +Risk note: scope key names by environment with supported matchers. Use explicit key-name paths or +segment wildcards (for example `/v1/transit/keys/*/rotate`), not partial-segment wildcards like +`prod-*`. ## 8) Tokenization operator @@ -203,6 +250,41 @@ Use for services that manage tokenization keys and token lifecycle operations. Risk note: avoid wildcard tokenization access for application clients that only need tokenize or detokenize. +## Copy-safe split-role snippets + +Transit rotate-only operator: + +```json +[ + { + "path": "/v1/transit/keys/*/rotate", + "capabilities": ["rotate"] + } +] +``` + +Secrets read-only workload (`decrypt` only): + +```json +[ + { + "path": "/v1/secrets/*", + "capabilities": ["decrypt"] + } +] +``` + +Secrets write-only workload (`encrypt` only): + +```json +[ + { + "path": "/v1/secrets/*", + "capabilities": ["encrypt"] + } +] +``` + ## Policy mismatch example (wrong vs fixed) Wrong policy (insufficient capability for secret reads): diff --git a/docs/api/response-shapes.md b/docs/api/response-shapes.md index 7c6fa0a..b733102 100644 --- a/docs/api/response-shapes.md +++ b/docs/api/response-shapes.md @@ -1,6 +1,6 @@ # ๐Ÿงฑ API Response Shapes -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Use these representative response schemas as a stable reference across endpoint docs. diff --git a/docs/api/secrets.md b/docs/api/secrets.md index ff94925..484449e 100644 --- a/docs/api/secrets.md +++ b/docs/api/secrets.md @@ -1,6 +1,6 @@ # ๐Ÿ“ฆ Secrets API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Secrets are versioned by path and encrypted with envelope encryption. @@ -145,6 +145,10 @@ Expected result: write returns `201 Created`; read returns `200 OK` with base64 - `GET /v1/secrets/*path` -> `decrypt` - `DELETE /v1/secrets/*path` -> `delete` +Wildcard matcher semantics reference: + +- [Policies cookbook / Path matching behavior](policies.md#path-matching-behavior) + ## Related Examples - `docs/examples/curl.md` diff --git a/docs/api/tokenization.md b/docs/api/tokenization.md index ecd6a9d..fb33d48 100644 --- a/docs/api/tokenization.md +++ b/docs/api/tokenization.md @@ -1,6 +1,6 @@ # ๐ŸŽซ Tokenization API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 The Tokenization API provides format-preserving token generation for sensitive values, @@ -14,7 +14,7 @@ with optional deterministic behavior and token lifecycle management. OpenAPI coverage note: -- Tokenization endpoint coverage is included in `docs/openapi.yaml` for `v0.4.0` +- Tokenization endpoint coverage is included in `docs/openapi.yaml` for `v0.4.1` - This page remains the most detailed contract reference with examples and operational guidance All endpoints require `Authorization: Bearer `. diff --git a/docs/api/transit.md b/docs/api/transit.md index 3b26ca0..64a1139 100644 --- a/docs/api/transit.md +++ b/docs/api/transit.md @@ -1,6 +1,6 @@ # ๐Ÿš„ Transit API -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 Transit API encrypts/decrypts data without storing your application payload. @@ -42,6 +42,10 @@ Capability mapping: - `POST /v1/transit/keys/:name/encrypt` -> `encrypt` - `POST /v1/transit/keys/:name/decrypt` -> `decrypt` +Wildcard matcher semantics reference: + +- [Policies cookbook / Path matching behavior](policies.md#path-matching-behavior) + ## Status Code Quick Reference | Endpoint | Success | Common error statuses | diff --git a/docs/api/versioning-policy.md b/docs/api/versioning-policy.md index b59cfdc..086ef1b 100644 --- a/docs/api/versioning-policy.md +++ b/docs/api/versioning-policy.md @@ -1,6 +1,6 @@ # ๐Ÿงฉ API Compatibility and Versioning Policy -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 > Applies to: API v1 This page defines compatibility expectations for HTTP API changes. @@ -11,16 +11,16 @@ This page defines compatibility expectations for HTTP API changes. - Existing endpoint paths and JSON field names are treated as stable unless explicitly deprecated - OpenAPI source of truth: `docs/openapi.yaml` -## OpenAPI Coverage (v0.4.0) +## OpenAPI Coverage (v0.4.1) - `docs/openapi.yaml` is a baseline subset focused on high-traffic/common integration flows -- `docs/openapi.yaml` includes tokenization endpoint coverage in `v0.4.0` +- `docs/openapi.yaml` includes tokenization endpoint coverage in `v0.4.1` - Endpoint pages in `docs/api/*.md` define full public behavior for covered operations - Endpoints may exist in runtime before they are expanded in OpenAPI detail ## App Version vs API Version -- Application release `v0.4.0` is pre-1.0 software and may evolve quickly +- Application release `v0.4.1` is pre-1.0 software and may evolve quickly - API v1 path contract (`/v1/*`) remains the compatibility baseline for consumers - Breaking API behavior changes require explicit documentation and migration notes diff --git a/docs/cli/commands.md b/docs/cli/commands.md index e5872d8..dd6d54f 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -1,6 +1,6 @@ # ๐Ÿงช CLI Commands Reference -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Use the `app` CLI for server runtime, key management, and client lifecycle operations. @@ -12,10 +12,10 @@ Local binary: ./bin/app [flags] ``` -Docker image (v0.4.0): +Docker image (v0.4.1): ```bash -docker run --rm --env-file .env allisson/secrets:v0.4.0 [flags] +docker run --rm --env-file .env allisson/secrets:v0.4.1 [flags] ``` ## Core Runtime @@ -33,7 +33,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env -p 8080:8080 allisson/secrets:v0.4.0 server +docker run --rm --network secrets-net --env-file .env -p 8080:8080 allisson/secrets:v0.4.1 server ``` ### `migrate` @@ -49,7 +49,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 migrate ``` ## Key Management @@ -71,7 +71,7 @@ Local: Docker: ```bash -docker run --rm allisson/secrets:v0.4.0 create-master-key --id default +docker run --rm allisson/secrets:v0.4.1 create-master-key --id default ``` ### `create-kek` @@ -91,7 +91,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 create-kek --algorithm aes-gcm ``` ### `rotate-kek` @@ -111,7 +111,7 @@ Local: Docker: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 rotate-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 rotate-kek --algorithm aes-gcm ``` After master key or KEK rotation, restart API server instances so they load updated key material. @@ -138,7 +138,7 @@ Examples: --deterministic \ --algorithm aes-gcm -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 \ create-tokenization-key --name payment-cards --format luhn-preserving --deterministic --algorithm aes-gcm ``` @@ -162,7 +162,7 @@ Examples: --deterministic \ --algorithm chacha20-poly1305 -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 \ rotate-tokenization-key --name payment-cards --format luhn-preserving --deterministic --algorithm chacha20-poly1305 ``` @@ -186,7 +186,7 @@ Examples: ./bin/app clean-expired-tokens --days 30 --format text # Docker form -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 \ clean-expired-tokens --days 30 --dry-run --format json ``` @@ -236,7 +236,7 @@ Flags: --id \ --name payments-api \ --active=true \ - --policies '[{"path":"/v1/secrets/*","capabilities":["read","encrypt"]}]' \ + --policies '[{"path":"/v1/secrets/*","capabilities":["decrypt","encrypt"]}]' \ --format json ``` @@ -269,7 +269,7 @@ Examples: ./bin/app clean-audit-logs --days 90 --format text # Docker form -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 \ clean-audit-logs --days 90 --dry-run --format json ``` diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index cc49cee..f73f0fa 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -1,6 +1,6 @@ # ๐Ÿ—๏ธ Architecture -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Secrets follows Clean Architecture with domain-driven boundaries so cryptographic rules stay isolated from transport and storage concerns. diff --git a/docs/concepts/security-model.md b/docs/concepts/security-model.md index 27e4b76..f0f13a7 100644 --- a/docs/concepts/security-model.md +++ b/docs/concepts/security-model.md @@ -1,6 +1,6 @@ # ๐Ÿ”’ Security Model -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Secrets is designed for practical defense-in-depth around secret storage and cryptographic operations. diff --git a/docs/contributing.md b/docs/contributing.md index 9a355f5..e0bb6e0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,6 +1,6 @@ # ๐Ÿค Documentation Contributing Guide -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Use this guide when adding or editing project documentation. @@ -18,6 +18,13 @@ Use this guide when adding or editing project documentation. - Use emojis for scanability, but keep usage moderate - Keep list items concise and without trailing periods +Documentation style baseline: + +- Prefer short sections with clear headings over long uninterrupted blocks +- Prefer plain bullet lists and tables over heavily emphasized text blocks +- Keep cross-links clickable (Markdown links) rather than inline code path references +- Keep operational steps copy/paste-ready and include expected status/result when useful + ## Technical Accuracy - Match implemented API paths exactly (`/v1/*`) @@ -73,6 +80,15 @@ This target runs markdown linting and offline markdown link validation. `make docs-check-metadata` validates release/API metadata alignment across docs entry points. +Optional strict freshness check for changed files: + +```bash +DOCS_CHANGED_FILES="docs/api/clients.md docs/api/policies.md" make docs-check-metadata +``` + +When `DOCS_CHANGED_FILES` is set, changed docs pages must refresh `Last updated` to +`docs/metadata.json:last_docs_refresh` (excluding `docs/adr/*` and `docs/releases/*`). + ## PR Checklist 1. Links are valid and relative paths resolve @@ -81,6 +97,14 @@ This target runs markdown linting and offline markdown link validation. 4. Terminology is consistent across files 5. `docs/CHANGELOG.md` updated for significant documentation changes +## Docs QA Checklist + +1. Capability and endpoint mappings are consistent across `docs/api/*.md` +2. Route-shape (`404`) and policy-shape (`403`) behavior is validated for authorization changes +3. Release links and current release references match `docs/metadata.json` +4. `Last updated` markers are refreshed in changed docs pages +5. `make docs-lint` passes locally + ## Feature PR Docs Consistency Checklist For behavior changes, update all relevant docs in the same PR: diff --git a/docs/examples/curl.md b/docs/examples/curl.md index 63a2bbd..eb45d47 100644 --- a/docs/examples/curl.md +++ b/docs/examples/curl.md @@ -1,6 +1,6 @@ # ๐Ÿงช Curl Examples -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 โš ๏ธ Security Warning: base64 is encoding, not encryption. Always use HTTPS/TLS. diff --git a/docs/examples/go.md b/docs/examples/go.md index dd1e756..8fc44f9 100644 --- a/docs/examples/go.md +++ b/docs/examples/go.md @@ -1,6 +1,6 @@ # ๐Ÿน Go Examples -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 โš ๏ธ Security Warning: base64 is encoding, not encryption. Always use HTTPS/TLS. diff --git a/docs/examples/javascript.md b/docs/examples/javascript.md index 2e62bf1..ee8c090 100644 --- a/docs/examples/javascript.md +++ b/docs/examples/javascript.md @@ -1,6 +1,6 @@ # ๐ŸŸจ JavaScript Examples -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 โš ๏ธ Security Warning: base64 is encoding, not encryption. Always use HTTPS/TLS. diff --git a/docs/examples/python.md b/docs/examples/python.md index 89cbbcf..80d5a77 100644 --- a/docs/examples/python.md +++ b/docs/examples/python.md @@ -1,6 +1,6 @@ # ๐Ÿ Python Examples -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 โš ๏ธ Security Warning: base64 is encoding, not encryption. Always use HTTPS/TLS. diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md index 5196ec3..39f67ab 100644 --- a/docs/getting-started/docker.md +++ b/docs/getting-started/docker.md @@ -1,10 +1,10 @@ # ๐Ÿณ Run with Docker (Recommended) -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 This is the default way to run Secrets. -For release reproducibility, this guide uses the pinned image tag `allisson/secrets:v0.4.0`. +For release reproducibility, this guide uses the pinned image tag `allisson/secrets:v0.4.1`. You can use `allisson/secrets:latest` for fast iteration. ## โšก Quickstart Copy Block @@ -12,7 +12,7 @@ You can use `allisson/secrets:latest` for fast iteration. Use this minimal flow when you just want to get a working instance quickly: ```bash -docker pull allisson/secrets:v0.4.0 +docker pull allisson/secrets:v0.4.1 docker network create secrets-net || true docker run -d --name secrets-postgres --network secrets-net \ @@ -21,19 +21,19 @@ docker run -d --name secrets-postgres --network secrets-net \ -e POSTGRES_DB=mydb \ postgres:16-alpine -docker run --rm allisson/secrets:v0.4.0 create-master-key --id default +docker run --rm allisson/secrets:v0.4.1 create-master-key --id default # copy generated MASTER_KEYS and ACTIVE_MASTER_KEY_ID into .env -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 migrate -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 create-kek --algorithm aes-gcm docker run --rm --name secrets-api --network secrets-net --env-file .env -p 8080:8080 \ - allisson/secrets:v0.4.0 server + allisson/secrets:v0.4.1 server ``` ## 1) Pull the image ```bash -docker pull allisson/secrets:v0.4.0 +docker pull allisson/secrets:v0.4.1 ``` ## 2) Start PostgreSQL @@ -51,7 +51,7 @@ docker run -d --name secrets-postgres --network secrets-net \ ## 3) Generate a master key ```bash -docker run --rm allisson/secrets:v0.4.0 create-master-key --id default +docker run --rm allisson/secrets:v0.4.1 create-master-key --id default ``` Copy the generated values into a local `.env` file. @@ -83,15 +83,15 @@ EOF ## 5) Run migrations and bootstrap KEK ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 migrate -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 create-kek --algorithm aes-gcm +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 migrate +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 create-kek --algorithm aes-gcm ``` ## 6) Start the API server ```bash docker run --rm --name secrets-api --network secrets-net --env-file .env -p 8080:8080 \ - allisson/secrets:v0.4.0 server + allisson/secrets:v0.4.1 server ``` ## 7) Verify @@ -111,7 +111,7 @@ Expected: Use the CLI command to create your first API client and policy set: ```bash -docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.0 create-client \ +docker run --rm --network secrets-net --env-file .env allisson/secrets:v0.4.1 create-client \ --name bootstrap-admin \ --active \ --policies '[{"path":"*","capabilities":["read","write","delete","encrypt","decrypt","rotate"]}]' \ diff --git a/docs/getting-started/smoke-test.md b/docs/getting-started/smoke-test.md index 8527753..dff91e3 100644 --- a/docs/getting-started/smoke-test.md +++ b/docs/getting-started/smoke-test.md @@ -1,6 +1,6 @@ # โœ… Smoke Test Script -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Run a fast end-to-end validation of a running Secrets instance. @@ -56,4 +56,5 @@ If transit decrypt fails with `422`, see [Troubleshooting](troubleshooting.md#42 - [Docker getting started](docker.md) - [Local development](local-development.md) - [Troubleshooting](troubleshooting.md) +- [v0.4.1 release notes](../releases/v0.4.1.md) - [Curl examples](../examples/curl.md) diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md index f26c62e..6a78361 100644 --- a/docs/getting-started/troubleshooting.md +++ b/docs/getting-started/troubleshooting.md @@ -1,6 +1,6 @@ # ๐Ÿงฐ Troubleshooting -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Use this guide for common setup and runtime errors. @@ -31,6 +31,7 @@ Use this quick route before diving into detailed sections: - [Tokenization migration verification](#tokenization-migration-verification) - [Rotation completed but server still uses old key context](#rotation-completed-but-server-still-uses-old-key-context) - [Token issuance fails with valid-looking credentials](#token-issuance-fails-with-valid-looking-credentials) +- [Policy matcher FAQ](#policy-matcher-faq) - [Quick diagnostics checklist](#quick-diagnostics-checklist) ## 401 Unauthorized @@ -48,9 +49,19 @@ Use this quick route before diving into detailed sections: - Likely cause: policy does not grant required capability on requested path - Fix: - verify capability mapping for endpoint (`read`, `write`, `delete`, `encrypt`, `decrypt`, `rotate`) - - verify path pattern (`*`, exact path, or prefix with `/*`) + - verify path pattern (`*`, exact path, trailing wildcard `/*`, or mid-path wildcard like `/v1/transit/keys/*/rotate`) + - avoid unsupported wildcard patterns (partial-segment `prod-*`, suffix/prefix `*prod`/`prod*`, and `**`) + - validate concrete matcher examples: + - `/v1/transit/keys/*/rotate` matches `/v1/transit/keys/payment/rotate` + - `/v1/transit/keys/*/rotate` does not match `/v1/transit/keys/payment/extra/rotate` - update client policy and retry +Common false positives (`403` vs `404`): + +- `404 Not Found` usually means route shape mismatch (endpoint path does not exist). +- `403 Forbidden` usually means route exists but caller policy/capability denies access. +- Validate route shape first, then evaluate policy matcher and capability mapping. + ## 409 Conflict - Symptom: request returns `409 Conflict` @@ -135,10 +146,10 @@ Common 422 cases: ## Tokenization migration verification -- Symptom: tokenization endpoints return `404`/`500` after upgrading to `v0.4.0` +- Symptom: tokenization endpoints return `404`/`500` after upgrading to `v0.4.x` - Likely cause: tokenization migration (`000002_add_tokenization`) not applied or partially applied - Fix: - - run `./bin/app migrate` (or Docker `... allisson/secrets:v0.4.0 migrate`) + - run `./bin/app migrate` (or Docker `... allisson/secrets:v0.4.1 migrate`) - verify migration logs indicate `000002_add_tokenization` applied for your DB - confirm initial KEK exists (`create-kek` if missing) - re-run smoke flow for tokenization (`tokenize -> detokenize -> validate -> revoke`) @@ -160,6 +171,21 @@ Common 422 cases: - recreate client and securely store the returned one-time secret - verify `is_active` is true +## Policy matcher FAQ + +Q: Why does `/v1/transit/keys/*/rotate` not match `/v1/transit/keys/payment/extra/rotate`? + +- A: Mid-path `*` matches exactly one segment; the extra segment changes route and policy shape. + +Q: Why does `prod-*` not work in policy paths? + +- A: Partial-segment wildcards are unsupported. Use exact paths, full `*`, trailing `/*`, or mid-path segment `*`. + +Q: Why is wildcard `*` risky for normal service clients? + +- A: `*` matches every path and can unintentionally grant broad admin-like access. Reserve it for controlled + break-glass workflows. + ## Quick diagnostics checklist 1. `curl http://localhost:8080/health` returns `{"status":"healthy"}` @@ -174,4 +200,5 @@ Common 422 cases: - [Smoke test](smoke-test.md) - [Docker getting started](docker.md) - [Local development](local-development.md) +- [Operator runbook index](../operations/runbook-index.md) - [Production operations](../operations/production.md) diff --git a/docs/metadata.json b/docs/metadata.json index 8ee022d..53a0381 100644 --- a/docs/metadata.json +++ b/docs/metadata.json @@ -1,5 +1,5 @@ { - "current_release": "v0.4.0", + "current_release": "v0.4.1", "api_version": "v1", - "last_docs_refresh": "2026-02-18" + "last_docs_refresh": "2026-02-19" } diff --git a/docs/operations/failure-playbooks.md b/docs/operations/failure-playbooks.md index 6e7242a..509db40 100644 --- a/docs/operations/failure-playbooks.md +++ b/docs/operations/failure-playbooks.md @@ -1,6 +1,6 @@ # ๐Ÿš‘ Failure Playbooks -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Use this page for fast incident triage on common API failures. @@ -26,7 +26,7 @@ Symptoms: Triage steps: 1. Identify failing endpoint path and required capability -2. Confirm client policy path matching (`*`, exact, `/*` prefix) +2. Confirm client policy path matching (`*`, exact, trailing `/*`, and mid-path `*` segment rules) 3. Validate capability mapping for endpoint (`read`, `write`, `delete`, `encrypt`, `decrypt`, `rotate`) 4. Re-issue token after policy update diff --git a/docs/operations/monitoring.md b/docs/operations/monitoring.md index fa32728..0059769 100644 --- a/docs/operations/monitoring.md +++ b/docs/operations/monitoring.md @@ -1,6 +1,6 @@ # ๐Ÿ“Š Monitoring -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 This document describes the metrics instrumentation and monitoring capabilities in the Secrets application. diff --git a/docs/operations/policy-smoke-tests.md b/docs/operations/policy-smoke-tests.md index 8c46a56..1314189 100644 --- a/docs/operations/policy-smoke-tests.md +++ b/docs/operations/policy-smoke-tests.md @@ -1,6 +1,6 @@ # ๐Ÿงช Policy Smoke Tests -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 Use this page to quickly validate authorization behavior after policy changes. @@ -74,6 +74,53 @@ test "$ALLOW_STATUS" = "200" test "$DENY_STATUS" = "403" ``` +Transit rotate check (mid-path wildcard, `rotate` required): + +```bash +ALLOW_STATUS=$(curl -s -o /tmp/allow-rotate.json -w "%{http_code}" -X POST \ + "$BASE_URL/v1/transit/keys/payment/rotate" \ + -H "Authorization: Bearer $ALLOW_TOKEN") + +DENY_STATUS=$(curl -s -o /tmp/deny-rotate.json -w "%{http_code}" -X POST \ + "$BASE_URL/v1/transit/keys/payment/rotate" \ + -H "Authorization: Bearer $DENY_TOKEN") + +echo "allowed status=$ALLOW_STATUS denied status=$DENY_STATUS" +test "$ALLOW_STATUS" = "200" +test "$DENY_STATUS" = "403" +``` + +Tip: this check validates policies like `/v1/transit/keys/*/rotate` and catches wildcard path drift. + +Malformed path shape check (extra segment should not match rotate route): + +```bash +BAD_SHAPE_STATUS=$(curl -s -o /tmp/bad-shape-rotate.json -w "%{http_code}" -X POST \ + "$BASE_URL/v1/transit/keys/payment/extra/rotate" \ + -H "Authorization: Bearer $ALLOW_TOKEN") + +echo "bad shape status=$BAD_SHAPE_STATUS" +test "$BAD_SHAPE_STATUS" = "404" +``` + +Tip: this validates caller path shape expectations; use the allow/deny rotate checks above to validate +capability enforcement. +See [Route shape vs policy shape](../api/policies.md#route-shape-vs-policy-shape) for triage guidance. + +Secrets malformed path-shape check (missing wildcard subpath should not match): + +```bash +BAD_SECRET_SHAPE_STATUS=$(curl -s -o /tmp/bad-shape-secret.json -w "%{http_code}" \ + "$BASE_URL/v1/secrets" \ + -H "Authorization: Bearer $ALLOW_TOKEN") + +echo "bad secret shape status=$BAD_SECRET_SHAPE_STATUS" +test "$BAD_SECRET_SHAPE_STATUS" = "404" +``` + +Tip: use this check to ensure policy path logic is not confused with route-template shape. +See [Route shape vs policy shape](../api/policies.md#route-shape-vs-policy-shape) for details. + Tokenization detokenize check (`decrypt` required): ```bash @@ -114,6 +161,32 @@ Expected: - Assert expected status pairs (allow vs deny) - Run after policy deployment but before traffic cutover +Optional strict CI mode: + +```bash +set -euo pipefail + +# Run the checks in this document and fail fast on first mismatch +# (each `test` command exits non-zero on failure) + +echo "policy smoke checks: PASS" +``` + +GitHub Actions example: + +```yaml +- name: Policy smoke checks + env: + BASE_URL: ${{ vars.SECRETS_BASE_URL }} + ALLOW_CLIENT_ID: ${{ secrets.POLICY_ALLOW_CLIENT_ID }} + ALLOW_CLIENT_SECRET: ${{ secrets.POLICY_ALLOW_CLIENT_SECRET }} + DENY_CLIENT_ID: ${{ secrets.POLICY_DENY_CLIENT_ID }} + DENY_CLIENT_SECRET: ${{ secrets.POLICY_DENY_CLIENT_SECRET }} + run: | + set -euo pipefail + # Run commands from this page and fail on first mismatch. +``` + ## See also - [Capability matrix](../api/capability-matrix.md) diff --git a/docs/operations/production.md b/docs/operations/production.md index a2bcfbc..0394446 100644 --- a/docs/operations/production.md +++ b/docs/operations/production.md @@ -1,6 +1,6 @@ # ๐Ÿญ Production Deployment Guide -> Last updated: 2026-02-18 +> Last updated: 2026-02-19 This guide covers baseline production hardening and operations for Secrets. @@ -159,8 +159,10 @@ Adjust retention to match your compliance and incident-response requirements. ## See also - [Key management operations](key-management.md) +- [Operator runbook index](runbook-index.md) - [Monitoring](monitoring.md) - [Policy smoke tests](policy-smoke-tests.md) +- [v0.4.1 release notes](../releases/v0.4.1.md) - [Environment variables](../configuration/environment-variables.md) - [Security model](../concepts/security-model.md) - [Troubleshooting](../getting-started/troubleshooting.md) diff --git a/docs/operations/runbook-index.md b/docs/operations/runbook-index.md new file mode 100644 index 0000000..8bd827c --- /dev/null +++ b/docs/operations/runbook-index.md @@ -0,0 +1,43 @@ +# ๐Ÿงญ Operator Runbook Index + +> Last updated: 2026-02-19 + +Use this page as the single entry point for rollout, validation, and incident runbooks. + +## Release and Rollout + +- [v0.4.1 release notes](../releases/v0.4.1.md) +- [Production deployment guide](production.md) + +## Authorization Policy Validation + +- [Policies cookbook](../api/policies.md) +- [Path matching behavior](../api/policies.md#path-matching-behavior) +- [Route shape vs policy shape](../api/policies.md#route-shape-vs-policy-shape) +- [Policy review checklist before deploy](../api/policies.md#policy-review-checklist-before-deploy) +- [Policy smoke tests](policy-smoke-tests.md) + +## API and Access Verification + +- [Capability matrix](../api/capability-matrix.md) +- [Authentication API](../api/authentication.md) +- [Audit logs API](../api/audit-logs.md) + +## Incident and Recovery + +- [Failure playbooks](failure-playbooks.md) +- [Troubleshooting](../getting-started/troubleshooting.md) +- [Key management operations](key-management.md) + +## Observability and Health + +- [Monitoring](monitoring.md) +- [Smoke test guide](../getting-started/smoke-test.md) + +## Suggested Operator Flow + +1. Read release notes for behavior changes and upgrade notes +2. Apply policy review checklist and rollout changes +3. Run smoke tests and policy smoke tests before traffic cutover +4. Verify denied/allowed patterns in audit logs after rollout +5. Use failure playbooks and troubleshooting for incidents diff --git a/docs/releases/v0.4.1.md b/docs/releases/v0.4.1.md new file mode 100644 index 0000000..9277a5d --- /dev/null +++ b/docs/releases/v0.4.1.md @@ -0,0 +1,89 @@ +# ๐Ÿš€ Secrets v0.4.1 Release Notes + +> Release date: 2026-02-19 + +This bugfix release improves authorization policy path matching behavior and updates +documentation for v0.4.1 release consumption. + +## Highlights + +- Fixed authorization path matching for policies using mid-path wildcards +- Clarified wildcard matching semantics for exact, trailing wildcard, and segment wildcard paths +- Expanded automated coverage for policy templates, wildcard edge cases, and common policy mistakes + +## Bug Fixes + +- Policy matcher now supports mid-path wildcard patterns such as `/v1/transit/keys/*/rotate` +- Mid-path `*` wildcard now matches exactly one path segment +- Trailing wildcard `/*` behavior remains greedy for nested subpaths + +## Runtime and Compatibility + +- API baseline remains v1 (`/v1/*`) +- No breaking API path or payload contract changes +- Local development targets: Linux and macOS +- CI baseline: Go `1.25.5`, PostgreSQL `16-alpine`, MySQL `8.0` +- Compatibility targets: PostgreSQL `12+`, MySQL `8.0+` + +## Upgrade Notes + +- Recommended for all users relying on wildcard policy path matching +- No schema migrations required specifically for this bugfix release +- Existing tokenization, secrets, transit, auth, and audit flows remain API-compatible + +## Policy Migration Note + +If existing policies assumed prefix-only behavior, review wildcard paths used for rotate and +similar endpoint-specific actions. + +Before (too broad for intent): + +```json +[ + { + "path": "/v1/transit/keys/*", + "capabilities": ["rotate"] + } +] +``` + +After (scoped to rotate endpoint pattern): + +```json +[ + { + "path": "/v1/transit/keys/*/rotate", + "capabilities": ["rotate"] + } +] +``` + +## Verification Checklist + +1. Deploy binaries/images with `v0.4.1` +2. Verify baseline health (`GET /health`, `GET /ready`) +3. Re-run policy smoke checks for expected allow/deny behavior +4. Confirm wildcard policies used in production match intended path semantics + +## Operator Quick Checklist (v0.4.1) + +1. Search client policies for rotate patterns and replace broad forms with `/v1/transit/keys/*/rotate` when needed. +2. Run route-shape smoke checks (`/v1/transit/keys/payment/extra/rotate` and `/v1/secrets`) and expect `404`. +3. Run allow/deny policy smoke checks and expect capability-denied calls to return `403`. +4. Review recent denied audit events and confirm mismatches are expected after policy rollout. + +## Documentation Migration Map (v0.4.1) + +- Policy matching semantics: [Policies cookbook / Path matching behavior](../api/policies.md#path-matching-behavior) +- Route-vs-policy triage: [Policies cookbook / Route shape vs policy shape](../api/policies.md#route-shape-vs-policy-shape) +- Pre-deploy policy checks: [Policies cookbook / Policy review checklist before deploy](../api/policies.md#policy-review-checklist-before-deploy) +- Capability verification: [Capability matrix](../api/capability-matrix.md) +- Operational validation steps: [Policy smoke tests](../operations/policy-smoke-tests.md) +- Incident triage and matcher FAQ: [Troubleshooting](../getting-started/troubleshooting.md) + +## See also + +- [Policies cookbook](../api/policies.md) +- [Policy smoke tests](../operations/policy-smoke-tests.md) +- [Troubleshooting](../getting-started/troubleshooting.md) +- [API compatibility policy](../api/versioning-policy.md) diff --git a/docs/tools/check_docs_metadata.py b/docs/tools/check_docs_metadata.py index 85f29ca..da1197d 100644 --- a/docs/tools/check_docs_metadata.py +++ b/docs/tools/check_docs_metadata.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json +import os import re from pathlib import Path @@ -50,6 +51,73 @@ def main() -> None: if not re.match(r"^\d{4}-\d{2}-\d{2}$", metadata["last_docs_refresh"]): raise ValueError("docs/metadata.json last_docs_refresh must be YYYY-MM-DD") + # Basic freshness hygiene: every docs Markdown page must declare a Last updated marker. + # This helps reduce metadata drift across the documentation set. + markdown_pages = sorted(Path("docs").glob("**/*.md")) + missing_last_updated = [] + invalid_last_updated = [] + + for page in markdown_pages: + # ADRs and release notes use their own date headers. + if page.parts[:2] in [("docs", "adr"), ("docs", "releases")]: + continue + content = page.read_text(encoding="utf-8") + match = re.search(r"^> Last updated: (.+)$", content, re.MULTILINE) + if match is None: + missing_last_updated.append(str(page)) + continue + date_text = match.group(1).strip() + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_text): + invalid_last_updated.append(f"{page} ({date_text})") + + if missing_last_updated: + raise ValueError( + "Docs pages missing '> Last updated: YYYY-MM-DD' marker: " + + ", ".join(missing_last_updated) + ) + + if invalid_last_updated: + raise ValueError( + "Docs pages with invalid Last updated date shape: " + + ", ".join(invalid_last_updated) + ) + + # Optional strict check for changed docs files in CI. + # Input format accepts comma, whitespace, or newline-separated paths. + # When set, changed docs pages must refresh Last updated to metadata last_docs_refresh. + changed_files_raw = os.getenv("DOCS_CHANGED_FILES", "").strip() + if changed_files_raw: + changed_files = [ + token for token in re.split(r"[\s,]+", changed_files_raw) if token + ] + stale_changed_pages = [] + for token in changed_files: + page = Path(token) + if page.suffix != ".md": + continue + if page.parts[:2] in [("docs", "adr"), ("docs", "releases")]: + continue + if not str(page).startswith("docs/"): + continue + if not page.exists(): + continue + + content = page.read_text(encoding="utf-8") + match = re.search(r"^> Last updated: (.+)$", content, re.MULTILINE) + if match is None: + stale_changed_pages.append(f"{page} (missing marker)") + continue + if match.group(1).strip() != metadata["last_docs_refresh"]: + stale_changed_pages.append( + f"{page} (expected {metadata['last_docs_refresh']})" + ) + + if stale_changed_pages: + raise ValueError( + "Changed docs pages must refresh Last updated to docs/metadata.json last_docs_refresh: " + + ", ".join(stale_changed_pages) + ) + print("docs metadata checks passed") diff --git a/internal/auth/domain/client.go b/internal/auth/domain/client.go index 9f55668..40a1c4f 100644 --- a/internal/auth/domain/client.go +++ b/internal/auth/domain/client.go @@ -30,14 +30,73 @@ type Client struct { CreatedAt time.Time } +// matchPath checks if the request path matches the policy path pattern. +// Supports three types of wildcards: +// 1. Full wildcard: "*" matches any path +// 2. Trailing wildcard: "prefix/*" matches any path starting with "prefix/" (greedy) +// 3. Mid-path wildcard: "/v1/keys/*/rotate" matches paths with * as single segment +// +// Examples: +// - "*" matches any path +// - "/v1/secrets/*" matches "/v1/secrets/app/db" and "/v1/secrets/app/db/password" +// - "/v1/keys/*/rotate" matches "/v1/keys/payment/rotate" but NOT "/v1/keys/rotate" +// - "/v1/*/keys/*/rotate" matches "/v1/transit/keys/payment/rotate" +func matchPath(policyPath, requestPath string) bool { + // Special case: full wildcard matches everything + if policyPath == "*" { + return true + } + + // No wildcard: exact match required + if !strings.Contains(policyPath, "*") { + return policyPath == requestPath + } + + // Trailing wildcard (/*): prefix match (greedy - matches remaining path) + if strings.HasSuffix(policyPath, "/*") { + prefix := strings.TrimSuffix(policyPath, "/*") + return strings.HasPrefix(requestPath, prefix+"/") + } + + // Mid-path wildcards: segment-by-segment matching + // Each * matches exactly one segment + policyParts := strings.Split(policyPath, "/") + requestParts := strings.Split(requestPath, "/") + + // Must have same number of segments for mid-path wildcards + if len(policyParts) != len(requestParts) { + return false + } + + // Compare each segment + for i := 0; i < len(policyParts); i++ { + if policyParts[i] == "*" { + // Wildcard matches any single segment + continue + } + if policyParts[i] != requestParts[i] { + return false + } + } + + return true +} + // IsAllowed checks if the client's policies permit the given capability on the specified path. -// Uses case-sensitive prefix matching with wildcard support. Returns true if any policy +// Uses case-sensitive path matching with wildcard support. Returns true if any policy // matches the path and includes the capability. // // Wildcard patterns: // - "*" matches everything (admin mode) -// - "secret/*" matches any path starting with "secret/" -// - "secret" matches exactly "secret" +// - "secret/*" matches any path starting with "secret/" (trailing wildcard - greedy) +// - "/v1/keys/*/rotate" matches "/v1/keys/payment/rotate" (single-segment wildcard) +// - "/v1/*/keys/*/rotate" matches "/v1/transit/keys/payment/rotate" (multiple wildcards) +// +// Path matching rules: +// - Exact match: "secret" matches only "secret" +// - Trailing wildcard: "secret/*" matches "secret/app", "secret/app/db", etc. +// - Mid-path wildcard: "/v1/keys/*/rotate" matches exactly 4 segments with 3rd being any value +// - Case-sensitive: "Secret" does NOT match "secret" func (c *Client) IsAllowed(path string, capability Capability) bool { // Edge case: empty path or capability if path == "" || capability == "" { @@ -46,24 +105,8 @@ func (c *Client) IsAllowed(path string, capability Capability) bool { // Iterate through all policies for _, policy := range c.Policies { - var pathMatches bool - - // Check path matching based on pattern type - switch { - case policy.Path == "*": - // Wildcard matches everything - pathMatches = true - case strings.HasSuffix(policy.Path, "/*"): - // Prefix matching: strip "/*" and check if path starts with "prefix/" - prefix := strings.TrimSuffix(policy.Path, "/*") - pathMatches = strings.HasPrefix(path, prefix+"/") - default: - // Exact match - pathMatches = policy.Path == path - } - - // If path matches, check if capability is in the list - if pathMatches { + // Check if path matches using wildcard support + if matchPath(policy.Path, path) { if slices.Contains(policy.Capabilities, capability) { return true } diff --git a/internal/auth/domain/client_test.go b/internal/auth/domain/client_test.go index c364f57..6356d7c 100644 --- a/internal/auth/domain/client_test.go +++ b/internal/auth/domain/client_test.go @@ -324,6 +324,114 @@ func TestClient_IsAllowed_EdgeCases(t *testing.T) { capability: ReadCapability, expected: false, }, + { + name: "EdgeCase_EmptySubpathAfterWildcard", + client: createTestClient([]PolicyDocument{ + { + Path: "secret/*", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "secret/", + capability: ReadCapability, + expected: true, + }, + { + name: "EdgeCase_MidPathWildcard_TooFewSegments", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/keys/rotate", + capability: RotateCapability, + expected: false, + }, + { + name: "EdgeCase_MidPathWildcard_TooManySegments", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/keys/payment/extra/rotate", + capability: RotateCapability, + expected: false, + }, + { + name: "EdgeCase_MidPathWildcard_ExactMatch", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/keys/payment/rotate", + capability: RotateCapability, + expected: true, + }, + { + name: "EdgeCase_MultipleWildcards", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/*/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: RotateCapability, + expected: true, + }, + { + name: "EdgeCase_MultipleWildcards_MissingSegment", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/*/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/transit/keys/rotate", + capability: RotateCapability, + expected: false, + }, + { + name: "EdgeCase_WildcardAtBeginning", + client: createTestClient([]PolicyDocument{ + { + Path: "*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "anything/rotate", + capability: RotateCapability, + expected: true, + }, + { + name: "EdgeCase_WildcardAtBeginning_TooManySegments", + client: createTestClient([]PolicyDocument{ + { + Path: "*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "anything/extra/rotate", + capability: RotateCapability, + expected: false, + }, + { + name: "EdgeCase_TrailingSlashInRequestPath", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/secrets/app/", + capability: DecryptCapability, + expected: true, + }, } for _, tt := range tests { @@ -542,3 +650,905 @@ func TestClient_IsAllowed_RealWorldScenarios(t *testing.T) { }) } } +func TestClient_IsAllowed_PolicyTemplates(t *testing.T) { + tests := []struct { + name string + client *Client + path string + capability Capability + expected bool + comment string + }{ + // Template #1: Read-only service + { + name: "PolicyTemplate_ReadOnlyService_AllowDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/secrets/app/db/password", + capability: DecryptCapability, + expected: true, + comment: "Read-only service can decrypt secrets", + }, + { + name: "PolicyTemplate_ReadOnlyService_DenyEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/secrets/app/db/password", + capability: EncryptCapability, + expected: false, + comment: "Read-only service cannot encrypt (write) secrets", + }, + + // Template #2: CI writer + { + name: "PolicyTemplate_CIWriter_AllowEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/secrets/ci/deploy/key", + capability: EncryptCapability, + expected: true, + comment: "CI writer can encrypt (write) secrets", + }, + { + name: "PolicyTemplate_CIWriter_DenyDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/secrets/ci/deploy/key", + capability: DecryptCapability, + expected: false, + comment: "CI writer cannot decrypt (read) secrets", + }, + + // Template #3: Transit encrypt-only service + { + name: "PolicyTemplate_TransitEncryptOnly_AllowEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/payment/encrypt", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/transit/keys/payment/encrypt", + capability: EncryptCapability, + expected: true, + comment: "Transit encrypt-only can encrypt with specific key", + }, + { + name: "PolicyTemplate_TransitEncryptOnly_DenyDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/payment/encrypt", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/transit/keys/payment/decrypt", + capability: DecryptCapability, + expected: false, + comment: "Transit encrypt-only cannot decrypt", + }, + { + name: "PolicyTemplate_TransitEncryptOnly_DenyDifferentKey", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/payment/encrypt", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/transit/keys/user/encrypt", + capability: EncryptCapability, + expected: false, + comment: "Transit encrypt-only scoped to specific key name", + }, + + // Template #4: Transit decrypt-only service + { + name: "PolicyTemplate_TransitDecryptOnly_AllowDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/payment/decrypt", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/transit/keys/payment/decrypt", + capability: DecryptCapability, + expected: true, + comment: "Transit decrypt-only can decrypt with specific key", + }, + { + name: "PolicyTemplate_TransitDecryptOnly_DenyEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/payment/decrypt", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/transit/keys/payment/encrypt", + capability: EncryptCapability, + expected: false, + comment: "Transit decrypt-only cannot encrypt", + }, + + // Template #5: Audit log reader + { + name: "PolicyTemplate_AuditLogReader_AllowRead", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/audit-logs", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/audit-logs", + capability: ReadCapability, + expected: true, + comment: "Audit log reader can read audit logs", + }, + { + name: "PolicyTemplate_AuditLogReader_DenyWrite", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/audit-logs", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/audit-logs", + capability: WriteCapability, + expected: false, + comment: "Audit log reader cannot write", + }, + { + name: "PolicyTemplate_AuditLogReader_DenyNestedPath", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/audit-logs", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/audit-logs/123", + capability: ReadCapability, + expected: false, + comment: "Exact match only - no nested paths", + }, + + // Template #6: Break-glass admin (already covered in RealWorldScenarios, but adding for completeness) + { + name: "PolicyTemplate_BreakGlassAdmin_AllowSecretsDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "*", + Capabilities: []Capability{ + ReadCapability, + WriteCapability, + DeleteCapability, + EncryptCapability, + DecryptCapability, + RotateCapability, + }, + }, + }), + path: "/v1/secrets/critical/prod", + capability: DecryptCapability, + expected: true, + comment: "Break-glass admin has full access", + }, + { + name: "PolicyTemplate_BreakGlassAdmin_AllowTransitRotate", + client: createTestClient([]PolicyDocument{ + { + Path: "*", + Capabilities: []Capability{ + ReadCapability, + WriteCapability, + DeleteCapability, + EncryptCapability, + DecryptCapability, + RotateCapability, + }, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: RotateCapability, + expected: true, + comment: "Break-glass admin can rotate keys", + }, + + // Template #7: Key operator (CRITICAL - tests mid-path wildcards!) + { + name: "PolicyTemplate_KeyOperator_AllowCreateKey", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys", + capability: WriteCapability, + expected: true, + comment: "Key operator can create keys", + }, + { + name: "PolicyTemplate_KeyOperator_AllowRotateWithMidPathWildcard", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: RotateCapability, + expected: true, + comment: "Mid-path wildcard matches single segment for rotate", + }, + { + name: "PolicyTemplate_KeyOperator_AllowRotateDifferentKey", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/user/rotate", + capability: RotateCapability, + expected: true, + comment: "Mid-path wildcard works for any key name", + }, + { + name: "PolicyTemplate_KeyOperator_AllowDeleteKey", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/payment", + capability: DeleteCapability, + expected: true, + comment: "Trailing wildcard allows delete of any key", + }, + { + name: "PolicyTemplate_KeyOperator_AllowDeleteNestedPath", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/payment/something", + capability: DeleteCapability, + expected: true, + comment: "Trailing wildcard matches nested paths", + }, + { + name: "PolicyTemplate_KeyOperator_DenyEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/payment/encrypt", + capability: EncryptCapability, + expected: false, + comment: "Key operator cannot encrypt (different capability needed)", + }, + { + name: "PolicyTemplate_KeyOperator_AllowDeleteOnRotatePath", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: DeleteCapability, + expected: true, + comment: "Trailing wildcard /* also matches rotate paths (greedy)", + }, + + // Template #8: Tokenization operator (complex multi-policy) + { + name: "PolicyTemplate_TokenizationOperator_AllowCreateKey", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/keys", + capability: WriteCapability, + expected: true, + comment: "Tokenization operator can create keys", + }, + { + name: "PolicyTemplate_TokenizationOperator_AllowRotate", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/keys/pci/rotate", + capability: RotateCapability, + expected: true, + comment: "Mid-path wildcard allows rotate for any key", + }, + { + name: "PolicyTemplate_TokenizationOperator_AllowTokenize", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/keys/pci/tokenize", + capability: EncryptCapability, + expected: true, + comment: "Mid-path wildcard allows tokenize with any key", + }, + { + name: "PolicyTemplate_TokenizationOperator_AllowDetokenize", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: DecryptCapability, + expected: true, + comment: "Exact path match for detokenize", + }, + { + name: "PolicyTemplate_TokenizationOperator_AllowValidate", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/validate", + capability: ReadCapability, + expected: true, + comment: "Exact path match for validate", + }, + { + name: "PolicyTemplate_TokenizationOperator_AllowRevoke", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/revoke", + capability: DeleteCapability, + expected: true, + comment: "Exact path match for revoke", + }, + { + name: "PolicyTemplate_TokenizationOperator_DenyDetokenizeWithWrongCapability", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: ReadCapability, + expected: false, + comment: "Detokenize requires decrypt, not read", + }, + { + name: "PolicyTemplate_TokenizationOperator_DenyTokenizeWithWrongCapability", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys", + Capabilities: []Capability{WriteCapability}, + }, + { + Path: "/v1/tokenization/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/tokenization/validate", + Capabilities: []Capability{ReadCapability}, + }, + { + Path: "/v1/tokenization/revoke", + Capabilities: []Capability{DeleteCapability}, + }, + }), + path: "/v1/tokenization/keys/pci/tokenize", + capability: RotateCapability, + expected: false, + comment: "Tokenize path requires encrypt, not rotate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.client.IsAllowed(tt.path, tt.capability) + assert.Equal(t, tt.expected, result, tt.comment) + }) + } +} + +func TestClient_IsAllowed_CommonMistakes(t *testing.T) { + tests := []struct { + name string + client *Client + path string + capability Capability + expected bool + mistake string + }{ + // Mistake #1: Wrong capability for secret reads (line 238 in policies.md) + { + name: "CommonMistake_SecretReadWithReadInsteadOfDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/secrets/app/prod/key", + capability: DecryptCapability, + expected: false, + mistake: "Using 'read' instead of 'decrypt' for GET /v1/secrets/*path", + }, + { + name: "CommonMistake_SecretReadFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/secrets/app/prod/key", + capability: DecryptCapability, + expected: true, + mistake: "Fixed: Using 'decrypt' for GET /v1/secrets/*path", + }, + + // Mistake #2: Missing rotate capability (line 239 in policies.md) + { + name: "CommonMistake_MissingRotateCapability", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/*", + Capabilities: []Capability{WriteCapability}, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: RotateCapability, + expected: false, + mistake: "Missing 'rotate' capability on /v1/transit/keys/*/rotate", + }, + { + name: "CommonMistake_RotateCapabilityFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/transit/keys/*/rotate", + Capabilities: []Capability{RotateCapability}, + }, + }), + path: "/v1/transit/keys/payment/rotate", + capability: RotateCapability, + expected: true, + mistake: "Fixed: Added 'rotate' on /v1/transit/keys/*/rotate", + }, + + // Mistake #3: Wrong capability for detokenize (line 240 in policies.md) + { + name: "CommonMistake_DetokenizeWithReadInsteadOfDecrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: DecryptCapability, + expected: false, + mistake: "Using 'read' instead of 'decrypt' for detokenize", + }, + { + name: "CommonMistake_DetokenizeFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: DecryptCapability, + expected: true, + mistake: "Fixed: Using 'decrypt' for detokenize", + }, + + // Mistake #4: Over-broad wildcard (line 241 in policies.md) + { + name: "CommonMistake_OverBroadWildcard_ProductionAccess", + client: createTestClient([]PolicyDocument{ + { + Path: "*", + Capabilities: []Capability{ + ReadCapability, + WriteCapability, + DeleteCapability, + EncryptCapability, + DecryptCapability, + RotateCapability, + }, + }, + }), + path: "/v1/secrets/production/critical", + capability: DecryptCapability, + expected: true, + mistake: "SECURITY RISK: Wildcard '*' grants excessive access to production", + }, + { + name: "CommonMistake_OverBroadWildcard_ClientDelete", + client: createTestClient([]PolicyDocument{ + { + Path: "*", + Capabilities: []Capability{ + ReadCapability, + WriteCapability, + DeleteCapability, + EncryptCapability, + DecryptCapability, + RotateCapability, + }, + }, + }), + path: "/v1/clients", + capability: DeleteCapability, + expected: true, + mistake: "SECURITY RISK: Wildcard '*' allows deleting clients", + }, + { + name: "CommonMistake_ScopedPathFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/app/prod/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/secrets/app/prod/db", + capability: DecryptCapability, + expected: true, + mistake: "Fixed: Scoped to /v1/secrets/app/prod/* instead of '*'", + }, + + // Mistake #5: Wrong capability for secret writes (line 242 in policies.md) + { + name: "CommonMistake_SecretWriteWithWriteInsteadOfEncrypt", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{WriteCapability}, + }, + }), + path: "/v1/secrets/app/key", + capability: EncryptCapability, + expected: false, + mistake: "Using 'write' instead of 'encrypt' for POST /v1/secrets/*path", + }, + { + name: "CommonMistake_SecretWriteFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/secrets/app/key", + capability: EncryptCapability, + expected: true, + mistake: "Fixed: Using 'encrypt' for POST /v1/secrets/*path", + }, + + // Mistake #6: Insufficient tokenization path scope (line 243 in policies.md) + { + name: "CommonMistake_TokenizationPathScope_OnlyTokenize", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/tokenization/keys/pci/tokenize", + capability: EncryptCapability, + expected: true, + mistake: "Can tokenize with scoped path", + }, + { + name: "CommonMistake_TokenizationPathScope_CannotDetokenize", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: DecryptCapability, + expected: false, + mistake: "Cannot detokenize - different path required", + }, + { + name: "CommonMistake_TokenizationPathScopeFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/tokenization/keys/*/tokenize", + Capabilities: []Capability{EncryptCapability}, + }, + { + Path: "/v1/tokenization/detokenize", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/tokenization/detokenize", + capability: DecryptCapability, + expected: true, + mistake: "Fixed: Added explicit /v1/tokenization/detokenize policy", + }, + + // Mistake #7: Missing audit read policy (line 244 in policies.md) + { + name: "CommonMistake_MissingAuditLogPolicy", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + }), + path: "/v1/audit-logs", + capability: ReadCapability, + expected: false, + mistake: "Missing explicit /v1/audit-logs policy", + }, + { + name: "CommonMistake_AuditLogPolicyFixed", + client: createTestClient([]PolicyDocument{ + { + Path: "/v1/secrets/*", + Capabilities: []Capability{DecryptCapability}, + }, + { + Path: "/v1/audit-logs", + Capabilities: []Capability{ReadCapability}, + }, + }), + path: "/v1/audit-logs", + capability: ReadCapability, + expected: true, + mistake: "Fixed: Added explicit /v1/audit-logs read policy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.client.IsAllowed(tt.path, tt.capability) + assert.Equal(t, tt.expected, result, tt.mistake) + }) + } +}