25 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
54 changed files with 1021 additions and 16 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
+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
+34
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, {
@@ -1,15 +0,0 @@
# Role minting ephemeral, scoped ghp agent tokens. Reading ghp/creds/agent mints
# a lease-bound token deleted from ghp on revoke/expiry. token_type "agent" binds
# the minted token to a ghp App installation, so installation_id is REQUIRED.
#
# installation_id below is a PLACEHOLDER (0) and MUST be set to the real ghp App
# installation id before this role can mint usable tokens. scopes are ghp
# permission:level pairs; contents:read is the least-privilege default.
---
token_type: agent
installation_id: 0 # PLACEHOLDER - set to the real ghp App installation id
scopes:
- contents:read
session_prefix: vault
ttl: 3600 # 1h
max_ttl: 86400 # 24h
@@ -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,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"
+5
View File
@@ -60,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
@@ -76,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
+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
+79
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"
@@ -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 = []
}
+65
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({
+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
+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
+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
@@ -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
+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:
@@ -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: