pyfacl 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyfacl-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 sail-mskcc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pyfacl-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.3
2
+ Name: pyfacl
3
+ Version: 1.0.0
4
+ Summary: Package to manage access control using POSIX ACLs
5
+ License: MIT
6
+ Author: tobiaspk
7
+ Author-email: tobiaspk1@gmail.com
8
+ Requires-Python: >=3.12
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Provides-Extra: dev
14
+ Requires-Dist: black (>=23.0.0) ; extra == "dev"
15
+ Requires-Dist: flake8 (>=6.0.0) ; extra == "dev"
16
+ Requires-Dist: isort (>=5.12.0) ; extra == "dev"
17
+ Requires-Dist: pre-commit (>=3.0.0) ; extra == "dev"
18
+ Requires-Dist: pytest (>=7.0.0) ; extra == "dev"
19
+ Description-Content-Type: text/markdown
20
+
21
+ # PyFACL
22
+
23
+ A Python library for parsing and checking POSIX File Access Control Lists (FACL).
24
+
25
+ ## Installation
26
+
27
+ ### From PyPI
28
+ ```bash
29
+ pip install pyfacl
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ from pyfacl import FACL
36
+
37
+ # Initialize and parse FACL for a file/directory
38
+ facl = FACL()
39
+ facl.parse("/path/to/file")
40
+
41
+ # Check permissions with different modes
42
+ facl.has_permission("user:username:r-x", mode="exact") # exact match
43
+ facl.has_permission("user:username:r--", mode="at_least") # has at least read
44
+ facl.has_permission("user:username:rwx", mode="at_most") # has at most rwx
45
+ ```
46
+
47
+ ### Permission Modes
48
+
49
+ - **`exact`**: Permissions must match exactly
50
+ - **`at_least`**: Must have at least the specified permissions
51
+ - **`at_most`**: Must have at most the specified permissions
52
+
53
+ ## Development
54
+
55
+ ### Setup Development Environment
56
+ ```bash
57
+ pip install -e ".[dev]"
58
+ pre-commit install
59
+ ```
60
+
61
+ ### Run Pre-commit Checks
62
+ ```bash
63
+ pre-commit run --all-files
64
+ ```
65
+
pyfacl-1.0.0/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # PyFACL
2
+
3
+ A Python library for parsing and checking POSIX File Access Control Lists (FACL).
4
+
5
+ ## Installation
6
+
7
+ ### From PyPI
8
+ ```bash
9
+ pip install pyfacl
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ from pyfacl import FACL
16
+
17
+ # Initialize and parse FACL for a file/directory
18
+ facl = FACL()
19
+ facl.parse("/path/to/file")
20
+
21
+ # Check permissions with different modes
22
+ facl.has_permission("user:username:r-x", mode="exact") # exact match
23
+ facl.has_permission("user:username:r--", mode="at_least") # has at least read
24
+ facl.has_permission("user:username:rwx", mode="at_most") # has at most rwx
25
+ ```
26
+
27
+ ### Permission Modes
28
+
29
+ - **`exact`**: Permissions must match exactly
30
+ - **`at_least`**: Must have at least the specified permissions
31
+ - **`at_most`**: Must have at most the specified permissions
32
+
33
+ ## Development
34
+
35
+ ### Setup Development Environment
36
+ ```bash
37
+ pip install -e ".[dev]"
38
+ pre-commit install
39
+ ```
40
+
41
+ ### Run Pre-commit Checks
42
+ ```bash
43
+ pre-commit run --all-files
44
+ ```
@@ -0,0 +1,3 @@
1
+ from .pyfacl import FACL
2
+
3
+ __all__ = ["FACL"]
@@ -0,0 +1,29 @@
1
+ import logging
2
+
3
+
4
+ def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
5
+ """
6
+ Set up and return a logger with the specified name and logging level.
7
+
8
+ Args:
9
+ name (str): The name of the logger.
10
+ level (int): The logging level (default is logging.INFO).
11
+
12
+ Returns:
13
+ logging.Logger: Configured logger instance.
14
+ """
15
+ logger = logging.getLogger(name)
16
+ logger.setLevel(level)
17
+
18
+ if not logger.hasHandlers():
19
+ ch = logging.StreamHandler()
20
+ ch.setLevel(level)
21
+
22
+ formatter = logging.Formatter(
23
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
24
+ )
25
+ ch.setFormatter(formatter)
26
+
27
+ logger.addHandler(ch)
28
+
29
+ return logger
@@ -0,0 +1,306 @@
1
+ import logging
2
+ import subprocess
3
+
4
+ from pyfacl import logger
5
+
6
+
7
+ class FACL:
8
+ """
9
+ Represents a POSIX File Access Control List (FACL) for a given file or directory.
10
+ """
11
+
12
+ def __init__(self, v: int = 0, _facl: str = ""):
13
+ """
14
+ Initialize the FACL object. Args are used for debugging and testing.
15
+ """
16
+ self.logger = logger.setup_logger(
17
+ __name__, level=logging.INFO if v == 0 else logging.DEBUG
18
+ )
19
+ self.is_init = False
20
+ self.facl = _facl
21
+ self.path = ""
22
+ self.owner = ""
23
+ self.group = ""
24
+ self.flags = ""
25
+ self.acls = []
26
+
27
+ def parse(self, path: str):
28
+ """
29
+ Parse the FACL for the given file or directory path.
30
+ Args:
31
+ path (str): The file or directory path.
32
+ """
33
+ self.is_init = True
34
+ self.facl = self._get_facl(path)
35
+ self._parse_metadata()
36
+ self._parse_acls()
37
+
38
+ @staticmethod
39
+ def _facl_available():
40
+ """
41
+ Check if the `getfacl` command is available on the system.
42
+
43
+ Returns:
44
+ bool: True if `getfacl` is available, False otherwise.
45
+ """
46
+ return (
47
+ subprocess.call(
48
+ ["which", "getfacl"],
49
+ stdout=subprocess.DEVNULL,
50
+ stderr=subprocess.DEVNULL,
51
+ )
52
+ == 0
53
+ )
54
+
55
+ def _get_facl(self, path: str) -> str:
56
+ """
57
+ Retrieve the FACL for the given path using the `getfacl` command.
58
+
59
+ Args:
60
+ path (str): The file or directory path.
61
+ Returns:
62
+ str: The raw FACL output from the `getfacl` command.
63
+ """
64
+ if not self._facl_available():
65
+ self.logger.error("The 'getfacl' command is not available on this system.")
66
+ return ""
67
+ try:
68
+ result = subprocess.run(
69
+ ["getfacl", path], capture_output=True, text=True, check=True
70
+ )
71
+ return result.stdout
72
+ except subprocess.CalledProcessError as e:
73
+ self.logger.error(f"Error retrieving FACL for {path}: {e}")
74
+ return ""
75
+
76
+ def _parse_metadata(self):
77
+ """
78
+ Parse metadata, such as path, owner, groups and flags.
79
+
80
+ Example:
81
+ ```
82
+ # file: data1/collab002/sail/isabl/datalake/prod/010/collaborators
83
+ # owner: krauset
84
+ # group: grp_hpc_collab002
85
+ # flags: -s-
86
+ ```
87
+ """
88
+ patterns = {
89
+ "path": "# file: ",
90
+ "owner": "# owner: ",
91
+ "group": "# group: ",
92
+ "flags": "# flags: ",
93
+ }
94
+ for key, pattern in patterns.items():
95
+ if pattern not in self.facl:
96
+ self.logger.warning(
97
+ f"Metadata pattern '{pattern}' not found in FACL output."
98
+ )
99
+ continue
100
+ for line in self.facl.splitlines():
101
+ if line.startswith(pattern):
102
+ setattr(self, key, line.split(":", 1)[1].strip())
103
+
104
+ def _parse_acl(self, acl_line: str):
105
+ """
106
+ Parse a single ACL entry line.
107
+ Example entries:
108
+ ```
109
+ user::rwx
110
+ user:krauset:rwx
111
+ group::r-x
112
+ group:grp_hpc_collab002:r-x
113
+ mask::rwx
114
+ other::r-x
115
+ default:user::rwx
116
+ default:group::r-x
117
+ """
118
+
119
+ # parse
120
+ acl_split = acl_line.split(":")
121
+ if len(acl_split) not in [3, 4]:
122
+ msg = (
123
+ f"ACL line does not have the correct number of fields (3 or 4):"
124
+ f"\nLine: {acl_line}\nFields: {acl_split}"
125
+ )
126
+ self.logger.warning(msg)
127
+ return None
128
+
129
+ # default
130
+ default = len(acl_split) == 4
131
+ if default:
132
+ if acl_split[0] not in ["d", "default"]:
133
+ msg = (
134
+ f"Unexpected default ACL prefix '{acl_split[0]}' in line:"
135
+ f"\n{acl_line}"
136
+ )
137
+ self.logger.warning(msg)
138
+ return None
139
+ acl_split = acl_split[1:] # remove default prefix
140
+
141
+ # type
142
+ type_map = {
143
+ "u": "user",
144
+ "user": "user",
145
+ "g": "group",
146
+ "group": "group",
147
+ "m": "mask",
148
+ "mask": "mask",
149
+ "o": "other",
150
+ "other": "other",
151
+ }
152
+ if acl_split[0] not in type_map:
153
+ self.logger.warning(
154
+ f"Unexpected ACL type '{acl_split[0]}' in line:\n{acl_line}"
155
+ )
156
+ return None
157
+ acl_type = type_map[acl_split[0]]
158
+
159
+ # name
160
+ name = acl_split[1]
161
+ if name == "":
162
+ if acl_type == "user":
163
+ if not self.is_init:
164
+ self.logger.warning("FACL not initialized before parsing ACLs.")
165
+ name = self.owner
166
+ elif acl_type == "group":
167
+ if not self.is_init:
168
+ self.logger.warning("FACL not initialized before parsing ACLs.")
169
+ name = self.group
170
+
171
+ # permissions
172
+ permissions = acl_split[2]
173
+ if not all(c in "rwx-" for c in permissions) or len(permissions) != 3:
174
+ self.logger.warning(
175
+ f"Invalid permissions '{permissions}' in line:\n{acl_line}"
176
+ )
177
+ return None
178
+
179
+ # create
180
+ acl_entry = {
181
+ "default": default,
182
+ "type": acl_type,
183
+ "name": name,
184
+ "permissions": permissions,
185
+ }
186
+ return acl_entry
187
+
188
+ def _parse_acls(self):
189
+ """
190
+ Parse ACL entries from the FACL output.
191
+
192
+ Example entries:
193
+ ```
194
+ user::rwx
195
+ user:krauset:rwx
196
+ group::r-x
197
+ group:grp_hpc_collab002:r-x
198
+ mask::rwx
199
+ other::r-x
200
+ default:user::rwx
201
+ default:group::r-x
202
+ default:other::r-x
203
+ ```
204
+ """
205
+ for line in self.facl.splitlines():
206
+ if line.startswith("#") or line.strip() == "":
207
+ continue
208
+ acl_entry = self._parse_acl(line)
209
+ if acl_entry:
210
+ self.acls.append(acl_entry)
211
+
212
+ @staticmethod
213
+ def _permission_match(perm_key: str, perm_query: str, mode: str) -> bool:
214
+ """
215
+ Check if the permission key matches the permission query for three different
216
+ modes:
217
+
218
+ Example: rwx vs rx
219
+ - 'exact': both must match exactly
220
+ - 'at_least': perm_key must have at least the permissions in perm_query
221
+ - 'at_most': perm_key must have at most the permissions in perm_query
222
+
223
+ Args:
224
+ perm_key (str): The permission key (e.g., 'rwx').
225
+ perm_query (str): The permission query (e.g., 'rx').
226
+ mode (str): The matching mode ('exact', 'at_least', 'at_most').
227
+ """
228
+ if mode == "exact":
229
+ return perm_key == perm_query
230
+ elif mode == "at_least":
231
+ for char_query in perm_query:
232
+ if char_query != "-" and char_query not in perm_key:
233
+ return False
234
+ return True
235
+ elif mode == "at_most":
236
+ for char_key in perm_key:
237
+ if char_key != "-" and char_key not in perm_query:
238
+ return False
239
+ return True
240
+ else:
241
+ raise ValueError(
242
+ f"Invalid mode '{mode}'. Choose from 'exact', 'at_least', 'at_most'."
243
+ )
244
+
245
+ def _infer_groups(self, user: str) -> list:
246
+ """
247
+ Infer groups for a given user using the `id -Gn` command.
248
+
249
+ Args:
250
+ user (str): The username.
251
+ Returns:
252
+ list: List of groups the user belongs to.
253
+ """
254
+ try:
255
+ result = subprocess.run(
256
+ ["id", "-Gn", user], capture_output=True, text=True, check=True
257
+ )
258
+ groups = result.stdout.strip().split()
259
+ return groups
260
+ except subprocess.CalledProcessError as e:
261
+ self.logger.warning(f"Error retrieving groups for user {user}: {e}")
262
+ return []
263
+
264
+ def has_permission(self, acl: str, mode: str = "at_least") -> bool:
265
+ """
266
+ Check if a specific user or group has a certain permission.
267
+ Users are checked first, then groups, and finally 'other'.
268
+
269
+ Args:
270
+ entity_type (str): 'user' or 'group'.
271
+ name (str): The name of the user or group.
272
+ permission (str): The permission to check ('r', 'w', or 'x').
273
+ """
274
+ # parse acl
275
+ acl_entry = self._parse_acl(acl)
276
+ entity_type = acl_entry["type"]
277
+ name = acl_entry["name"]
278
+ permission = acl_entry["permissions"]
279
+
280
+ # check user
281
+ if entity_type in ["user"]:
282
+ for acl in self.acls:
283
+ if acl["default"]:
284
+ continue
285
+ if acl["type"] == "user" and acl["name"] == name:
286
+ return self._permission_match(acl["permissions"], permission, mode)
287
+ # check groups
288
+ if entity_type in ["user", "group"]:
289
+ if entity_type == "user":
290
+ groups = self._infer_groups(name)
291
+ else:
292
+ groups = [name]
293
+ for acl in self.acls:
294
+ if acl["default"]:
295
+ continue
296
+ if acl["type"] == "group" and acl["name"] in groups:
297
+ return self._permission_match(acl["permissions"], permission, mode)
298
+
299
+ # check other
300
+ if entity_type in ["user", "group", "other"]:
301
+ for acl in self.acls:
302
+ if acl["default"]:
303
+ continue
304
+ if acl["type"] == "other":
305
+ return self._permission_match(acl["permissions"], permission, mode)
306
+ return False
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "pyfacl"
3
+ version = "1.0.0"
4
+ description = "Package to manage access control using POSIX ACLs"
5
+ authors = [
6
+ {name = "tobiaspk",email = "tobiaspk1@gmail.com"}
7
+ ]
8
+ license = {text = "MIT"}
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pre-commit>=3.0.0",
17
+ "black>=23.0.0",
18
+ "flake8>=6.0.0",
19
+ "isort>=5.12.0",
20
+ "pytest>=7.0.0"
21
+ ]
22
+
23
+ [build-system]
24
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
25
+ build-backend = "poetry.core.masonry.api"
26
+
27
+ [tool.black]
28
+ line-length = 88
29
+ target-version = ['py312']
30
+
31
+ [tool.isort]
32
+ profile = "black"
33
+ line_length = 88
34
+
35
+ [tool.flake8]
36
+ max-line-length = 88
37
+ extend-ignore = ["E203"]