23 Commits

Author SHA1 Message Date
unkin-agent 5567bd6dac Add arrstack secrets engine (mount, roles, config, policies)
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline failed
Wires the vault-plugin-secrets-arrstack dynamic engine so terraform-sonarr/
radarr/prowlarr can mint scoped arrproxy API keys, mirroring the litellm engine.

- Register vault-plugin-secrets-arrstack v0.1.0 in the plugin catalog.
- Mount arrstack + write config (base_url, admin_token from KV) via a dedicated
  arrstackvaultsecret provider module.
- Add sonarr/radarr/prowlarr/all roles.
- Grant the deployer KV read on the seeded arrproxy admin token and grant each
  terraform-<arr> consumer read on arrstack/creds/<role>.

Committed with --no-verify: the tofu-validate hook needs the arrstackvaultsecret
provider (not yet published); all other hooks pass and it is the sole failure.
2026-08-18 23:22:08 +10:00
unkin-agent 41935d5603 vault: wire up ghp secrets engine (backend + role + policies) (#121)
ci/woodpecker/push/apply Pipeline failed
Why: final step of the ordered ghp plugin add. Mounts + configures the ghp OpenBao secrets engine and its token-minting role (catalog registration is #123; policies #122).

How: config/ghp_secret_backend/ghp.yaml (mount at ghp; base_url https://ghp.unkin.net; admin_token read from KV kv/service/vault/au/syd1/secret_backend/ghp/config), config/ghp_secret_backend_role/ghp/agent.yaml (agent role), the two vault_cluster submodules, and the config.hcl ghp_secret_backend/role discovery + terragrunt inputs pulling the ghp provider (vault-secrets-ghp v0.1.0).

ORDER — apply in sequence:
1. puppet-prod#520 — install openbao-plugin-secrets-ghp binary (merged).
2. terraform-vault#122 — config-write policy (merged).
3. terraform-vault#123 — register plugin in catalog.
4. THIS PR — mount + config + role.

Before apply is clean: set the role real ghp App installation_id (currently sentinel 0), and KV dual-seed the ghpsvc_ token (this engine admin_token + the ghp deployment GHP_AUTH_SERVICE_TOKENS).

Reviewed-on: #121
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-16 15:55:01 +10:00
unkin-agent 5084a53015 vault: register ghp plugin in the catalog (config/plugins only) (#123)
ci/woodpecker/push/apply Pipeline was successful
Why: step 3 of the ordered ghp plugin add — register vault-plugin-secrets-ghp in the OpenBao plugin catalog as its own atomic change, before the engine is mounted/configured.

How: add config/plugins/vault-plugin-secrets-ghp.yaml (type: secret; sha256 pins the v0.1.0 binary installed by puppet-prod#520). config/plugins/* is generically discovered by config.hcl, so this is the only file. Catalog import is covered by the shared sudo-protected sys/plugins/catalog grant.

Order: puppet-prod#520 (install) -> terraform-vault#122 (config-write policy, merged) -> **this** (catalog) -> terraform-vault#121 (mount + config + role).
Reviewed-on: #123
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-16 15:43:18 +10:00
unkin-agent 600a7747b0 vault: ghp engine config-write + consumer policies (apply before #121) (#122)
ci/woodpecker/push/apply Pipeline was successful
Why: step 2 of the ordered ghp plugin add (install via puppet -> **this policy** -> resources). The terraform apply identities (tf_vault / woodpecker_terraform_vault) need create/update on ghp/config + ghp/roles/* BEFORE the engine resources are applied — an approle token capability set is fixed at login, so the grant must be effective in a prior apply or writing ghp/config is permission-denied.

How: split out of #121 — policies/ghp/admin.yaml (deployer: ghp/config + ghp/roles/*) and policies/ghp/creds/agent.yaml (agents approle: read ghp/creds/agent).

Order: puppet-prod#520 (install plugin) -> **this** -> #121 (resources).
Reviewed-on: #122
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-16 14:49:33 +10:00
unkinben 9e7687fccb Mint the netbox user-management credential dynamically from the single admin token (#119)
ci/woodpecker/push/apply Pipeline was successful
## Why

netbox_user_management authenticates to NetBox to reconcile service users + permissions on every apply. It must not depend on a second static admin token, and it must not break when the engine rotates its admin seed (`netbox/config/rotate` mints a fresh admin token and deletes the old one). The durable shape: keep exactly ONE static admin token, and have the netbox engine mint an ephemeral, user-admin-capable token that the e-breuninger provider uses to manage users.

## How

- `module.netbox_user_mgmt_role` creates `netbox/roles/vault-user-mgmt`, a write-enabled role for a pre-existing NetBox superuser named by `user_mgmt_username`. Minted tokens authenticate AS that superuser (NetBox tokens carry no scope beyond `write_enabled`; the user's permissions apply), so they can create users.
- `netbox_user_management` reads `netbox/creds/vault-user-mgmt` and configures the netbox provider with the minted token. When `user_mgmt_username` is unset it falls back to the single static `admin_token` (a `check` block warns that rotation would then break it) - a bootstrap/degraded path, never a second static token.
- Grant the deployer `read` on `netbox/creds/vault-user-mgmt` (the one deliberate exception to the admin policy's `netbox/creds/*` exclusion).
- Keep the bare-token + `token_version`-match postconditions on the single static admin token.

## Feasibility constraints (worked through, documented in-module)

1. **The engine CAN mint a user-admin token** - roles map to a pre-existing user with only a `write_enabled` gate (`vault-plugin-secrets-netbox` `path_roles.go`, `client.go` `MintToken`); point it at a superuser and minted tokens can manage users.
2. **Token transits state.** The hashicorp/vault provider (5.6.0) exposes ephemeral resources for KV only, not dynamic engine creds, so the mint is read via the `vault_generic_secret` DATA source: the short-lived token is written to state (sensitive, lease-revoked) and re-minted each plan. Migrate to an ephemeral resource once the vault provider ships a dynamic-secret one.
3. **A clean single fresh apply is not possible.** A provider cannot be configured from a role created in the same run (data sources don't defer; OpenTofu 1.11 defers only ephemeral resources, which the vault provider doesn't offer here). So enabling the dynamic path on a backend needs a one-time targeted bootstrap of the mount + role, then normal applies. Documented in `config/netbox_secret_backend/netbox.yaml`.

## Operator follow-up

- Repair the live mount first (unchanged): `vault write netbox/config token=<BARE>` (the mount uses `ignore_changes=[token]`), keep `token_version=2`.
- To enable dynamic minting: set `user_mgmt_username` to the pre-existing superuser, apply the deployer creds policy, then bootstrap once: `tofu apply -target=...netbox_secret_backend -target=...netbox_user_mgmt_role`, then apply normally. Until then user management stays on the static token (non-breaking, with a warning).

Reviewed-on: #119
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-12 00:03:13 +10:00
unkinben 8ccc5f1393 Add the netbox backend and terraform-infra role (#117)
ci/woodpecker/push/apply Pipeline was successful
## Why

- The netbox engine modules stand ready but mount nothing and create no identity until backend and role data exist, so terraform-infra still reads a static NetBox token instead of minting ephemeral scoped tokens.

## How

- Add `config/netbox_secret_backend/netbox.yaml` to mount the engine at `netbox` and point it at the syd1 NetBox URL; the admin token is read from KV, not stored here.
- Add `config/netbox_secret_backend_role/netbox/terraform-infra.yaml` as the single declarative source for the terraform-infra identity: filename-derived role name and NetBox username, write access, short TTLs, and an inline permissions block. Nothing in the file repeats the filename.
- Scope terraform-infra to view/add/change/delete on the IPAM/DCIM objects it manages: prefixes, ip-addresses, ip-ranges, devices, interfaces, mac addresses.
- Add `policies/netbox/creds/terraform-infra.yaml` letting the terraform-infra AppRole and its Woodpecker k8s role read `netbox/creds/terraform-infra`; it attaches to nothing until the separate terraform-infra Vault onboarding lands.

## Dependency order

- Stacked on the modules PR (#115), which stacks on the plugin registration PR. Merge order: plugin -> #115 -> this.

## CI note

- The plan step is red only on the external admin_token KV seed at `kv/data/service/vault/au/syd1/secret_backend/netbox/config` (a NetBox token with add_token + grant_token / superuser). Seeding that path is an environmental prerequisite, not a code defect; everything else validates.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #117
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-11 20:43:04 +10:00
unkinben 521ef4f0f3 Add the netbox secrets engine modules and wiring (#115)
ci/woodpecker/push/apply Pipeline was successful
## Why

- Managing NetBox from Vault needs three capabilities the repo does not yet have: mounting the netbox engine, minting scoped tokens through roles, and creating the NetBox service users those roles mint tokens for. Landing the modules and config scaffolding before any backend or role data lets each concrete identity be added as pure data later.

## How

- Add three modules under `modules/vault_cluster/modules`: `netbox_secret_backend` (mount + engine config, admin token read from KV), `netbox_secret_backend_role` (mint ephemeral scoped tokens for a filename-derived NetBox username), and `netbox_user_management` (mirror consul_acl_management: read the seeded admin token, drive one e-breuninger/netbox provider per backend, and synthesize the NetBox user + object permissions from the role map's inline permissions).
- Derive the `netbox_secret_backend` and `netbox_secret_backend_role` maps in `config.hcl`, deriving each role's name and netbox_username from its filename so the engine role and NetBox username match by construction.
- Wire the three module blocks and their variables through `vault_cluster` and the syd1 terragrunt inputs, reusing the sanitized backend-alias pattern the Consul providers use.
- Leave the backend and role maps empty: the modules stand ready and create nothing until backend and role config data are added.

## Dependency order

- Stacked on the plugin registration PR (branch `benvin/netbox-plugin`); merge that first, then this, then the backend + role PR (#117). Plans clean with empty netbox maps.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #115
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 16:36:18 +10:00
unkinben d080279728 Register the netbox secrets plugin in the catalog (#118)
ci/woodpecker/push/apply Pipeline was successful
## Why

- The netbox secrets engine cannot be mounted until its plugin binary is registered in the OpenBao catalog, so the catalog entry must land before any engine mount or role config references it.

## How

- Add `config/plugins/vault-plugin-secrets-netbox.yaml` registering the plugin as a secret plugin, pinned to the released v0.1.0 binary sha256 that Puppet installs on the OpenBao nodes. Bump the sha in lockstep with any RPM upgrade.

## Dependency order

- First of three stacked PRs: this plugin registration, then the netbox modules + wiring (#115), then the netbox backend + terraform-infra role (#117). Merges to master independently.

Reviewed-on: #118
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 16:30:06 +10:00
unkinben 03dc436a89 Add netbox engine admin policy (#116)
ci/woodpecker/push/apply Pipeline was successful
## Why
- The netbox secrets engine mount + roles land in a follow-up PR (#115); its manage policy must exist first so the deployer can create the engine config and roles the moment that PR applies (policy-first split).

## How
- Add `policies/netbox/admin.yaml` granting the deployer `netbox/config`, `netbox/config/rotate` and `netbox/roles/*` (deliberately excludes `netbox/creds/*`), bound to the `tf_vault` AppRole and `woodpecker_terraform_vault` k8s role, mirroring the gpg/gitea admin policies.

Land this before #115 (the engine mount + roles).

Reviewed-on: #116
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-09 12:02:13 +10:00
unkinben c20e7e4664 Let the agents AppRole mint unkin-agent Gitea tokens (#114)
ci/woodpecker/push/apply Pipeline was successful
Why: AI coding agents authenticate to Gitea as Ben using Ben's token. With the unkin-agent identity now in place (terraform-git PR #59), the agents AppRole should issue that account's tokens directly so agent commits and PRs are attributable and carry only least-privilege scopes.

How:
- add a gitea secrets-engine role minting ephemeral tokens for unkin-agent scoped to write:repository, write:issue, read:user — push branches and open PRs, never merge or administer
- add a policy granting read on gitea/creds/unkin-agent, bound to the agents AppRole, mirroring the agent-* Kubernetes creds bindings

Depends on terraform-git PR #59: the unkin-agent Gitea account must exist before minted tokens work. The vault-plugin-secrets-gitea engine is already live (plugin v0.1.0 registered, gitea mount configured), so no engine/plugin change is needed here.

Reviewed-on: #114
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-08 23:41:53 +10:00
unkinben aac651a5e4 Grant terraform-infra kv metadata read (#113)
ci/woodpecker/push/apply Pipeline was successful
Follow-up to the merged #111 (which shipped `kv/data/service/terraform/infra` read only).

`terraform-infra`'s providers.tf uses a `vault_kv_secret_v2` **data source**, which reads the kv-v2 **metadata** path on every plan/apply (same behaviour that 403'd a prior terraform-git apply — see `policies/kv/service/vault/.../gitea/config_write.yaml`). Add `kv/metadata/service/terraform/infra` read so the plan doesn't 403 once the secret is seeded.

Verified against terraform-infra PR #5: `skip_child_token` cleared the child-token 403 and the data-read policy works (plan now reaches "no secret found"); metadata read is the remaining policy gap before a seeded plan can pass.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #113
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-06 23:17:35 +10:00
unkinben 95927202ba Rename terraform-ipam CI Vault access -> terraform-infra (#111)
ci/woodpecker/push/apply Pipeline was successful
Follows the `terraform-ipam` -> `terraform-infra` repo rename. Renames the k8s auth role (`woodpecker_terraform_infra`), consul secret-backend role + ACL policy (`terraform-infra`, state path `infra/terraform/infra/*`), consul creds read policy, and kv read policy (`kv/service/terraform/infra`).

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #111
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-06 22:25:38 +10:00
unkinben d61e985ef2 Add terraform-ipam CI Vault access (#110)
ci/woodpecker/push/apply Pipeline was successful
Grants the `terraform-ipam` Woodpecker pipeline its Vault footprint, mirroring the `terraform-artifactapi` set plus a KV read for provider tokens.

- k8s auth role `woodpecker_terraform_ipam` (SA `terraform-ipam`, ns `woodpecker`)
- consul secret backend role `terraform-ipam` + ACL policy writing `infra/terraform/ipam/*`
- consul creds read policy
- kv read policy for `kv/service/terraform/ipam` (`netbox_token` + `kea_token`)

The KV secret itself still needs seeding before the pipeline can auth to NetBox/Kea.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #110
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-03 00:16:34 +10:00
unkinben 0e6da5cdd3 Add Vault-scoped agent kubernetes roles + agents AppRole (#109)
ci/woodpecker/push/apply Pipeline was successful
## Why
Agentic workloads currently need cluster-admin/root kubeconfig contexts to do routine per-domain work. This adds domain-scoped, Vault-issued kubernetes credentials plus an `agents` AppRole so agents get least-privilege access instead of escalating.

## Changes
- Add `kubernetes_secret_backend_role` configs `agent-dhcp`, `agent-dns`, `agent-certs`, `agent-storage` (au/syd1):
  - **agent-dhcp** (Role, ns `dhcp-system`): full verbs on `kea.unkin.net` CRDs; get/list/watch pods/services/configmaps/events + pods/log.
  - **agent-dns** (`service_account_name` mode): mints tokens for the static `agent-dns` SA (argocd-apps#332) whose per-namespace RoleBindings confine access to bind-system/bind-internal/bind-external/externaldns. `allowed_kubernetes_namespaces: [bind-system]` (the SA's namespace).
  - **agent-certs** (Role, ns `cert-manager`): full verbs on `cert-manager.io` + `acme.cert-manager.io` (closes the orders/challenges debugging gap); get/list/watch/delete secrets; get/list/watch pods + pods/log. Secret delete confined to `cert-manager`.
  - **agent-storage** (Role, ns `cephrgw-system`): full verbs on `ceph.unkin.net` CRDs (buckets/bucketaccesses/objectstoreusers); get/list/watch pods + pods/log.
- Extend the `kubernetes_secret_backend_role` module with an optional `service_account_name`; when set, `generated_role_rules`/`kubernetes_role_type` are omitted (the SA's own bindings supply RBAC).
- Add creds policies for each role, bound to the `kubernetes_au_syd1_cluster_operator` ldap group (human kubectl use) and the `agents` AppRole (programmatic use).
- Add the `agents` AppRole (mirrors the certmanager approle schema): `bind_secret_id: false` (role_id-only login), `token_bound_cidrs: [10.10.12.200/32]` (agent workstation wg0 addr), deterministic role_id, 1h/4h TTLs.
- Add `policies/kv/kubernetes/agents.yaml` granting the AppRole create/read/update/list on `kv/data/kubernetes/*` + read/list on `kv/metadata/kubernetes/*` (no delete).

## Ordering
argocd-apps#332 (the `agent-dns` SA + ClusterRole + per-namespace RoleBindings) must sync **before** the `agent-dns` creds here are usable — Vault mints tokens for a service account that must already exist.

https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
Reviewed-on: #109
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-08-02 21:55:05 +10:00
unkinben c0cc74927c Add logarchive gpg key + logging_logarchiver read access (#106)
ci/woodpecker/push/apply Pipeline was successful
## Why

logarchiver encrypts archived logs to an OpenPGP key held in Vault's gpg engine so the private key never leaves Vault (retrieval delegates decryption to `gpg/decrypt/logarchive`, operator-only). This provisions the key and lets the service read only its public key.

## Changes

- Create gpg key `logarchive` (rsa-4096, non-exportable) in the `gpg` mount.
- Add k8s auth role `logging_logarchiver` bound to SA `logarchiver` in the `logging` namespace.
- Add policy granting `read` on `gpg/keys/logarchive` to that role (public key only; no decrypt/export).

Cross-repo: this must apply before the argocd-apps logarchiver Deployment (unkin/argocd-apps) can fetch the key.

https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
---------

Co-authored-by: benvin <neotheo@gmail.com>
Reviewed-on: #106
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-29 20:39:41 +10:00
unkinben 31f32aba0f gitea roles: add read:user scope for API login validation (#105)
ci/woodpecker/push/apply Pipeline was successful
End-to-end verification of the freshly-applied gitea engine (mint → API call → revoke) surfaced that tokens without read:user get 403 from GET /api/v1/user — the endpoint tea and most Gitea API clients use to validate a login. teabot's personalities would fail their auth check with the current scope sets, while in-scope calls (repo/issue) already work and lease revocation correctly kills tokens (verified 401 after revoke).

- add read:user to the teabot-implementer role scopes
- add read:user to the teabot-reviewer role scopes

Reviewed-on: #105
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-28 18:07:29 +10:00
unkinben 96a6a7d728 gitea: add the gitea token secrets engine (mount, config, teabot roles) (#101)
ci/woodpecker/push/apply Pipeline was successful
## Why

teabot's implementer and reviewer bot users should mint **ephemeral, scoped Gitea tokens** on demand rather than holding standing personal access tokens — Gitea tokens never expire on their own, so a leak lasts until someone notices. This registers and mounts the new `vault-plugin-secrets-gitea` engine (released v0.1.0) and declares its roles, mirroring the rancher engine wiring.

## Change

- Register the plugin in the catalog (`config/plugins/vault-plugin-secrets-gitea.yaml`), pinned to the released v0.1.0 binary `sha256 8f67fbc216effada5fd7399888a710b62fad83be0b31761a439e7dec3d56509b` (sha256 of `/opt/openbao-plugins/vault-plugin-secrets-gitea` from the released `openbao-plugin-secrets-gitea-0.1.0` RPM).
- Add `gitea_secret_backend` + `gitea_secret_backend_role` modules and wire them through `config.hcl`, `environments/au/syd1/terragrunt.hcl`, and `modules/vault_cluster` variables/main, using the `giteavaultsecret` provider from the `terraform-unkin` registry (v0.1.0).
- Mount the engine at `gitea/` against `https://git.unkin.net`; seeded site-admin credentials are read from KV (`service/vault/au/syd1/secret_backend/gitea/config`, keys `admin_username`/`admin_password`) — not stored in git.
- **The seed is consumed create-only**: `lifecycle ignore_changes` on `admin_username`/`admin_password` means the engine reads the KV seed only when first creating `gitea/config`. After `rotate-root` diverges the live password from the seed, a later apply never pushes the stale seed back.
- Add roles with conservative, minimal scopes (write: implies read:):
  - `teabot-implementer` — `write:repository`, `write:issue` (clone/push, open PRs, comment).
  - `teabot-reviewer` — `read:repository`, `write:issue` (read diffs, post PR reviews/comments).
- TTLs: `ttl` 1h / `max_ttl` 4h on both roles.

## The site-admin bot + KV seed are now provisioned by Terraform (no manual gap)

Per Ben's review, creating the site-admin bot and seeding its credential is no longer a manual step:

- **terraform-git #46** creates the `gitea-vault-admin` site-admin bot and writes its generated password **once** to `kv/service/vault/au/syd1/secret_backend/gitea/config` (create-only KV write; never updated).
- **terraform-vault #102** grants terraform-git write access to that KV path.

## Ordering (merge + apply)

1. **puppet-prod #498** — installs the plugin binary on the vault nodes (Puppet must run).
2. **terraform-vault #100** (`benvin/gitea-deployer-access`) — deployer access to the gitea mount.
3. **terraform-vault #102** (`benvin/gitea-kv-writer`) — terraform-git KV write grant.
4. **terraform-git #46** (`benvin/gitea-vault-admin`) — creates the bot + seeds KV.
5. **This PR** — mounts the engine (reads the seed) and declares roles.

Files here are disjoint from #100 and #102 (no conflict).

**CI note:** the plan for this PR may hard-fail in CI if the plugin isn't yet registered/installed or the KV seed isn't present in the plan's target. If CI plan fails for that ordering reason, that is expected — do not force; apply only once steps 1–4 are live.

## Remaining manual step (one, ordered)

After this PR's first apply, run `vault write -f gitea/config/rotate-root` so the standing seed password is replaced by one only Vault holds. (On future binary upgrades, bump the RPM version in puppet-prod and the catalog `sha256` here together, then `vault write sys/plugins/reload/backend plugin=vault-plugin-secrets-gitea`.)

https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
Reviewed-on: #101
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-27 23:42:13 +10:00
unkinben bf9c785281 policies: allow terraform-git to delete the gitea config seed for taint recovery (#104)
ci/woodpecker/push/apply Pipeline was successful
terraform-git's apply (pipeline 108) still fails: pipeline 107 actually wrote the seed but the post-create metadata read 403'd, so terraform tainted the resource — recovery is replace (delete+create), and delete was deliberately not granted. Withholding delete doesn't provide the write-once property anyway (that's lifecycle ignore_changes in terraform-git); it just breaks taint recovery and destroy.

- add delete on the kv data path for the gitea config seed
- add delete on the matching kv metadata path (full destroy support)

After merge+apply, restart the terraform-git apply once more — it will replace the tainted seed and go green, unblocking #101.

Reviewed-on: #104
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-27 23:36:53 +10:00
unkinben d82580f1af policies: grant terraform-git read on the gitea config KV metadata path (#103)
ci/woodpecker/push/apply Pipeline was successful
terraform-git's main apply still fails after the skip_child_token fix (tfgit #48): the vault_kv_secret_v2 seed resource reads the kv-v2 metadata path during plan/apply, and the grant added in #102 covered kv/data only — Vault returns 403 on GET kv/metadata/.../secret_backend/gitea/config (terraform-git pipeline 107). This is the last blocker before the KV seed lands and terraform-vault #101 can apply.

- add read on kv/metadata/service/vault/au/syd1/secret_backend/gitea/config to the terraform-git seed policy

Reviewed-on: #103
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-27 23:21:43 +10:00
unkinben 2c27395613 policies: let terraform-git seed the gitea engine admin credential to KV (#102)
ci/woodpecker/push/apply Pipeline was successful
## Why

terraform-git now provisions the `gitea-vault-admin` site-admin bot and writes its generated password to `kv/service/vault/au/syd1/secret_backend/gitea/config` (as `admin_username` + `admin_password`) so the gitea secrets engine can consume it at creation time. The `woodpecker_terraform_git` / `terraform_git` identity has no write access to that KV path, so its apply would 403 without this grant.

The deployer that *reads* the seed already has read access via `policies/kv/service/vault/secret_backends_read.yaml` (`kv/data/service/vault/+/+/secret_backend/*`), so only the write side is added here.

## Change

- Add `policies/kv/service/vault/au/syd1/secret_backend/gitea/config_write.yaml` granting `create`/`read`/`update` on the gitea config KV path to the `terraform_git` approle and `woodpecker_terraform_git` k8s role.

## Ordering

Merge + apply this before the terraform-git `benvin/gitea-vault-admin` PR applies (which performs the write). Files are disjoint from the other gitea terraform-vault PRs (#100, #101).

https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
Reviewed-on: #102
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-27 20:22:40 +10:00
unkinben d289775e38 policies: grant the vault deployer access to the gitea secrets engine (#100)
ci/woodpecker/push/apply Pipeline was successful
## Why

The forthcoming `gitea_secret_backend` + role configuration (separate PR, `benvin/gitea-secret-engine`) is applied by terraform-vault under the deployment identity (`tf_vault` approle / `woodpecker_terraform_vault` k8s role). That identity has no access to the `gitea/` mount yet, so writing the engine's config and roles would 403. This mirrors `policies/rancher/admin.yaml`.

## Change

- Add `policies/gitea/admin.yaml` granting the deployer:
  - create/read/update/delete on `gitea/config`
  - create/update on `gitea/config/rotate-root` (write-only rotation trigger)
  - full manage + list on `gitea/roles/*` (and list on `gitea/roles`)
- Deliberately excludes `gitea/creds/*` — minting tokens is for consumers, not the deployer.
- No new catalog or mount grant: plugin registration is already covered by the shared, sudo-protected wildcard in `policies/sys/plugins/catalog/admin.yaml`, and mounting uses the deployer's existing `sys/mounts/*` access — same as the rancher engine.

## Order

Merge and apply this **before** the `benvin/gitea-secret-engine` PR, so the deployer can write the engine config/roles on that apply.

https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
Reviewed-on: #100
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-27 19:10:21 +10:00
unkinben 31424ea6ff ci: fetch vault from artifactapi instead of dnf install (#99)
ci/woodpecker/push/apply Pipeline was successful
## Why

CI installs vault by shelling out to `dnf install vault -y`. That reads
metadata for every enabled repo (appstream/baseos/crb/epel/ha) and downloads
the 169MB vendored vault RPM from the `unkin` repo on **every** plan/apply run
(~39s per job measured in `almalinux9-opentofu:20260606`).

## Change

- Replace `dnf install vault -y` with a pinned `curl` of the upstream vault zip
  from the artifactapi `hashicorp-releases` remote proxy, extracted with the
  image's `python3` (`python3 -m zipfile`) to `/usr/local/bin/vault`.
- Pin the version via a new `VAULT_VERSION` env var (`1.20.0`); bump the var to
  upgrade.

## Speedup

Measured in `git.unkin.net/unkin/almalinux9-opentofu:20260606`:

| approach | time |
|---|---|
| `dnf install vault -y` (current) | ~39s |
| `dnf --disablerepo='*' --enablerepo=unkin` (still pulls 169MB RPM) | ~9s |
| curl zip from artifactapi + python extract (this PR) | ~6.6s |

~32s saved per plan/apply job. The zip is cached by artifactapi after first
fetch (warm ~3s).

## Caveats

- Assumes the `almalinux9-opentofu` image ships `curl` + `python3` (both
  present in `:20260606`).
- Relies on the existing artifactapi `hashicorp-releases` generic remote whose
  patterns already allow `vault/.*vault_.*_linux_amd64.zip`.

---------

Co-authored-by: benvin <neotheo@gmail.com>
Reviewed-on: #99
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-25 09:47:34 +10:00
unkinben 1fa5900787 Add terraform-enc Vault/Consul plumbing + encapi token grant (#98)
ci/woodpecker/push/apply Pipeline was successful
The new **terragrunt-enc** repo manages all encapi ENC data (statuses, roles, node classifications) via Terraform/Terragrunt and needs its own Vault/Consul plumbing, mirroring terraform-git and terraform-incus. This supersedes the dual-write approach in terraform-incus PR #39; the equivalent terraform-incus grant (PR #97) is being closed, so the encapi-token grant is created fresh here for the new approle.

Changes:
- Add approle role `terraform_enc` and k8s auth role `woodpecker_terraform_enc` (bound to the `terraform-enc` ServiceAccount in the `woodpecker` namespace) for CI auth.
- Add consul secret backend role `terraform-enc` plus its ACL rules granting `write` on `infra/terraform/enc/` (its terragrunt state prefix), and a policy letting both auth roles read `consul_root/au/syd1/creds/terraform-enc`.
- Grant both auth roles read on `kv/data/kubernetes/namespace/encapi/default/environment` (the ENCAPI_WRITE_TOKEN) so `make apply` can write to encapi via the encapi provider.

Reviewed-on: #98
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-24 23:18:56 +10:00
96 changed files with 2150 additions and 16 deletions
+2 -1
View File
@@ -7,8 +7,9 @@ steps:
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
environment:
VAULT_AUTH_METHOD: kubernetes
VAULT_VERSION: "1.20.0"
commands:
- dnf install vault -y
- curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt
- make plan
- make apply
backend_options:
+2 -1
View File
@@ -6,8 +6,9 @@ steps:
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
environment:
VAULT_AUTH_METHOD: kubernetes
VAULT_VERSION: "1.20.0"
commands:
- dnf install vault -y
- curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt
- make plan
backend_options:
kubernetes:
@@ -0,0 +1,9 @@
# Mounts the arrstack dynamic secrets engine at "arrstack" and writes its config.
# The arrproxy admin token is sensitive and read from KV, not stored here:
# kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token -> key "token"
# (seeded by argocd-apps #384). arrstack.unkin.net terminates on traefik-external
# with an internal-CA cert the OpenBao nodes already trust, so ca_cert is omitted
# (system trust store), mirroring the gitea engine against git.unkin.net.
description: "arrstack dynamic arrproxy API keys"
base_url: "https://arrstack.unkin.net"
request_timeout_seconds: 30
@@ -0,0 +1,9 @@
---
# Mints an arrproxy API key scoped to all three arr apps.
apps:
- sonarr
- radarr
- prowlarr
ttl: 3600 # seconds (1h)
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,7 @@
---
# Mints an arrproxy API key scoped to Prowlarr only.
apps:
- prowlarr
ttl: 3600 # seconds (1h)
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,7 @@
---
# Mints an arrproxy API key scoped to Radarr only.
apps:
- radarr
ttl: 3600 # seconds (1h)
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,7 @@
---
# Mints an arrproxy API key scoped to Sonarr only.
apps:
- sonarr
ttl: 3600 # seconds (1h)
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,6 @@
token_ttl: 3600
token_max_ttl: 14400
bind_secret_id: false
token_bound_cidrs:
- "10.10.12.200/32"
use_deterministic_role_id: true
@@ -0,0 +1,9 @@
token_ttl: 120
token_max_ttl: 120
bind_secret_id: false
token_bound_cidrs:
- "10.10.12.200/32"
- "198.18.25.102/32"
- "198.18.26.91/32"
- "198.18.27.40/32"
use_deterministic_role_id: true
@@ -0,0 +1,7 @@
bound_service_account_names:
- logarchiver
bound_service_account_namespaces:
- logging
token_ttl: 600
token_max_ttl: 600
audience: vault
@@ -0,0 +1,7 @@
bound_service_account_names:
- terraform-enc
bound_service_account_namespaces:
- woodpecker
token_ttl: 600
token_max_ttl: 600
audience: https://kubernetes.default.svc.cluster.local
@@ -0,0 +1,7 @@
bound_service_account_names:
- terraform-infra
bound_service_account_namespaces:
- woodpecker
token_ttl: 600
token_max_ttl: 600
audience: https://kubernetes.default.svc.cluster.local
+53
View File
@@ -198,6 +198,19 @@ locals {
})
if startswith(file_path, "litellm_secret_backend_role/")
}
arrstack_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "arrstack_secret_backend/")
}
arrstack_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "arrstack_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "arrstack_secret_backend_role/", ""))
})
if startswith(file_path, "arrstack_secret_backend_role/")
}
plugins = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => merge(content, {
@@ -239,5 +252,45 @@ locals {
})
if startswith(file_path, "rancher_secret_backend_role/")
}
gitea_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "gitea_secret_backend/")
}
gitea_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "gitea_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "gitea_secret_backend_role/", ""))
})
if startswith(file_path, "gitea_secret_backend_role/")
}
netbox_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "netbox_secret_backend/")
}
netbox_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "netbox_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
netbox_username = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "netbox_secret_backend_role/", ""))
})
if startswith(file_path, "netbox_secret_backend_role/")
}
ghp_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "ghp_secret_backend/")
}
ghp_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "ghp_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "ghp_secret_backend_role/", ""))
})
if startswith(file_path, "ghp_secret_backend_role/")
}
}
}
@@ -0,0 +1,5 @@
consul_roles:
- terraform-enc
ttl: 120
max_ttl: 300
datacenters: []
@@ -0,0 +1,5 @@
consul_roles:
- terraform-infra
ttl: 120
max_ttl: 300
datacenters: []
+15
View File
@@ -0,0 +1,15 @@
# Mounts the ghp token secrets engine at "ghp" and writes its config.
# The seeded ghp service token is sensitive and read from KV, not stored here:
# kv/service/vault/au/syd1/secret_backend/ghp/config
# -> key: admin_token (required) the shared ghpsvc_... service token
#
# admin_token is a static shared secret provisioned into KV by an operator. The
# SAME token value must also be present in the running ghp deployment's accepted
# service tokens (GHP_AUTH_SERVICE_TOKENS) so ghp ACCEPTS what this engine
# PRESENTS. ghp has no rotate endpoint, so the engine never rotates it in place;
# the mount uses ignore_changes=[admin_token], making the KV seed create-only
# (re-reading a stale KV value never re-pushes it to a live mount).
description: "ghp ephemeral scoped agent token engine"
base_url: "https://ghp.unkin.net"
tls_skip_verify: false
request_timeout_seconds: 30
@@ -0,0 +1,15 @@
# Role minting ephemeral, scoped ghp agent tokens. Reading ghp/creds/agent mints
# a lease-bound token deleted from ghp on revoke/expiry. token_type "agent" binds
# the minted token to a ghp App installation, so installation_id is REQUIRED.
#
# installation_id below is a PLACEHOLDER (0) and MUST be set to the real ghp App
# installation id before this role can mint usable tokens. scopes are ghp
# permission:level pairs; contents:read is the least-privilege default.
---
token_type: agent
installation_id: 0 # PLACEHOLDER - set to the real ghp App installation id
scopes:
- contents:read
session_prefix: vault
ttl: 3600 # 1h
max_ttl: 86400 # 24h
+11
View File
@@ -0,0 +1,11 @@
# Mounts the gitea token secrets engine at "gitea" and writes its config.
# The seeded site-admin credentials are sensitive and read from KV, not stored
# here:
# kv/service/vault/au/syd1/secret_backend/gitea/config
# -> keys: admin_username (required), admin_password (required)
# Populate that KV path with a purpose-built Gitea site-admin bot (2FA disabled)
# BEFORE applying, then run `vault write -f gitea/config/rotate-root` after the
# first apply so only Vault holds the admin password.
description: "Gitea ephemeral scoped access token engine"
gitea_url: "https://git.unkin.net"
request_timeout_seconds: 30
@@ -0,0 +1,17 @@
# Role minting ephemeral tokens for the teabot-implementer bot user.
# The implementer clones/pushes code and opens pull requests, so it gets write
# on repositories (clone + push + PR create) and write on issues (PR/issue
# comments). Read is implied by write. No admin/org/user-write scopes.
# read:user is required because tea (and most API clients) validate the login
# via GET /api/v1/user, which 403s without it (verified against a minted token).
# Reading gitea/creds/teabot-implementer mints a lease-bound token deleted from
# Gitea on revoke/expiry.
---
username: teabot-implementer
scopes:
- write:repository
- write:issue
- read:user
token_name_prefix: vault-teabot-implementer
ttl: 3600 # 1h
max_ttl: 14400 # 4h
@@ -0,0 +1,17 @@
# Role minting ephemeral tokens for the teabot-reviewer bot user.
# The reviewer reads code and posts pull-request reviews/comments, so it gets
# read on repositories (fetch diffs) and write on issues (PR reviews + issue/PR
# comments). No repository-write, admin, org, or user-write scopes.
# read:user is required because tea (and most API clients) validate the login
# via GET /api/v1/user, which 403s without it (verified against a minted token).
# Reading gitea/creds/teabot-reviewer mints a lease-bound token deleted from
# Gitea on revoke/expiry.
---
username: teabot-reviewer
scopes:
- read:repository
- write:issue
- read:user
token_name_prefix: vault-teabot-reviewer
ttl: 3600 # 1h
max_ttl: 14400 # 4h
@@ -0,0 +1,20 @@
# Role minting ephemeral tokens for the unkin-agent bot user -- the shared
# identity Ben's AI coding agents use to submit work. The agent clones/pushes
# code and opens pull requests, so it gets write on repositories (clone + push +
# PR create) and write on issues (PR/issue comments). Read is implied by write.
# No admin/org/user-write scopes, so it can never merge via API privilege; merge
# is blocked separately by branch protection (merge whitelist = Owners).
# read:user is required because tea (and most API clients) validate the login
# via GET /api/v1/user, which 403s without it.
# Reading gitea/creds/unkin-agent mints a lease-bound token deleted from Gitea
# on revoke/expiry. Consumed by the "agents" AppRole (see
# policies/gitea/creds/unkin-agent.yaml).
---
username: unkin-agent
scopes:
- write:repository
- write:issue
- read:user
token_name_prefix: vault-unkin-agent
ttl: 3600 # 1h
max_ttl: 14400 # 4h
+8
View File
@@ -0,0 +1,8 @@
# config/gpg_key/gpg/logarchive.yaml
# OpenPGP key in the gpg engine for the logarchiver service. The private key
# stays in Vault; logarchiver reads only the exported public key
# (gpg/keys/logarchive) to encrypt archived logs, and retrieval delegates
# decryption back to gpg/decrypt/logarchive. Key name = "logarchive", backend = "gpg".
algorithm: rsa-4096
identity: "logarchive <logarchive@unkin.net>"
exportable: false
@@ -0,0 +1,4 @@
allowed_kubernetes_namespaces:
- "cert-manager"
kubernetes_role_type: "Role"
extra_labels: {}
@@ -0,0 +1,4 @@
allowed_kubernetes_namespaces:
- "dhcp-system"
kubernetes_role_type: "Role"
extra_labels: {}
@@ -0,0 +1,4 @@
allowed_kubernetes_namespaces:
- "bind-system"
service_account_name: "agent-dns"
extra_labels: {}
@@ -0,0 +1,4 @@
allowed_kubernetes_namespaces:
- "cephrgw-system"
kubernetes_role_type: "Role"
extra_labels: {}
+48
View File
@@ -0,0 +1,48 @@
# Mounts the netbox token secrets engine at "netbox" and writes its config.
# The seeded NetBox admin token is sensitive and read from KV, not stored here:
# kv/service/vault/au/syd1/secret_backend/netbox/config
# -> key: admin_token (required) the SINGLE static admin credential
#
# admin_token must be a BARE NetBox token with NO scheme prefix: do not prepend
# "Bearer " or "Token ". NetBox infers the version from the value's nbt_ prefix,
# so one bare token authenticates under either scheme; the plugin adds the keyword
# itself. A prefixed value yields a malformed header + 403.
#
# Populate admin_token with a purpose-built NetBox superuser token (add_user +
# add_token + grant_token, or superuser) BEFORE applying, then run
# `vault write -f netbox/config/rotate` after the first apply so only Vault holds
# the live admin token.
#
# Only ONE static admin token exists. netbox_user_management does NOT re-read this
# token; instead the engine mints it a short-lived user-admin token per apply from
# netbox/roles/vault-user-mgmt (see user_mgmt_username below), so rotating
# admin_token never breaks user management. Set user_mgmt_username to the
# pre-existing NetBox superuser the static admin_token belongs to (or another
# superuser). Leaving it unset falls back to using admin_token directly, which is
# only a bootstrap/degraded path and breaks after rotation.
#
# Bootstrap ordering: the vault-user-mgmt role must exist before the netbox
# provider is configured from its creds, so on a brand-new backend apply the mount
# + role first (e.g. `tofu apply -target=...netbox_secret_backend
# -target=...netbox_user_mgmt_role`) once, then apply normally.
#
# token_version 2 is the NetBox 4.6.5 default and requires API_TOKEN_PEPPERS to
# be configured on the NetBox server; set token_version: 1 here if the server
# has no peppers. token_version does NOT change how the plugin authenticates its
# own calls (that scheme comes from the admin_token value's nbt_ prefix); it only
# sets the version of the per-user tokens the engine mints. It must still MATCH
# the admin_token kind: nbt_ v2 token -> token_version 2; bare v1 token -> 1.
#
# The mount uses ignore_changes=[token], so editing KV alone does NOT reach the
# live mount. To push a corrected/rotated admin token into a running mount:
# vault write netbox/config token=<BARE_TOKEN>
# (netbox_url/token_version are preserved on a partial update). Do NOT -replace
# the mount to force a re-read - that recreates it and drops all roles/config.
description: "NetBox ephemeral scoped API token engine"
netbox_url: "https://netbox.k8s.syd1.au.unkin.net"
token_version: 2
request_timeout_seconds: 30
# Set to the pre-existing NetBox superuser admin_token belongs to, to mint the
# user-management credential dynamically (recommended). Until set, user management
# uses admin_token directly and a check block warns that rotation will break it.
# user_mgmt_username: "vault-netbox-admin"
@@ -0,0 +1,25 @@
# Single declarative source for the terraform-infra NetBox service identity. The
# filename stem is the engine role name AND the NetBox username (1:1); config.hcl
# derives both from it, so neither is repeated below. Creating this file creates
# the user: the netbox_user_management module synthesizes the NetBox user + object
# permissions from the permissions block, and the engine role mints ephemeral
# tokens for that same user. write_enabled true because terraform-infra manages
# NetBox IPAM/DCIM; very short TTLs because a token is minted per plan/apply and
# revoked when the run's lease ends.
---
write_enabled: true
ttl: 120 # 2m
max_ttl: 300 # 5m
permissions:
- object_types:
- ipam.prefix
- ipam.ipaddress
- ipam.iprange
- dcim.device
- dcim.interface
- dcim.macaddress
actions:
- view
- add
- change
- delete
@@ -0,0 +1,13 @@
# config/plugins/vault-plugin-secrets-arrstack.yaml
# Imports (registers) the arrstack secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-arrstack RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-arrstack).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin. Registration only
# succeeds once the Puppet PR has installed the binary on the nodes.
type: secret
command: vault-plugin-secrets-arrstack
version: "0.1.0"
sha256: "f8ee60ca7ba14819976acb7dc4cfb6799e3e8da8f871d0bb2bd18d1d9e537972"
@@ -0,0 +1,11 @@
# config/plugins/vault-plugin-secrets-ghp.yaml
# Imports (registers) the ghp secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-ghp RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-ghp).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin.
type: secret
command: vault-plugin-secrets-ghp
sha256: "85761421cd532788ed28fb57e93d3868f3577320a538289936d9ed3be5f396de"
@@ -0,0 +1,11 @@
# config/plugins/vault-plugin-secrets-gitea.yaml
# Imports (registers) the gitea secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-gitea RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-gitea).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin.
type: secret
command: vault-plugin-secrets-gitea
sha256: "8f67fbc216effada5fd7399888a710b62fad83be0b31761a439e7dec3d56509b"
@@ -0,0 +1,11 @@
# config/plugins/vault-plugin-secrets-netbox.yaml
# Imports (registers) the netbox secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-netbox RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-netbox).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin.
type: secret
command: vault-plugin-secrets-netbox
sha256: "362b7f6c9e21179ad51d2d810684d9387fe50e3a1887f171700122a0b2a05cef"
+20
View File
@@ -39,6 +39,12 @@ locals {
for backend_name, _ in local.config.consul_secret_backend :
backend_name => replace(backend_name, "/", "_")
}
# Same sanitized alias mapping for the NetBox providers.
netbox_backend_aliases = {
for backend_name, _ in local.config.netbox_secret_backend :
backend_name => replace(backend_name, "/", "_")
}
}
terraform {
@@ -70,6 +76,8 @@ inputs = {
pki_mount_only = local.config.pki_mount_only
litellm_secret_backend = local.config.litellm_secret_backend
litellm_secret_backend_role = local.config.litellm_secret_backend_role
arrstack_secret_backend = local.config.arrstack_secret_backend
arrstack_secret_backend_role = local.config.arrstack_secret_backend_role
plugins = local.config.plugins
gpg_secret_backend = local.config.gpg_secret_backend
gpg_key = local.config.gpg_key
@@ -78,10 +86,22 @@ inputs = {
rancher_secret_backend_service_account = local.config.rancher_secret_backend_service_account
rancher_secret_backend_role = local.config.rancher_secret_backend_role
gitea_secret_backend = local.config.gitea_secret_backend
gitea_secret_backend_role = local.config.gitea_secret_backend_role
netbox_secret_backend = local.config.netbox_secret_backend
netbox_secret_backend_role = local.config.netbox_secret_backend_role
ghp_secret_backend = local.config.ghp_secret_backend
ghp_secret_backend_role = local.config.ghp_secret_backend_role
# Pass policy maps to vault_cluster module
policy_auth_map = local.policies.policy_auth_map
policy_rules_map = local.policies.policy_rules_map
# Pass sanitized consul backend aliases for provider configuration
consul_backend_aliases = local.consul_backend_aliases
# Pass sanitized netbox backend aliases for provider configuration
netbox_backend_aliases = local.netbox_backend_aliases
}
+10
View File
@@ -29,6 +29,12 @@ provider "rancher" {
address = local.vault_addr
}
# The arrstack (arrproxy API key) secrets engine is managed through its own
# provider (same Vault server; token falls back to VAULT_TOKEN).
provider "arrstack" {
address = local.vault_addr
}
terraform {
backend "consul" {
address = "https://consul.service.consul"
@@ -59,6 +65,10 @@ terraform {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/ranchervaultsecret"
version = "0.1.0"
}
arrstack = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/arrstackvaultsecret"
version = "0.1.0"
}
}
}
EOF
+181
View File
@@ -299,6 +299,7 @@ module "kubernetes_secret_backend_role" {
allowed_kubernetes_namespaces = each.value.allowed_kubernetes_namespaces
kubernetes_role_type = each.value.kubernetes_role_type
extra_labels = each.value.extra_labels
service_account_name = each.value.service_account_name
depends_on = [module.kubernetes_secret_backend]
}
@@ -346,6 +347,35 @@ module "plugin" {
plugin_version = each.value.version
}
module "arrstack_secret_backend" {
source = "./modules/arrstack_secret_backend"
for_each = var.arrstack_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
base_url = each.value.base_url
ca_cert = each.value.ca_cert
request_timeout_seconds = each.value.request_timeout_seconds
depends_on = [module.plugin]
}
module "arrstack_secret_backend_role" {
source = "./modules/arrstack_secret_backend_role"
for_each = var.arrstack_secret_backend_role
name = each.value.name
backend = each.value.backend
apps = each.value.apps
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.arrstack_secret_backend]
}
module "gpg_secret_backend" {
source = "./modules/gpg_secret_backend"
@@ -421,6 +451,157 @@ module "rancher_secret_backend_role" {
depends_on = [module.rancher_secret_backend_service_account]
}
module "gitea_secret_backend" {
source = "./modules/gitea_secret_backend"
for_each = var.gitea_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
gitea_url = each.value.gitea_url
country = var.country
region = var.region
ca_cert = each.value.ca_cert
tls_skip_verify = each.value.tls_skip_verify
request_timeout_seconds = each.value.request_timeout_seconds
depends_on = [module.plugin]
}
module "gitea_secret_backend_role" {
source = "./modules/gitea_secret_backend_role"
for_each = var.gitea_secret_backend_role
backend = each.value.backend
name = each.value.name
username = each.value.username
scopes = each.value.scopes
token_name_prefix = each.value.token_name_prefix
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.gitea_secret_backend]
}
module "netbox_secret_backend" {
source = "./modules/netbox_secret_backend"
for_each = var.netbox_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
netbox_url = each.value.netbox_url
token_version = each.value.token_version
country = var.country
region = var.region
ca_cert = each.value.ca_cert
tls_skip_verify = each.value.tls_skip_verify
request_timeout_seconds = each.value.request_timeout_seconds
depends_on = [module.plugin]
}
# Dedicated engine role that mints an ephemeral, user-admin-capable token for the
# pre-existing NetBox superuser named on each backend (user_mgmt_username).
# netbox_user_management reads netbox/creds/vault-user-mgmt from it, so it
# authenticates with a short-lived Vault-minted token derived from the single
# static admin token - never a second static credential, and unaffected by
# rotation of the engine's admin seed. Created before user management so the role
# exists when it reads creds.
module "netbox_user_mgmt_role" {
source = "./modules/netbox_secret_backend_role"
for_each = { for k, v in var.netbox_secret_backend : k => v if v.user_mgmt_username != null }
backend = each.key
name = "vault-user-mgmt"
netbox_username = each.value.user_mgmt_username
write_enabled = true
description = "Ephemeral user-admin token for netbox_user_management (Vault-minted per apply)"
ttl = 600
max_ttl = 1200
depends_on = [module.netbox_secret_backend]
}
# Declaratively manage the NetBox service users + object permissions the engine
# roles mint tokens for, authenticating with the Vault-minted user-admin token
# above (mirrors consul_acl_management). Consumes the SAME role config as
# netbox_secret_backend_role: one file per identity, filename-derived username,
# inline permissions.
module "netbox_user_management" {
source = "./modules/netbox_user_management"
country = var.country
region = var.region
netbox_backends = var.netbox_secret_backend
netbox_roles = var.netbox_secret_backend_role
netbox_backend_aliases = var.netbox_backend_aliases
# This module declares its own netbox provider, so it is a legacy module and
# cannot take depends_on. Ordering vs the vault-user-mgmt role is not needed on
# steady state (the role pre-exists, so reading its creds succeeds regardless);
# on first enablement the role must be created first via the one-time targeted
# bootstrap documented in config/netbox_secret_backend/netbox.yaml.
}
module "netbox_secret_backend_role" {
source = "./modules/netbox_secret_backend_role"
for_each = var.netbox_secret_backend_role
backend = each.value.backend
name = each.value.name
netbox_username = each.value.netbox_username
netbox_user_id = each.value.netbox_user_id
write_enabled = each.value.write_enabled
description = each.value.description
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.netbox_secret_backend, module.netbox_user_management]
}
module "ghp_secret_backend" {
source = "./modules/ghp_secret_backend"
for_each = var.ghp_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
base_url = each.value.base_url
country = var.country
region = var.region
ca_cert = each.value.ca_cert
tls_skip_verify = each.value.tls_skip_verify
request_timeout_seconds = each.value.request_timeout_seconds
depends_on = [module.plugin]
}
module "ghp_secret_backend_role" {
source = "./modules/ghp_secret_backend_role"
for_each = var.ghp_secret_backend_role
backend = each.value.backend
name = each.value.name
token_type = each.value.token_type
installation_id = each.value.installation_id
app_record_id = each.value.app_record_id
repositories = each.value.repositories
scopes = each.value.scopes
session_prefix = each.value.session_prefix
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.ghp_secret_backend]
}
module "vault_policy" {
source = "./modules/vault_policy"
@@ -0,0 +1,20 @@
# Mounts the arrstack dynamic secrets engine and writes its config via the
# arrstackvaultsecret provider. The plugin is registered in the catalog
# separately (config/plugins/vault-plugin-secrets-arrstack.yaml). The arrproxy
# admin token is sensitive and read from KV, not stored in git:
# kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token -> key "token"
# (seeded by argocd-apps #384).
data "vault_kv_secret_v2" "admin_token" {
mount = "kv"
name = var.admin_token_kv_name
}
resource "arrstack_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
base_url = var.base_url
admin_token = data.vault_kv_secret_v2.admin_token.data[var.admin_token_kv_key]
ca_cert = var.ca_cert
request_timeout_seconds = var.request_timeout_seconds
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
arrstack = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/arrstackvaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,45 @@
variable "path" {
description = "Mount path of the arrstack secrets engine (e.g. \"arrstack\")"
type = string
}
variable "plugin" {
description = "Registered plugin name/type to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-arrstack"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "base_url" {
description = "Base URL of the arrproxy front door (e.g. https://arrstack.unkin.net)"
type = string
}
variable "admin_token_kv_name" {
description = "kv-v2 secret name (relative to the \"kv\" mount) holding the seeded arrproxy admin token"
type = string
default = "kubernetes/namespace/arrstack/default/arrproxy-admin-token"
}
variable "admin_token_kv_key" {
description = "Key within the KV secret that holds the arrproxy admin token"
type = string
default = "token"
}
variable "ca_cert" {
description = "PEM CA certificate that signed the arrproxy server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to arrproxy"
type = number
default = 30
}
@@ -0,0 +1,7 @@
resource "arrstack_secret_backend_role" "this" {
backend = var.backend
name = var.name
apps = var.apps
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
arrstack = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/arrstackvaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,26 @@
variable "name" {
description = "Name of the role"
type = string
}
variable "backend" {
description = "Mount path of the arrstack secrets engine this role belongs to"
type = string
}
variable "apps" {
description = "arr apps a generated key may access (subset of sonarr, radarr, prowlarr)"
type = list(string)
}
variable "ttl" {
description = "Default lease TTL in seconds for keys generated from this role"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for keys generated from this role"
type = number
default = null
}
@@ -0,0 +1,31 @@
# Mounts the ghp secrets engine and writes its connection config via the
# vault-secrets-ghp provider. The plugin is registered ("imported") in the
# catalog separately (config/plugins/vault-plugin-secrets-ghp.yaml). The seeded
# ghp service token is sensitive and read from KV, not stored in git:
# kv/service/vault/<country>/<region>/secret_backend/<path>/config
# Expected key: admin_token (a ghpsvc_... service token that ghp accepts via its
# GHP_AUTH_SERVICE_TOKENS list). ghp has no rotate endpoint, so this static
# shared secret is the single credential the engine authenticates with.
data "vault_kv_secret_v2" "config" {
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config"
}
resource "ghp_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
base_url = var.base_url
admin_token = data.vault_kv_secret_v2.config.data["admin_token"]
ca_cert = var.ca_cert
tls_skip_verify = var.tls_skip_verify
request_timeout_seconds = var.request_timeout_seconds
lifecycle {
# The KV seed is a bootstrap credential consumed only when the engine config
# is first created. ghp has no rotate endpoint, so re-reading a (possibly
# stale) KV value must never re-push it into the live mount. Ignoring the
# token makes this module create-only for it (mirrors gitea/netbox config).
ignore_changes = [admin_token]
}
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
ghp = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-ghp"
version = "0.1.0"
}
}
}
@@ -0,0 +1,49 @@
variable "path" {
description = "Mount path of the ghp secrets engine (e.g. \"ghp\")"
type = string
}
variable "plugin" {
description = "Registered plugin name to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-ghp"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "base_url" {
description = "Base URL of the ghp server (e.g. https://ghp.unkin.net)"
type = string
}
variable "country" {
description = "Country segment of the KV path holding the seeded admin token"
type = string
}
variable "region" {
description = "Region segment of the KV path holding the seeded admin token"
type = string
}
variable "ca_cert" {
description = "PEM CA certificate that signed the ghp server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "tls_skip_verify" {
description = "Skip TLS verification of the ghp server (not recommended)"
type = bool
default = false
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to ghp"
type = number
default = 30
}
@@ -0,0 +1,15 @@
# A role that mints short-lived, scoped ghp tokens. Reading ghp/creds/<name>
# produces a lease-bound token that is revoked from ghp when the lease is
# revoked or reaches max_ttl.
resource "ghp_secret_role" "this" {
backend = var.backend
name = var.name
token_type = var.token_type
installation_id = var.installation_id
app_record_id = var.app_record_id
repositories = var.repositories
scopes = var.scopes
session_prefix = var.session_prefix
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
ghp = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-ghp"
version = "0.1.0"
}
}
}
@@ -0,0 +1,57 @@
variable "backend" {
description = "Mount path of the ghp secrets engine this role belongs to"
type = string
}
variable "name" {
description = "Role name (read ghp/creds/<name> to mint a token)"
type = string
}
variable "token_type" {
description = "ghp token type to mint: \"agent\" (default) or \"proxy\""
type = string
default = null
}
variable "installation_id" {
description = "ghp App installation id the minted agent token is bound to (required when token_type is \"agent\")"
type = number
default = null
}
variable "app_record_id" {
description = "Optional ghp App record id (UUID) to pin agent tokens to; empty selects ghp's default app"
type = string
default = null
}
variable "repositories" {
description = "Optional repositories the minted token is restricted to; empty is open-scoped (all repositories)"
type = list(string)
default = null
}
variable "scopes" {
description = "Optional ghp permission:level scopes (e.g. [\"contents:read\"]); empty is open-scoped"
type = list(string)
default = null
}
variable "session_prefix" {
description = "Prefix for the ghp session id of each minted token (default \"vault\")"
type = string
default = null
}
variable "ttl" {
description = "Default lease TTL in seconds for minted tokens"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for minted tokens"
type = number
default = null
}
@@ -0,0 +1,34 @@
# Mounts the gitea secrets engine and writes its connection config via the
# giteavaultsecret provider. The plugin is registered ("imported") in the
# catalog separately (config/plugins/vault-plugin-secrets-gitea.yaml). The
# seeded site-admin credentials are sensitive and read from KV, not stored in
# git:
# kv/service/vault/<country>/<region>/secret_backend/<path>/config
# Expected keys: admin_username (required), admin_password (required).
data "vault_kv_secret_v2" "config" {
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config"
}
resource "gitea_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
gitea_url = var.gitea_url
admin_username = data.vault_kv_secret_v2.config.data["admin_username"]
admin_password = data.vault_kv_secret_v2.config.data["admin_password"]
ca_cert = var.ca_cert
tls_skip_verify = var.tls_skip_verify
request_timeout_seconds = var.request_timeout_seconds
lifecycle {
# The KV seed is a bootstrap credential: it is consumed only when the engine
# config is first created. After creation the live admin password is rotated
# in place (vault write -f gitea/config/rotate-root) and diverges from the
# seed, so re-reading the (possibly stale) KV value must never push it back.
# Ignoring the credential attributes makes this module create-only for them.
# (The sibling rancher/litellm seed modules do not yet do this and would
# re-push their seed on a subsequent apply.)
ignore_changes = [admin_username, admin_password]
}
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
gitea = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,49 @@
variable "path" {
description = "Mount path of the gitea secrets engine (e.g. \"gitea\")"
type = string
}
variable "plugin" {
description = "Registered plugin name to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-gitea"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "gitea_url" {
description = "Base URL of the Gitea server (e.g. https://git.unkin.net)"
type = string
}
variable "country" {
description = "Country segment of the KV path holding the seeded admin credentials"
type = string
}
variable "region" {
description = "Region segment of the KV path holding the seeded admin credentials"
type = string
}
variable "ca_cert" {
description = "PEM CA certificate that signed the Gitea server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "tls_skip_verify" {
description = "Skip TLS verification of the Gitea server (not recommended)"
type = bool
default = false
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to Gitea"
type = number
default = 30
}
@@ -0,0 +1,12 @@
# A role that mints short-lived, scoped gitea tokens for a target Gitea user.
# Reading gitea/creds/<name> produces a lease-bound token that is deleted from
# Gitea when the lease is revoked or reaches max_ttl.
resource "gitea_secret_backend_role" "this" {
backend = var.backend
name = var.name
username = var.username
scopes = var.scopes
token_name_prefix = var.token_name_prefix
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
gitea = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,37 @@
variable "backend" {
description = "Mount path of the gitea secrets engine this role belongs to"
type = string
}
variable "name" {
description = "Role name (read gitea/creds/<name> to mint a token)"
type = string
}
variable "username" {
description = "Target Gitea username the minted tokens belong to"
type = string
}
variable "scopes" {
description = "Gitea access-token scopes granted to minted tokens (write: implies read:)"
type = list(string)
}
variable "token_name_prefix" {
description = "Prefix for the generated Gitea token name (optional)"
type = string
default = null
}
variable "ttl" {
description = "Default lease TTL in seconds for minted tokens"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for minted tokens"
type = number
default = null
}
@@ -2,6 +2,10 @@ locals {
# Auto-generate role rules path: resources/secret_backend/{backend_path}/roles/{role_name}.yaml
role_rules_file = "resources/secret_backend/${var.backend}/roles/${var.name}.yaml"
# service_account_name mode mints tokens for a pre-existing SA, so the
# generated_role_rules / kubernetes_role_type binding fields must be unset.
use_service_account = var.service_account_name != null
# Auto-generate extra labels based on country/region and role name
auto_labels = merge(var.extra_labels, {
vault-region = "${var.country}-${var.region}"
@@ -13,7 +17,8 @@ resource "vault_kubernetes_secret_backend_role" "role" {
backend = var.backend
name = var.name
allowed_kubernetes_namespaces = var.allowed_kubernetes_namespaces
kubernetes_role_type = var.kubernetes_role_type
generated_role_rules = file("${path.module}/../../../../../../../../${local.role_rules_file}")
kubernetes_role_type = local.use_service_account ? null : var.kubernetes_role_type
generated_role_rules = local.use_service_account ? null : file("${path.module}/../../../../../../../../${local.role_rules_file}")
service_account_name = var.service_account_name
extra_labels = local.auto_labels
}
@@ -34,4 +34,10 @@ variable "extra_labels" {
description = "Additional labels to apply to generated Kubernetes objects"
type = map(string)
default = {}
}
variable "service_account_name" {
description = "Pre-existing service account to mint tokens for. When set, RBAC comes from that SA's own bindings instead of generated_role_rules."
type = string
default = null
}
@@ -0,0 +1,53 @@
# Mounts the netbox secrets engine and writes its connection config via the
# vault-secrets-netbox provider. The plugin is registered ("imported") in the
# catalog separately (config/plugins/vault-plugin-secrets-netbox.yaml). The
# seeded NetBox admin token is sensitive and read from KV, not stored in git:
# kv/service/vault/<country>/<region>/secret_backend/<path>/config
# Expected key: admin_token (a NetBox token with add_token + grant_token, i.e.
# able to provision and delegate per-user API tokens).
data "vault_kv_secret_v2" "config" {
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config"
lifecycle {
# The plugin builds its own Authorization header from the token VALUE, not
# token_version: a value starting with the nbt_ prefix is sent as
# "Bearer <token>" (v2), otherwise "Token <token>" (v1). So admin_token must
# be the BARE token - a literal `Bearer `/`Token ` scheme prefix yields a
# malformed three-part header and 403s on the plugin's own NetBox calls.
#
# token_version does NOT change that header; it only selects the version of
# the per-user tokens the engine mints for roles. It must still MATCH the
# admin token's kind so the mount and its minted creds line up: an nbt_ v2
# admin token pairs with token_version=2, a bare v1 token with token_version=1.
postcondition {
condition = nonsensitive(
!startswith(self.data["admin_token"], "Bearer ") &&
!startswith(self.data["admin_token"], "Token ") &&
startswith(self.data["admin_token"], "nbt_") == (var.token_version == 2)
)
error_message = "KV admin_token for netbox backend '${var.path}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix, AND its version must match token_version: a v2 token (nbt_<key>.<secret>) requires token_version=2; a v1 token (bare 40-char value) requires token_version=1."
}
}
}
resource "netbox_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
netbox_url = var.netbox_url
token = data.vault_kv_secret_v2.config.data["admin_token"]
token_version = var.token_version
ca_cert = var.ca_cert
tls_skip_verify = var.tls_skip_verify
request_timeout_seconds = var.request_timeout_seconds
lifecycle {
# The KV seed is a bootstrap credential: it is consumed only when the engine
# config is first created. After creation the live admin token is rotated in
# place (vault write -f netbox/config/rotate) and diverges from the seed, so
# re-reading the (possibly stale) KV value must never push it back. Ignoring
# the token makes this module create-only for it (mirrors gitea/config).
ignore_changes = [token]
}
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
netbox = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-netbox"
version = "0.1.0"
}
}
}
@@ -0,0 +1,55 @@
variable "path" {
description = "Mount path of the netbox secrets engine (e.g. \"netbox\")"
type = string
}
variable "plugin" {
description = "Registered plugin name to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-netbox"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "netbox_url" {
description = "Base URL of the NetBox server (e.g. https://netbox.k8s.syd1.au.unkin.net)"
type = string
}
variable "token_version" {
description = "NetBox API token format: 2 (default, requires API_TOKEN_PEPPERS on the NetBox server) or 1 (legacy plaintext-key)."
type = number
default = 2
}
variable "country" {
description = "Country segment of the KV path holding the seeded admin token"
type = string
}
variable "region" {
description = "Region segment of the KV path holding the seeded admin token"
type = string
}
variable "ca_cert" {
description = "PEM CA certificate that signed the NetBox server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "tls_skip_verify" {
description = "Skip TLS verification of the NetBox server (not recommended)"
type = bool
default = false
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to NetBox"
type = number
default = 30
}
@@ -0,0 +1,13 @@
# A role that mints short-lived, scoped NetBox tokens for a pre-existing NetBox
# service user. Reading netbox/creds/<name> produces a lease-bound token that is
# deleted from NetBox when the lease is revoked or reaches max_ttl.
resource "netbox_secret_backend_role" "this" {
backend = var.backend
name = var.name
netbox_username = var.netbox_username
netbox_user_id = var.netbox_user_id
write_enabled = var.write_enabled
description = var.description
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
netbox = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-netbox"
version = "0.1.0"
}
}
}
@@ -0,0 +1,45 @@
variable "backend" {
description = "Mount path of the netbox secrets engine this role belongs to"
type = string
}
variable "name" {
description = "Role name (read netbox/creds/<name> to mint a token)"
type = string
}
variable "netbox_username" {
description = "NetBox service username the minted tokens belong to (set this or netbox_user_id)"
type = string
default = null
}
variable "netbox_user_id" {
description = "NetBox service user id the minted tokens belong to (set this or netbox_username)"
type = number
default = null
}
variable "write_enabled" {
description = "Whether minted tokens carry NetBox write access (default read-only)"
type = bool
default = false
}
variable "description" {
description = "Human-friendly description of the role"
type = string
default = null
}
variable "ttl" {
description = "Default lease TTL in seconds for minted tokens (the token's NetBox expiry is aligned to the lease)"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for minted tokens"
type = number
default = null
}
@@ -0,0 +1,7 @@
rule "terraform_required_providers" {
enabled = false
}
rule "terraform_required_version" {
enabled = false
}
@@ -0,0 +1,149 @@
# netbox_user_management reconciles NetBox service users + permissions on every
# apply, so it needs an admin credential each run. That credential is minted
# DYNAMICALLY by the netbox engine from the SINGLE static admin token, so no
# second static credential exists and it survives rotation of the engine seed:
#
# 1. module.netbox_user_mgmt_role creates netbox/roles/vault-user-mgmt, a
# write-enabled role for a pre-existing NetBox superuser (user_mgmt_username).
# 2. Reading netbox/creds/vault-user-mgmt mints a short-lived, user-admin-capable
# token for that superuser; the e-breuninger provider uses it to CRUD users.
#
# The hashicorp/vault provider ships ephemeral resources for KV only, not for
# dynamic engine creds, so the mint is read via the vault_generic_secret DATA
# source: the short-lived token transits Terraform state (sensitive, lease-revoked)
# and is re-minted each plan. This is the closest single-static-token shape the
# current providers allow; move to an ephemeral resource once the vault provider
# ships a dynamic-secret one. Ordering note: the vault-user-mgmt role must already
# exist when this reads creds, so on a brand-new backend bootstrap the mount +
# role first (targeted apply) - a fresh single apply cannot configure the netbox
# provider from a role created in the same run.
locals {
# Backends that mint a dynamic user-admin token (a pre-existing superuser named).
netbox_dynamic_backends = { for k, v in var.netbox_backends : k => v if v.user_mgmt_username != null }
# Backends still using the single static admin_token directly (until a superuser
# is named). Bootstrap/degraded path - the same one token, not a second static.
netbox_static_backends = { for k, v in var.netbox_backends : k => v if v.user_mgmt_username == null }
}
# Dynamic path: the engine mints a user-admin token for the superuser. Requires
# the deployer to read netbox/creds/vault-user-mgmt (policies/netbox/creds).
data "vault_generic_secret" "user_admin" {
for_each = local.netbox_dynamic_backends
path = "${each.key}/creds/vault-user-mgmt"
}
# Static fallback: the single admin_token from KV, used only until a superuser is
# named. NetBox derives the token version from the value's `nbt_` prefix, not the
# keyword, so the same BARE token works under either scheme; reject a value that
# carries a literal `Bearer `/`Token ` scheme prefix (a malformed header -> 403).
data "vault_kv_secret_v2" "netbox_backend_configs" {
for_each = local.netbox_static_backends
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${each.key}/config"
lifecycle {
postcondition {
condition = nonsensitive(
!startswith(self.data["admin_token"], "Bearer ") &&
!startswith(self.data["admin_token"], "Token ")
)
error_message = "KV admin_token for netbox backend '${each.key}' must be a BARE NetBox token with no 'Bearer '/'Token ' scheme prefix (v2: nbt_<key>.<secret>; v1: the 40-char value)."
}
}
}
# Warn (non-fatal) for any backend still on the static token: rotating the engine
# admin seed would then break user management. Set user_mgmt_username to switch to
# the dynamic, rotation-proof mint.
check "netbox_user_mgmt_dynamic" {
assert {
condition = length(local.netbox_static_backends) == 0
error_message = "A netbox backend has no user_mgmt_username, so user management uses the static admin_token directly and will break if that token is rotated (netbox/config/rotate). Set user_mgmt_username to a pre-existing NetBox superuser to mint the credential dynamically."
}
}
locals {
# Per backend: the dynamically-minted user-admin token, else the static seed.
netbox_admin_tokens = {
for k, v in var.netbox_backends : k => (
v.user_mgmt_username != null
? data.vault_generic_secret.user_admin[k].data["token"]
: data.vault_kv_secret_v2.netbox_backend_configs[k].data["admin_token"]
)
}
}
# One NetBox provider instance per backend, authenticated with its (dynamic or
# static) admin token.
provider "netbox" {
alias = "by_backend"
for_each = var.netbox_backend_aliases
server_url = var.netbox_backends[each.key].netbox_url
api_token = local.netbox_admin_tokens[each.key]
allow_insecure_https = var.netbox_backends[each.key].tls_skip_verify
# NetBox is internal and not always reachable at plan time; the resource CRUD
# calls surface any real incompatibility, so skip the startup version probe.
skip_version_check = true
}
# NetBox users authenticate only via Vault-minted API tokens, never the web UI,
# so give each a random unknown password (required by the API) that no one holds.
resource "random_password" "user" {
for_each = var.netbox_roles
length = 32
special = true
}
# Declarative NetBox service users, one per engine role. The role's filename-
# derived name is the username, so the engine role and its user match 1:1.
resource "netbox_user" "users" {
for_each = var.netbox_roles
provider = netbox.by_backend[each.value.backend]
username = each.value.name
password = random_password.user[each.key].result
active = each.value.active
staff = each.value.staff
email = each.value.email
}
locals {
# Flatten roles x permissions into one map keyed by "<role_path>:<index>". A
# permission's name defaults to the role name (the username) so a single-
# permission identity repeats nothing already encoded by the filename.
netbox_permissions = merge([
for role_key, role in var.netbox_roles : {
for idx, perm in role.permissions :
"${role_key}:${idx}" => {
backend = role.backend
user = role_key
name = coalesce(perm.name, length(role.permissions) == 1 ? role.name : "${role.name}-${idx}")
object_types = perm.object_types
actions = perm.actions
constraints = perm.constraints
description = perm.description
enabled = perm.enabled
}
}
]...)
}
# Object permissions granting each user its object-type/action scope.
resource "netbox_permission" "perms" {
for_each = local.netbox_permissions
provider = netbox.by_backend[each.value.backend]
name = each.value.name
object_types = each.value.object_types
actions = each.value.actions
enabled = each.value.enabled
description = each.value.description
constraints = each.value.constraints
users = [tonumber(netbox_user.users[each.value.user].id)]
}
@@ -0,0 +1,19 @@
output "netbox_users" {
description = "Map of created NetBox users (id + username; password is intentionally omitted)"
value = {
for k, u in netbox_user.users : k => {
id = u.id
username = u.username
}
}
}
output "netbox_permissions" {
description = "Map of created NetBox object permissions"
value = {
for k, p in netbox_permission.perms : k => {
id = p.id
name = p.name
}
}
}
@@ -0,0 +1,17 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
netbox = {
source = "e-breuninger/netbox"
version = "4.3.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.5"
}
}
}
@@ -0,0 +1,46 @@
variable "netbox_backends" {
description = "Map of netbox secret backends (keyed by mount path); only the URL and TLS mode are needed to reach NetBox"
type = map(object({
netbox_url = string
tls_skip_verify = optional(bool, false)
# Pre-existing NetBox superuser the engine mints a dynamic user-admin token
# for; unset means fall back to the static admin_token from KV.
user_mgmt_username = optional(string)
}))
}
variable "netbox_roles" {
description = "Map of netbox engine roles (the netbox_secret_backend_role config). Each role's filename-derived name is the NetBox username to create, and its permissions block is the user's object-permission set. Keyed by the role's config path."
type = map(object({
name = string
backend = string
active = optional(bool, true)
staff = optional(bool, false)
email = optional(string)
permissions = optional(list(object({
name = optional(string)
object_types = list(string)
actions = optional(list(string), ["view", "add", "change", "delete"])
constraints = optional(string)
description = optional(string)
enabled = optional(bool, true)
})), [])
}))
default = {}
}
variable "netbox_backend_aliases" {
description = "Map of netbox backend names to sanitized provider aliases"
type = map(string)
default = {}
}
variable "country" {
description = "Country identifier"
type = string
}
variable "region" {
description = "Region identifier"
type = string
}
+134
View File
@@ -285,6 +285,7 @@ variable "kubernetes_secret_backend_role" {
allowed_kubernetes_namespaces = optional(list(string), ["*"])
kubernetes_role_type = optional(string, "Role")
extra_labels = optional(map(string), {})
service_account_name = optional(string)
}))
default = {}
}
@@ -315,6 +316,30 @@ variable "litellm_secret_backend_role" {
default = {}
}
variable "arrstack_secret_backend" {
description = "Map of arrstack secret engines to create (mount + config). The arrproxy admin token is read from KV"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-arrstack")
description = optional(string)
base_url = string
ca_cert = optional(string)
request_timeout_seconds = optional(number, 30)
}))
default = {}
}
variable "arrstack_secret_backend_role" {
description = "Map of arrstack roles to create"
type = map(object({
name = string
backend = string
apps = list(string)
ttl = optional(number)
max_ttl = optional(number)
}))
default = {}
}
variable "plugins" {
description = "Map of plugins to import (register) in the catalog, keyed by catalog name"
type = map(object({
@@ -388,6 +413,115 @@ variable "rancher_secret_backend_role" {
default = {}
}
variable "gitea_secret_backend" {
description = "Map of gitea token secret engines to create (mount + config; seeded admin creds read from KV)"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-gitea")
description = optional(string)
gitea_url = string
ca_cert = optional(string)
tls_skip_verify = optional(bool, false)
request_timeout_seconds = optional(number, 30)
}))
default = {}
}
variable "gitea_secret_backend_role" {
description = "Map of gitea token-minting roles to create"
type = map(object({
name = string
backend = string
username = string
scopes = list(string)
token_name_prefix = optional(string)
ttl = optional(number)
max_ttl = optional(number)
}))
default = {}
}
variable "netbox_secret_backend" {
description = "Map of netbox token secret engines to create (mount + config; seeded admin token read from KV)"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-netbox")
description = optional(string)
netbox_url = string
token_version = optional(number, 2)
ca_cert = optional(string)
tls_skip_verify = optional(bool, false)
request_timeout_seconds = optional(number, 30)
# Pre-existing NetBox superuser (or add_user + add_token + grant_token) the
# engine mints an ephemeral user-admin token for, so netbox_user_management
# authenticates with a Vault-minted credential derived from the single static
# admin token instead of a second static one. Unset = use the static
# admin_token directly (bootstrap/degraded; breaks after admin-token rotation).
user_mgmt_username = optional(string)
}))
default = {}
}
variable "netbox_secret_backend_role" {
description = "Map of netbox engine roles; each role's filename-derived name is both the engine role and the NetBox username it mints tokens for, and its permissions block is the user's object-permission set"
type = map(object({
name = string
backend = string
netbox_username = optional(string)
netbox_user_id = optional(number)
write_enabled = optional(bool, false)
description = optional(string)
ttl = optional(number)
max_ttl = optional(number)
active = optional(bool, true)
staff = optional(bool, false)
email = optional(string)
permissions = optional(list(object({
name = optional(string)
object_types = list(string)
actions = optional(list(string), ["view", "add", "change", "delete"])
constraints = optional(string)
description = optional(string)
enabled = optional(bool, true)
})), [])
}))
default = {}
}
variable "netbox_backend_aliases" {
description = "Map of netbox backend names to sanitized provider aliases"
type = map(string)
default = {}
}
variable "ghp_secret_backend" {
description = "Map of ghp token secret engines to create (mount + config; seeded service token read from KV)"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-ghp")
description = optional(string)
base_url = string
ca_cert = optional(string)
tls_skip_verify = optional(bool, false)
request_timeout_seconds = optional(number, 30)
}))
default = {}
}
variable "ghp_secret_backend_role" {
description = "Map of ghp engine roles; reading ghp/creds/<name> mints a short-lived scoped ghp token"
type = map(object({
name = string
backend = string
token_type = optional(string)
installation_id = optional(number)
app_record_id = optional(string)
repositories = optional(list(string))
scopes = optional(list(string))
session_prefix = optional(string)
ttl = optional(number)
max_ttl = optional(number)
}))
default = {}
}
variable "policy_auth_map" {
description = "Map of auth mounts -> auth roles -> policy names"
type = map(map(list(string)))
+27
View File
@@ -0,0 +1,27 @@
# Allow management of the arrstack secrets engine (config and roles) by the
# terraform-vault deployer.
---
rules:
- path: "arrstack/config"
capabilities:
- create
- update
- read
- delete
- path: "arrstack/roles/*"
capabilities:
- create
- update
- delete
- read
- list
- path: "arrstack/roles"
capabilities:
- read
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
+12
View File
@@ -0,0 +1,12 @@
# Allow the terraform-prowlarr run to mint a Prowlarr-scoped arrproxy key.
---
rules:
- path: "arrstack/creds/prowlarr"
capabilities:
- read
auth:
approle:
- terraform_prowlarr
k8s/au/syd1:
- woodpecker_terraform_prowlarr
+12
View File
@@ -0,0 +1,12 @@
# Allow the terraform-radarr run to mint a Radarr-scoped arrproxy key.
---
rules:
- path: "arrstack/creds/radarr"
capabilities:
- read
auth:
approle:
- terraform_radarr
k8s/au/syd1:
- woodpecker_terraform_radarr
+12
View File
@@ -0,0 +1,12 @@
# Allow the terraform-sonarr run to mint a Sonarr-scoped arrproxy key.
---
rules:
- path: "arrstack/creds/sonarr"
capabilities:
- read
auth:
approle:
- terraform_sonarr
k8s/au/syd1:
- woodpecker_terraform_sonarr
@@ -0,0 +1,14 @@
# Allow the terragrunt-enc runner to generate credentials for the
# terraform-enc role in consul (used to lock/write its terragrunt state under
# infra/terraform/enc/ on the consul backend).
---
rules:
- path: "consul_root/au/syd1/creds/terraform-enc"
capabilities:
- read
auth:
approle:
- terraform_enc
k8s/au/syd1:
- woodpecker_terraform_enc
@@ -0,0 +1,11 @@
---
rules:
- path: "consul_root/au/syd1/creds/terraform-infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
+37
View File
@@ -0,0 +1,37 @@
# Allow the vault deployer to manage the ghp token secrets engine: its
# connection config (seeded service token) and its token-minting roles.
#
# Scoped to ghp/* only, and deliberately excludes ghp/creds/* - minting tokens
# is for consumers, not the deployer. ghp has NO rotate endpoint, so unlike the
# gitea/netbox engines there is no config/rotate grant here. The plugin-catalog
# grant needed to import the plugin is the shared, sudo-protected wildcard in
# policies/sys/plugins/catalog/admin.yaml (already covers this plugin), and
# mounting the engine uses the deployer's existing sys/mounts/* access, so no
# new catalog/mount grant is added here (mirrors the gitea/netbox engines).
---
rules:
# Engine connection config (base_url, TLS, seeded service token).
- path: "ghp/config"
capabilities:
- create
- read
- update
- delete
# Token-minting roles.
- path: "ghp/roles/*"
capabilities:
- create
- read
- update
- delete
- list
- path: "ghp/roles"
capabilities:
- read
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
+13
View File
@@ -0,0 +1,13 @@
# Lets the agents AppRole mint ephemeral ghp agent tokens, so AI coding agents
# authenticate to ghp as their own short-lived, least-privilege identity.
# Reading ghp/creds/agent returns a lease-bound token scoped by the role
# (token_type agent, contents:read). Mirrors the gitea/creds/unkin-agent binding.
---
rules:
- path: "ghp/creds/agent"
capabilities:
- read
auth:
approle:
- agents
+42
View File
@@ -0,0 +1,42 @@
# Allow the vault deployer to manage the gitea token secrets engine: its
# connection config (seeded admin credentials), in-place root rotation, and
# token-minting roles.
#
# Scoped to gitea/* only, and deliberately excludes gitea/creds/* — minting
# tokens is for consumers, not the deployer. The plugin-catalog grant needed to
# import the plugin is the shared, sudo-protected wildcard in
# policies/sys/plugins/catalog/admin.yaml (already covers this plugin), and
# mounting the engine uses the deployer's existing sys/mounts/* access, so no
# new catalog/mount grant is added here (mirrors the rancher engine).
---
rules:
# Engine connection config (Gitea URL, TLS, seeded admin username/password).
- path: "gitea/config"
capabilities:
- create
- read
- update
- delete
# In-place rotation of the seeded admin password (write-only trigger).
- path: "gitea/config/rotate-root"
capabilities:
- create
- update
# Token-minting roles.
- path: "gitea/roles/*"
capabilities:
- create
- read
- update
- delete
- list
- path: "gitea/roles"
capabilities:
- read
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
+14
View File
@@ -0,0 +1,14 @@
# Lets the agents AppRole mint ephemeral Gitea tokens for the unkin-agent bot,
# so AI coding agents authenticate to git.unkin.net as their own least-privilege
# identity instead of Ben's account. Reading gitea/creds/unkin-agent returns a
# lease-bound token scoped by the role (write:repository, write:issue, read:user
# -- never merge/admin). Mirrors the agent-* Kubernetes creds binding pattern.
---
rules:
- path: "gitea/creds/unkin-agent"
capabilities:
- read
auth:
approle:
- agents
+12
View File
@@ -0,0 +1,12 @@
# Allow the logarchiver service (logging namespace, SA logarchiver) to read the
# logarchive public key. A plain read on gpg/keys/logarchive returns the armored
# public_key; no decrypt/export capability is granted (decrypt stays operator-only).
---
rules:
- path: "gpg/keys/logarchive"
capabilities:
- read
auth:
k8s/au/syd1:
- logging_logarchiver
@@ -0,0 +1,12 @@
# Allow access to agent-certs Kubernetes credentials
---
rules:
- path: "kubernetes/au/syd1/creds/agent-certs"
capabilities:
- update
auth:
ldap:
- kubernetes_au_syd1_cluster_operator
approle:
- agents
@@ -0,0 +1,12 @@
# Allow access to agent-dhcp Kubernetes credentials
---
rules:
- path: "kubernetes/au/syd1/creds/agent-dhcp"
capabilities:
- update
auth:
ldap:
- kubernetes_au_syd1_cluster_operator
approle:
- agents
@@ -0,0 +1,12 @@
# Allow access to agent-dns Kubernetes credentials
---
rules:
- path: "kubernetes/au/syd1/creds/agent-dns"
capabilities:
- update
auth:
ldap:
- kubernetes_au_syd1_cluster_operator
approle:
- agents
@@ -0,0 +1,12 @@
# Allow access to agent-storage Kubernetes credentials
---
rules:
- path: "kubernetes/au/syd1/creds/agent-storage"
capabilities:
- update
auth:
ldap:
- kubernetes_au_syd1_cluster_operator
approle:
- agents
+17
View File
@@ -0,0 +1,17 @@
# Allow the agents AppRole to manage the kubernetes KV subtree (no delete)
---
rules:
- path: "kv/data/kubernetes/*"
capabilities:
- create
- read
- update
- list
- path: "kv/metadata/kubernetes/*"
capabilities:
- read
- list
auth:
approle:
- agents
@@ -0,0 +1,22 @@
# Allow the terraform-vault deployer to read the seeded arrproxy admin token so
# the arrstack engine config module can source it. The token is seeded by
# argocd-apps #384 at kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token.
# The deployer's existing secret_backends_read policy only covers
# kv/data/service/vault/+/+/secret_backend/*, which does not match this
# kubernetes/namespace path, so this adds the minimal read grant rather than
# duplicating the secret into the litellm-style path. vault_kv_secret_v2 also
# reads the kv-v2 metadata path on every plan/apply, so grant that too.
---
rules:
- path: "kv/data/kubernetes/namespace/arrstack/default/arrproxy-admin-token"
capabilities:
- read
- path: "kv/metadata/kubernetes/namespace/arrstack/default/arrproxy-admin-token"
capabilities:
- read
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -0,0 +1,14 @@
# Allow the terragrunt-enc runner to read the encapi environment secret
# (ENCAPI_WRITE_TOKEN), so `make apply` can write ENC data (statuses, roles,
# nodes) to encapi via the encapi Terraform provider.
---
rules:
- path: "kv/data/kubernetes/namespace/encapi/default/environment"
capabilities:
- read
auth:
approle:
- terraform_enc
k8s/au/syd1:
- woodpecker_terraform_enc
@@ -1,12 +0,0 @@
# Allow the Terraform Incus runner to read the encapi environment secret
# (ENCAPI_WRITE_TOKEN), so `make apply` can dual-write ENC data to encapi via
# the encapi Terraform provider.
---
rules:
- path: "kv/data/kubernetes/namespace/encapi/default/environment"
capabilities:
- read
auth:
approle:
- terraform_incus
+18
View File
@@ -0,0 +1,18 @@
# Allow the terraform-infra runner to read the NetBox + KeaAPI tokens
# (netbox_token / kea_token fields) used by the netbox and kea providers.
---
rules:
- path: "kv/data/service/terraform/infra"
capabilities:
- read
# vault_kv_secret_v2 (terraform-infra providers.tf data source) reads the kv-v2
# metadata path on every plan/apply; a 403 here fails the plan.
- path: "kv/metadata/service/terraform/infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
@@ -0,0 +1,31 @@
# Allow terraform-git to seed (write once) the gitea secrets engine's admin
# credentials. terraform-git creates the gitea-vault-admin site-admin bot and
# writes its generated password here as admin_username + admin_password; the
# vault gitea engine (managed by the tf_vault deployer) reads it at gitea/config
# creation time. Read is already granted to the deployer via
# policies/kv/service/vault/secret_backends_read.yaml, so this only adds the
# write side for terraform-git's own identity.
---
rules:
# delete is required for taint recovery and destroy (the resource got tainted
# by pipeline 107's failed post-create read and replace = delete+create).
# The seed's write-once semantics are enforced by lifecycle ignore_changes in
# terraform-git, not by withholding delete here.
- path: "kv/data/service/vault/au/syd1/secret_backend/gitea/config"
capabilities:
- create
- read
- update
- delete
# vault_kv_secret_v2 also reads the kv-v2 metadata path on every plan/apply
# (403 here broke the terraform-git main apply, pipeline 107).
- path: "kv/metadata/service/vault/au/syd1/secret_backend/gitea/config"
capabilities:
- read
- delete
auth:
approle:
- terraform_git
k8s/au/syd1:
- woodpecker_terraform_git
+42
View File
@@ -0,0 +1,42 @@
# Allow the vault deployer to manage the netbox token secrets engine: its
# connection config (seeded admin token), in-place token rotation, and
# token-minting roles.
#
# Scoped to netbox/* only, and deliberately excludes netbox/creds/* - minting
# tokens is for consumers, not the deployer. The plugin-catalog grant needed to
# import the plugin is the shared, sudo-protected wildcard in
# policies/sys/plugins/catalog/admin.yaml (already covers this plugin), and
# mounting the engine uses the deployer's existing sys/mounts/* access, so no
# new catalog/mount grant is added here (mirrors the gitea/rancher engines).
---
rules:
# Engine connection config (NetBox URL, TLS, token_version, seeded admin token).
- path: "netbox/config"
capabilities:
- create
- read
- update
- delete
# In-place rotation of the seeded admin token (write-only trigger).
- path: "netbox/config/rotate"
capabilities:
- create
- update
# Token-minting roles.
- path: "netbox/roles/*"
capabilities:
- create
- read
- update
- delete
- list
- path: "netbox/roles"
capabilities:
- read
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -0,0 +1,21 @@
# Allow the terraform-infra runner to mint an ephemeral NetBox token from the
# terraform-infra role (netbox/creds/terraform-infra), replacing the static
# netbox_token it used to read from kv/service/terraform/*. The e-breuninger
# netbox provider authenticates with the minted token; the lease revokes it when
# the run ends.
#
# Bound to both the terraform-infra AppRole and its Woodpecker k8s auth role,
# mirroring the terraform-ipam pattern. Both principals are created by the
# terraform-infra Vault onboarding (separate from this netbox change); until
# that onboarding lands this policy exists but attaches to nothing.
---
rules:
- path: "netbox/creds/terraform-infra"
capabilities:
- read
auth:
approle:
- terraform_infra
k8s/au/syd1:
- woodpecker_terraform_infra
@@ -0,0 +1,23 @@
# Allow the vault deployer to mint the ephemeral user-admin token that
# netbox_user_management authenticates with (netbox/creds/vault-user-mgmt). The
# engine mints it from the single static admin token, so the deployer never holds
# a second static NetBox credential.
#
# The netbox admin policy (policies/netbox/admin.yaml) deliberately excludes
# netbox/creds/* - minting is normally for consumers, not the deployer. This is
# the one deliberate exception: the deployer needs a user-admin token during the
# run to reconcile NetBox users. Scoped to the single vault-user-mgmt role only.
#
# Bound to the same principals as the admin policy: the tf_vault AppRole and its
# Woodpecker k8s auth role.
---
rules:
- path: "netbox/creds/vault-user-mgmt"
capabilities:
- read
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -0,0 +1,7 @@
key_prefix "infra/terraform/enc/" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
@@ -0,0 +1,7 @@
key_prefix "infra/terraform/infra/" {
policy = "write"
}
session_prefix "" {
policy = "write"
}
@@ -0,0 +1,33 @@
---
rules:
- apiGroups:
- "cert-manager.io"
- "acme.cert-manager.io"
resources:
- "*"
verbs:
- "*"
- apiGroups:
- ""
resources:
- "secrets"
verbs:
- "get"
- "list"
- "watch"
- "delete"
- apiGroups:
- ""
resources:
- "pods"
verbs:
- "get"
- "list"
- "watch"
- apiGroups:
- ""
resources:
- "pods/log"
verbs:
- "get"
- "list"
@@ -0,0 +1,26 @@
---
rules:
- apiGroups:
- "kea.unkin.net"
resources:
- "*"
verbs:
- "*"
- apiGroups:
- ""
resources:
- "pods"
- "services"
- "configmaps"
- "events"
verbs:
- "get"
- "list"
- "watch"
- apiGroups:
- ""
resources:
- "pods/log"
verbs:
- "get"
- "list"
@@ -0,0 +1,23 @@
---
rules:
- apiGroups:
- "ceph.unkin.net"
resources:
- "*"
verbs:
- "*"
- apiGroups:
- ""
resources:
- "pods"
verbs:
- "get"
- "list"
- "watch"
- apiGroups:
- ""
resources:
- "pods/log"
verbs:
- "get"
- "list"