Data-driven user onboarding: add a JSON entry to Vault KV k8s_users, apply vault + platform + woodpecker stacks, and everything is auto-generated. Vault stack: namespace creation, per-user Vault policies with secret isolation via identity entities/aliases, K8s deployer roles, CI policy update. Platform stack: domains field in k8s_users type, TLS secrets per user namespace, user domains merged into Cloudflare DNS, user-roles ConfigMap mounted in portal. Woodpecker stack: admin list auto-generated from k8s_users, WOODPECKER_OPEN=true. K8s-portal: dual-track onboarding (general/namespace-owner), namespace-owner dashboard with Vault/kubectl commands, setup script adds Vault+Terraform+Terragrunt, contributing page with CI pipeline template, versioned image tags in CI pipeline. New: stacks/_template/ with copyable stack template for namespace-owners.
263 lines
6.9 KiB
HCL
263 lines
6.9 KiB
HCL
variable "tls_secret_name" {}
|
|
variable "tier" { type = string }
|
|
|
|
variable "k8s_users" {
|
|
type = map(object({
|
|
role = string # "admin", "power-user", "namespace-owner"
|
|
email = string # OIDC email claim
|
|
namespaces = optional(list(string), []) # for namespace-owners
|
|
domains = optional(list(string), []) # subdomains for user apps
|
|
quota = optional(object({
|
|
cpu_requests = optional(string, "2")
|
|
memory_requests = optional(string, "4Gi")
|
|
memory_limits = optional(string, "8Gi")
|
|
pods = optional(string, "20")
|
|
}), {})
|
|
}))
|
|
default = {}
|
|
}
|
|
|
|
# --- Admin role ---
|
|
# Binds to built-in cluster-admin ClusterRole
|
|
|
|
resource "kubernetes_cluster_role_binding" "admin_users" {
|
|
for_each = nonsensitive({ for name, user in var.k8s_users : name => user if user.role == "admin" })
|
|
|
|
metadata {
|
|
name = "oidc-admin-${each.key}"
|
|
}
|
|
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = "cluster-admin"
|
|
}
|
|
|
|
subject {
|
|
kind = "User"
|
|
name = each.value.email
|
|
api_group = "rbac.authorization.k8s.io"
|
|
}
|
|
}
|
|
|
|
# --- Power-user role ---
|
|
# Can manage workloads cluster-wide but cannot modify RBAC, nodes, or persistent volumes
|
|
|
|
resource "kubernetes_cluster_role" "power_user" {
|
|
metadata {
|
|
name = "oidc-power-user"
|
|
}
|
|
|
|
# Core resources
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods", "pods/log", "pods/exec", "services", "endpoints", "configmaps", "secrets", "persistentvolumeclaims", "events", "namespaces"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims"]
|
|
verbs = ["create", "update", "patch", "delete"]
|
|
}
|
|
|
|
# Apps
|
|
rule {
|
|
api_groups = ["apps"]
|
|
resources = ["deployments", "statefulsets", "daemonsets", "replicasets"]
|
|
verbs = ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
}
|
|
|
|
# Batch
|
|
rule {
|
|
api_groups = ["batch"]
|
|
resources = ["jobs", "cronjobs"]
|
|
verbs = ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
}
|
|
|
|
# Networking
|
|
rule {
|
|
api_groups = ["networking.k8s.io"]
|
|
resources = ["ingresses", "networkpolicies"]
|
|
verbs = ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
}
|
|
|
|
# Autoscaling
|
|
rule {
|
|
api_groups = ["autoscaling"]
|
|
resources = ["horizontalpodautoscalers"]
|
|
verbs = ["get", "list", "watch", "create", "update", "patch", "delete"]
|
|
}
|
|
|
|
# Read-only on cluster-level resources
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["nodes"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["storage.k8s.io"]
|
|
resources = ["storageclasses"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["rbac.authorization.k8s.io"]
|
|
resources = ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
}
|
|
|
|
resource "kubernetes_cluster_role_binding" "power_users" {
|
|
for_each = nonsensitive({ for name, user in var.k8s_users : name => user if user.role == "power-user" })
|
|
|
|
metadata {
|
|
name = "oidc-power-user-${each.key}"
|
|
}
|
|
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = kubernetes_cluster_role.power_user.metadata[0].name
|
|
}
|
|
|
|
subject {
|
|
kind = "User"
|
|
name = each.value.email
|
|
api_group = "rbac.authorization.k8s.io"
|
|
}
|
|
}
|
|
|
|
# --- Namespace-owner role ---
|
|
# Full admin within assigned namespaces + read-only cluster-wide
|
|
|
|
locals {
|
|
# Flatten user->namespace pairs for iteration
|
|
namespace_owner_pairs = flatten([
|
|
for name, user in var.k8s_users : [
|
|
for ns in user.namespaces : {
|
|
user_key = name
|
|
namespace = ns
|
|
email = user.email
|
|
quota = user.quota
|
|
}
|
|
] if user.role == "namespace-owner"
|
|
])
|
|
}
|
|
|
|
resource "kubernetes_role_binding" "namespace_owner" {
|
|
for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair })
|
|
|
|
metadata {
|
|
name = "namespace-owner-${each.value.user_key}"
|
|
namespace = each.value.namespace
|
|
}
|
|
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = "admin" # Built-in ClusterRole with full namespace access
|
|
}
|
|
|
|
subject {
|
|
kind = "User"
|
|
name = each.value.email
|
|
api_group = "rbac.authorization.k8s.io"
|
|
}
|
|
}
|
|
|
|
# Read-only cluster-wide access for namespace owners
|
|
resource "kubernetes_cluster_role" "namespace_owner_readonly" {
|
|
metadata {
|
|
name = "oidc-namespace-owner-readonly"
|
|
}
|
|
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["namespaces", "nodes"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods", "services", "configmaps", "events"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["apps"]
|
|
resources = ["deployments", "statefulsets", "daemonsets"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
}
|
|
|
|
resource "kubernetes_cluster_role_binding" "namespace_owner_readonly" {
|
|
for_each = nonsensitive({ for name, user in var.k8s_users : name => user if user.role == "namespace-owner" })
|
|
|
|
metadata {
|
|
name = "oidc-ns-owner-readonly-${each.key}"
|
|
}
|
|
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = kubernetes_cluster_role.namespace_owner_readonly.metadata[0].name
|
|
}
|
|
|
|
subject {
|
|
kind = "User"
|
|
name = each.value.email
|
|
api_group = "rbac.authorization.k8s.io"
|
|
}
|
|
}
|
|
|
|
# Resource quotas per user namespace
|
|
resource "kubernetes_resource_quota" "user_namespace_quota" {
|
|
for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair })
|
|
|
|
metadata {
|
|
name = "user-quota"
|
|
namespace = each.value.namespace
|
|
}
|
|
|
|
spec {
|
|
hard = {
|
|
"requests.cpu" = each.value.quota.cpu_requests
|
|
"requests.memory" = each.value.quota.memory_requests
|
|
"limits.memory" = each.value.quota.memory_limits
|
|
"pods" = each.value.quota.pods
|
|
}
|
|
}
|
|
|
|
depends_on = [kubernetes_role_binding.namespace_owner]
|
|
}
|
|
|
|
# ConfigMap with user-role mapping for the self-service portal
|
|
resource "kubernetes_config_map" "user_roles" {
|
|
metadata {
|
|
name = "k8s-user-roles"
|
|
namespace = "k8s-portal"
|
|
}
|
|
|
|
data = {
|
|
"users.json" = jsonencode({
|
|
for name, user in var.k8s_users : user.email => {
|
|
role = user.role
|
|
namespaces = user.namespaces
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
# TLS secret in each user namespace (so they can create HTTPS ingresses)
|
|
module "user_namespace_tls" {
|
|
for_each = nonsensitive(toset(flatten([
|
|
for name, user in var.k8s_users : user.namespaces
|
|
if user.role == "namespace-owner"
|
|
])))
|
|
|
|
source = "../../../../modules/kubernetes/setup_tls_secret"
|
|
namespace = each.value
|
|
tls_secret_name = var.tls_secret_name
|
|
}
|