Repository: inventree/InvenTree

Issue:
[FR] Support creating reading initial superuser account password from file
### Please verify that this feature request has NOT been suggested before.

- [X] I checked and didn't find a similar feature request

### Problem statement

I am running the docker-compose project on [NixOS](https://nixos.org/) through [Arion](https://docs.hercules-ci.com/arion/).

I am managing all of my secrets/passwords through [agenix](https://github.com/ryantm/agenix), which writes files containing the decrypted passwords to a specific path on the system (with appropriate permissions).
Due to the way Nix works, it is not safe to read the password file directly into the Arion config (Arion will generate a docker-compose.yaml file which will be readable to all users).

Instead, the recommended way for applications to ingest secrets is for them to be read at runtime.


### Suggested solution

I would like an option like `INVENTREE_ADMIN_PASSWORD_FILE` to be added to `add_user_on_startup()` which will read the contents of a file containing the password (mapped as a volume in the generated docker-compose.yaml)


### Describe alternatives you've considered

I could of course just forgo this feature and set up the superuser account manually, but part of the appeal of NixOS is the ability to bootstrap and configure the entire system with just a few commands

### Examples of other systems

From the [agenix readme](https://github.com/ryantm/agenix/blob/91220a701d0bf8055547620baa765b36d885db7a/README.md), shows the suggested way to access the secret (in this case defining linux user accounts)
```
{
  users.users.user1 = {
    isNormalUser = true;
    passwordFile = config.age.secrets.secret1.path;
  };
}
```

Instead of directly accessing a file, some NixOS modules instead take a generic shell command which prints the password to stdout, which gets run at the programs runtime:
```
  programs.msmtp = {
    enable = true;
    accounts.default = {
      auth = true;
      tls = true;
      tls_starttls = true;
      tls_trust_file = "/etc/ssl/certs/ca-bundle.crt";
      tls_certcheck = true;
      host = "smtp.office365.com";
      port = 587;
      from = "asdf@outlook.com";
      user = "asdf@outlook.com";
      passwordeval = "cat ${config.age.secrets.alert-outlook.path}";
    };
  };
```

### Do you want to develop this?

- [X] I want to develop this.


Discussion:
@Gigahawk can you not set this up as an environment variable?
> @Gigahawk can you not set this up as an environment variable?

Placing the password itself into an environment variable will cause the password to be readable to all users on the system.
What I want is to have the container read a file containing the password at runtime (where the file will only be readable by the container)
Sounds interesting. I think you already found the correct part of the code - ping us if you need help implementing it.

From experience there can be some issues with reading from secrets files - please make sure that it fails gracefully and logs (not using print but longer.error so that all the logging drivers work).
@Gigahawk you selected that you want to develop this - should I assign you?
@matmair  yea thats fine
This issue seems stale. Please react to show this is still important.
@Gigahawk still interested? Would be great to support NixOS better
This issue seems stale. Please react to show this is still important.
I had some discussions with users and they pressed that this is blocking for real world usage of inventree with nixos; I will implement the suggestion by @Gigahawk in the next few days

Resolving patch:
diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py
--- a/InvenTree/InvenTree/apps.py
+++ b/InvenTree/InvenTree/apps.py
@@ -60,6 +60,7 @@ def ready(self):
 
         if canAppAccessDatabase() or settings.TESTING_ENV:
             self.add_user_on_startup()
+            self.add_user_from_file()
 
     def remove_obsolete_tasks(self):
         """Delete any obsolete scheduled tasks in the database."""
@@ -214,6 +215,7 @@ def add_user_on_startup(self):
         add_user = get_setting('INVENTREE_ADMIN_USER', 'admin_user')
         add_email = get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email')
         add_password = get_setting('INVENTREE_ADMIN_PASSWORD', 'admin_password')
+        add_password_file = get_setting("INVENTREE_ADMIN_PASSWORD_FILE", "admin_password_file", None)
 
         # check if all values are present
         set_variables = 0
@@ -229,11 +231,21 @@ def add_user_on_startup(self):
 
         # not all needed variables set
         if set_variables < 3:
-            logger.warning('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD')
             settings.USER_ADDED = True
+
+            # if a password file is present, do not warn - will be handled later
+            if add_password_file:
+                return
+            logger.warning('Not all required settings for adding a user on startup are present:\nINVENTREE_ADMIN_USER, INVENTREE_ADMIN_EMAIL, INVENTREE_ADMIN_PASSWORD')
             return
 
         # good to go -> create user
+        self._create_admin_user(add_user, add_email, add_password)
+
+        # do not try again
+        settings.USER_ADDED = True
+
+    def _create_admin_user(self, add_user, add_email, add_password):
         user = get_user_model()
         try:
             with transaction.atomic():
@@ -245,8 +257,34 @@ def add_user_on_startup(self):
         except IntegrityError:
             logger.warning('The user "%s" could not be created', add_user)
 
+    def add_user_from_file(self):
+        """Add the superuser from a file."""
+        # stop if checks were already created
+        if hasattr(settings, "USER_ADDED_FILE") and settings.USER_ADDED_FILE:
+            return
+
+        # get values
+        add_password_file = get_setting(
+            "INVENTREE_ADMIN_PASSWORD_FILE", "admin_password_file", None
+        )
+
+        # no variable set -> do not try anything
+        if not add_password_file:
+            settings.USER_ADDED_FILE = True
+            return
+
+        # check if file exists
+        add_password_file = Path(str(add_password_file))
+        if not add_password_file.exists():
+            logger.warning('The file "%s" does not exist', add_password_file)
+            settings.USER_ADDED_FILE = True
+            return
+
+        # good to go -> create user
+        self._create_admin_user(get_setting('INVENTREE_ADMIN_USER', 'admin_user', 'admin'), get_setting('INVENTREE_ADMIN_EMAIL', 'admin_email', ''), add_password_file.read_text(encoding="utf-8"))
+
         # do not try again
-        settings.USER_ADDED = True
+        settings.USER_ADDED_FILE = True
 
     def collect_notification_methods(self):
         """Collect all notification methods."""


Test patch: