5 Commits

Author SHA1 Message Date
unkin-agent dc18f2e556 Add logging/vlogs kubernetes auth role
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Bind the templated kv/kubernetes policy to it
2026-09-28 22:37:24 +10:00
unkin-agent 890d666a53 Keep policy rule paths within their own directory (#158)
ci/woodpecker/push/apply Pipeline was successful
Nothing stopped a policy under policies/x/y/ from granting a rule path outside its own directory, so an over-broad grant read as ordinary review noise and only surfaced once applied to Vault.

- add tests/test_policies.py: a pydantic model rejecting unknown keys, empty rules and non-Vault capabilities
- check every rule path segment-wise against the policy's own directory, stripping the kv-v2 data/metadata segment
- relocate the rules that reached outside their directory, carrying capabilities and auth bindings verbatim
- run the suite from a local pre-commit hook and from make test

Reviewed-on: #158
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-28 22:12:14 +10:00
unkin-agent 2ec552d6fd Add agent-observability kubernetes creds role and policy (#156)
ci/woodpecker/push/apply Pipeline was successful
No agent Vault role covers the VictoriaMetrics/VictoriaLogs stack, so a scoped Kubernetes token cannot be issued for it and writes there fall back to an admin context.

- add the agent-observability kubernetes secret backend role, allowed in vm-system, observability and logging
- add its generated role rules: read plus patch/update on VictoriaMetrics CRs and workloads, pod delete for rolling restarts, read-only on services, configmaps, endpoints, events and Gateway API routes
- add a policy granting update on kubernetes/au/syd1/creds/agent-observability to the cluster_operator LDAP group and the agents approle

Reviewed-on: #156
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-27 11:23:36 +10:00
unkin-agent 563b2164c7 Let terraform-authentik read the vlogs OIDC client secret (#157)
ci/woodpecker/push/apply Pipeline was successful
vlogs is the first app needing a second OIDC secret in a namespace that already has one, so the one-per-namespace `oauth-credentials` wildcard does not cover it and the terraform-authentik plan fails on a denied kv read.

- grant the runner read on `kv/kubernetes/namespace/logging/default/vlogs-oauth-credentials`

Unblocks terraform-authentik #40.

Reviewed-on: #157
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-27 10:35:05 +10:00
unkin-agent b576524553 Accept IP and short-hostname principals on sshca/signhost (#155)
ci/woodpecker/push/apply Pipeline was successful
Puppet signs host certs with principals hostname, FQDN and IP (plus extra IPs on k8s nodes). The signhost role only matched allowed_domains entries exactly or by suffix, so every agent run failed with `198.18.29.56 is not a valid value for valid_principals`.

- Set `allowed_domains` on `sshca/signhost` to `*`, the only value OpenBao treats as unrestricted for host principals (per-entry globs are not honoured).
- Note the sole-entry requirement in the config.

Role stays host-only (`allow_user_certificates: false`); the CA key is untouched.

Reviewed-on: #155
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-24 22:26:55 +10:00
25 changed files with 319 additions and 78 deletions
+9
View File
@@ -22,3 +22,12 @@ repos:
"-d {extends: relaxed, rules: {line-length: disable}, ignore: chart}",
"-s",
]
- repo: local
hooks:
- id: vault-yaml-tests
name: vault yaml definitions pass their unit tests
entry: python3 -m unittest discover -s tests -t .
language: python
additional_dependencies: [pyyaml, pydantic]
pass_filenames: false
always_run: true
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: init plan apply format
.PHONY: init plan apply format test
VAULT_AUTH_METHOD ?= approle
VAULT_K8S_ROLE ?= woodpecker_terraform_vault
@@ -28,6 +28,9 @@ apply: init
@$(call vault_env) && \
terragrunt run --all --parallelism 2 --non-interactive apply
test:
@uv run --with pyyaml --with pydantic python -m unittest discover -s tests -t .
format:
@echo "Formatting OpenTofu files..."
@tofu fmt -recursive .
@@ -0,0 +1,7 @@
bound_service_account_names:
- vlogs
bound_service_account_namespaces:
- logging
token_ttl: 600
token_max_ttl: 600
audience: vault
@@ -0,0 +1,6 @@
allowed_kubernetes_namespaces:
- "vm-system"
- "observability"
- "logging"
kubernetes_role_type: "Role"
extra_labels: {}
@@ -3,6 +3,8 @@ algorithm_signer: rsa-sha2-256
ttl: 315360000 # 87600 * 3600
allow_host_certificates: true
allow_user_certificates: false
allowed_domains: "unkin.net,main.unkin.net,consul"
# "*" must be the sole entry: OpenBao only treats allowed_domains as unrestricted
# when the whole string is "*", and otherwise matches entries exactly or by suffix.
allowed_domains: "*"
allow_subdomains: true
allow_bare_domains: false
+3 -15
View File
@@ -1,20 +1,8 @@
# Allow the vault deployer to import the gpg plugin and manage its OpenPGP keys.
#
# terraform-vault registers the plugin itself (vault_plugin -> sys/plugins/catalog,
# a sudo-protected path) and manages keys via the gpgvaultsecret provider, so the
# deployer needs catalog access on top of the mount access it already has
# (sys/mounts/*). Without this, apply 403s on the plugin registration and on
# gpg/keys writes.
# Allow the vault deployer to manage the gpg mount's OpenPGP keys via the
# gpgvaultsecret provider. Registering the plugin itself is a sys/plugins/catalog
# grant, carried by policies/sys/plugins/catalog/admin.yaml.
---
rules:
# Import / register (and deregister) the gpg plugin in the catalog.
- path: "sys/plugins/catalog/secret/vault-plugin-secrets-gpg"
capabilities:
- create
- read
- update
- delete
- sudo
# Manage keys (create/rotate/config/delete) in the gpg mount.
- path: "gpg/keys/*"
capabilities:
+24
View File
@@ -0,0 +1,24 @@
# Allow the deployer to manage the identity group aliases that map external OIDC
# group membership (the ak_groups claim) onto the Vault groups managed under
# policies/identity/group/. Both endpoints are needed: create posts to
# identity/group-alias, subsequent reads and updates address
# identity/group-alias/id/<id>.
---
rules:
- path: "identity/group-alias"
capabilities:
- create
- update
- path: "identity/group-alias/*"
capabilities:
- create
- update
- read
- delete
- list
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
+4 -19
View File
@@ -1,7 +1,7 @@
# 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>.
# Allow the deployer to manage the external identity groups that OIDC group
# membership (the ak_groups claim) maps onto Vault policies through. Both the
# collection endpoint 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"
@@ -15,21 +15,6 @@ rules:
- 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:
+14
View File
@@ -0,0 +1,14 @@
# Allow the deployer to look up an identity group by name, which is how it
# resolves the group it is about to update under policies/identity/group/.
---
rules:
- path: "identity/lookup/group"
capabilities:
- create
- update
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
@@ -0,0 +1,12 @@
# Allow access to agent-observability Kubernetes credentials
---
rules:
- path: "kubernetes/au/syd1/creds/agent-observability"
capabilities:
- update
auth:
ldap:
- kubernetes_au_syd1_cluster_operator
approle:
- agents
+1
View File
@@ -14,3 +14,4 @@ rules:
auth:
k8s/au/syd1:
- default
- logging_vlogs
@@ -0,0 +1,15 @@
# Allow the Terraform Authentik runner to read the OAuth2/OIDC client secret that
# backs an authentik provider, in whichever namespace the target service lives
# (the module's data.vault_kv_secret_v2). The literal + is a Vault single-segment
# wildcard: one oauth-credentials secret per onboarded namespace.
---
rules:
- path: "kv/data/kubernetes/namespace/+/default/oauth-credentials"
capabilities:
- read
auth:
approle:
- terraform_authentik
k8s/au/syd1:
- woodpecker_terraform_authentik
@@ -0,0 +1,14 @@
# Allow the Terraform Rancher runner to read the OAuth2/OIDC client secret backing
# the Rancher keycloakoidc AuthConfig (the same secret Authentik sets on the
# provider).
---
rules:
- path: "kv/data/kubernetes/namespace/cattle-system/default/oauth-credentials"
capabilities:
- read
auth:
approle:
- terraform_rancher
k8s/au/syd1:
- woodpecker_terraform_rancher
@@ -0,0 +1,14 @@
# Allow the Terraform Authentik runner to read the vlogs OIDC client secret. It is
# a second OIDC client in an already-onboarded namespace, so it cannot use the
# one-per-namespace oauth-credentials path.
---
rules:
- path: "kv/data/kubernetes/namespace/logging/default/vlogs-oauth-credentials"
capabilities:
- read
auth:
approle:
- terraform_authentik
k8s/au/syd1:
- woodpecker_terraform_authentik
+3 -7
View File
@@ -1,15 +1,11 @@
# Allow the Terraform Authentik runner to read:
# - its own Authentik API token (provider auth), and
# - OAuth2/OIDC client secrets that back authentik providers (per target
# service's kv namespace path, via the module's data.vault_kv_secret_v2).
# Allow the Terraform Authentik runner to read its own Authentik API token
# (provider auth). The OAuth2/OIDC client secrets backing authentik providers are
# granted per secret under policies/kv/kubernetes/namespace/.
---
rules:
- path: "kv/data/service/terraform/authentik"
capabilities:
- read
- path: "kv/data/kubernetes/namespace/+/default/oauth-credentials"
capabilities:
- read
auth:
approle:
+3 -7
View File
@@ -1,15 +1,11 @@
# Allow the Terraform Rancher runner to read:
# - its own Rancher admin API token (rancher2 provider auth), and
# - the OAuth2/OIDC client secret backing the Rancher keycloakoidc AuthConfig
# (same secret Authentik sets on the provider).
# Allow the Terraform Rancher runner to read its own Rancher admin API token
# (rancher2 provider auth). The OAuth2/OIDC client secret backing the Rancher
# keycloakoidc AuthConfig is granted under policies/kv/kubernetes/namespace/.
---
rules:
- path: "kv/data/service/terraform/rancher"
capabilities:
- read
- path: "kv/data/kubernetes/namespace/cattle-system/default/oauth-credentials"
capabilities:
- read
auth:
approle:
-12
View File
@@ -1,12 +0,0 @@
# Allow the puppet catalog compilers to issue host certificates
# via certmanager during catalog compilation
---
rules:
- path: "pki_int/issue/servers_default"
capabilities:
- create
- update
auth:
k8s/au/syd1:
- puppet
-12
View File
@@ -1,12 +0,0 @@
# Allow the puppet catalog compilers to sign SSH host certificates
# via sshsignhost during catalog compilation
---
rules:
- path: "sshca/sign/signhost"
capabilities:
- create
- update
auth:
k8s/au/syd1:
- puppet
+15
View File
@@ -0,0 +1,15 @@
# Allow the deployer to read and tune the configuration of a mounted secret
# engine. sys/mounts-tune is the tuning endpoint beside the mount management
# granted by policies/sys/mounts/admin.yaml.
---
rules:
- path: "sys/mounts-tune/*"
capabilities:
- update
- read
auth:
approle:
- tf_vault
k8s/au/syd1:
- woodpecker_terraform_vault
-4
View File
@@ -8,10 +8,6 @@ rules:
- delete
- read
- list
- path: "sys/mounts-tune/*"
capabilities:
- update
- read
- path: "sys/mounts"
capabilities:
- read
@@ -0,0 +1,62 @@
---
rules:
# Verbs are listed explicitly rather than "*": delete on a VLCluster reclaims
# its cephrbd-fast-delete PVCs, destroying the log store.
- apiGroups:
- "operator.victoriametrics.com"
resources:
- "*"
verbs:
- "get"
- "list"
- "watch"
- "create"
- "patch"
- "update"
- apiGroups:
- "apps"
resources:
- "deployments"
- "statefulsets"
- "daemonsets"
verbs:
- "get"
- "list"
- "watch"
- "patch"
- "update"
- apiGroups:
- ""
resources:
- "pods"
verbs:
- "get"
- "list"
- "watch"
- "delete"
- apiGroups:
- ""
resources:
- "pods/log"
verbs:
- "get"
- apiGroups:
- ""
resources:
- "services"
- "configmaps"
- "endpoints"
- "events"
verbs:
- "get"
- "list"
- "watch"
- apiGroups:
- "gateway.networking.k8s.io"
resources:
- "gateways"
- "httproutes"
verbs:
- "get"
- "list"
- "watch"
View File
+106
View File
@@ -0,0 +1,106 @@
"""Validate the Vault policy definitions under policies/."""
import unittest
from pathlib import Path, PurePosixPath
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
REPO_ROOT = Path(__file__).resolve().parent.parent
# kv-v2 inserts one of these directly after the mount; it is not part of the scope.
KV_API_SEGMENTS = {"data", "metadata", "delete", "undelete", "destroy"}
class Rule(BaseModel):
model_config = ConfigDict(extra="forbid")
path: str = Field(min_length=1)
capabilities: list[
Literal["create", "read", "update", "patch", "delete", "list", "sudo", "deny"]
] = Field(min_length=1)
class Policy(BaseModel):
model_config = ConfigDict(extra="forbid")
rules: list[Rule] = Field(min_length=1)
auth: dict[str, list[str]]
def policy_files():
return sorted(REPO_ROOT.glob("policies/**/*.yaml"))
def escaping_scope(policy_file, rule_path):
"""The policy directory a rule path reaches outside of, or None when in scope."""
parts = PurePosixPath(policy_file).parts
# a policy at the policies/ root is located at the root, so its scope is the whole tree
scope = PurePosixPath(*parts[parts.index("policies") + 1:]).parent
path = PurePosixPath(rule_path)
if len(path.parts) > 1 and path.parts[1] in KV_API_SEGMENTS:
path = PurePosixPath(path.parts[0], *path.parts[2:])
return None if path.is_relative_to(scope) else str(scope)
class PolicyFileTests(unittest.TestCase):
def setUp(self):
self.files = policy_files()
self.assertTrue(self.files, f"no policy files discovered under {REPO_ROOT}/policies")
def test_schema(self):
for f in self.files:
with self.subTest(path=f.relative_to(REPO_ROOT).as_posix()):
Policy.model_validate(yaml.safe_load(f.read_text()))
def test_paths(self):
for f in self.files:
rel = f.relative_to(REPO_ROOT).as_posix()
for rule in yaml.safe_load(f.read_text()).get("rules") or []:
rule_path = rule.get("path")
with self.subTest(path=rel, rule=rule_path):
self.assertIsNotNone(rule_path, "rule has no path")
scope = escaping_scope(rel, rule_path)
self.assertIsNone(scope, f'rule path "{rule_path}" escapes policy scope "{scope}"')
class RuleScopeTests(unittest.TestCase):
"""The scope rule against fixtures, so a clean tree cannot hide a broken check."""
def test_in_scope(self):
for policy_file, rule_path in [
("policies/sys/mounts/admin.yaml", "sys/mounts"),
("policies/sys/mounts/admin.yaml", "sys/mounts/*"),
("policies/rundeck/rundeck.yaml", "rundeck/data/*"),
("policies/rundeck/rundeck.yaml", "rundeck/metadata/*"),
("policies/kv/service/authentik/oidc-vault/read.yaml", "kv/data/service/authentik/oidc-vault"),
("policies/kv/service/vault/read.yaml", "kv/data/service/vault/+/+/auth_backend/*"),
("policies/kubernetes/au/admin.yaml", "kubernetes/au/+/config"),
("policies/global-root.yaml", "*"), # a root-level policy is scoped to the whole tree
]:
with self.subTest(policy=policy_file, rule=rule_path):
self.assertIsNone(escaping_scope(policy_file, rule_path))
def test_escapes(self):
for policy_file, rule_path, scope in [
("policies/sys/thing/admin.yaml", "sys/thing-tune/*", "sys/thing"),
("policies/kv/foo/bar/baz.yaml", "kv/data/foo/baz/bar", "kv/foo/bar"),
("policies/kv/service/vault/au/syd1/ghp/w.yaml", "kv/data/service/vault/+/+/ghp/config", "kv/service/vault/au/syd1/ghp"),
]:
with self.subTest(policy=policy_file, rule=rule_path):
self.assertEqual(escaping_scope(policy_file, rule_path), scope)
def test_schema_rejects_malformed(self):
auth = {"approle": ["tf_vault"]}
rule = {"path": "kv/data/x", "capabilities": ["read"]}
for doc in [
{"auth": auth}, # no rules
{"rules": [], "auth": auth}, # empty rules
{"rules": [{"capabilities": ["read"]}], "auth": auth}, # rule without a path
{"rules": [{"path": "kv/data/x", "capabilities": []}], "auth": auth}, # rule without capabilities
{"rules": [{"path": "kv/data/x", "capabilities": ["write"]}], "auth": auth}, # not a Vault capability
{"rules": [rule]}, # no auth
{"rules": [rule], "auth": auth, "rulez": []}, # typo'd top-level key
]:
with self.subTest(doc=doc), self.assertRaises(ValidationError):
Policy.model_validate(doc)