feat(ceph): authorize ops SSH keys from the identity registry (#174)

This commit is contained in:
Andy Molenda
2026-06-25 09:27:45 -07:00
committed by GitHub
parent 6d4056dffd
commit 8618de8864
7 changed files with 54 additions and 4 deletions
+1
View File
@@ -16,6 +16,7 @@ inventories/*/inventory.ini
inventories/*/inventory-provision.ini
inventories/*/inventory-destroy.ini
inventories/*/secrets.yml.tpl
inventories/*/group_vars/all/operators.yml
# host_vars IS committed (per-node hardware facts — stable, part of the inventory
# source of truth). Operator-local overrides use host_vars/*.local.yml if needed.
@@ -7,6 +7,12 @@ baseline_ops_user: ops
baseline_ops_uid: 1001
baseline_ops_sudo: "ALL" # "ALL" = password required; "NOPASSWD:ALL" for dev
# Operator SSH public keys authorized on the ops account. Sourced from the
# identity registry (tf/shared/modules/identity, members of a `server`-mapped
# group) and rendered into group_vars/all/operators.yml by
# render-inventories.sh. Default empty: with no keys, ops stays password-only.
ops_authorized_keys: []
# --- /etc/hosts ---
# Rendered from the same cluster variables used by ceph_deploy/hosts.j2.
# Keeps host entries converged even if ceph_deploy hasn't run yet.
@@ -36,3 +36,14 @@
dest: "/etc/sudoers.d/{{ baseline_ops_user }}"
mode: '0440'
validate: "visudo -cf %s"
# Operator SSH access to the shared ops account, sourced from the identity
# registry (rendered into group_vars/all/operators.yml). exclusive: true makes
# the registry the single source of truth -- keys not in it are removed. Skipped
# when the list is empty so ops stays password-only rather than getting wiped.
- name: Authorize operator SSH keys on the ops account
ansible.posix.authorized_key:
user: "{{ baseline_ops_user }}"
key: "{{ ops_authorized_keys | join('\n') }}"
exclusive: true
when: ops_authorized_keys | length > 0
@@ -47,6 +47,7 @@ for cluster, spec in data.items():
os.makedirs(d, exist_ok=True)
for fname, content in spec["files"].items():
p = os.path.join(d, fname)
os.makedirs(os.path.dirname(p), exist_ok=True) # fname may include subdirs (e.g. group_vars/all/)
with open(p, "w") as fh:
fh.write(content)
os.chmod(p, 0o644)
+14 -1
View File
@@ -39,12 +39,25 @@ output "cluster_summaries" {
# Rendered content lives in a TF OUTPUT (not in local_file resources), so the
# shared remote state never records a checkout-specific filesystem path. See
# the module's rendering.tf for the full rationale.
# Canonical user/group registry. Used here only for server operator access:
# members of a `server`-mapped group get their SSH keys into the node ops
# account's authorized_keys (rendered as group_vars/all/operators.yml below).
module "identity" {
source = "../../../shared/modules/identity"
}
output "render" {
description = "Per-cluster { dirname, files } for the local render wrapper."
value = {
for k, m in module.cluster : k => {
dirname = m.inventory_dirname
files = m.rendered_files
files = merge(m.rendered_files, {
# Operator SSH keys for the shared ops account, from the identity
# registry. Public keys only; gitignored (rendered, not committed).
"group_vars/all/operators.yml" = yamlencode({
ops_authorized_keys = module.identity.server_authorized_keys
})
})
}
}
}
+8
View File
@@ -8,6 +8,14 @@ output "groups" {
value = var.groups
}
output "server_authorized_keys" {
description = "Union of SSH public keys (ed25519 + rsa) for members of any group with a `server` mapping. For populating a shared node ops account's authorized_keys."
value = sort(distinct(flatten([
for uname, u in var.users : concat(u.ssh_ed25519_keys, u.ssh_rsa_keys)
if length([for g in u.groups : g if try(var.groups[g].server, null) != null]) > 0
])))
}
output "members_of" {
description = "Group name -> sorted member usernames. Lets a consumer (e.g. servers) provision a group's people."
value = {
+13 -3
View File
@@ -23,7 +23,7 @@ variable "users" {
nutgood = {
full_name = "Antoine"
uid = 3000
groups = ["fabric_admins"]
groups = ["fabric_admins", "server_admins"]
ssh_ed25519_keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOaH71qha6kLO2qRu+w6C5wPpWhkiBjEUeY1fAjAVApR"
]
@@ -31,7 +31,7 @@ variable "users" {
andy = {
full_name = "Andy"
uid = 3001
groups = ["fabric_viewer"]
groups = ["fabric_viewer", "server_admins"]
ssh_ed25519_keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO7XdW03gJKyABt5KCMxOLPb5sOGTXuZV0OHc1Ro46Nt andy@futo.org"
]
@@ -40,7 +40,7 @@ variable "users" {
}
variable "groups" {
description = "Groups keyed by name. A group carries per-system role mappings; `fabric` grants switch-fabric rights to its members. (A `server` mapping will be added when server provisioning is wired up.)"
description = "Groups keyed by name. A group carries per-system role mappings; `fabric` grants switch-fabric rights, `server` grants login access to provisioned servers (their members' SSH keys flow to the node ops account)."
type = map(object({
description = optional(string)
# How membership manifests on the switch fabric. Set exactly one of:
@@ -50,6 +50,12 @@ variable "groups" {
class = optional(string)
permissions = optional(list(string))
}))
# How membership manifests on servers. Presence grants login access; today
# members' SSH keys populate the shared node ops account's authorized_keys.
# `sudo` is reserved for per-user accounts if/when those are wired up.
server = optional(object({
sudo = optional(string)
}))
}))
default = {
@@ -61,6 +67,10 @@ variable "groups" {
description = "Read-only access to the switch fabric."
fabric = { class = "read-only" }
}
server_admins = {
description = "Login access to provisioned servers (e.g. the ceph nodes)."
server = { sudo = "ALL" }
}
}
validation {