36 Commits

Author SHA1 Message Date
unkin-agent a19256fdba Grant the vault deployer sudo to enable the oidc auth mount (#149)
ci/woodpecker/push/apply Pipeline was successful
## Why

The deployer identities cannot enable the `oidc` auth mount: `POST /v1/sys/auth/oidc` returns 403 because `sys/auth/<path>` (and its `/tune`) is sudo-protected, and `policies/sys/auth/admin.yaml` granted create/update/delete/read/list without `sudo`.

## How

- Add exact-path rules for `sys/auth/oidc` and `sys/auth/oidc/tune` to `policies/sys/auth/admin.yaml` with the wildcard's capability set plus `sudo` (exact match wins over the glob, so the set is repeated in full); auth block unchanged.

Merge order: apply this, then re-run the master apply so `module.auth_oidc_backend["oidc"]` can create the mount.

Reviewed-on: #149
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-31 22:21:01 +10:00
unkin-agent a1e7029615 Add Authentik OIDC SSO as the default human login for OpenBao (#147)
ci/woodpecker/push/apply Pipeline failed
## Why

Human login to OpenBao is LDAP-only, so operators keep a second credential set outside Authentik and group membership is maintained twice.

## How

- Add `auth_oidc_backend` module: `oidc`-type JWT auth mount, Authentik discovery URL, `listing_visibility: unauth`, credentials from `kv/service/authentik/oidc-vault`.
- Add `auth_oidc_role` module: oidc role with `user_claim` email, `groups_claim` `ak_groups`, scopes `openid profile email ak_groups`, the registered redirect URIs, `bound_audiences` `[vault]`.
- Add `auth_oidc_group` module: external identity group plus group alias on the OIDC mount accessor, policies from `policy_auth_map`.
- Add config under `config/auth_oidc_{backend,role,group}/`, discovery locals in `config/config.hcl`, `vault_cluster` variables and wiring, and terragrunt inputs.
- Bind `akP-vault-admin` on the `oidc` mount to `global-root`, alongside the existing LDAP `vault_admin` binding.
- Pin the mount path to the literal `oidc`: the registered redirect URIs embed `/ui/vault/auth/oidc/oidc/callback`.

Requires #146, terraform-authentik #33 and #148 applied first.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #147
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 22:38:05 +10:00
unkin-agent a3a8854a16 Grant the vault deployer auth/oidc and identity group capabilities (#148)
ci/woodpecker/push/apply Pipeline was successful
## Why

AppRole capabilities are fixed at login, so the deployer needs `auth/oidc/*` and identity-group grants in an apply that precedes the one creating those resources.

## How

- Add `policies/auth/oidc/admin.yaml`: full `auth/oidc/*` administration (mount config and login roles), mirroring `policies/auth/ldap/admin.yaml`.
- Add `policies/identity/group/admin.yaml`: manage external identity groups, group aliases and `identity/lookup/group`, on both the collection and per-id endpoints.
- Grant both policies to the `tf_vault` approle and the `woodpecker_terraform_vault` k8s role.

Apply before #147.

Reviewed-on: #148
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 22:14:41 +10:00
unkin-agent 67e79e72dc Grant the vault deployer read on the Authentik OIDC client secret (#146)
ci/woodpecker/push/apply Pipeline was successful
**Why:** the terraform-vault deployer must read the OpenBao OIDC client credentials that Authentik's provider module generates before it can configure `auth/oidc`, and AppRole capabilities are fixed at login so the grant has to exist in a prior apply.

**How:**
- Add `policies/kv/service/authentik/oidc-vault/read.yaml`: read on `kv/data/service/authentik/oidc-vault` for the deployer identities (approle `tf_vault`, k8s/au/syd1 `woodpecker_terraform_vault`); `terraform_authentik` still owns the write side of `kv/service/authentik/*`.

**Merge order:** this PR must merge and apply *before* the follow-up PR that adds the `auth/oidc` modules. OIDC becomes the default human auth path; approle/k8s (CI and agents) and break-glass are unchanged.

Reviewed-on: #146
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 21:50:57 +10:00
unkin-agent 1f03bcdc67 Bump vault-secrets-arrstack provider to 0.2.0 and plumb methods (#145)
ci/woodpecker/push/apply Pipeline was successful
## Why

Engine plugin v0.2.0 (catalog bumped in #144) added a `methods` field to arrstack roles, pinning a minted arrproxy key to a set of HTTP methods so a read-only integration can hold a key that cannot write. Provider v0.2.0 (just published to the `terraform-unkin` registry) exposes it as an optional set attribute, but the module had no input for it, so no role yaml could use it.

## How

- Bumps the `vault-secrets-arrstack` provider pin from 0.1.1 to 0.2.0 in `environments/root.hcl` and both arrstack modules.
- Adds an optional `methods` input to `modules/vault_cluster/modules/arrstack_secret_backend_role` and passes it through to the resource.
- Threads `methods` through the `vault_cluster` `arrstack_secret_backend_role` object type, so a role yaml may now carry a `methods:` list and it flows via the existing config.hcl merge with no discovery change.

`methods` defaults to `null` rather than `[]`: the provider reads an unrestricted role back as null, so a null default keeps a role yaml that omits the field drift-free. An empty-set default would plan `null -> []` on every existing role.

**Expected plan: no resource changes.** No role yaml changes here, so the plan should be a provider-version-only diff (provider upgrade, zero add/change/destroy).

Follow-up PR scopes the mediamark role to GET/HEAD.

Reviewed-on: #145
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 18:19:04 +10:00
unkin-agent 6c489c6003 Bump arrstack plugin catalog to v0.2.0 (#144)
ci/woodpecker/push/apply Pipeline was successful
## Why

The v0.2.0 arrstack plugin binary is live on all five OpenBao nodes (RPM applied via post-merge puppet runs), and the catalog still pins the v0.1.0 sha — OpenBao refuses to launch a plugin whose binary hash does not match the catalog entry.

## Changes

- `config/plugins/vault-plugin-secrets-arrstack.yaml`: `version` -> `0.2.0` and `sha256` -> `9ea7f160…12fa1`, computed from the binary extracted from `openbao-plugin-secrets-arrstack-0.2.0-1.x86_64.rpm` (the same RPM puppet pins).

## Post-apply

Run `vault plugin reload -plugin=vault-plugin-secrets-arrstack` after the apply so the running mount swaps to the v0.2.0 binary.

Reviewed-on: #144
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 17:07:28 +10:00
unkin-agent bcbf121818 Grant terraform-rancher read on rancher/creds/ci (#143)
ci/woodpecker/push/apply Pipeline was successful
## Why

`terraform-rancher` authenticates the rancher2 provider with a static 90-day admin API token stored in `kv/service/terraform/rancher` — the repo carries its own Makefile TODO to retire it. The Rancher secrets engine is already mounted at `rancher/` with a `ci` role (seeded `admin` service account, ttl 1h / max 8h), but no policy grants read on `rancher/creds/*`, so nothing can use it yet.

## What

Adds `policies/rancher/creds/ci.yaml`: `read` on `rancher/creds/ci`, bound to the same runner identities as the existing kv policy — approle `terraform_rancher` and k8s/au/syd1 role `woodpecker_terraform_rancher`.

Minted tokens are lease-bound and deleted from Rancher on revoke. They inherit the seeded admin service account's RBAC, so this is the same privilege as the static token it replaces, just short-lived.

## Follow-up

A terraform-rancher Makefile PR swapping `vault kv get kv/service/terraform/rancher` for `vault read -field=token rancher/creds/ci` must merge **only after** this one applies.

Reviewed-on: #143
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 09:42:00 +10:00
unkin-agent 9fa51f401a Add arrstack creds role and k8s auth for mediamark (#141)
ci/woodpecker/push/apply Pipeline was canceled
## Why

mediamark (kids-content marking UI, namespace `mediamark`) needs Sonarr/Radarr access to list series/movies and read metadata + artwork. It should get ephemeral virtual arrproxy keys from the arrstack secrets engine via arrproxy, not a copy of the static app API keys.

## How

- `config/arrstack_secret_backend_role/arrstack/mediamark.yaml` — role minting keys scoped to `sonarr` + `radarr` (no prowlarr), `ttl: 60` / `max_ttl: 86400`, mirroring the existing per-app roles.
- `config/auth_kubernetes_role/k8s/au/syd1/mediamark.yaml` — k8s auth role `mediamark`, bound to serviceaccount `default` in namespace `mediamark`, `token_ttl`/`token_max_ttl` 600, audience `vault`.
- `policies/arrstack/creds/mediamark.yaml` — `read` on `arrstack/creds/mediamark`, granted to `k8s/au/syd1: [mediamark]` only (deliberately not the shared `default` k8s role, which would expose the creds to every namespace).

Engine mount/config and the existing roles are untouched.

## Note

The companion argocd-apps change consumes `arrstack/creds/mediamark` via a `VaultDynamicSecret` (response fields: `token`, `id`, `apps`, `subject`, `expires_at`).

**GET/HEAD-only is not expressible today.** `arrstack_secret_backend_role` carries `apps`/`ttl`/`max_ttl` only, and arrproxy scopes machine tokens by app — the GET/HEAD restriction on the cheeztv/kids tier is a grant on an OIDC *group*, not on a minted token. The role is scoped as tightly as the engine allows and the limitation is documented in the yaml; per-token method scoping needs a plugin + arrproxy feature.

Reviewed-on: #141
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 09:40:26 +10:00
unkin-agent 5879fbd2d8 Add ephemeral Gitea creds for repospawner (#142)
ci/woodpecker/push/apply Pipeline was successful
## Why

`repospawner` creates and seeds Gitea repositories on demand from in-cluster. It needs a Gitea token, and it should not carry a static one -- it gets ephemeral, lease-bound creds like every other service. The `agents` AppRole is CIDR-bound to Ben's workstation, so pods authenticate via Kubernetes auth instead.

## How

- `config/gitea_secret_backend_role/gitea/repospawner.yaml` -- gitea engine role for the `repospawner` user. Scopes `write:repository`, `write:issue`, `read:user` (`read:user` is mandatory: clients validate the login via `GET /api/v1/user`, which 403s without it). ttl 1h / max_ttl 4h. Mirrors `unkin-agent.yaml`.
- `config/auth_kubernetes_role/k8s/au/syd1/repospawner.yaml` -- k8s auth role bound to serviceaccount `repospawner` in namespace `repospawner`, 600s ttl, `audience: vault` (VSO/projected-token flavor, same as `media-apps` / `logging_logarchiver`).
- `policies/gitea/creds/repospawner.yaml` -- `read` on `gitea/creds/repospawner`, bound to `k8s/au/syd1: [repospawner]` only. Deliberately **not** bound to the `agents` AppRole: the service runs in-cluster only.

## Depends on

The terraform-git PR that creates the `repospawner` Gitea user. The engine role cannot mint creds until the user exists -- **merge that one first**.

Reviewed-on: #142
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 09:29:26 +10:00
unkin-agent f3a3e4b874 Grant terraform-authentik write on the kv/service/authentik subtree (#140)
ci/woodpecker/push/apply Pipeline was successful
## Why

`kv/service/authentik/agent-api-token` is seeded by hand today. terraform-authentik should own it as IaC (`vault_kv_secret_v2`), along with any future Authentik automation tokens, but its runner identities only hold read on `kv/service/terraform/authentik` and the per-namespace oauth-credentials paths.

This must land and **apply** first: an AppRole/k8s token's capabilities are fixed at login, so the companion terraform-authentik PR would fail its very first plan against a policy that is not yet live.

## How

- Adds `policies/kv/service/authentik/write.yaml`, granting:
  - `kv/data/service/authentik/*` — create, read, update, delete
  - `kv/metadata/service/authentik/*` — read, list, delete
- Assigned to the same identities that already hold the terraform-authentik read policy: approle `terraform_authentik` and `k8s/au/syd1` role `woodpecker_terraform_authentik`.
- `delete` is included (unlike the `kv/kubernetes` agents grant) so `terraform destroy` and resource replacement clean up both the data and the metadata; metadata `read`/`list` is what `vault_kv_secret_v2` hits on every plan.
- Scope note in the file header: the ask was to scope to `*-token` paths, but Vault ACL paths only support a trailing glob, so the whole `kv/service/authentik/` subtree is granted. terraform-authentik is the owner of everything under that prefix.
- Existing `policies/kv/service/authentik/agent-api-token/read.yaml` (agents approle) is untouched.

## Order

Merge + apply this first; the companion terraform-authentik PR merges only afterwards.

Reviewed-on: #140
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 23:01:43 +10:00
unkin-agent 7c7826997e Grant agents approle read on kv/service/authentik/agent-api-token (#139)
ci/woodpecker/push/apply Pipeline was successful
## Summary
- Grants the agents approle read on kv/service/authentik/agent-api-token

## Why
Automation seeds oauth client secrets and LDAP outpost tokens; fetching outpost tokens needs a scoped Authentik API token, seeded at this path by the operator.

Reviewed-on: #139
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 12:56:06 +10:00
unkin-agent 36d2b99255 Revert temporary agents read grant on Authentik provider token (#138)
ci/woodpecker/push/apply Pipeline was successful
## Why
The one-off terragrunt import in terraform-authentik that required this grant is complete (jellyfin provider, groups, application, and policy bindings are all reconciled into state; apply pipeline is green). Per the recovery plan the temporary read grant is removed again.

## Changes
- Reverts de9d6e5: removes policies/kv/service/terraform/authentik/read.yaml (agents AppRole read on kv/data/service/terraform/authentik)

Reviewed-on: #138
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 21:35:57 +10:00
unkin-agent 9fcd9d7ab5 Grant agents AppRole read on the Authentik provider token (#137)
ci/woodpecker/push/apply Pipeline was successful
A one-off `terragrunt import` in terraform-authentik is needed to reconcile the Authentik resources orphaned by the jellyfin apply failure. The agents AppRole must be able to read the Authentik provider token (`kv/service/terraform/authentik`) to run the import; this grant is read-only on that single path and can be reverted once the import is done.

- Add `kv/service/terraform/authentik/read` policy (read on `kv/data/service/terraform/authentik`) bound to the `agents` AppRole

Reviewed-on: #137
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 22:34:31 +10:00
unkin-agent 57fe279d7a Run plan without acquiring the Consul state lock (#136)
ci/woodpecker/push/apply Pipeline was successful
PR plan pipelines were failing with "Error acquiring the state lock ... OperationTypePlan" when a plan collided with an apply (or another plan) holding the lock on the same Consul-backed state. Plans are read-only and don't need the lock.

- `plan`: pass `-lock=false` to `terragrunt ... plan`; `apply` is unchanged and still locks.

Reviewed-on: #136
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 22:31:58 +10:00
unkin-agent 6c68057e73 operator RO creds: read operator CRD API groups (#135)
ci/woodpecker/push/apply Pipeline was successful
## Why

The `operator` kube context is a Vault-minted, read-only credential (Kubernetes
secret engine role `cluster-operator`, bound to a `get/list/watch`-only
ClusterRole). It is currently RBAC-forbidden from listing operator-owned CRDs —
the immediate breakage is `valkeyclusters.valkey.io` — and likewise every other
operator CRD group deployed via `argocd-apps`. This extends the RO ruleset so the
context can read those CRDs. Still strictly read-only: no create/update/delete.

## Change

- Extend the `cluster-operator` generated_role_rules
  (`resources/secret_backend/kubernetes/au/syd1/roles/cluster-operator.yaml`)
  with `get/list/watch` on the CRD API groups of the operators deployed via
  `argocd-apps` (verbs and `resources: "*"` unchanged; same single rule block).

## API groups added

- `valkey.io` (valkey-operator — immediate need)
- `ceph.unkin.net` (cephrgw-operator)
- `bind.unkin.net` (bind-operator)
- `kea.unkin.net` (kea/dhcp operator)
- `k8up.io` (k8up)
- `grafana.integreatly.org` (grafana-operator)
- `operator.victoriametrics.com` (VictoriaMetrics operator)
- `clickhouse.altinity.com`, `clickhouse-keeper.altinity.com` (altinity clickhouse-operator)
- `acme.cert-manager.io` (cert-manager companion CRD group)
- `deviceplugin.intel.com`, `fpga.intel.com` (intel device plugins operator)
- `autoscaling.k8s.io` (VPA)
- `apm.k8s.elastic.co`, `beat.k8s.elastic.co`, `agent.k8s.elastic.co`,
  `maps.k8s.elastic.co`, `enterprisesearch.k8s.elastic.co`,
  `autoscaling.k8s.elastic.co`, `stackconfigpolicy.k8s.elastic.co` (ECK — the
  `elasticsearch`/`kibana`/`logstash` ECK groups were already granted)
- `snapshot.storage.k8s.io`, `groupsnapshot.storage.k8s.io` (CSI external-snapshotter, deployed via csi-cephfs/csi-cephrbd)

Groups already present (`postgresql.cnpg.io`, `cert-manager.io`,
`externaldns.k8s.io`, `secrets.hashicorp.com`, `purelb.io`, `nfd.k8s-sigs.io`,
`elasticsearch/kibana/logstash.k8s.elastic.co`, `gateway.networking.k8s.io`,
etc.) are unchanged. Rancher/RKE/Calico/cluster-api/fleet management-layer CRD
groups are intentionally excluded — they are not `argocd-apps` operators.

---------

Co-authored-by: unkin-agent <agent@unkin.net>
Reviewed-on: #135
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-23 12:29:02 +10:00
unkin-agent aa8d41a7bb Bump vault-secrets-arrstack provider to 0.1.1 (#134)
ci/woodpecker/push/apply Pipeline was successful
v0.1.1 models the role apps attribute as a Set instead of a List, fixing the ordering-based "inconsistent result after apply" error and superseding the interim alphabetical yaml sort (#133).

- Bumps the vault-secrets-arrstack provider pin from 0.1.0 to 0.1.1 in root.hcl and both arrstack modules

Reviewed-on: #134
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 11:30:21 +10:00
unkin-agent 36fbfb86bc Sort arrstack role apps alphabetically (#133)
ci/woodpecker/push/apply Pipeline was successful
The master apply (pipeline 202) fails with "Provider produced inconsistent result after apply": the arrstack Vault engine returns apps alphabetically sorted while the provider models apps as an ordered List, so the declared order [sonarr, radarr, prowlarr] never matches the read-back. This is an interim unblock while the provider moves apps to a Set.

- Reorders apps in config/arrstack_secret_backend_role/arrstack/all.yaml to alphabetical order to match the engine read-back

Reviewed-on: #133
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 23:25:44 +10:00
unkin-agent a33fc2b42f Allow CSI auth to read ceph-mediafs-secret KV (#132)
ci/woodpecker/push/apply Pipeline failed
A new VaultStaticSecret `ceph-mediafs-secret` in ns `csi-cephfs` (for the legacy mediafs CephFS static PV) gets 403 on `kv/data/service/kubernetes/au/syd1/csi/ceph-mediafs-secret` — the `ceph-csi` role can already read the sibling `ceph-cephfs-secret` path via the same `ceph-csi-cephfs` VaultAuth, but no policy covers the new path.

- Adds `policies/kv/service/kubernetes/au/syd1/csi/ceph-mediafs-secret/read.yaml` granting read on `kv/data/service/kubernetes/au/syd1/csi/ceph-mediafs-secret`, bound to role `ceph-csi` on mount `k8s/au/syd1` (mirrors the existing ceph-cephfs-secret/ceph-rbd-secret policies)

Reviewed-on: #132
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 23:17:18 +10:00
unkin-agent 0e82cda02d vault: mount arrstack engine + config + roles (3/3) (#127)
ci/woodpecker/push/apply Pipeline failed
## Why

Creates the arrstack secrets engine itself: the mount + config and the per-scope roles that mint arrproxy API keys. **PR 3 of 3 (resources)**, stacked on #126 (policy). Final step of the register -> policy -> resources split (was #124).

## Change

- Adds `config/arrstack_secret_backend/arrstack.yaml`: mounts the engine at `arrstack` and writes its config (`base_url`, timeout). The arrproxy admin token stays out of git and is read from KV by the module.
- Adds `config/arrstack_secret_backend_role/arrstack/{all,sonarr,radarr,prowlarr}.yaml`: roles scoped to each arr app (plus one covering all three). Default `ttl` is **60s** (short-lived, renewed on demand); `max_ttl` 86400 mirrors the litellm sibling convention. The engine additionally caps renewal at the arrproxy admin token's fixed mint expiry.
- Adds `modules/vault_cluster/modules/arrstack_secret_backend{,_role}` and wires them in: `config/config.hcl` maps, `modules/vault_cluster/main.tf`, `variables.tf`, the environment inputs, and the root provider block.
- Provider source is `artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets-arrstack` (repo `terraform-provider-vault-secrets-arrstack`), local name `arrstack`.

## Apply order

Apply **after PR #126 (policy) AND after `terraform-provider-vault-secrets-arrstack` v0.1.0 is published** to the artifactapi terraform registry. Until the provider is published, `tofu init` cannot resolve it, so **CI/plan on this PR is red by design** — that is expected, not a regression.

Note **plan-green != apply-green**: the KV-sourced `admin_token` is only fetched at apply time, so a green plan does not prove the seeded token is readable.

## Stack

1. register -> #125
2. policy -> #126
3. **resources (this PR)** -> `benvin/arrstack-resources` off `benvin/arrstack-policy`

Supersedes #124.

---------

Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Co-authored-by: BenVincent <benvin@main.unkin.net>
Reviewed-on: #127
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-21 00:09:47 +10:00
unkin-agent 08ec281b1d Remove ghp agent role (missing installation_id; unblock apply) (#131)
ci/woodpecker/push/apply Pipeline was successful
## Why

The `ghp` agent role fails at apply with `Code: 400 — installation_id is required for agent tokens`. The role config `config/ghp_secret_backend_role/ghp/agent.yaml` carries only a placeholder `installation_id`, so the role can never be created. This failure blocks the terraform-vault master apply, which in turn blocks the arrstack #127 apply.

Remove the ghp role for now so the master apply goes green. The `ghp` secret backend itself is retained (it now mounts and configures cleanly). The role can be re-added once a real `installation_id` is provided.

Because the role never successfully created (apply failed on it), removing it is non-destructive — it is not in state, so no destroy is introduced.

## Changes

- Delete `config/ghp_secret_backend_role/ghp/agent.yaml`, which empties the `ghp_secret_backend_role` for_each map so no role instance (and no downstream ghp_secret_role) is planned.

Backend `config/ghp_secret_backend/ghp.yaml` and all other config are unchanged. Net diff vs master is exactly this one file deletion.

---------

Co-authored-by: unkin-agent <unkin-agent@users.noreply.git.unkin.net>
Reviewed-on: #131
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-20 00:05:13 +10:00
unkin-agent 392c5d2ac7 Restore ghp secret backend + roles (config now seeded) (#130)
ci/woodpecker/push/apply Pipeline failed
## Why

Reverts the temporary removal in #129. That PR deleted the ghp backend + role
config YAMLs to unblock the `master` apply, which was failing with:

```
Error: no secret found at "kv/data/service/vault/au/syd1/secret_backend/ghp/config"
  from module.ghp_secret_backend["ghp"].data.vault_kv_secret_v2.config
```

The ghp config KV is now seeded: `kv/data/service/vault/au/syd1/secret_backend/ghp/config`
holds key `admin_token`, and the ghp service secret
`kv/kubernetes/namespace/ghp/default/app` carries the matching `service_token`.
With the KV populated, `data.vault_kv_secret_v2.config` resolves, so the ghp
secret backend + role can be created. The ghp module wiring, plugin
registration, and policies were never removed (they stayed on `master`), so
restoring these two YAMLs re-populates the `for_each` maps and instantiates the
backend + role against the seeded config.

## Changes

- Restore `config/ghp_secret_backend/ghp.yaml`.
- Restore `config/ghp_secret_backend_role/ghp/agent.yaml`.

Net diff vs `master` is exactly the re-addition of those two files
(byte-identical to their pre-#129 content, the mirror-inverse of #129).

## Sequence

Final step (4/4) of the remove -> grant write policy -> seed KV -> add-back
sequence: #129 (remove) -> #128 (grant) -> KV seed -> this PR (add back).

## Verification

- `tofu fmt` clean, `yamllint` passes (pre-commit hooks green), `terragrunt validate` succeeds (only unrelated `vault_kv_secret_v2` deprecation warnings).
- `tofu init` installs the `vault-secrets-ghp` provider with no plugin/catalog error.
- ghp config KV path confirmed seeded with `admin_token`, so the previously-failing data source now resolves.
- A full privileged `plan` is not runnable under the agent AppRole (it lacks the policy to mint the consul backend token), so the created/destroyed resource counts are not machine-confirmed here; the git diff is exactly the two file additions, so no config-driven destroys are introduced.
- Note: the ghp backend mount at apply requires the `vault-plugin-secrets-ghp` binary present on the OpenBao nodes (pre-existing Puppet-managed plugin).

Reviewed-on: #130
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-19 23:47:32 +10:00
unkin-agent d1cb790de8 Grant agents approle write on ghp config KV path (to seed) (#128)
ci/woodpecker/push/apply Pipeline was successful
## Why

The `terraform-vault` master apply aborts because the KV path `kv/data/service/vault/au/syd1/secret_backend/ghp/config` (key `admin_token`, a `ghpsvc_` service token) is unseeded. The ghp secrets engine reads that value at `ghp/config` creation time, so the ghp data-source read fails and the apply stops. Granting the `agents` AppRole scoped write to just this one KV path lets an agent seed the value so the apply can proceed.

## Changes

- Add `policies/kv/service/vault/au/syd1/secret_backend/ghp/config_write.yaml`, a `vault_policy` bound to the `agents` AppRole role only.
- Grant `create`, `update`, `read` on the kv-v2 data path `kv/data/service/vault/au/syd1/secret_backend/ghp/config`.
- Grant `read` on the kv-v2 metadata path `kv/metadata/service/vault/au/syd1/secret_backend/ghp/config` (read on plan/apply).
- Scope to this single ghp config path only; no wildcards, no delete, no list, no other `secret_backend` configs (least privilege).

## Caveat

This grant is itself a `vault_policy` applied by the master apply, which currently aborts on the ghp data-source read. So the policy likely needs to be applied first (a targeted apply of just this `vault_policy`) before the agent can seed the KV path. The agent also still needs the actual `ghpsvc_` service token value provided out-of-band to write into `admin_token`.

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Co-authored-by: unkin-agent <agent@unkin.net>
Reviewed-on: #128
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-19 23:17:44 +10:00
unkin-agent 31a7abec99 vault: add arrstack policies (deployer + KV read + creds) (2/3) (#126)
ci/woodpecker/push/apply Pipeline was successful
## Why

Grants the Vault access the arrstack engine needs, before any engine resources exist. **PR 2 of 3 (policy)**, stacked on #125 (register). Keeping policy separate from resources honours the never-bundle / sequential-apply rule.

## Change

- Adds `policies/arrstack/admin.yaml`: the terraform-vault deployer (`tf_vault` approle + `woodpecker_terraform_vault` k8s role) may create/read/update/delete `arrstack/config` and manage `arrstack/roles/*`.
- Adds `policies/kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token/read.yaml`: the deployer may read the KV-seeded arrproxy admin token (both `kv/data/...` and `kv/metadata/...`) that the engine config sources. The existing `secret_backends_read` policy does not cover this `kubernetes/namespace` KV path.
- Adds `policies/arrstack/creds/{sonarr,radarr,prowlarr}.yaml`: each `terraform-<app>` run may read its own `arrstack/creds/<app>` to mint a scoped key.
- Policy YAMLs are auto-discovered by `policies/policies.hcl`, so no wiring changes are needed.

## Apply order

Apply **after PR #125 (register)**. Safe to apply before the engine exists — these only grant capabilities on paths.

## Stack

1. register -> #125
2. **policy (this PR)** -> `benvin/arrstack-policy` off `benvin/arrstack-register`
3. resources -> `benvin/arrstack-resources`

Supersedes #124.

Reviewed-on: #126
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-19 22:54:13 +10:00
unkin-agent 2525bae1d7 Temporarily remove ghp secret backend + roles (unblock apply) (#129)
ci/woodpecker/push/apply Pipeline was successful
## Why

The `terraform-vault` master apply aborts with:

```
Error: no secret found at "kv/data/service/vault/au/syd1/secret_backend/ghp/config"
  from module.ghp_secret_backend["ghp"].data.vault_kv_secret_v2.config
```

The ghp secret backend reads its admin token from a KV path that has not been
seeded yet, so the apply fails and blocks every other change — including the
arrstack plugin registration (#125).

This PR **removes only the ghp backend + role config YAMLs (empties the
`for_each` map)**. With no config YAMLs, `var.ghp_secret_backend` /
`var.ghp_secret_backend_role` are empty maps, so zero ghp backend/role
instances are created, the unseeded `ghp/config` KV is never read, and the
apply passes. The ghp module wiring, plugin registration, and policies all stay
in place. This is part 1 of a remove -> grant write policy -> seed KV -> re-add
sequence, and the YAMLs will be restored once the ghp config KV is seeded.

## Changes

- Delete `config/ghp_secret_backend/ghp.yaml`.
- Delete `config/ghp_secret_backend_role/ghp/agent.yaml`.

Net diff vs `master` is exactly those two file deletions. All ghp wiring is
unchanged (identical to master): the `module.ghp_secret_backend` /
`module.ghp_secret_backend_role` instantiations, their variables, the
`config.hcl` parsing blocks, the `terragrunt.hcl` inputs, the
`vault-plugin-secrets-ghp` plugin registration, and the `ghp/admin` +
`ghp/creds/agent` policies all remain.

Reviewed-on: #129
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-19 22:51:55 +10:00
unkin-agent df0510e33b vault: register vault-plugin-secrets-arrstack in the catalog (1/3) (#125)
ci/woodpecker/push/apply Pipeline failed
## Why

Splits the arrstack Vault engine work (was #124) into three independently-appliable PRs so registration, policy, and engine resources are never bundled. This is **PR 1 of 3 (register)**.

## Change

- Registers the `vault-plugin-secrets-arrstack` plugin (v0.1.0) in the OpenBao plugin catalog via `config/plugins/vault-plugin-secrets-arrstack.yaml`.
- `sha256` pins the released v0.1.0 binary.
- No wiring changes needed: the `plugins` glob and `module.plugin` already exist on `master`.

## Apply order

Apply this **after** the Puppet plugin-install PR (unkin/puppet-prod #521, merged) has placed the binary at `/opt/openbao-plugins/vault-plugin-secrets-arrstack` on the OpenBao nodes. Registration fails until the binary is present on-node.

## Stack

1. **register (this PR)** -> `benvin/arrstack-register` off `master`
2. policy -> `benvin/arrstack-policy`
3. resources -> `benvin/arrstack-resources`

Supersedes #124.

Reviewed-on: #125
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-19 21:50:55 +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
89 changed files with 2174 additions and 28 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ init:
plan: init
@$(call vault_env) && \
terragrunt run --all --parallelism 4 --non-interactive plan
terragrunt run --all --parallelism 4 --non-interactive plan -- -lock=false
apply: init
@$(call vault_env) && \
@@ -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:
- prowlarr
- radarr
- sonarr
ttl: 60 # seconds (1m); short-lived by design, renewed on demand
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,17 @@
---
# Mints an arrproxy API key for the mediamark kids-content marking UI, scoped to
# Sonarr and Radarr only (no Prowlarr: mediamark never touches indexers).
#
# mediamark only lists series/movies and reads metadata + artwork, so its key is
# read-only in intent. The engine role cannot express that today: an
# arrstack_secret_backend_role carries apps/ttl/max_ttl only, and arrproxy scopes
# machine tokens by app, not by HTTP method (the GET/HEAD restriction on the
# cheeztv/kids tier is a grant on an OIDC *group*, not on a minted token). Until
# the engine and arrproxy grow per-token method scoping, mediamark's read-only
# posture is enforced by the app itself and by the arrproxy tier it routes to.
apps:
- radarr
- sonarr
ttl: 60 # seconds (1m); short-lived by design, renewed on demand
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: 60 # seconds (1m); short-lived by design, renewed on demand
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: 60 # seconds (1m); short-lived by design, renewed on demand
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: 60 # seconds (1m); short-lived by design, renewed on demand
max_ttl: 86400 # seconds (24h); the engine additionally caps renewal at the
# arrproxy admin token's fixed mint expiry.
@@ -0,0 +1,10 @@
---
# Kubernetes auth role for the mediamark app: the default service account in the
# mediamark namespace, used by VaultDynamicSecret to read arrstack/creds/mediamark.
bound_service_account_names:
- default
bound_service_account_namespaces:
- mediamark
token_ttl: 600
token_max_ttl: 600
audience: vault
@@ -0,0 +1,7 @@
bound_service_account_names:
- repospawner
bound_service_account_namespaces:
- repospawner
token_ttl: 600
token_max_ttl: 600
audience: vault
@@ -1,5 +1,5 @@
bound_service_account_names:
- terraform-ipam
- terraform-infra
bound_service_account_namespaces:
- woodpecker
token_ttl: 600
+17
View File
@@ -0,0 +1,17 @@
# Authentik (OIDC) human SSO. The mount path is the filename and MUST stay
# "oidc": the Authentik provider registers strict redirect URIs containing
# /ui/vault/auth/oidc/oidc/callback, which encode the mount path.
#
# identity.unkin.net serves the public LetsEncrypt wildcard, so the OpenBao VMs
# validate discovery against the system roots with no CA bundle work. Do not
# swap it for identity.k8s.syd1.au.unkin.net (internal CA).
#
# listing_visibility unauth makes OIDC the offered default on the UI login page.
# client_id/client_secret are read from kv/service/authentik/oidc-vault.
---
oidc_discovery_url: "https://identity.unkin.net/application/o/vault/"
default_role: "default"
description: "Authentik SSO - default human login"
listing_visibility: "unauth"
default_lease_ttl: 24h
max_lease_ttl: 168h
@@ -0,0 +1,5 @@
---
# this file doesnt need anything in it, so this data is just to make sure yamlencode reads some yaml data
# The filename is the Authentik group name asserted in the ak_groups claim.
# Only akP-* permission groups may appear here; akR-* roles are grouping-only.
description: foo
+25
View File
@@ -0,0 +1,25 @@
# Default OIDC login role (the mount's default_role), used by both the web UI
# and `bao login -method=oidc`.
#
# The role grants no policies of its own: authorization comes from the external
# identity groups under config/auth_oidc_group, matched on the ak_groups claim.
# ak_groups is Authentik's hierarchy-expanding claim (plain `groups` only carries
# direct memberships), so nested akP-* permission groups resolve.
#
# allowed_redirect_uris must match the provider's strict URIs exactly.
---
user_claim: "email"
groups_claim: "ak_groups"
oidc_scopes:
- openid
- profile
- email
- ak_groups
bound_audiences:
- vault
allowed_redirect_uris:
- "http://localhost:8250/oidc/callback"
- "https://vault.k8s.syd1.au.unkin.net/ui/vault/auth/oidc/oidc/callback"
- "https://vault.service.consul:8200/ui/vault/auth/oidc/oidc/callback"
token_ttl: 3600
token_max_ttl: 28800
+61
View File
@@ -97,6 +97,27 @@ locals {
})
if startswith(file_path, "auth_ldap_group/")
}
auth_oidc_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "auth_oidc_backend/")
}
auth_oidc_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "auth_oidc_role/", ""), ".yaml") => merge(content, {
role_name = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "auth_oidc_role/", ""))
})
if startswith(file_path, "auth_oidc_role/")
}
auth_oidc_group = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "auth_oidc_group/", ""), ".yaml") => merge(content, {
groupname = trimsuffix(basename(file_path), ".yaml")
backend = split("/", replace(file_path, "auth_oidc_group/", ""))[0]
})
if startswith(file_path, "auth_oidc_group/")
}
auth_kubernetes_backend = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "auth_kubernetes_backend/", ""), ".yaml") => content
@@ -198,6 +219,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, {
@@ -252,5 +286,32 @@ locals {
})
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/")
}
}
}
@@ -1,5 +1,5 @@
consul_roles:
- terraform-ipam
- 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,19 @@
# Role minting ephemeral tokens for the repospawner service user. repospawner
# creates and seeds Gitea repositories on demand, so it gets write on
# repositories (create + push) and write on issues (issue/PR comments). Read is
# implied by write. No admin/org/user-write scopes.
# read:user is required because most API clients validate the login via
# GET /api/v1/user, which 403s without it.
# Reading gitea/creds/repospawner mints a lease-bound token deleted from Gitea
# on revoke/expiry. Consumed by the repospawner pods via Kubernetes auth (see
# policies/gitea/creds/repospawner.yaml) -- the agents AppRole is CIDR-bound to
# Ben's workstation and cannot be used from in-cluster.
---
username: repospawner
scopes:
- write:repository
- write:issue
- read:user
token_name_prefix: vault-repospawner
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
+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.2.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.2.0"
sha256: "9ea7f16013b3bcbadfcb15b08fd1545f8069893cf864bfdaa57e7e7e72612fa1"
@@ -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-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 {
@@ -54,6 +60,9 @@ inputs = {
auth_approle_role = local.config.auth_approle_role
auth_ldap_backend = local.config.auth_ldap_backend
auth_ldap_group = local.config.auth_ldap_group
auth_oidc_backend = local.config.auth_oidc_backend
auth_oidc_role = local.config.auth_oidc_role
auth_oidc_group = local.config.auth_oidc_group
auth_kubernetes_backend = local.config.auth_kubernetes_backend
auth_kubernetes_role = local.config.auth_kubernetes_role
kv_secret_backend = local.config.kv_secret_backend
@@ -70,6 +79,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
@@ -81,10 +92,19 @@ inputs = {
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/vault-secrets-arrstack"
version = "0.2.0"
}
}
}
EOF
+196
View File
@@ -64,6 +64,55 @@ module "auth_ldap_group" {
depends_on = [module.auth_ldap_backend]
}
module "auth_oidc_backend" {
source = "./modules/auth_oidc_backend"
for_each = var.auth_oidc_backend
path = each.key
description = each.value.description
oidc_discovery_url = each.value.oidc_discovery_url
client_secret_mount = each.value.client_secret_mount
client_secret_path = each.value.client_secret_path
default_role = each.value.default_role
listing_visibility = each.value.listing_visibility
default_lease_ttl = each.value.default_lease_ttl
max_lease_ttl = each.value.max_lease_ttl
}
module "auth_oidc_role" {
source = "./modules/auth_oidc_role"
for_each = var.auth_oidc_role
backend = each.value.backend
role_name = each.value.role_name
user_claim = each.value.user_claim
groups_claim = each.value.groups_claim
oidc_scopes = each.value.oidc_scopes
bound_audiences = each.value.bound_audiences
allowed_redirect_uris = each.value.allowed_redirect_uris
token_ttl = each.value.token_ttl
token_max_ttl = each.value.token_max_ttl
# Human authorization comes from the external identity groups below, so a
# login role carrying no policies is the normal case.
token_policies = try(var.policy_auth_map[each.value.backend][each.value.role_name], [])
depends_on = [module.auth_oidc_backend]
}
module "auth_oidc_group" {
source = "./modules/auth_oidc_group"
for_each = var.auth_oidc_group
groupname = each.value.groupname
policies = var.policy_auth_map[each.value.backend][each.value.groupname]
mount_accessor = module.auth_oidc_backend[each.value.backend].accessor
depends_on = [module.auth_oidc_backend]
}
module "auth_kubernetes_backend" {
source = "./modules/auth_kubernetes_backend"
@@ -347,6 +396,36 @@ 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
methods = each.value.methods
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"
@@ -456,6 +535,123 @@ module "gitea_secret_backend_role" {
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
# vault-secrets-arrstack 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/vault-secrets-arrstack"
version = "0.2.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,8 @@
resource "arrstack_secret_backend_role" "this" {
backend = var.backend
name = var.name
apps = var.apps
methods = var.methods
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/vault-secrets-arrstack"
version = "0.2.0"
}
}
}
@@ -0,0 +1,33 @@
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 "methods" {
description = "HTTP methods a generated key is limited to (subset of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS). Null leaves the role unrestricted"
type = set(string)
# null, not [], so a role yaml that omits methods matches the provider's null read-back and shows no drift.
default = null
}
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,22 @@
# Expected keys in KV secret: client_id, client_secret (generated and written by
# terraform-authentik's oauth2 provider module, never seeded by hand).
data "vault_kv_secret_v2" "oidc_client" {
mount = var.client_secret_mount
name = var.client_secret_path
}
resource "vault_jwt_auth_backend" "oidc" {
path = var.path
type = "oidc"
description = var.description
oidc_discovery_url = var.oidc_discovery_url
oidc_client_id = data.vault_kv_secret_v2.oidc_client.data["client_id"]
oidc_client_secret = data.vault_kv_secret_v2.oidc_client.data["client_secret"]
default_role = var.default_role
tune {
default_lease_ttl = var.default_lease_ttl
max_lease_ttl = var.max_lease_ttl
listing_visibility = var.listing_visibility
}
}
@@ -0,0 +1,9 @@
output "accessor" {
description = "Accessor of the OIDC auth mount, used to bind identity group aliases"
value = vault_jwt_auth_backend.oidc.accessor
}
output "path" {
description = "Mount path of the OIDC auth backend"
value = vault_jwt_auth_backend.oidc.path
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,56 @@
variable "path" {
description = "Mount path of the OIDC auth backend"
type = string
default = "oidc"
}
variable "description" {
description = "Human-readable description of the auth mount"
type = string
default = null
}
variable "oidc_discovery_url" {
description = "OIDC issuer discovery URL of the identity provider"
type = string
}
variable "client_secret_mount" {
description = "KV-v2 mount holding the OIDC client credentials"
type = string
default = "kv"
}
variable "client_secret_path" {
description = "Path within the KV-v2 mount holding client_id and client_secret"
type = string
default = "service/authentik/oidc-vault"
}
variable "default_role" {
description = "Role used when none is supplied at login (the UI/CLI default)"
type = string
default = "default"
}
variable "listing_visibility" {
description = "Specifies whether to show this mount in the UI-specific listing endpoint. Valid values are 'unauth' or 'hidden'"
type = string
default = null
validation {
condition = var.listing_visibility == null || contains(["unauth", "hidden"], var.listing_visibility)
error_message = "listing_visibility must be either 'unauth' or 'hidden'."
}
}
variable "default_lease_ttl" {
description = "Specifies the default time-to-live. If set, this overrides the global default. Must be a valid duration string"
type = string
default = null
}
variable "max_lease_ttl" {
description = "Specifies the maximum time-to-live. If set, this overrides the global default. Must be a valid duration string"
type = string
default = null
}
@@ -0,0 +1,13 @@
# External identity group: membership is asserted by the IdP through the role's
# groups_claim, matched by the alias name below.
resource "vault_identity_group" "group" {
name = var.groupname
type = "external"
policies = var.policies
}
resource "vault_identity_group_alias" "alias" {
name = var.groupname
mount_accessor = var.mount_accessor
canonical_id = vault_identity_group.group.id
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,14 @@
variable "groupname" {
description = "Name of the IdP group, as it appears in the groups claim"
type = string
}
variable "policies" {
description = "List of policies to assign to the identity group"
type = list(string)
}
variable "mount_accessor" {
description = "Accessor of the OIDC auth mount the alias is bound to"
type = string
}
@@ -0,0 +1,13 @@
resource "vault_jwt_auth_backend_role" "role" {
backend = var.backend
role_name = var.role_name
role_type = "oidc"
user_claim = var.user_claim
groups_claim = var.groups_claim
oidc_scopes = var.oidc_scopes
bound_audiences = var.bound_audiences
allowed_redirect_uris = var.allowed_redirect_uris
token_ttl = var.token_ttl
token_max_ttl = var.token_max_ttl
token_policies = var.token_policies
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,56 @@
variable "backend" {
description = "The unique path of the OIDC auth backend to configure"
type = string
}
variable "role_name" {
description = "The name of the role"
type = string
}
variable "user_claim" {
description = "Claim used as the entity alias name (the Vault identity of the human)"
type = string
default = "email"
}
variable "groups_claim" {
description = "Claim holding the caller's group memberships, matched against identity group aliases"
type = string
default = "ak_groups"
}
variable "oidc_scopes" {
description = "Scopes requested from the identity provider during the authorization request"
type = list(string)
default = []
}
variable "bound_audiences" {
description = "List of audiences (aud claim) accepted in the ID token"
type = list(string)
default = []
}
variable "allowed_redirect_uris" {
description = "Redirect URIs accepted for this role. Must match the provider's registered URIs exactly"
type = list(string)
}
variable "token_ttl" {
description = "The TTL period of tokens issued using this role, in seconds"
type = number
default = 3600
}
variable "token_max_ttl" {
description = "The maximum lifetime for generated tokens in number of seconds. Its current value will be referenced at renewal time."
type = number
default = 28800
}
variable "token_policies" {
description = "List of policies to assign to the role (passed from policy_auth_map). Human authorization normally comes from external identity groups instead"
type = list(string)
default = []
}
@@ -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,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
}
+147
View File
@@ -62,6 +62,46 @@ variable "auth_ldap_group" {
default = {}
}
variable "auth_oidc_backend" {
description = "Map of OIDC (JWT) auth backends to create"
type = map(object({
oidc_discovery_url = string
description = optional(string)
client_secret_mount = optional(string, "kv")
client_secret_path = optional(string, "service/authentik/oidc-vault")
default_role = optional(string, "default")
listing_visibility = optional(string)
default_lease_ttl = optional(string)
max_lease_ttl = optional(string)
}))
default = {}
}
variable "auth_oidc_role" {
description = "Map of OIDC auth roles to create"
type = map(object({
role_name = string
backend = string
allowed_redirect_uris = list(string)
user_claim = optional(string, "email")
groups_claim = optional(string, "ak_groups")
oidc_scopes = optional(list(string), [])
bound_audiences = optional(list(string), [])
token_ttl = optional(number, 3600)
token_max_ttl = optional(number, 28800)
}))
default = {}
}
variable "auth_oidc_group" {
description = "Map of external identity groups bound to an OIDC auth mount"
type = map(object({
groupname = string
backend = string
}))
default = {}
}
variable "auth_kubernetes_backend" {
description = "Map of Kubernetes auth backends to create"
type = map(object({
@@ -316,6 +356,31 @@ 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)
methods = optional(set(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({
@@ -416,6 +481,88 @@ variable "gitea_secret_backend_role" {
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 mediamark app to mint its own Sonarr/Radarr-scoped arrproxy key.
# Bound to the dedicated "mediamark" k8s role only, never the shared "default"
# role, so no other namespace can read these creds.
---
rules:
- path: "arrstack/creds/mediamark"
capabilities:
- read
auth:
k8s/au/syd1:
- mediamark
+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
+19
View File
@@ -0,0 +1,19 @@
# Allow full administration of the OIDC auth backend (mount config and login
# roles), mirroring policies/auth/ldap/admin.yaml. sys/auth/* already covers
# enabling the mount itself; this covers writing auth/oidc/config and
# auth/oidc/role/*.
---
rules:
- path: "auth/oidc/*"
capabilities:
- create
- update
- read
- delete
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -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
@@ -1,11 +0,0 @@
---
rules:
- path: "consul_root/au/syd1/creds/terraform-ipam"
capabilities:
- read
auth:
approle:
- terraform_ipam
k8s/au/syd1:
- woodpecker_terraform_ipam
+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
+16
View File
@@ -0,0 +1,16 @@
# Lets the repospawner pods mint ephemeral Gitea tokens for the repospawner
# service user, so the service authenticates to git.unkin.net as its own
# least-privilege identity. Reading gitea/creds/repospawner returns a
# lease-bound token scoped by the role (write:repository, write:issue,
# read:user -- never merge/admin).
# Kubernetes auth only: repospawner runs in-cluster and the agents AppRole is
# CIDR-bound to Ben's workstation.
---
rules:
- path: "gitea/creds/repospawner"
capabilities:
- read
auth:
k8s/au/syd1:
- repospawner
+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
+6
View File
@@ -10,6 +10,12 @@ rules:
- list
- sudo
# The oidc entry is an Authentik akP-* permission group, not an LDAP group name:
# it names the external identity group under config/auth_oidc_group, so a human
# who logs in via Authentik SSO lands on exactly the policy the LDAP vault_admin
# group already carries. akR-* roles are grouping-only and never named here.
auth:
ldap:
- vault_admin
oidc:
- akP-vault-admin
+38
View File
@@ -0,0 +1,38 @@
# Allow the deployer to manage external identity groups and their aliases, which
# is how OIDC group membership (the ak_groups claim) maps onto Vault policies.
# Both the collection endpoints and the per-id endpoints are needed: create posts
# to identity/group, subsequent reads and updates address identity/group/id/<id>.
---
rules:
- path: "identity/group"
capabilities:
- create
- update
- path: "identity/group/*"
capabilities:
- create
- update
- read
- delete
- list
- path: "identity/group-alias"
capabilities:
- create
- update
- path: "identity/group-alias/*"
capabilities:
- create
- update
- read
- delete
- list
- path: "identity/lookup/group"
capabilities:
- create
- update
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -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 @@
# Lets the agents AppRole read a dedicated, scoped Authentik API token seeded by
# the operator. Automation seeds OAuth2 client secrets and LDAP outpost tokens as
# part of normal IaC workflows; fetching an outpost token requires calling the
# Authentik API, so a scoped token (not the CI admin credential) is read here.
# Mirrors the gitea/creds/unkin-agent agents-approle read grant pattern.
---
rules:
- path: "kv/data/service/authentik/agent-api-token"
capabilities:
- read
auth:
approle:
- agents
@@ -0,0 +1,21 @@
# Let the terraform-vault deployer read the OpenBao OIDC client credentials that
# Authentik's provider module generates and writes here (terraform_authentik owns
# kv/service/authentik/* — see policies/kv/service/authentik/write.yaml). The
# deployer consumes client_id/client_secret to configure the auth/oidc backend.
#
# OIDC becomes the default human auth path; approle and k8s (CI and agents) plus
# the break-glass root path are unchanged.
#
# AppRole capabilities are fixed at login, so this grant must be applied before
# the PR that adds the auth/oidc modules.
---
rules:
- path: "kv/data/service/authentik/oidc-vault"
capabilities:
- read
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
+32
View File
@@ -0,0 +1,32 @@
# Let the terraform-authentik runner manage the Authentik service KV subtree as
# IaC (vault_kv_secret_v2), starting with kv/service/authentik/agent-api-token,
# which is currently seeded by hand.
#
# Scoping: the ask was to grant only *-token paths, but Vault ACL paths support
# a trailing glob only (no infix/suffix wildcards), so the grant is the whole
# kv/service/authentik/ subtree. That is acceptable because terraform-authentik
# is the owner of every secret under kv/service/authentik/.
#
# delete is included (unlike the agents kv/kubernetes grant) so `terraform
# destroy` and resource replacement can remove a secret and its metadata
# cleanly; vault_kv_secret_v2 also reads the kv-v2 metadata path on every
# plan/apply, so metadata read/list is required for the plan to succeed.
---
rules:
- path: "kv/data/service/authentik/*"
capabilities:
- create
- read
- update
- delete
- path: "kv/metadata/service/authentik/*"
capabilities:
- read
- list
- delete
auth:
approle:
- terraform_authentik
k8s/au/syd1:
- woodpecker_terraform_authentik
@@ -0,0 +1,10 @@
# Allow reading Ceph mediafs CSI secrets
---
rules:
- path: "kv/data/service/kubernetes/au/syd1/csi/ceph-mediafs-secret"
capabilities:
- read
auth:
k8s/au/syd1:
- ceph-csi
+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
-13
View File
@@ -1,13 +0,0 @@
# Allow the Terraform IPAM 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/ipam"
capabilities:
- read
auth:
approle:
- terraform_ipam
k8s/au/syd1:
- woodpecker_terraform_ipam
@@ -0,0 +1,24 @@
# Allow the agents AppRole to seed the ghp secrets engine's admin credentials.
# The ghp engine (managed by the tf_vault deployer) reads its ghpsvc_ service
# token from admin_token at ghp/config creation time; that key is currently
# unseeded, so the master apply aborts on the ghp data-source read. This grants
# the agents AppRole scoped write here so an agent can seed the value. Deployer
# read is already granted via policies/kv/service/vault/secret_backends_read.yaml,
# so this only adds the write side for the agents identity.
---
rules:
# Seed value: admin_token (a ghpsvc_ service token). create/update to seed,
# read so the agent can verify what it wrote. No delete (least privilege).
- path: "kv/data/service/vault/au/syd1/secret_backend/ghp/config"
capabilities:
- create
- update
- read
# vault_kv_secret_v2 also reads the kv-v2 metadata path on every plan/apply.
- path: "kv/metadata/service/vault/au/syd1/secret_backend/ghp/config"
capabilities:
- read
auth:
approle:
- agents
+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
+20
View File
@@ -0,0 +1,20 @@
# Allow the terraform-rancher runner to mint an ephemeral Rancher API token
# from the "ci" role, so its Makefile can swap
# vault kv get kv/service/terraform/rancher
# for
# vault read -field=token rancher/creds/ci
# and retire the static 90-day admin token. The minted token is lease-bound
# (ttl 1h, max 8h) and deleted from Rancher on revoke; it inherits the seeded
# admin service account's RBAC, so this is the same privilege as the static
# token it replaces, just short-lived.
---
rules:
- path: "rancher/creds/ci"
capabilities:
- read
auth:
approle:
- terraform_rancher
k8s/au/syd1:
- woodpecker_terraform_rancher
+18
View File
@@ -8,6 +8,24 @@ rules:
- delete
- read
- list
# Enabling/tuning an auth mount is sudo-protected, and an exact path match
# wins over the wildcard above, so both rules repeat the full capability set.
- path: "sys/auth/oidc"
capabilities:
- create
- update
- delete
- read
- list
- sudo
- path: "sys/auth/oidc/tune"
capabilities:
- create
- update
- delete
- read
- list
- sudo
auth:
approle:
@@ -1,4 +1,4 @@
key_prefix "infra/terraform/ipam/" {
key_prefix "infra/terraform/infra/" {
policy = "write"
}
@@ -22,6 +22,28 @@ rules:
- "elasticsearch.k8s.elastic.co"
- "kibana.k8s.elastic.co"
- "gateway.networking.k8s.io"
- "apm.k8s.elastic.co"
- "beat.k8s.elastic.co"
- "agent.k8s.elastic.co"
- "maps.k8s.elastic.co"
- "enterprisesearch.k8s.elastic.co"
- "autoscaling.k8s.elastic.co"
- "stackconfigpolicy.k8s.elastic.co"
- "valkey.io"
- "ceph.unkin.net"
- "bind.unkin.net"
- "kea.unkin.net"
- "k8up.io"
- "grafana.integreatly.org"
- "operator.victoriametrics.com"
- "clickhouse.altinity.com"
- "clickhouse-keeper.altinity.com"
- "acme.cert-manager.io"
- "deviceplugin.intel.com"
- "fpga.intel.com"
- "autoscaling.k8s.io"
- "snapshot.storage.k8s.io"
- "groupsnapshot.storage.k8s.io"
resources:
- "*"
verbs: