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:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-08 21:48:39 +02:00
1 parent feccf1c281
commit 7916c0177e
10 files changed
+42 -14

No files matched your search

+2
View File
@@ -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
@@ -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: []
@@ -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."
+1
View File
@@ -8,6 +8,7 @@ no-reply:
rate: 60 / 1min
burst: 3600
mcp-web-app-nextcloud:
username: mcp-nextcloud
firstname: Infinito
lastname: MCP
accounts: []
+1
View File
@@ -1,5 +1,6 @@
---
gitlab-web-app-openproject:
username: gitlab-openproject
firstname: Infinito
lastname: GitLab
accounts: []
+1 -1
View File
@@ -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
View File
@@ -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)
)