auth 1.2.0__tar.gz → 1.3.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.
Files changed (71) hide show
  1. {auth-1.2.0 → auth-1.3.0}/PKG-INFO +3 -1
  2. {auth-1.2.0 → auth-1.3.0}/auth/services/service.py +113 -123
  3. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/PKG-INFO +3 -1
  4. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/requires.txt +2 -0
  5. {auth-1.2.0 → auth-1.3.0}/pyproject.toml +3 -1
  6. {auth-1.2.0 → auth-1.3.0}/LICENSE +0 -0
  7. {auth-1.2.0 → auth-1.3.0}/README.rst +0 -0
  8. {auth-1.2.0 → auth-1.3.0}/auth/__init__.py +0 -0
  9. {auth-1.2.0 → auth-1.3.0}/auth/audit.py +0 -0
  10. {auth-1.2.0 → auth-1.3.0}/auth/circuit_breaker.py +0 -0
  11. {auth-1.2.0 → auth-1.3.0}/auth/client.py +0 -0
  12. {auth-1.2.0 → auth-1.3.0}/auth/cmd/__init__.py +0 -0
  13. {auth-1.2.0 → auth-1.3.0}/auth/cmd/server.py +0 -0
  14. {auth-1.2.0 → auth-1.3.0}/auth/config.py +0 -0
  15. {auth-1.2.0 → auth-1.3.0}/auth/core/REST/__init__.py +0 -0
  16. {auth-1.2.0 → auth-1.3.0}/auth/core/REST/client.py +0 -0
  17. {auth-1.2.0 → auth-1.3.0}/auth/core/__init__.py +0 -0
  18. {auth-1.2.0 → auth-1.3.0}/auth/core/models/__init__.py +0 -0
  19. {auth-1.2.0 → auth-1.3.0}/auth/dal/authorization_sqlite.py +0 -0
  20. {auth-1.2.0 → auth-1.3.0}/auth/database.py +0 -0
  21. {auth-1.2.0 → auth-1.3.0}/auth/decorators.py +0 -0
  22. {auth-1.2.0 → auth-1.3.0}/auth/encryption.py +0 -0
  23. {auth-1.2.0 → auth-1.3.0}/auth/jwt_auth.py +0 -0
  24. {auth-1.2.0 → auth-1.3.0}/auth/logging_config.py +0 -0
  25. {auth-1.2.0 → auth-1.3.0}/auth/main.py +0 -0
  26. {auth-1.2.0 → auth-1.3.0}/auth/models/sql.py +0 -0
  27. {auth-1.2.0 → auth-1.3.0}/auth/models/sqlite.py +0 -0
  28. {auth-1.2.0 → auth-1.3.0}/auth/response_format.py +0 -0
  29. {auth-1.2.0 → auth-1.3.0}/auth/routes.py +0 -0
  30. {auth-1.2.0 → auth-1.3.0}/auth/sanitizer.py +0 -0
  31. {auth-1.2.0 → auth-1.3.0}/auth/server.py +0 -0
  32. {auth-1.2.0 → auth-1.3.0}/auth/services/rest_service.py +0 -0
  33. {auth-1.2.0 → auth-1.3.0}/auth/validation.py +0 -0
  34. {auth-1.2.0 → auth-1.3.0}/auth/workflow_checker.py +0 -0
  35. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/SOURCES.txt +0 -0
  36. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/dependency_links.txt +0 -0
  37. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/entry_points.txt +0 -0
  38. {auth-1.2.0 → auth-1.3.0}/auth.egg-info/top_level.txt +0 -0
  39. {auth-1.2.0 → auth-1.3.0}/docs/api/authorization.rst +0 -0
  40. {auth-1.2.0 → auth-1.3.0}/docs/api/client.rst +0 -0
  41. {auth-1.2.0 → auth-1.3.0}/docs/api/database.rst +0 -0
  42. {auth-1.2.0 → auth-1.3.0}/docs/api/models.rst +0 -0
  43. {auth-1.2.0 → auth-1.3.0}/docs/api/service.rst +0 -0
  44. {auth-1.2.0 → auth-1.3.0}/docs/audit_logging.rst +0 -0
  45. {auth-1.2.0 → auth-1.3.0}/docs/changelog.rst +0 -0
  46. {auth-1.2.0 → auth-1.3.0}/docs/concepts.rst +0 -0
  47. {auth-1.2.0 → auth-1.3.0}/docs/conf.py +0 -0
  48. {auth-1.2.0 → auth-1.3.0}/docs/configuration.rst +0 -0
  49. {auth-1.2.0 → auth-1.3.0}/docs/contributing.rst +0 -0
  50. {auth-1.2.0 → auth-1.3.0}/docs/deployment.rst +0 -0
  51. {auth-1.2.0 → auth-1.3.0}/docs/encryption.rst +0 -0
  52. {auth-1.2.0 → auth-1.3.0}/docs/examples.rst +0 -0
  53. {auth-1.2.0 → auth-1.3.0}/docs/index.rst +0 -0
  54. {auth-1.2.0 → auth-1.3.0}/docs/installation.rst +0 -0
  55. {auth-1.2.0 → auth-1.3.0}/docs/production.rst +0 -0
  56. {auth-1.2.0 → auth-1.3.0}/docs/python_usage.rst +0 -0
  57. {auth-1.2.0 → auth-1.3.0}/docs/quickstart.rst +0 -0
  58. {auth-1.2.0 → auth-1.3.0}/docs/rest_api.rst +0 -0
  59. {auth-1.2.0 → auth-1.3.0}/docs/security.rst +0 -0
  60. {auth-1.2.0 → auth-1.3.0}/docs/troubleshooting.rst +0 -0
  61. {auth-1.2.0 → auth-1.3.0}/scripts/create_db.py +0 -0
  62. {auth-1.2.0 → auth-1.3.0}/setup.cfg +0 -0
  63. {auth-1.2.0 → auth-1.3.0}/tests/test_auth_sqlite.py +0 -0
  64. {auth-1.2.0 → auth-1.3.0}/tests/test_authorization_sqlite.py +0 -0
  65. {auth-1.2.0 → auth-1.3.0}/tests/test_client_rest.py +0 -0
  66. {auth-1.2.0 → auth-1.3.0}/tests/test_cmd_server.py +0 -0
  67. {auth-1.2.0 → auth-1.3.0}/tests/test_db_sqlite.py +0 -0
  68. {auth-1.2.0 → auth-1.3.0}/tests/test_flask.py +0 -0
  69. {auth-1.2.0 → auth-1.3.0}/tests/test_server.py +0 -0
  70. {auth-1.2.0 → auth-1.3.0}/tests/test_service_rest.py +0 -0
  71. {auth-1.2.0 → auth-1.3.0}/tests/tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -33,6 +33,8 @@ Requires-Dist: PyJWT>=2.0.0
