feat: auto-grant superuser to owner email addresses

Security-hardened implementation that automatically grants superuser status
to graham@towerops.net and graham@mcintire.me during user registration.

Security measures:
- Only runs during NEW user creation (__meta__.state == :built check)
- Cannot be triggered via account updates (prevents privilege escalation)
- Case-insensitive comparison (prevents 'Graham@TOWEROPS.NET' bypass)
- Email must pass validation and uniqueness constraint first
- Explicit defense-in-depth with comprehensive security comments

Prevents scenarios where:
- Owner loses access to their own system
- Need to manually grant superuser via database manipulation
- Case variations could bypass the check

Test coverage includes:
- Both email addresses get superuser
- Case variations are handled correctly
- Similar/spoofed emails are rejected
- Other emails don't get superuser
This commit is contained in:
Graham McIntire 2026-03-09 15:45:23 -05:00
parent 4e3f732f21
commit 1dd6d687a7
No known key found for this signature in database

View file

@ -150,6 +150,7 @@ defmodule Towerops.Accounts.User do
|> validate_timezone()
|> validate_length(:first_name, max: 100)
|> validate_length(:last_name, max: 100)
|> maybe_grant_superuser()
end
defp validate_consent(changeset) do
@ -162,6 +163,32 @@ defmodule Towerops.Accounts.User do
)
end
# Automatically grant superuser status to specific trusted email addresses.
#
# SECURITY MEASURES:
# 1. Only runs during NEW user creation (checks __meta__.state == :built)
# 2. Email must pass validation and uniqueness constraint before reaching here
# 3. Case-insensitive comparison prevents "Graham@towerops.net" bypass attempts
# 4. Cannot be triggered via account updates (prevents privilege escalation)
#
# This ensures only legitimate registrations with exact email addresses gain superuser access.
defp maybe_grant_superuser(changeset) do
# Only grant superuser on NEW user creation, never on updates
if changeset.data.__meta__.state == :built do
email = get_field(changeset, :email)
# Case-insensitive comparison - defense against "Graham@TOWEROPS.net" variants
if email && String.downcase(email) in ["graham@towerops.net", "graham@mcintire.me"] do
put_change(changeset, :is_superuser, true)
else
changeset
end
else
# This is an update to an existing user - never grant superuser via update
changeset
end
end
defp validate_timezone(changeset) do
validate_change(changeset, :timezone, fn :timezone, timezone ->
validate_timezone_value(timezone)