1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/accounts/apps.py
catborise 99dabbdcec fix(accounts): migrate no longer overrides the change-password permission
SHOW_PROFILE_EDIT_PASSWORD was replaced in 2020 by the change_password
permission, settable per user or group. A post_migrate hook applied the
old setting to all users so existing installs kept their behavior, and
asked to remove the setting afterwards; but the settings template still
set it to True. Every migrate, so every upgrade, therefore granted the
permission to all users again and undid an admin's per-user or
per-group choice (with False it would have revoked it from everyone).

- Migration accounts.0008 applies the setting one last time, if a
  settings.py still has it; the post_migrate hook is gone.
- New users get the permission when they are created (any path,
  including LDAP); the admin user form ticks it and saves the admin's
  choice afterwards.
- The template no longer sets SHOW_PROFILE_EDIT_PASSWORD; a check
  (accounts.W001) asks to remove it from existing settings, and the
  README upgrade notes say so.

The test that removes the permission and runs migrate fails on the
previous code. An upgrade from master with users was simulated: the
setting is applied once, and a permission removed afterwards stays
removed through the next migrate.
2026-10-04 20:26:53 +03:00

120 lines
4.1 KiB
Python

import os
from django.apps import AppConfig
from django.db.models.signals import post_migrate
from django.db import transaction
def admin_password_path():
from django.conf import settings
return os.path.join(str(settings.BASE_DIR), "data", "admin_password")
def _migrated(state_apps, model, field=None):
"""post_migrate follows every migrate, also targeted ones and rollbacks:
act only once the post-migration state has the model (and field)."""
if state_apps is None:
return False
try:
fields = state_apps.get_model(model)._meta.get_fields()
except LookupError:
return False
return field is None or any(f.name == field for f in fields)
def grant_change_password(sender, instance, created, raw=False, **kwargs):
"""New users may change their own password unless an admin takes the
permission away; the user form saves its own choice after this."""
if not created or raw:
return
from django.contrib.auth.models import Permission
permission = Permission.objects.filter(content_type__app_label="accounts", codename="change_password").first()
if permission is not None: # created by post_migrate on a fresh database
instance.user_permissions.add(permission)
def _store_generated_password(password):
"""
Write the generated admin password to data/admin_password (mode 0600)
instead of printing it, so it does not end up in install logs or
`docker logs`. Abort provisioning if private storage fails.
"""
path = admin_password_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
# Never write through a pre-existing file or symlink (this runs as
# root in Docker, while data/ belongs to www-data).
if os.path.lexists(path):
os.unlink(path)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as f:
os.fchmod(f.fileno(), 0o600)
f.write(password + "\n")
print(f"\033[1m* \033[93mGenerated admin password written to {path}\033[0m")
except OSError as err:
raise RuntimeError(
f"Cannot securely store the generated admin password at {path}. "
"Fix the data directory permissions and run migrate again."
) from err
def create_admin(sender, **kwargs):
"""
Create initial admin user
"""
import os
import secrets
import sys
from django.contrib.auth.models import User
from accounts.models import UserAttributes
# Any real migrate run provisions while no user exists, so a run that
# failed to store the password can simply be repeated. flush (used by
# tests) sends plan=None.
if kwargs.get("plan") is None:
return
state_apps = kwargs.get("apps")
if not (_migrated(state_apps, "auth.User") and _migrated(state_apps, "accounts.UserAttributes", "must_change_password")):
return
if User.objects.exists():
return
is_testing = "test" in sys.argv
admin_user = os.environ.get("ADMIN_USERNAME", "admin")
admin_pass = os.environ.get("ADMIN_PASSWORD")
generated_password = not admin_pass and not is_testing
if not admin_pass:
if is_testing:
admin_pass = "admin"
else:
admin_pass = secrets.token_urlsafe(16)
_store_generated_password(admin_pass)
print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m")
with transaction.atomic():
admin = User.objects.create_superuser(admin_user, None, admin_pass)
UserAttributes.objects.create(
user=admin,
must_change_password=generated_password,
max_instances=-1,
max_cpus=-1,
max_memory=-1,
max_disk_size=-1,
)
class AccountsConfig(AppConfig):
name = "accounts"
verbose_name = "Accounts"
def ready(self):
post_migrate.connect(create_admin, sender=self)
from django.conf import settings
from django.db.models.signals import post_save
post_save.connect(grant_change_password, sender=settings.AUTH_USER_MODEL)
from . import checks # noqa: F401