mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
feat(iam): cap every username at the length its tightest consumer accepts
feccf1c281 shortened one account after Discourse refused it. The limit
itself lived nowhere, so the next name could be minted too long again and
nothing would say so until a Rails validation ran mid-deploy.
IAM_USERNAME_MAX_LENGTH in group_vars/all/00_general.yml is now the one
place the number lives, and the three places that can refuse a name read
it:
- the Keycloak user profile renders it as the username `length.max`,
where the value was 255, so registration rejects an over-long name
beside the character pattern it already enforced
- OpenLDAP gains the constraint overlay, `olcConstraintAttribute: uid
size <max>`, in two LDIFs because the module has to load before the
overlay can be added
- the lint reads the variable instead of carrying its own copy, so the
number cannot drift between the gate and the deploy
The lint now measures every declared username rather than only the ones
registered as an identity. That is the broader rule: a name is minted once
and may gain an `identity` account later, so the ceiling belongs on all of
them. The character rule stays scoped to identity accounts, because a
hyphen is fine for an account no IdP ever sees.
Broadening it failed on five names, all of them exempt from the character
rule by having `accounts: []`, which is why nothing had caught them:
gitlab-web-app-openproject 26 -> gitlab-openproject
mcp-web-app-homeassistant 25 -> mcp-homeassistant
breakglass-administrator 24 -> breakglass-admin
mcp-web-app-nextcloud 21 -> mcp-nextcloud
mcp-web-app-wordpress 21 -> mcp-wordpress
Each is shortened by declaring `username` explicitly and keeping the user
key, because the key is what meta/mcp.yml `owner` and the token store wire
to; renaming the keys would have moved the token ownership with them.
All 313 users still resolve, which also proves no duplicate username or
e-mail came out of it, since the builder raises on either.
Existing deployments keep the accounts they already created under the old
names and gain the new ones; web-app-semaphore realigns the break-glass
password against the new login on its next deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
feccf1c281
commit
7916c0177e
10 files changed
+42
-14
No files matched your search
@@ -15,6 +15,8 @@ STORAGE_CONSTRAINED: false # Storage constraint means that Infinito.Nexus prefer
|
||||
|
||||
MASK_CREDENTIALS_IN_LOGS: true
|
||||
|
||||
IAM_USERNAME_MAX_LENGTH: 20 # Discourse refuses a longer one with ActiveRecord::RecordInvalid, and it is the tightest consumer
|
||||
|
||||
HOST_CURRENCY: "EUR"
|
||||
HOST_TIMEZONE: "UTC"
|
||||
HOST_TIMEZONE_LABEL: "United Kingdom"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Load constraint module (cn=config)
|
||||
dn: {{ OPENLDAP_MODULE_DN }}
|
||||
changetype: modify
|
||||
add: olcModuleLoad
|
||||
olcModuleLoad: constraint
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Add constraint overlay to data backend (cn=config): cap the uid length
|
||||
dn: olcOverlay={2}constraint,{{ OPENLDAP_DATA_BACKEND_DN }}
|
||||
objectClass: olcOverlayConfig
|
||||
objectClass: olcConstraintConfig
|
||||
olcOverlay: constraint
|
||||
olcConstraintAttribute: uid size {{ IAM_USERNAME_MAX_LENGTH }}
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
mcp-web-app-homeassistant:
|
||||
username: mcp-homeassistant
|
||||
firstname: Infinito
|
||||
lastname: MCP
|
||||
accounts: []
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"name": "username",
|
||||
"displayName": "${username}",
|
||||
"validations": {
|
||||
"length": { "min": 3, "max": 255 },
|
||||
"length": { "min": 3, "max": {{ IAM_USERNAME_MAX_LENGTH }} },
|
||||
"pattern": {
|
||||
"pattern": "^(?!(?:" ~ KEYCLOAK_RESERVED_USERNAMES_REGEX | replace('\\', '\\\\') ~ ")$)[a-z0-9]+$",
|
||||
"error-message": "Username is reserved or contains invalid characters. Only lowercase letters (a–z) and digits (0–9) are allowed."
|
||||
|
||||
@@ -8,6 +8,7 @@ no-reply:
|
||||
rate: 60 / 1min
|
||||
burst: 3600
|
||||
mcp-web-app-nextcloud:
|
||||
username: mcp-nextcloud
|
||||
firstname: Infinito
|
||||
lastname: MCP
|
||||
accounts: []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
gitlab-web-app-openproject:
|
||||
username: gitlab-openproject
|
||||
firstname: Infinito
|
||||
lastname: GitLab
|
||||
accounts: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
web-app-semaphore-breakglass:
|
||||
username: breakglass-administrator
|
||||
username: breakglass-admin
|
||||
accounts: []
|
||||
forward: platform_administrator
|
||||
description: Semaphore local break-glass admin; not provisioned to IAM, mail forwards to the platform administrator mailbox
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
mcp-web-app-wordpress:
|
||||
username: mcp-wordpress
|
||||
firstname: Infinito
|
||||
lastname: MCP
|
||||
accounts: []
|
||||
|
||||
@@ -4,11 +4,22 @@ import re
|
||||
import unittest
|
||||
|
||||
from utils.cache.users import get_user_defaults
|
||||
from utils.cache.yaml import load_yaml
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
IDENTITY_USERNAME = re.compile(r"^[a-z0-9]+$")
|
||||
IDENTITY_USERNAME_MAX = 20
|
||||
_GENERAL_VARS = PROJECT_ROOT / "group_vars" / "all" / "00_general.yml"
|
||||
|
||||
|
||||
def username_max_length() -> int:
|
||||
"""The username ceiling every IAM consumer is configured against.
|
||||
|
||||
Returns:
|
||||
``IAM_USERNAME_MAX_LENGTH`` from the global variables, which the
|
||||
Keycloak user profile and the OpenLDAP constraint overlay both render.
|
||||
"""
|
||||
return int(load_yaml(str(_GENERAL_VARS))["IAM_USERNAME_MAX_LENGTH"])
|
||||
|
||||
|
||||
class TestIdentityUsernameCharset(unittest.TestCase):
|
||||
@@ -38,27 +49,27 @@ class TestIdentityUsernameCharset(unittest.TestCase):
|
||||
was too long stays too long, and the second consumer only speaks up
|
||||
once the first is satisfied.
|
||||
"""
|
||||
maximum = username_max_length()
|
||||
users = get_user_defaults(roles_dir=str(PROJECT_ROOT / "roles"))
|
||||
offenders: list[str] = []
|
||||
for key, entry in sorted(users.items()):
|
||||
if "identity" not in (entry.get("accounts") or []):
|
||||
continue
|
||||
username = str(entry.get("username", ""))
|
||||
if not IDENTITY_USERNAME.match(username):
|
||||
offenders.append(
|
||||
f"{key}: {username!r} is not lowercase alphanumeric"
|
||||
)
|
||||
elif len(username) > IDENTITY_USERNAME_MAX:
|
||||
if len(username) > maximum:
|
||||
offenders.append(
|
||||
f"{key}: {username!r} is {len(username)} characters, "
|
||||
f"over the {IDENTITY_USERNAME_MAX} Discourse allows"
|
||||
f"over the {maximum} of IAM_USERNAME_MAX_LENGTH"
|
||||
)
|
||||
if "identity" in (entry.get("accounts") or []) and not (
|
||||
IDENTITY_USERNAME.match(username)
|
||||
):
|
||||
offenders.append(
|
||||
f"{key}: {username!r} is not lowercase alphanumeric"
|
||||
)
|
||||
|
||||
if offenders:
|
||||
self.fail(
|
||||
"These users are registered as an identity but carry a "
|
||||
"username one of Keycloak, Mastodon or Discourse refuses:\n"
|
||||
" - " + "\n - ".join(offenders)
|
||||
"These declared users carry a username an IAM consumer "
|
||||
"refuses:\n - " + "\n - ".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user