33
33
  Requires-Dist: cryptography>=3.0.0
34
34
  Requires-Dist: APScheduler>=3.0.0
35
35
  Requires-Dist: psycopg[binary]>=3.0.0
36
+ Requires-Dist: pydantic>=2.0.0
37
+ Requires-Dist: pydantic-settings>=2.0.0
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=6.0; extra == "dev"
38
40
  Requires-Dist: pytest-cov>=2.0; extra == "dev"
@@ -165,45 +165,32 @@ class AuthorizationService:
165
165
  return result
166
166
 
167
167
  def add_role(self, role: str, description: Optional[str] = None) -> bool:
168
- """Add a new role"""
169
- # Check if role already exists
170
- existing = (
171
- self.db.query(AuthGroup)
172
- .filter(AuthGroup.creator == self.client, AuthGroup.role == role)
173
- .first()
174
- )
168
+ """Add a new role - atomic idempotent using ON CONFLICT.
175
169
 
176
- if existing:
177
- if not existing.is_active:
178
- existing.is_active = True # type: ignore[assignment]
179
- if description is not None:
180
- existing.description = description
181
- self.db.commit()
182
- return True
183
- # For compatibility with old tests, return True if role already exists
184
- return True
170
+ Uses PostgreSQL's INSERT ... ON CONFLICT for race-condition-free upsert.
171
+ The unique constraint (creator, role) ensures atomicity.
172
+ """
173
+ from sqlalchemy import text
185
174
 
186
175
  try:
