Add identity OIDC modules and YAML discovery (#166)
ci/woodpecker/push/apply Pipeline was successful

Identity OIDC (token roles, provider clients, scopes, assignments) is not managed from YAML yet, and role/client names share one global credential path namespace that is easy to collide on.

- add identity_oidc{,_key,_role,_client,_scope,_assignment,_provider} modules and config discovery; roles/clients live under config/<kind>/<key>/
- allow each role/client client_id on its key via vault_identity_oidc_key_allowed_client_id
- resolve provider allowed_clients from client names
- add a test rejecting duplicate role/client names and references to missing keys, clients, scopes or assignments

Reviewed-on: #166
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #166.
This commit is contained in:
2026-10-16 22:00:23 +11:00
committed by BenVincent
parent c64f3f90b3
commit 9c1f05ed60
31 changed files with 630 additions and 0 deletions
+41
View File
@@ -300,6 +300,47 @@ locals {
})
if startswith(file_path, "netbox_secret_backend_role/")
}
identity_oidc = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "identity_oidc/")
}
identity_oidc_key = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "identity_oidc_key/")
}
identity_oidc_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "identity_oidc_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
key = dirname(replace(file_path, "identity_oidc_role/", ""))
})
if startswith(file_path, "identity_oidc_role/")
}
identity_oidc_client = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "identity_oidc_client/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
key = dirname(replace(file_path, "identity_oidc_client/", ""))
})
if startswith(file_path, "identity_oidc_client/")
}
identity_oidc_scope = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "identity_oidc_scope/")
}
identity_oidc_assignment = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "identity_oidc_assignment/")
}
identity_oidc_provider = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "identity_oidc_provider/")
}
ghp_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
+8
View File
@@ -98,6 +98,14 @@ inputs = {
ghp_secret_backend = local.config.ghp_secret_backend
ghp_secret_backend_role = local.config.ghp_secret_backend_role
identity_oidc = local.config.identity_oidc
identity_oidc_key = local.config.identity_oidc_key
identity_oidc_role = local.config.identity_oidc_role
identity_oidc_client = local.config.identity_oidc_client
identity_oidc_scope = local.config.identity_oidc_scope
identity_oidc_assignment = local.config.identity_oidc_assignment
identity_oidc_provider = local.config.identity_oidc_provider
# Pass policy maps to vault_cluster module
policy_auth_map = local.policies.policy_auth_map
policy_rules_map = local.policies.policy_rules_map
+102
View File
@@ -683,3 +683,105 @@ module "pki_mount_only" {
delta_rebuild_interval = each.value.delta_rebuild_interval
}
module "identity_oidc" {
source = "./modules/identity_oidc"
for_each = var.identity_oidc
issuer = each.value.issuer
}
module "identity_oidc_key" {
source = "./modules/identity_oidc_key"
for_each = var.identity_oidc_key
name = each.key
algorithm = each.value.algorithm
rotation_period = each.value.rotation_period
verification_ttl = each.value.verification_ttl
depends_on = [module.identity_oidc]
}
module "identity_oidc_role" {
source = "./modules/identity_oidc_role"
for_each = var.identity_oidc_role
name = each.value.name
key = each.value.key
template = each.value.template
ttl = each.value.ttl
client_id = each.value.client_id
depends_on = [module.identity_oidc, module.identity_oidc_key]
}
module "identity_oidc_assignment" {
source = "./modules/identity_oidc_assignment"
for_each = var.identity_oidc_assignment
name = each.key
entity_ids = each.value.entity_ids
group_ids = each.value.group_ids
}
module "identity_oidc_client" {
source = "./modules/identity_oidc_client"
for_each = var.identity_oidc_client
name = each.value.name
key = each.value.key
redirect_uris = each.value.redirect_uris
assignments = each.value.assignments
client_type = each.value.client_type
id_token_ttl = each.value.id_token_ttl
access_token_ttl = each.value.access_token_ttl
depends_on = [module.identity_oidc, module.identity_oidc_key, module.identity_oidc_assignment]
}
module "identity_oidc_key_allowed_client_id" {
source = "./modules/identity_oidc_key_allowed_client_id"
for_each = merge(
{ for k, v in var.identity_oidc_role : "role/${k}" => { key = v.key, client_id = module.identity_oidc_role[k].client_id } },
{ for k, v in var.identity_oidc_client : "client/${k}" => { key = v.key, client_id = module.identity_oidc_client[k].client_id } },
)
key_name = each.value.key
allowed_client_id = each.value.client_id
depends_on = [module.identity_oidc_key]
}
module "identity_oidc_scope" {
source = "./modules/identity_oidc_scope"
for_each = var.identity_oidc_scope
name = each.key
description = each.value.description
template = each.value.template
}
module "identity_oidc_provider" {
source = "./modules/identity_oidc_provider"
for_each = var.identity_oidc_provider
name = each.key
issuer_host = each.value.issuer_host
https_enabled = each.value.https_enabled
scopes_supported = each.value.scopes_supported
allowed_client_ids = [
for c in each.value.allowed_clients :
c == "*" ? c : one([for k, v in var.identity_oidc_client : module.identity_oidc_client[k].client_id if v.name == c])
]
depends_on = [module.identity_oidc, module.identity_oidc_scope, module.identity_oidc_client]
}
@@ -0,0 +1,3 @@
resource "vault_identity_oidc" "this" {
issuer = var.issuer
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,5 @@
variable "issuer" {
description = "Issuer URL for identity tokens (scheme://host:port, no path); null uses Vault's api_addr"
type = string
default = null
}
@@ -0,0 +1,5 @@
resource "vault_identity_oidc_assignment" "this" {
name = var.name
entity_ids = var.entity_ids
group_ids = var.group_ids
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,16 @@
variable "name" {
description = "Assignment name"
type = string
}
variable "entity_ids" {
description = "Identity entity ids in this assignment"
type = list(string)
default = []
}
variable "group_ids" {
description = "Identity group ids in this assignment"
type = list(string)
default = []
}
@@ -0,0 +1,9 @@
resource "vault_identity_oidc_client" "this" {
name = var.name
key = var.key
redirect_uris = var.redirect_uris
assignments = var.assignments
client_type = var.client_type
id_token_ttl = var.id_token_ttl
access_token_ttl = var.access_token_ttl
}
@@ -0,0 +1,3 @@
output "client_id" {
value = vault_identity_oidc_client.this.client_id
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,39 @@
variable "name" {
description = "OIDC client name"
type = string
}
variable "key" {
description = "Named key that signs this client's ID tokens"
type = string
}
variable "redirect_uris" {
description = "Allowed redirect URIs"
type = list(string)
default = []
}
variable "assignments" {
description = "Assignment names allowed to authenticate with this client"
type = list(string)
default = []
}
variable "client_type" {
description = "confidential or public"
type = string
default = null
}
variable "id_token_ttl" {
description = "ID token TTL in seconds"
type = number
default = null
}
variable "access_token_ttl" {
description = "Access token TTL in seconds"
type = number
default = null
}
@@ -0,0 +1,6 @@
resource "vault_identity_oidc_key" "this" {
name = var.name
algorithm = var.algorithm
rotation_period = var.rotation_period
verification_ttl = var.verification_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,22 @@
variable "name" {
description = "Name of the named key"
type = string
}
variable "algorithm" {
description = "Signing algorithm (RS256, RS384, RS512, ES256, ES384, ES512, EdDSA)"
type = string
default = null
}
variable "rotation_period" {
description = "Seconds between key rotations"
type = number
default = null
}
variable "verification_ttl" {
description = "Seconds a rotated public key stays published for verification"
type = number
default = null
}
@@ -0,0 +1,4 @@
resource "vault_identity_oidc_key_allowed_client_id" "this" {
key_name = var.key_name
allowed_client_id = var.allowed_client_id
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,9 @@
variable "key_name" {
description = "Named key the client id may sign with"
type = string
}
variable "allowed_client_id" {
description = "Role or client id allowed to use the key"
type = string
}
@@ -0,0 +1,7 @@
resource "vault_identity_oidc_provider" "this" {
name = var.name
issuer_host = var.issuer_host
https_enabled = var.https_enabled
scopes_supported = var.scopes_supported
allowed_client_ids = var.allowed_client_ids
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,28 @@
variable "name" {
description = "OIDC provider name"
type = string
}
variable "issuer_host" {
description = "Issuer host[:port]; null uses Vault's api_addr"
type = string
default = null
}
variable "https_enabled" {
description = "Use https in the issuer URL"
type = bool
default = true
}
variable "scopes_supported" {
description = "Scope names the provider serves"
type = list(string)
default = []
}
variable "allowed_client_ids" {
description = "Client ids allowed to use the provider (\"*\" allows all)"
type = list(string)
default = []
}
@@ -0,0 +1,7 @@
resource "vault_identity_oidc_role" "this" {
name = var.name
key = var.key
template = var.template
ttl = var.ttl
client_id = var.client_id
}
@@ -0,0 +1,3 @@
output "client_id" {
value = vault_identity_oidc_role.this.client_id
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,27 @@
variable "name" {
description = "Role name (read identity/oidc/token/<name> to mint a token)"
type = string
}
variable "key" {
description = "Named key that signs this role's tokens"
type = string
}
variable "template" {
description = "JSON claims template for issued tokens"
type = string
default = null
}
variable "ttl" {
description = "Token TTL in seconds"
type = number
default = null
}
variable "client_id" {
description = "Token audience (aud); null lets Vault generate one"
type = string
default = null
}
@@ -0,0 +1,5 @@
resource "vault_identity_oidc_scope" "this" {
name = var.name
description = var.description
template = var.template
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
}
}
@@ -0,0 +1,16 @@
variable "name" {
description = "Scope name"
type = string
}
variable "description" {
description = "Scope description"
type = string
default = null
}
variable "template" {
description = "JSON claims template added when this scope is requested"
type = string
default = null
}
+73
View File
@@ -578,3 +578,76 @@ variable "policy_rules_map" {
default = {}
}
variable "identity_oidc" {
description = "Identity token issuer config (singleton in practice)"
type = map(object({
issuer = optional(string)
}))
default = {}
}
variable "identity_oidc_key" {
description = "Map of identity OIDC named keys to create"
type = map(object({
algorithm = optional(string, "RS256")
rotation_period = optional(number, 86400)
verification_ttl = optional(number, 86400)
}))
default = {}
}
variable "identity_oidc_role" {
description = "Map of identity token roles to create, keyed <key>/<role>"
type = map(object({
name = string
key = string
template = optional(string)
ttl = optional(number, 86400)
client_id = optional(string)
}))
default = {}
}
variable "identity_oidc_client" {
description = "Map of identity OIDC provider clients to create, keyed <key>/<client>"
type = map(object({
name = string
key = string
redirect_uris = optional(list(string), [])
assignments = optional(list(string), [])
client_type = optional(string, "confidential")
id_token_ttl = optional(number, 86400)
access_token_ttl = optional(number, 86400)
}))
default = {}
}
variable "identity_oidc_scope" {
description = "Map of identity OIDC provider scopes to create"
type = map(object({
description = optional(string)
template = optional(string)
}))
default = {}
}
variable "identity_oidc_assignment" {
description = "Map of identity OIDC client assignments to create"
type = map(object({
entity_ids = optional(list(string), [])
group_ids = optional(list(string), [])
}))
default = {}
}
variable "identity_oidc_provider" {
description = "Map of identity OIDC providers to create; allowed_clients are client names or \"*\""
type = map(object({
issuer_host = optional(string)
https_enabled = optional(bool, true)
scopes_supported = optional(list(string), [])
allowed_clients = optional(list(string), [])
}))
default = {}
}
+120
View File
@@ -0,0 +1,120 @@
"""Validate identity OIDC role/client definitions under config/."""
import tempfile
import unittest
from collections import defaultdict
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
def problems(config_dir):
"""Duplicate role/client names and references to missing keys, clients, scopes or assignments."""
config_dir = Path(config_dir)
keys = {f.stem for f in config_dir.glob("identity_oidc_key/*.yaml")}
names = defaultdict(list)
found = []
for kind in ("identity_oidc_role", "identity_oidc_client"):
for f in sorted(config_dir.glob(f"{kind}/**/*.yaml")):
rel = f.relative_to(config_dir).as_posix()
names[f.stem].append(rel)
key = f.parent.relative_to(config_dir / kind).as_posix()
if key not in keys:
found.append(f'{rel}: key "{key}" has no config/identity_oidc_key/{key}.yaml')
for name, files in sorted(names.items()):
if len(files) > 1:
found.append(f'role/client name "{name}" is used by {", ".join(files)}')
def stems(kind):
return {f.stem for f in config_dir.glob(f"{kind}/*.yaml")}
clients = {f.stem for f in config_dir.glob("identity_oidc_client/**/*.yaml")}
refs = [
("identity_oidc_provider", "allowed_clients", clients | {"*"}),
("identity_oidc_provider", "scopes_supported", stems("identity_oidc_scope")),
# allow_all is Vault's built-in assignment.
("identity_oidc_client", "assignments", stems("identity_oidc_assignment") | {"allow_all"}),
]
for kind, field, known in refs:
for f in sorted(config_dir.glob(f"{kind}/**/*.yaml")):
for ref in (yaml.safe_load(f.read_text()) or {}).get(field, []):
if ref not in known:
found.append(f'{f.relative_to(config_dir).as_posix()}: {field} "{ref}" does not exist')
return found
class RepoTests(unittest.TestCase):
def test_repo_config(self):
self.assertEqual(problems(REPO_ROOT / "config"), [])
class ProblemsTests(unittest.TestCase):
def tree(self, *paths, **content):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
for p in (*paths, *content):
f = Path(tmp.name, p)
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(yaml.safe_dump(content.get(p, {})))
return tmp.name
def test_clean(self):
d = self.tree(
"identity_oidc_key/a.yaml",
"identity_oidc_key/b.yaml",
"identity_oidc_role/a/r1.yaml",
"identity_oidc_role/b/r2.yaml",
"identity_oidc_client/a/c1.yaml",
)
self.assertEqual(problems(d), [])
def test_role_client_collision(self):
d = self.tree(
"identity_oidc_key/a.yaml",
"identity_oidc_role/a/x.yaml",
"identity_oidc_client/a/x.yaml",
)
self.assertEqual(problems(d), [
'role/client name "x" is used by identity_oidc_role/a/x.yaml, identity_oidc_client/a/x.yaml'
])
def test_collision_across_keys(self):
d = self.tree(
"identity_oidc_key/a.yaml",
"identity_oidc_key/b.yaml",
"identity_oidc_role/a/x.yaml",
"identity_oidc_role/b/x.yaml",
)
self.assertEqual(problems(d), [
'role/client name "x" is used by identity_oidc_role/a/x.yaml, identity_oidc_role/b/x.yaml'
])
def test_missing_key(self):
d = self.tree("identity_oidc_client/nope/c.yaml")
self.assertEqual(problems(d), [
'identity_oidc_client/nope/c.yaml: key "nope" has no config/identity_oidc_key/nope.yaml'
])
def test_references(self):
d = self.tree(
"identity_oidc_key/a.yaml",
"identity_oidc_scope/groups.yaml",
"identity_oidc_assignment/admins.yaml",
**{
"identity_oidc_client/a/c.yaml": {"assignments": ["admins", "allow_all", "ghost"]},
"identity_oidc_provider/p.yaml": {
"allowed_clients": ["c", "*", "nope"],
"scopes_supported": ["groups", "email"],
},
},
)
self.assertEqual(problems(d), [
'identity_oidc_provider/p.yaml: allowed_clients "nope" does not exist',
'identity_oidc_provider/p.yaml: scopes_supported "email" does not exist',
'identity_oidc_client/a/c.yaml: assignments "ghost" does not exist',
])
if __name__ == "__main__":
unittest.main()