Repository: spack/spack

Issue:
spack creates app directory with permissions of 777
A fresh clone of spack is creating the install directory with permissions of 777. This began with commit 3a026f14123e8a010e0c969bb38dfdde2738226f, #11995. These are bad permissions, particularly when deploying a stack on a system.

With the following config:

```yaml
config:
  build_stage: /dev/shm
  misc_cache: $spack/var/spack/cache
  install_tree: $spack/../apps
```
an installation will create directories as follows:
```console
$ ls -l ../apps/                                                                        
total 0
drwxrwxrwx 3 gpjohnsn its-rs-user 31 Jul 23 15:12 linux-centos7-x86_64
$ ls -l ../apps/linux-centos7-x86_64/
total 0
drwxrwxrwx 3 gpjohnsn its-rs-user 66 Jul 23 15:12 gcc-4.8.5
```

The expected behavior is:
```console
$ ls -l ../apps/                                                                            
total 0
drwxr-xr-x 3 gpjohnsn its-rs-user 31 Jul 23 15:11 linux-centos7-x86_64
$ ls -l ../apps/                                                                            
total 0
drwxr-xr-x 3 gpjohnsn its-rs-user 31 Jul 23 15:11 linux-centos7-x86_64/gcc-4.8.5
```



Discussion:
Permissions of 775 would be okay as the group used among people deploying spack packages can be set, but not 777.
It seems this change was deliberate and documented in code:

https://github.com/spack/spack/blob/3a026f14123e8a010e0c969bb38dfdde2738226f/lib/spack/spack/directory_layout.py#L258-L269

On the other hand I don't see why 777 should be the only viable solution to #11240

@albestro ?
As a system administrator, I do not want everyone to be able to write to those directories. I would want to control the group ownership of the parent directory, as well as the intermediate directories, so that only members of a "spack" group could write to them.

Also, the 777 permissions would allow everyone on the system the ability to remove package directories.

```console
$ rm -rf /some/path/linux-centos7-x86_64/gcc-4.8.5
```

would succeed for everyone.

I understand your concerns, the decision was taken thinking of using the `install_tree` directory as a chokepoint, but maybe something better can be done.

In fact, what I do is to completely restrict access for `install_tree` to `others`, but this make me think:
- since `others` does not have neither `r-x` access to the `install_tree`, there should be no reason to set `rwx` for them on nested folders;
- completely restricting access to the `install_tree` for `others`, means that they are not able to install packages, but also that they cannot use installed packages (in general, I think it is interesting to have this option!)

In order to model the problem, I think that we can identify:
- Spack users: generally, who can manage packages;
- Passive users: they can not manage packages, but they can use them (accordingly to specific package restrictions);
- Restricted users: users that cannot use packages at all (e.g. they cannot even list them);

And what has to be considered is:
- Spack users must have `rwx` access to `install_tree` and `intermediate folders` in order to be able to install packages, otherwise we fall into the problem of not being able to install packages because `intermediate folders` belong to the user who created them;
- Passive users should not have write permissions on `install_tree` and `intermediate folders`, but they should have `r-x` permissions in order to be able to list packages directories;
- Then at package level there is the final access control: packages can have their specific permissions (this can be controlled from the configuration in `packages.yaml`), but their default permissions should respect the categorization of users above.

I'm thinking that setting permissions for `install_tree` and `intermediate folders` is case-specific; one of the target of the PR was to try not adding a configuration for this (@becker33), but I think that it may be the case.

In fact, at this point it seems that there is no general permissions that can be used to address all cases. In a group-instance I may want to have `other` as "passive users", but same concepts can be applied in a single-user instance where also group members have to be "passive users".

Looking forward to hear your thoughts.
@glennpj @albestro @alalazo 

What do you think of setting the intermediate directories to the same permissions as `config:install_tree`? I think that should be easily doable with the functionality that @albestro put in place.