187
- new_group = AuthGroup(
188
- creator=self.client, role=role, description=description
176
+ self.db.execute(
177
+ text("""
178
+ INSERT INTO auth_rbac.auth_group
179
+ (creator, role, description, is_active, date_created, modified)
180
+ VALUES
181
+ (:creator, :role, :description, true, NOW(), NOW())
182
+ ON CONFLICT (creator, role) DO UPDATE SET
183
+ is_active = true,
184
+ description = COALESCE(EXCLUDED.description, auth_rbac.auth_group.description),
185
+ modified = NOW()
186
+ """),
187
+ {"creator": self.client, "role": role, "description": description},
189
188
  )
190
- self.db.add(new_group)
191
189
  self.db.commit()
190
+ return True
192
191
  except Exception:
193
192
  self.db.rollback()
194
- # Refresh session state after rollback
195
- self.db.expunge_all()
196
- # Check if the role was created by another transaction
197
- existing = (
198
- self.db.query(AuthGroup)
199
- .filter(AuthGroup.creator == self.client, AuthGroup.role == role)
200
- .first()
201
- )
202
- if existing:
203
- return True # Role already exists
204
- return False # Failed to create role
205
-
206
- return True
193
+ return False
207
194
 
208
195
  def del_role(self, role: str) -> bool:
209
196
  """Delete a role"""
@@ -220,54 +207,57 @@ class AuthorizationService:
220
207
  return False
221
208
 
222
209
  def add_membership(self, user: str, role: str) -> bool:
223
- """Add user to a role"""
224
- group = (
225
- self.db.query(AuthGroup)
226
- .filter(
227
- AuthGroup.creator == self.client,
228
- AuthGroup.role == role,
229
- AuthGroup.is_active,
230
- )
231
- .first()
210
+ """Add user to a role - atomic idempotent using ON CONFLICT.
211
+
212
+ Uses PostgreSQL's INSERT ... ON CONFLICT for race-condition-free operations.
213
+ """
214
+ from sqlalchemy import text
215
+
216
+ # Get group ID
217
+ group_result = self.db.execute(
218
+ text("""
219
+ SELECT id FROM auth_rbac.auth_group
220
+ WHERE creator = :creator AND role = :role AND is_active = true
221
+ """),
222
+ {"creator": self.client, "role": role},
232
223
  )
233
-
234
- if not group:
224
+ row = group_result.fetchone()
225
+ if not row:
235
226
  return False
227
+ group_id = row[0]
236
228
 
237
- # Get or create membership
238
- membership = (
239
- self.db.query(AuthMembership)
240
- .filter(AuthMembership.creator == self.client, AuthMembership._user == self._get_encrypted_user(user))
241
- .first()
242
- )
243
-
244
- if not membership:
245
- try:
246
- membership = AuthMembership(creator=self.client, user=user)
247
- self.db.add(membership)
248
- self.db.flush() # Get the ID
249
- except Exception:
250
- self.db.rollback()
251
- # Refresh session state after rollback
252
- self.db.expunge_all()
253
- # Try to get the membership again in case it was created by another transaction
254
- membership = (
255
- self.db.query(AuthMembership)
256
- .filter(
257
- AuthMembership.creator == self.client,
258
- AuthMembership._user == self._get_encrypted_user(user),
259
- )
260
- .first()
261
- )
262
- if not membership:
263
- return False
264
-
265
- # Check if user is already in the group
266
- if group not in membership.groups:
267
- membership.groups.append(group)
229
+ try:
230
+ # Upsert membership
231
+ encrypted_user = self._get_encrypted_user(user)
232
+ membership_result = self.db.execute(
233
+ text("""
234
+ INSERT INTO auth_rbac.auth_membership
235
+ (creator, "user", is_active, date_created, modified)
236
+ VALUES
237
+ (:creator, :user, true, NOW(), NOW())
238
+ ON CONFLICT (creator, "user") DO UPDATE SET
239
+ is_active = true,
240
+ modified = NOW()
241
+ RETURNING id
242
+ """),
243
+ {"creator": self.client, "user": encrypted_user},
244
+ )
245
+ membership_id = membership_result.fetchone()[0]
246
+
247
+ # Link membership to group (junction table)
248
+ self.db.execute(
249
+ text("""
250
+ INSERT INTO auth_rbac.membership_groups (membership_id, group_id)
251
+ VALUES (:membership_id, :group_id)
252
+ ON CONFLICT (membership_id, group_id) DO NOTHING
253
+ """),
254
+ {"membership_id": membership_id, "group_id": group_id},
255
+ )
268
256
  self.db.commit()
269
-
270
- return True
257
+ return True
258
+ except Exception:
259
+ self.db.rollback()
260
+ return False
271
261
 
272
262
  def del_membership(self, user: str, role: str) -> bool:
273
263
  """Remove user from a role"""
@@ -318,60 +308,60 @@ class AuthorizationService:
318
308
  )
319
309
 
320
310
  def add_permission(self, role: str, name: str) -> bool:
321
- """Add permission to a role"""
311
+ """Add permission to a role - atomic idempotent using ON CONFLICT.
312
+
313
+ Uses PostgreSQL's INSERT ... ON CONFLICT for race-condition-free operations.
314
+ """
315
+ from sqlalchemy import text
316
+
322
317
  if self.has_permission(role, name):
323
318
  return True
324
319
 
325
- group = (
326
- self.db.query(AuthGroup)
327
- .filter(
328
- AuthGroup.creator == self.client,
329
- AuthGroup.role == role,
330
- AuthGroup.is_active,
331
- )
332
- .first()
320
+ # Get group ID
321
+ group_result = self.db.execute(
322
+ text("""
323
+ SELECT id FROM auth_rbac.auth_group
324
+ WHERE creator = :creator AND role = :role AND is_active = true
325
+ """),
326
+ {"creator": self.client, "role": role},
333
327
  )
334
-
335
- if not group:
328
+ row = group_result.fetchone()
329
+ if not row:
336
330
  return False
331
+ group_id = row[0]
337
332
 
338
- # Try to get existing permission first
339
- permission = (
340
- self.db.query(AuthPermission)
341
- .filter(AuthPermission.creator == self.client, AuthPermission._name == self._get_encrypted_permission(name))
342
- .first()
343
- )
344
-
345
- if not permission:
346
- # Check again inside a try-catch to handle race conditions or concurrent creation
347
- try:
348
- permission = AuthPermission(creator=self.client, name=name)
349
- self.db.add(permission)
350
- self.db.flush()
351
- except Exception:
352
- # If there was an exception (likely IntegrityError due to duplicate),
353
- # rollback and get the existing permission
354
- self.db.rollback()
355
- # Refresh the session state after rollback
356
- self.db.expunge_all()
357
- permission = (
358
- self.db.query(AuthPermission)
359
- .filter(
360
- AuthPermission.creator == self.client,
361
- AuthPermission._name == self._get_encrypted_permission(name),
362
- )
363
- .first()
364
- )
365
- # If it's still None after rollback, something else went wrong
366
- if not permission:
367
- return False
368
-
369
- # Check if permission is already in the group
370
- if group not in permission.groups:
371
- permission.groups.append(group)
333
+ try:
334
+ # Upsert permission
335
+ encrypted_name = self._get_encrypted_permission(name)
336
+ perm_result = self.db.execute(
337
+ text("""
338
+ INSERT INTO auth_rbac.auth_permission
339
+ (creator, name, is_active, date_created, modified)
340
+ VALUES
341
+ (:creator, :name, true, NOW(), NOW())
342
+ ON CONFLICT (creator, name) DO UPDATE SET
343
+ is_active = true,
344
+ modified = NOW()
345
+ RETURNING id
346
+ """),
347
+ {"creator": self.client, "name": encrypted_name},
348
+ )
349
+ perm_id = perm_result.fetchone()[0]
350
+
351
+ # Link permission to group (junction table)
352
+ self.db.execute(
353
+ text("""
354
+ INSERT INTO auth_rbac.permission_groups (permission_id, group_id)
355
+ VALUES (:perm_id, :group_id)
356
+ ON CONFLICT (permission_id, group_id) DO NOTHING
357
+ """),
358
+ {"perm_id": perm_id, "group_id": group_id},
359
+ )
372
360
  self.db.commit()
373
-
374
- return True
361
+ return True
362
+ except Exception:
363
+ self.db.rollback()
364
+ return False
375
365
 
376
366
  def del_permission(self, role: str, name: str) -> bool:
377
367
  """Remove permission from a role"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -33,6 +33,8 @@ Requires-Dist: PyJWT>=2.0.0
33
33
  Requires-Dist: cryptography>=3.0.0
34
34
  Requires-Dist: APScheduler>=3.0.0
35
35
  Requires-Dist: psycopg[binary]>=3.0.0
36
+ Requires-Dist: pydantic>=2.0.0
37
+ Requires-Dist: pydantic-settings>=2.0.0
36
38
  Provides-Extra: dev
37
39
  Requires-Dist: pytest>=6.0; extra == "dev"
38
40
  Requires-Dist: pytest-cov>=2.0; extra == "dev"
@@ -6,6 +6,8 @@ PyJWT>=2.0.0
6
6
  cryptography>=3.0.0
7
7
  APScheduler>=3.0.0
8
8
  psycopg[binary]>=3.0.0
9
+ pydantic>=2.0.0
10
+ pydantic-settings>=2.0.0
9
11
 
10
12
  [dev]
11
13
  pytest>=6.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Authorization for humans"
9
9
  readme = {file = "README.rst", content-type = "text/x-rst"}
10
10
  requires-python = ">=3.9"
@@ -41,6 +41,8 @@ dependencies = [
41
41
  "cryptography>=3.0.0",
42
42
  "APScheduler>=3.0.0",
43
43
  "psycopg[binary]>=3.0.0",
44
+ "pydantic>=2.0.0",
45
+ "pydantic-settings>=2.0.0",
44
46
  ]
45
47
 
46
48
  [project.optional-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes