6d224861 came from a --no-checkout worktree whose empty index made the
commit drop every file except two. This restores 05b50d2b's full tree and
correctly adds stacks/stem95su/gdrive-sync.tf + the service-catalog stem95su
entry. Forward-only (parent=6d224861, no force-push); [ci skip] since the
live infra was never applied from the broken commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
318 lines
8.7 KiB
HCL
318 lines
8.7 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 (read+write+secrets) — RETAINED BUT UNBOUND ---
|
|
# Superseded by oidc-power-user-readonly (below) per ADR-0005: power-users are bound
|
|
# to the read-only role, NOT this one. Kept defined for reference/rollback; do NOT
|
|
# bind it without a deliberate decision (it grants cluster-wide write + secrets).
|
|
|
|
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"]
|
|
}
|
|
}
|
|
|
|
# --- Power-user READ-ONLY role (ADR-0005) ---
|
|
# Cluster-wide get/list/watch, explicitly NO secrets and NO pods/exec. This is the
|
|
# role power-users are actually bound to (workstation tier: "cluster-wide read,
|
|
# no Secrets"). Mirrors power_user's resource breadth minus writes/secrets/exec.
|
|
resource "kubernetes_cluster_role" "power_user_readonly" {
|
|
metadata {
|
|
name = "oidc-power-user-readonly"
|
|
}
|
|
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods", "pods/log", "services", "endpoints", "configmaps", "persistentvolumeclaims", "events", "namespaces", "nodes"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["apps"]
|
|
resources = ["deployments", "statefulsets", "daemonsets", "replicasets"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["batch"]
|
|
resources = ["jobs", "cronjobs"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["networking.k8s.io"]
|
|
resources = ["ingresses", "networkpolicies"]
|
|
verbs = ["get", "list", "watch"]
|
|
}
|
|
|
|
rule {
|
|
api_groups = ["autoscaling"]
|
|
resources = ["horizontalpodautoscalers"]
|
|
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"]
|
|
}
|
|
}
|
|
|
|
# Power-users are bound to the READ-ONLY role above (NOT the read+write+secrets one).
|
|
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-readonly-${each.key}"
|
|
}
|
|
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = kubernetes_cluster_role.power_user_readonly.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
|
|
}
|