I think in most cases, the `install_tree` folder should provide the necessary chokepoint to avoid the problems that @glennpj was discussing, but I don't think we considered the case where `install_tree` does not yet exist when implementing #11995.
I think there needs to be an expectation that the `install_tree` ownership and permissions need to be managed outside of spack, even if spack creates the directory, unless there are configure options. If spack creates `install_tree` then the current default of 755 is fairly sane. I assume that those permissions are set explicitly and not dependent on the umask value. That is not an ideal value for a group of people who will install spack packages but that is easily changed.

I do like the idea of having the intermediate directories use the same permissions as `install_tree` if it exists. I would also add that it would be nice if the group ownership of an existing `install_tree` were used as well. Again, if spack creates that directory it will not know the desired group ownership so it would be up to the admin/user to change that at some point. Ideally, one will know where spack packages will be installed and set up `install_tree` ownership and permissions before deploying.
Re group ownership: We should probably only respect the group ownership of `install_tree` for the intermediate directories if the group ownership differs from the user ownership, because if someone (intentionally) has a world writable Spack we could have a shared Spack instance with install tree group ownership tied to an individual, so other users wouldn't have permissions to match the group ownership if they were creating the intermediate directories.

I think we should probably add a configuration option for `install_tree` permissions (`readable`, `writable`, `group` with defaults `world`, `group`, `None` respectively. 

Alternatively, the `install_tree` permissions could be the `all` permissions for packages. The more I think about it the more that makes sense, but it does have the possible downside that it would not be possible to user package-specific configurations to make a more permissive package than the `all` package defaults.

We could then apply the `install_tree` permissions to both `install_tree` and all intermediate directories.

Resolving patch:
diff --git a/lib/spack/llnl/util/filesystem.py b/lib/spack/llnl/util/filesystem.py
--- a/lib/spack/llnl/util/filesystem.py
+++ b/lib/spack/llnl/util/filesystem.py
@@ -226,7 +226,10 @@ def group_ids(uid=None):
 
 def chgrp(path, group):
     """Implement the bash chgrp function on a single path"""
-    gid = grp.getgrnam(group).gr_gid
+    if isinstance(group, six.string_types):
+        gid = grp.getgrnam(group).gr_gid
+    else:
+        gid = group
     os.chown(path, -1, gid)
 
 
@@ -414,6 +417,13 @@ def get_filetype(path_name):
     return output.strip()
 
 
+def chgrp_if_not_world_writable(path, group):
+    """chgrp path to group if path is not world writable"""
+    mode = os.stat(path).st_mode
+    if not mode & stat.S_IWOTH:
+        chgrp(path, group)
+
+
 def mkdirp(*paths, **kwargs):
     """Creates a directory, as well as parent directories if needed.
 
@@ -421,27 +431,38 @@ def mkdirp(*paths, **kwargs):
         paths (str): paths to create with mkdirp
 
     Keyword Aguments:
-        mode (permission bits or None, optional): optional permissions to
-            set on the created directory -- use OS default if not provided
-        mode_intermediate (permission bits or None, optional):
-            same as mode, but for newly-created intermediate directories
+        mode (permission bits or None, optional): optional permissions to set
+            on the created directory -- use OS default if not provided
+        group (group name or None, optional): optional group for permissions of
+            final created directory -- use OS default if not provided. Only
+            used if world write permissions are not set
+        default_perms ('parents' or 'args', optional): The default permissions
+            that are set for directories that are not themselves an argument
+            for mkdirp. 'parents' means intermediate directories get the
+            permissions of their direct parent directory, 'args' means
+            intermediate get the same permissions specified in the arguments to
+            mkdirp -- default value is 'args'
     """
     mode = kwargs.get('mode', None)
-    mode_intermediate = kwargs.get('mode_intermediate', None)
+    group = kwargs.get('group', None)
+    default_perms = kwargs.get('default_perms', 'args')
+
     for path in paths:
         if not os.path.exists(path):
             try:
+                # detect missing intermediate folders
                 intermediate_folders = []
-                if mode_intermediate is not None:
-                    # detect missing intermediate folders
-                    intermediate_path = os.path.dirname(path)
+                last_parent = ''
+
+                intermediate_path = os.path.dirname(path)
 
-                    while intermediate_path:
-                        if os.path.exists(intermediate_path):
-                            break
+                while intermediate_path:
+                    if os.path.exists(intermediate_path):
+                        last_parent = intermediate_path
+                        break
 
-                        intermediate_folders.append(intermediate_path)
-                        intermediate_path = os.path.dirname(intermediate_path)
+                    intermediate_folders.append(intermediate_path)
+                    intermediate_path = os.path.dirname(intermediate_path)
 
                 # create folders
                 os.makedirs(path)
@@ -449,13 +470,36 @@ def mkdirp(*paths, **kwargs):
                 # leaf folder permissions
                 if mode is not None:
                     os.chmod(path, mode)
+                if group:
+                    chgrp_if_not_world_writable(path, group)
+                    if mode is not None:
+                        os.chmod(path, mode)  # reset sticky grp bit post chgrp
 
                 # for intermediate folders, change mode just for newly created
                 # ones and if mode_intermediate has been specified, otherwise
                 # intermediate folders list is not populated at all and default
                 # OS mode will be used
+                if default_perms == 'args':
+                    intermediate_mode = mode
+                    intermediate_group = group
+                elif default_perms == 'parents':
+                    stat_info = os.stat(last_parent)
+                    intermediate_mode = stat_info.st_mode
+                    intermediate_group = stat_info.st_gid
+                else:
+                    msg = "Invalid value: '%s'. " % default_perms
+                    msg += "Choose from 'args' or 'parents'."
+                    raise ValueError(msg)
+
                 for intermediate_path in reversed(intermediate_folders):
-                    os.chmod(intermediate_path, mode_intermediate)
+                    if intermediate_mode is not None:
+                        os.chmod(intermediate_path, intermediate_mode)
+                    if intermediate_group is not None:
+                        chgrp_if_not_world_writable(intermediate_path,
+                                                    intermediate_group)
+                        os.chmod(intermediate_path,
+                                 intermediate_mode)  # reset sticky bit after
+
             except OSError as e:
                 if e.errno != errno.EEXIST or not os.path.isdir(path):
                     raise e
diff --git a/lib/spack/spack/directory_layout.py b/lib/spack/spack/directory_layout.py
--- a/lib/spack/spack/directory_layout.py
+++ b/lib/spack/spack/directory_layout.py
@@ -12,7 +12,7 @@
 
 import ruamel.yaml as yaml
 
-from llnl.util.filesystem import mkdirp, chgrp
+from llnl.util.filesystem import mkdirp
 
 import spack.config
 import spack.spec
@@ -256,23 +256,13 @@ def create_install_directory(self, spec):
         from spack.package_prefs import get_package_group
 
         # Each package folder can have its own specific permissions, while
-        # intermediate folders (arch/compiler) are set with full access to
-        # everyone (0o777) and install_tree root folder is the chokepoint
-        # for restricting global access.
-        # So, whoever has access to the install_tree is allowed to install
-        # packages for same arch/compiler and since no data is stored in
-        # intermediate folders, it does not represent a security threat.
+        # intermediate folders (arch/compiler) are set with access permissions
+        # equivalent to the root permissions of the layout.
         group = get_package_group(spec)
         perms = get_package_dir_permissions(spec)
-        perms_intermediate = 0o777
 
-        mkdirp(spec.prefix, mode=perms, mode_intermediate=perms_intermediate)
-        if group:
-            chgrp(spec.prefix, group)
-            # Need to reset the sticky group bit after chgrp
-            os.chmod(spec.prefix, perms)
-
-        mkdirp(self.metadata_path(spec), mode=perms)
+        mkdirp(spec.prefix, mode=perms, group=group, default_perms='parents')
+        mkdirp(self.metadata_path(spec), mode=perms, group=group)  # in prefix
 
         self.write_spec(spec, self.spec_file_path(spec))
 


Test patch: