auth 2.4.0__tar.gz → 2.5.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 (58) hide show
  1. {auth-2.4.0 → auth-2.5.0}/PKG-INFO +9 -3
  2. {auth-2.4.0 → auth-2.5.0}/README.md +7 -0
  3. {auth-2.4.0 → auth-2.5.0}/auth/audit.py +3 -0
  4. {auth-2.4.0 → auth-2.5.0}/auth/client.py +56 -0
  5. {auth-2.4.0 → auth-2.5.0}/auth/docs_page.py +30 -3
  6. {auth-2.4.0 → auth-2.5.0}/auth/models/sql.py +22 -0
  7. {auth-2.4.0 → auth-2.5.0}/auth/routes.py +117 -5
  8. {auth-2.4.0 → auth-2.5.0}/auth/services/service.py +99 -1
  9. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/PKG-INFO +9 -3
  10. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/SOURCES.txt +2 -2
  11. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/requires.txt +1 -2
  12. {auth-2.4.0 → auth-2.5.0}/pyproject.toml +4 -6
  13. {auth-2.4.0 → auth-2.5.0}/tests/test_api_keys_encryption.py +0 -2
  14. {auth-2.4.0 → auth-2.5.0}/tests/test_client.py +38 -0
  15. {auth-2.4.0 → auth-2.5.0}/tests/test_key_rotation.py +2 -0
  16. auth-2.5.0/tests/test_strict_users.py +318 -0
  17. auth-2.4.0/tests/test_migrations.py +0 -45
  18. {auth-2.4.0 → auth-2.5.0}/LICENSE +0 -0
  19. {auth-2.4.0 → auth-2.5.0}/auth/__init__.py +0 -0
  20. {auth-2.4.0 → auth-2.5.0}/auth/api_keys.py +0 -0
  21. {auth-2.4.0 → auth-2.5.0}/auth/circuit_breaker.py +0 -0
  22. {auth-2.4.0 → auth-2.5.0}/auth/cmd/__init__.py +0 -0
  23. {auth-2.4.0 → auth-2.5.0}/auth/cmd/server.py +0 -0
  24. {auth-2.4.0 → auth-2.5.0}/auth/config.py +0 -0
  25. {auth-2.4.0 → auth-2.5.0}/auth/core/REST/__init__.py +0 -0
  26. {auth-2.4.0 → auth-2.5.0}/auth/core/REST/client.py +0 -0
  27. {auth-2.4.0 → auth-2.5.0}/auth/core/__init__.py +0 -0
  28. {auth-2.4.0 → auth-2.5.0}/auth/core/models/__init__.py +0 -0
  29. {auth-2.4.0 → auth-2.5.0}/auth/database.py +0 -0
  30. {auth-2.4.0 → auth-2.5.0}/auth/decorators.py +0 -0
  31. {auth-2.4.0 → auth-2.5.0}/auth/encryption.py +0 -0
  32. {auth-2.4.0 → auth-2.5.0}/auth/logging_config.py +0 -0
  33. {auth-2.4.0 → auth-2.5.0}/auth/main.py +0 -0
  34. {auth-2.4.0 → auth-2.5.0}/auth/response_format.py +0 -0
  35. {auth-2.4.0 → auth-2.5.0}/auth/sanitizer.py +0 -0
  36. {auth-2.4.0 → auth-2.5.0}/auth/server.py +0 -0
  37. {auth-2.4.0 → auth-2.5.0}/auth/validation.py +0 -0
  38. {auth-2.4.0 → auth-2.5.0}/auth/workflow_checker.py +0 -0
  39. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/dependency_links.txt +0 -0
  40. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/entry_points.txt +0 -0
  41. {auth-2.4.0 → auth-2.5.0}/auth.egg-info/top_level.txt +0 -0
  42. {auth-2.4.0 → auth-2.5.0}/setup.cfg +0 -0
  43. {auth-2.4.0 → auth-2.5.0}/tests/test_api_keys.py +0 -0
  44. {auth-2.4.0 → auth-2.5.0}/tests/test_audit_integrity.py +0 -0
  45. {auth-2.4.0 → auth-2.5.0}/tests/test_client_rest.py +0 -0
  46. {auth-2.4.0 → auth-2.5.0}/tests/test_cmd_server.py +0 -0
  47. {auth-2.4.0 → auth-2.5.0}/tests/test_config.py +0 -0
  48. {auth-2.4.0 → auth-2.5.0}/tests/test_correctness_hardening.py +0 -0
  49. {auth-2.4.0 → auth-2.5.0}/tests/test_docs_page.py +0 -0
  50. {auth-2.4.0 → auth-2.5.0}/tests/test_encryption.py +0 -0
  51. {auth-2.4.0 → auth-2.5.0}/tests/test_encryption_integration.py +0 -0
  52. {auth-2.4.0 → auth-2.5.0}/tests/test_flask.py +0 -0
  53. {auth-2.4.0 → auth-2.5.0}/tests/test_log_redaction.py +0 -0
  54. {auth-2.4.0 → auth-2.5.0}/tests/test_phase_a_hardening.py +0 -0
  55. {auth-2.4.0 → auth-2.5.0}/tests/test_reencryption.py +0 -0
  56. {auth-2.4.0 → auth-2.5.0}/tests/test_routes_errors.py +0 -0
  57. {auth-2.4.0 → auth-2.5.0}/tests/test_server.py +0 -0
  58. {auth-2.4.0 → auth-2.5.0}/tests/test_service_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -41,11 +41,10 @@ Provides-Extra: ratelimit
41
41
  Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
42
42
  Provides-Extra: migrations
43
43
  Requires-Dist: migretti>=0.10.0; extra == "migrations"
44
- Requires-Dist: alembic>=1.13.0; extra == "migrations"
45
44
  Provides-Extra: dev
46
45
  Requires-Dist: pytest>=6.0; extra == "dev"
47
46
  Requires-Dist: pytest-cov>=2.0; extra == "dev"
48
- Requires-Dist: alembic>=1.13.0; extra == "dev"
47
+ Requires-Dist: migretti>=0.10.0; extra == "dev"
49
48
  Requires-Dist: ruff>=0.0.260; extra == "dev"
50
49
  Requires-Dist: mypy>=1.0; extra == "dev"
51
50
  Requires-Dist: types-requests; extra == "dev"
@@ -121,6 +120,13 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
121
120
  a hash, and validates them inside your namespace — an identity UI fronts the
122
121
  lifecycle, backends validate then use the RBAC checks. Client methods:
123
122
  `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
123
+ - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
124
+ key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
125
+ `PUT /api/settings {"strict_users": true}` makes keyless users answer
126
+ negatively (`user_not_key_backed`), and `POST /api/apikeys/check_permission`
127
+ does validate + permission in one round trip. **3.0.0 makes key-backed users
128
+ mandatory** — only after every consuming platform confirms.
129
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
124
130
 
125
131
  ## Documentation
126
132
 
@@ -64,6 +64,13 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
64
64
  a hash, and validates them inside your namespace — an identity UI fronts the
65
65
  lifecycle, backends validate then use the RBAC checks. Client methods:
66
66
  `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
67
+ - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
68
+ key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
69
+ `PUT /api/settings {"strict_users": true}` makes keyless users answer
70
+ negatively (`user_not_key_backed`), and `POST /api/apikeys/check_permission`
71
+ does validate + permission in one round trip. **3.0.0 makes key-backed users
72
+ mandatory** — only after every consuming platform confirms.
73
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
67
74
 
68
75
  ## Documentation
69
76
 
@@ -42,6 +42,9 @@ class AuditAction(Enum):
42
42
  LIST_API_KEYS = "LIST_API_KEYS"
43
43
  REVOKE_API_KEY = "REVOKE_API_KEY"
44
44
  VALIDATE_API_KEY = "VALIDATE_API_KEY"
45
+ CHECK_API_KEY_PERMISSION = "CHECK_API_KEY_PERMISSION"
46
+ GET_SETTINGS = "GET_SETTINGS"
47
+ UPDATE_SETTINGS = "UPDATE_SETTINGS"
45
48
 
46
49
 
47
50
  class AuditLog(Base):
@@ -166,6 +166,8 @@ class EnhancedAuthClient:
166
166
  "apikeys_user": "/api/apikeys/user/{user}",
167
167
  "apikey_revoke": "/api/apikeys/user/{user}/{key_id}",
168
168
  "apikey_validate": "/api/apikeys/validate",
169
+ "apikey_check_permission": "/api/apikeys/check_permission",
170
+ "settings": "/api/settings",
169
171
  }
170
172
 
171
173
  # Built on first use: a session without retries for non-idempotent
@@ -543,6 +545,60 @@ class EnhancedAuthClient:
543
545
  except Exception as e:
544
546
  return self._transport_failure(e, data={"key_prefix": api_key[:12]})
545
547
 
548
+ def check_api_key_permission(
549
+ self, api_key: str, permission: str
550
+ ) -> Dict[str, Any]:
551
+ """Validate a secret AND check its subject's permission in one call.
552
+
553
+ ``data.valid`` false → the key failed (reason as in validate_api_key);
554
+ true → ``data.has_permission`` answers for the key's user. The secret
555
+ travels in the JSON body; failure payloads echo only its prefix.
556
+
557
+ Transport failure: error dict without the answer field (or
558
+ ``AuthTransportError`` if ``raise_on_error=True``); check ``success``
559
+ before reading ``data`` — a missing ``valid`` is an outage, not a
560
+ denial.
561
+ """
562
+ try:
563
+ return self._make_request(
564
+ "POST",
565
+ self.endpoints["apikey_check_permission"],
566
+ json={"api_key": api_key, "permission": permission},
567
+ )
568
+ except Exception as e:
569
+ return self._transport_failure(
570
+ e, data={"key_prefix": api_key[:12], "permission": permission}
571
+ )
572
+
573
+ # Tenant settings (SPEC 0010)
574
+ def get_settings(self) -> Dict[str, Any]:
575
+ """This tenant's settings (``data.strict_users``).
576
+
577
+ Transport failure: error dict without the answer field (or
578
+ ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
579
+ """
580
+ try:
581
+ return self._make_request("GET", self.endpoints["settings"])
582
+ except Exception as e:
583
+ return self._transport_failure(e)
584
+
585
+ def set_strict_users(self, enabled: bool) -> Dict[str, Any]:
586
+ """Enable/disable strict user identity for this tenant (idempotent).
587
+
588
+ While enabled, authorization decisions about users with no live API
589
+ key answer negatively (``reason: user_not_key_backed``) — issue keys
590
+ before flipping this on.
591
+
592
+ Transport failure: error dict without the answer field (or
593
+ ``AuthTransportError`` if ``raise_on_error=True``); check ``success``.
594
+ """
595
+ try:
596
+ return self._make_request(
597
+ "PUT", self.endpoints["settings"], json={"strict_users": enabled}
598
+ )
599
+ except Exception as e:
600
+ return self._transport_failure(e, data={"strict_users": enabled})
601
+
546
602
  # Workflow-related methods
547
603
  def get_users_for_workflow(self, workflow_name: str) -> Dict[str, Any]:
548
604
  """Get all users who can run a specific workflow.
@@ -142,6 +142,19 @@ authenticate `/api/*` itself — the Bearer header always takes your client key.
142
142
  Rotating your client key moves your users' API keys with the namespace; the
143
143
  secrets keep validating afterwards.
144
144
 
145
+ **DEPRECATION — bare user strings (decommission target: 3.0.0).** Today any
146
+ `<user>` string answers authorization checks whether or not a key backs it.
147
+ That is being retired. **Since 2.5.0 the opt-in phase is live**: enable strict
148
+ mode with `PUT /api/settings` `{{"strict_users": true}}` and authorization
149
+ decisions about users with no active API key answer negatively (same response
150
+ shapes, additive reason `user_not_key_backed`); membership adds for key-less
151
+ users return `{{"result": false}}` — create the key first, then grant roles.
152
+ Strict mode never blocks key issuance or any delete/revoke path. 3.0.0 makes
153
+ strict identity the default, and ships only after every consuming platform has
154
+ confirmed readiness. Migrate now: issue keys to your users, derive the user
155
+ from `/api/apikeys/validate` — or do both steps in one round trip with
156
+ `POST /api/apikeys/check_permission`.
157
+
145
158
  ## 4. Endpoints
146
159
 
147
160
  All paths need the `Authorization` header except `/ping` and `/health`.
@@ -207,7 +220,7 @@ only copy — persist it. See section 3 for the full semantics and threat model.
207
220
 
208
221
  | Method | Path | Returns |
209
222
  |---|---|---|
210
- | POST | `/api/keys/rotate` | wrapped, `data` = `{{"new_key": "<uuid4>", "migrated": {{"roles": 1, "memberships": 1, "permissions": 1, "api_keys": 0}}}}` |
223
+ | POST | `/api/keys/rotate` | wrapped, `data` = `{{"new_key": "<uuid4>", "migrated": {{"roles": 1, "memberships": 1, "permissions": 1, "api_keys": 0, "settings": 0}}}}` |
211
224
 
212
225
  ### Per-user API keys
213
226
 
@@ -224,6 +237,18 @@ active keys per user — revoke to free a slot.
224
237
  | GET | `/api/apikeys/user/<user>` | wrapped, `data` = `{{"count": 1, "keys": [{{"key_id": "<uuid4>", "key_prefix": "rak_ab12cd34", "label": "laptop", "is_active": true, "created": "<iso>", "revoked_at": null, "expires_at": null, "last_used_at": "<iso>"}}]}}` |
225
238
  | DELETE | `/api/apikeys/user/<user>/<key_id>` | wrapped, `data` = `{{"revoked": true, "already_revoked": false}}` (repeat calls idempotent; 404 JSON if no such key for that user in your namespace) |
226
239
  | POST | `/api/apikeys/validate` | body `{{"api_key": "rak_..."}}` → wrapped, `data` = `{{"valid": true, "user": "alice", "key_id": "<uuid4>", "label": "laptop", "expires_at": null}}` or `{{"valid": false, "reason": "revoked" | "expired" | "unknown_key"}}` |
240
+ | POST | `/api/apikeys/check_permission` | body `{{"api_key": "rak_...", "permission": "deploy"}}` → wrapped, `data` = `{{"valid": true, "user": "alice", "key_id": "<uuid4>", "has_permission": true}}` or the validate-style `{{"valid": false, "reason": ...}}` — validate + permission check in ONE round trip |
241
+
242
+ ### Tenant settings
243
+
244
+ Per-tenant switches. Today there is one: `strict_users` (see the deprecation
245
+ note in section 3). Enabling it is how you opt in to strict user identity
246
+ before 3.0.0 makes it the default.
247
+
248
+ | Method | Path | Returns |
249
+ |---|---|---|
250
+ | GET | `/api/settings` | wrapped, `data` = `{{"strict_users": false}}` (defaults when never set) |
251
+ | PUT | `/api/settings` | body `{{"strict_users": true}}` → wrapped, `data` = `{{"strict_users": true}}` (idempotent, audited) |
227
252
 
228
253
  ### Service
229
254
 
@@ -279,8 +304,10 @@ Methods mirror the endpoints: `create_role`, `delete_role`, `list_roles`,
279
304
  `remove_permission`, `has_permission`, `user_has_permission`,
280
305
  `get_user_permissions`, `get_role_permissions`, `get_user_roles`,
281
306
  `get_role_members`, `which_roles_can`, `which_users_can`,
282
- `get_users_for_workflow`, `rotate_key`, `ping`, and the per-user key lifecycle
283
- `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
307
+ `get_users_for_workflow`, `rotate_key`, `ping`, the per-user key lifecycle
308
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`,
309
+ `check_api_key_permission`, and tenant settings `get_settings` /
310
+ `set_strict_users`.
284
311
  Each returns the parsed JSON body, so the shapes in section 4 still apply.
285
312
  `rotate_key()` also switches the live client (and its session header) to the new
286
313
  key on success and returns it — persist `data.new_key`, it is the only copy.
@@ -271,3 +271,25 @@ class AuthApiKey(Base):
271
271
  )
272
272
  else:
273
273
  self._label = value # type: ignore[assignment]
274
+
275
+
276
+ class AuthTenantSettings(Base):
277
+ """Per-tenant settings (SPEC 0010). One row per creator; absence of a row
278
+ means every default. `strict_users` gates SPEC 0008 strict user identity:
279
+ while true, authorization decisions about users with no live API key
280
+ answer negatively. Key rotation moves this row with the namespace."""
281
+
282
+ __tablename__ = "auth_tenant_settings"
283
+ __table_args__ = (
284
+ UniqueConstraint("creator", name="uq_auth_tenant_settings_creator"),
285
+ {
286
+ "sqlite_autoincrement": True,
287
+ "schema": _SCHEMA,
288
+ },
289
+ )
290
+
291
+ id = Column(Integer, primary_key=True, index=True)
292
+ creator = Column(String(64), nullable=False)
293
+ strict_users = Column(Boolean, nullable=False, default=False)
294
+ date_created = Column(DateTime, default=func.now())
295
+ modified = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -142,6 +142,18 @@ def register_routes(app):
142
142
  return jsonify({"status": "unhealthy"}), 503
143
143
  return jsonify({"status": "healthy"})
144
144
 
145
+ def _strict_reason(auth_service, user):
146
+ """Additive reason for negative answers under strict user identity.
147
+
148
+ Computed only on negative paths; None whenever strict mode is off or
149
+ the user is key-backed (i.e. the negative is a genuine denial).
150
+ """
151
+ if auth_service.strict_users_enabled() and not auth_service.user_is_key_backed(
152
+ user
153
+ ):
154
+ return "user_not_key_backed"
155
+ return None
156
+
145
157
  @app.route("/api/membership/<user>/<group>", methods=["GET"])
146
158
  @with_db_session
147
159
  @audit_log(AuditAction.CHECK_MEMBERSHIP, resource_extractor=lambda kwargs: f"{client_fingerprint(kwargs['user'])}:{kwargs['group']}")
@@ -153,10 +165,17 @@ def register_routes(app):
153
165
  return APIResponse.bad_request(error_msg)
154
166
 
155
167
  auth_service = _get_auth_service(db) # Use helper
156
- result = auth_service.has_membership(user, group)
168
+ # Strict gate lives here (not in the service) so del_membership's
169
+ # internal has_membership call keeps working — revocation must never
170
+ # be blocked by strict mode.
171
+ reason = _strict_reason(auth_service, user)
172
+ result = False if reason else auth_service.has_membership(user, group)
173
+ data = format_permission_response(result)
174
+ if reason:
175
+ data["reason"] = reason
157
176
 
158
177
  return APIResponse.success(
159
- data=format_permission_response(result),
178
+ data=data,
160
179
  message=f"Membership check for user '{user}' and group '{group}' completed",
161
180
  )
162
181
 
@@ -172,6 +191,10 @@ def register_routes(app):
172
191
 
173
192
  auth_service = _get_auth_service(db) # Use helper
174
193
  result = auth_service.add_membership(user, group)
194
+ if not result:
195
+ reason = _strict_reason(auth_service, user)
196
+ if reason:
197
+ return jsonify({"result": result, "reason": reason})
175
198
 
176
199
  return jsonify({"result": result})
177
200
 
@@ -259,9 +282,14 @@ def register_routes(app):
259
282
 
260
283
  auth_service = _get_auth_service(db) # Use helper
261
284
  result = auth_service.user_has_permission(user, name)
285
+ data = format_permission_response(result)
286
+ if not result:
287
+ reason = _strict_reason(auth_service, user)
288
+ if reason:
289
+ data["reason"] = reason
262
290
 
263
291
  return APIResponse.success(
264
- data=format_permission_response(result),
292
+ data=data,
265
293
  message=f"Permission check for user '{user}' and permission '{name}' completed",
266
294
  )
267
295
 
@@ -277,9 +305,14 @@ def register_routes(app):
277
305
 
278
306
  auth_service = _get_auth_service(db) # Use helper
279
307
  permissions = auth_service.get_user_permissions(user)
308
+ data = format_user_permissions_response(permissions)
309
+ if not permissions:
310
+ reason = _strict_reason(auth_service, user)
311
+ if reason:
312
+ data["reason"] = reason
280
313
 
281
314
  return APIResponse.success(
282
- data=format_user_permissions_response(permissions),
315
+ data=data,
283
316
  message=f"Retrieved permissions for user '{user}'",
284
317
  )
285
318
 
@@ -436,9 +469,14 @@ def register_routes(app):
436
469
  auth_service = _get_auth_service(db)
437
470
 
438
471
  result = auth_service.user_has_permission(user, workflow_name)
472
+ data = format_permission_response(result)
473
+ if not result:
474
+ reason = _strict_reason(auth_service, user)
475
+ if reason:
476
+ data["reason"] = reason
439
477
 
440
478
  return APIResponse.success(
441
- data=format_permission_response(result),
479
+ data=data,
442
480
  message=f"Workflow permission check for user '{user}' and workflow '{workflow_name}' completed",
443
481
  )
444
482
 
@@ -634,3 +672,77 @@ def register_routes(app):
634
672
  return APIResponse.success(
635
673
  data=result, message="API key validation completed"
636
674
  )
675
+
676
+ @app.route("/api/apikeys/check_permission", methods=["POST"])
677
+ @with_db_session
678
+ @audit_log(
679
+ AuditAction.CHECK_API_KEY_PERMISSION,
680
+ resource_extractor=lambda kwargs: "api_key",
681
+ )
682
+ def check_api_key_permission(db):
683
+ """Validate a secret AND answer its subject's permission in one call.
684
+
685
+ Body: ``{"api_key": "rak_...", "permission": "<name>"}``. An invalid
686
+ key answers like validate (``valid: false`` + reason, no permission
687
+ evaluation); a valid key adds ``has_permission`` for the key's user.
688
+ The recommended backend pattern under strict user identity.
689
+ """
690
+ body = request.get_json(silent=True, force=True)
691
+ if not isinstance(body, dict):
692
+ return APIResponse.bad_request(
693
+ 'JSON body required: {"api_key": "rak_...", "permission": "<name>"}'
694
+ )
695
+ api_key = body.get("api_key")
696
+ permission = body.get("permission")
697
+ if not isinstance(api_key, str) or not API_KEY_PATTERN.match(api_key):
698
+ return APIResponse.bad_request(
699
+ "api_key must be a string matching rak_[0-9A-Za-z]{43}"
700
+ )
701
+ if not isinstance(permission, str) or not validate_permission_name(
702
+ permission
703
+ ):
704
+ return APIResponse.bad_request(f"Invalid permission name: {permission!r}")
705
+
706
+ auth_service = _get_auth_service(db)
707
+ result = auth_service.check_api_key_permission(api_key, permission)
708
+ return APIResponse.success(
709
+ data=result, message="API-key permission check completed"
710
+ )
711
+
712
+ # Tenant settings (SPEC 0010)
713
+ @app.route("/api/settings", methods=["GET"])
714
+ @with_db_session
715
+ @audit_log(
716
+ AuditAction.GET_SETTINGS, resource_extractor=lambda kwargs: "settings"
717
+ )
718
+ def get_tenant_settings(db):
719
+ """This tenant's settings; defaults when nothing was ever set."""
720
+ auth_service = _get_auth_service(db)
721
+ return APIResponse.success(
722
+ data=auth_service.get_settings(), message="Tenant settings"
723
+ )
724
+
725
+ @app.route("/api/settings", methods=["PUT"])
726
+ @with_db_session
727
+ @audit_log(
728
+ AuditAction.UPDATE_SETTINGS,
729
+ resource_extractor=lambda kwargs: "strict_users",
730
+ )
731
+ def update_tenant_settings(db):
732
+ """Update tenant settings. Body: ``{"strict_users": true|false}``.
733
+
734
+ Enabling strict_users makes authorization decisions answer negatively
735
+ for users with no live API key (SPEC 0008); disabling restores 2.4.x
736
+ behavior. Idempotent upsert, audited.
737
+ """
738
+ body = request.get_json(silent=True, force=True)
739
+ if not isinstance(body, dict) or not isinstance(
740
+ body.get("strict_users"), bool
741
+ ):
742
+ return APIResponse.bad_request(
743
+ 'JSON body required: {"strict_users": true|false}'
744
+ )
745
+
746
+ auth_service = _get_auth_service(db)
747
+ result = auth_service.set_strict_users(body["strict_users"])
748
+ return APIResponse.success(data=result, message="Tenant settings updated")
@@ -18,6 +18,7 @@ from auth.models.sql import (
18
18
  AuthGroup,
19
19
  AuthMembership,
20
20
  AuthPermission,
21
+ AuthTenantSettings,
21
22
  membership_groups,
22
23
  permission_groups,
23
24
  )
@@ -58,6 +59,7 @@ class AuthorizationService:
58
59
  client: str,
59
60
  validate_client: bool = True,
60
61
  manage_transaction: bool = True,
62
+ strict_users: Optional[bool] = None,
61
63
  ):
62
64
  if validate_client and not validate_client_key(client):
63
65
  # Never echo the raw key (it is the credential) — it would land in
@@ -72,6 +74,10 @@ class AuthorizationService:
72
74
  # method commits its own transaction. The HTTP layer sets this False and
73
75
  # commits once itself, so the mutation and its audit row commit together.
74
76
  self.manage_transaction = manage_transaction
77
+ # SPEC 0008/0010: None reads the tenant's stored setting (HTTP path);
78
+ # an explicit bool overrides it for in-process/library callers.
79
+ self._strict_override = strict_users
80
+ self._strict_cache: Optional[bool] = None
75
81
 
76
82
  def _commit(self) -> None:
77
83
  """Commit only when this service owns the transaction (see __init__)."""
@@ -150,6 +156,8 @@ class AuthorizationService:
150
156
 
151
157
  def get_user_permissions(self, user: str) -> List[Dict[str, Any]]:
152
158
  """Get all permissions for a user"""
159
+ if self._strict_blocks(user):
160
+ return []
153
161
  membership = (
154
162
  self.db.query(AuthMembership)
155
163
  .filter(
@@ -324,8 +332,12 @@ class AuthorizationService:
324
332
  """Add user to a role - atomic idempotent upsert.
325
333
 
326
334
  Uses INSERT ... ON CONFLICT (PostgreSQL and SQLite) for
327
- race-condition-free operations.
335
+ race-condition-free operations. Under strict user identity a user with
336
+ no live API key cannot be granted membership (create the key first);
337
+ removal is deliberately NOT strict-gated, so revocation always works.
328
338
  """
339
+ if self._strict_blocks(user):
340
+ return False
329
341
  self._lock_tenant()
330
342
  try:
331
343
  group_table = AuthGroup.__table__
@@ -547,6 +559,8 @@ class AuthorizationService:
547
559
 
548
560
  def user_has_permission(self, user: str, name: str) -> bool:
549
561
  """Check if user has permission"""
562
+ if self._strict_blocks(user):
563
+ return False
550
564
  membership = (
551
565
  self.db.query(AuthMembership)
552
566
  .filter(
@@ -565,6 +579,89 @@ class AuthorizationService:
565
579
  return True
566
580
  return False
567
581
 
582
+ def check_api_key_permission(
583
+ self, api_key: str, permission: str
584
+ ) -> Dict[str, Any]:
585
+ """Validate a secret and answer its subject's permission in one call.
586
+
587
+ An invalid key returns the validate result unchanged; a valid key adds
588
+ the effective-permission answer. The subject is key-backed by
589
+ construction (this very key just validated), so strict mode never
590
+ blocks the second half.
591
+ """
592
+ validated = self.validate_api_key(api_key)
593
+ if not validated["valid"]:
594
+ return validated
595
+ user = cast(str, validated["user"])
596
+ return {
597
+ "valid": True,
598
+ "user": user,
599
+ "key_id": validated["key_id"],
600
+ "has_permission": self.user_has_permission(user, permission),
601
+ }
602
+
603
+ # --- Tenant settings & strict user identity (SPEC 0008/0010) ---------
604
+
605
+ def strict_users_enabled(self) -> bool:
606
+ """Whether strict user identity applies to this tenant.
607
+
608
+ Explicit constructor override wins; otherwise the tenant's stored
609
+ setting is read once per service instance (one PK-adjacent lookup,
610
+ cached for the request).
611
+ """
612
+ if self._strict_override is not None:
613
+ return self._strict_override
614
+ if self._strict_cache is None:
615
+ row = (
616
+ self.db.query(AuthTenantSettings)
617
+ .filter(AuthTenantSettings.creator == self.client)
618
+ .first()
619
+ )
620
+ self._strict_cache = bool(row.strict_users) if row else False
621
+ return self._strict_cache
622
+
623
+ def get_settings(self) -> Dict[str, Any]:
624
+ """This tenant's settings (defaults when no row exists)."""
625
+ return {"strict_users": self.strict_users_enabled()}
626
+
627
+ def set_strict_users(self, enabled: bool) -> Dict[str, Any]:
628
+ """Upsert this tenant's strict_users flag (serialized vs rotation)."""
629
+ self._lock_tenant()
630
+ row = (
631
+ self.db.query(AuthTenantSettings)
632
+ .filter(AuthTenantSettings.creator == self.client)
633
+ .first()
634
+ )
635
+ if row is None:
636
+ row = AuthTenantSettings(creator=self.client, strict_users=enabled)
637
+ self.db.add(row)
638
+ else:
639
+ row.strict_users = enabled # type: ignore[assignment]
640
+ self._strict_cache = enabled
641
+ self._commit()
642
+ return {"strict_users": enabled}
643
+
644
+ def user_is_key_backed(self, user: str) -> bool:
645
+ """True when the user holds ≥1 active, unexpired API key here."""
646
+ row = (
647
+ self.db.query(AuthApiKey.id)
648
+ .filter(
649
+ AuthApiKey.creator == self.client,
650
+ AuthApiKey._user == self._get_encrypted_user(user),
651
+ AuthApiKey.is_active,
652
+ )
653
+ .filter(
654
+ (AuthApiKey.expires_at.is_(None))
655
+ | (AuthApiKey.expires_at > _utcnow())
656
+ )
657
+ .first()
658
+ )
659
+ return row is not None
660
+
661
+ def _strict_blocks(self, user: str) -> bool:
662
+ """Strict mode on AND the user has no live key → decision is negative."""
663
+ return self.strict_users_enabled() and not self.user_is_key_backed(user)
664
+
568
665
  # --- Per-user API keys (SPEC 0004) -----------------------------------
569
666
 
570
667
  @staticmethod
@@ -781,6 +878,7 @@ class AuthorizationService:
781
878
  ("memberships", cast(Table, AuthMembership.__table__), ["user"]),
782
879
  ("permissions", cast(Table, AuthPermission.__table__), ["name"]),
783
880
  ("api_keys", cast(Table, AuthApiKey.__table__), ["user", "label"]),
881
+ ("settings", cast(Table, AuthTenantSettings.__table__), []),
784
882
  ]
785
883
  migrated: Dict[str, int] = {}
786
884
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -41,11 +41,10 @@ Provides-Extra: ratelimit
41
41
  Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
42
42
  Provides-Extra: migrations
43
43
  Requires-Dist: migretti>=0.10.0; extra == "migrations"
44
- Requires-Dist: alembic>=1.13.0; extra == "migrations"
45
44
  Provides-Extra: dev
46
45
  Requires-Dist: pytest>=6.0; extra == "dev"
47
46
  Requires-Dist: pytest-cov>=2.0; extra == "dev"
48
- Requires-Dist: alembic>=1.13.0; extra == "dev"
47
+ Requires-Dist: migretti>=0.10.0; extra == "dev"
49
48
  Requires-Dist: ruff>=0.0.260; extra == "dev"
50
49
  Requires-Dist: mypy>=1.0; extra == "dev"
51
50
  Requires-Dist: types-requests; extra == "dev"
@@ -121,6 +120,13 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
121
120
  a hash, and validates them inside your namespace — an identity UI fronts the
122
121
  lifecycle, backends validate then use the RBAC checks. Client methods:
123
122
  `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
123
+ - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
124
+ key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
125
+ `PUT /api/settings {"strict_users": true}` makes keyless users answer
126
+ negatively (`user_not_key_backed`), and `POST /api/apikeys/check_permission`
127
+ does validate + permission in one round trip. **3.0.0 makes key-backed users
128
+ mandatory** — only after every consuming platform confirms.
129
+ Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
124
130
 
125
131
  ## Documentation
126
132
 
@@ -47,9 +47,9 @@ tests/test_encryption_integration.py
47
47
  tests/test_flask.py
48
48
  tests/test_key_rotation.py
49
49
  tests/test_log_redaction.py
50
- tests/test_migrations.py
51
50
  tests/test_phase_a_hardening.py
52
51
  tests/test_reencryption.py
53
52
  tests/test_routes_errors.py
54
53
  tests/test_server.py
55
- tests/test_service_lifecycle.py
54
+ tests/test_service_lifecycle.py
55
+ tests/test_strict_users.py
@@ -14,7 +14,7 @@ python-json-logger<4,>=3.1.0
14
14
  [dev]
15
15
  pytest>=6.0
16
16
  pytest-cov>=2.0
17
- alembic>=1.13.0
17
+ migretti>=0.10.0
18
18
  ruff>=0.0.260
19
19
  mypy>=1.0
20
20
  types-requests
@@ -25,7 +25,6 @@ responses>=0.23
25
25
 
26
26
  [migrations]
27
27
  migretti>=0.10.0
28
- alembic>=1.13.0
29
28
 
30
29
  [ratelimit]
31
30
  flask-limiter>=3.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth"
7
- version = "2.4.0"
7
+ version = "2.5.0"
8
8
  description = "Authorization for humans"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -58,17 +58,15 @@ dependencies = [
58
58
  ratelimit = [
59
59
  "flask-limiter>=3.0.0",
60
60
  ]
61
- # Database schema migrations. migretti (house standard) authors and applies
62
- # NEW migrations; alembic remains only to read the retired legacy tree in
63
- # migrations_legacy_alembic/. Not required to import or serve the app.
61
+ # Database schema migrations (migretti, SQL-first house standard). Not
62
+ # required to import or serve the app.
64
63
  migrations = [
65
64
  "migretti>=0.10.0",
66
- "alembic>=1.13.0",
67
65
  ]
68
66
  dev = [
69
67
  "pytest>=6.0",
70
68
  "pytest-cov>=2.0",
71
- "alembic>=1.13.0",
69
+ "migretti>=0.10.0",
72
70
  "ruff>=0.0.260",
73
71
  "mypy>=1.0",
74
72
  "types-requests",
@@ -15,8 +15,6 @@ from sqlalchemy import select
15
15
 
16
16
  from auth.models.sql import AuthApiKey
17
17
 
18
- pytestmark = []
19
-
20
18
  _SHA256_HEX = re.compile(r"^[0-9a-f]{64}$")
21
19
 
22
20
 
@@ -315,3 +315,41 @@ def test_create_api_key_raises_under_raise_on_error():
315
315
  with make_client(raise_on_error=True) as client:
316
316
  with pytest.raises(AuthTransportError):
317
317
  client.create_api_key("alice")
318
+
319
+
320
+ @responses.activate
321
+ def test_check_api_key_permission_and_settings_methods():
322
+ responses.post(
323
+ f"{BASE}/api/apikeys/check_permission",
324
+ json={
325
+ "success": True,
326
+ "data": {"valid": True, "user": "alice", "has_permission": True},
327
+ },
328
+ )
329
+ responses.get(
330
+ f"{BASE}/api/settings",
331
+ json={"success": True, "data": {"strict_users": False}},
332
+ )
333
+ responses.put(
334
+ f"{BASE}/api/settings",
335
+ json={"success": True, "data": {"strict_users": True}},
336
+ )
337
+ with make_client() as client:
338
+ checked = client.check_api_key_permission(SECRET, "deploy")
339
+ assert checked["data"]["has_permission"] is True
340
+ assert responses.calls[0].request.body == (
341
+ '{"api_key": "%s", "permission": "deploy"}' % SECRET
342
+ ).encode()
343
+ assert client.get_settings()["data"]["strict_users"] is False
344
+ flipped = client.set_strict_users(True)
345
+ assert flipped["data"]["strict_users"] is True
346
+ assert responses.calls[2].request.body == b'{"strict_users": true}'
347
+
348
+
349
+ @responses.activate
350
+ def test_check_api_key_permission_failure_echoes_prefix_never_secret():
351
+ with make_client() as client: # nothing registered -> connection error
352
+ result = client.check_api_key_permission(SECRET, "deploy")
353
+ assert result["success"] is False and result["transport_error"] is True
354
+ assert result["data"] == {"key_prefix": SECRET[:12], "permission": "deploy"}
355
+ assert SECRET not in str(result)
@@ -76,6 +76,7 @@ def test_rotate_moves_the_namespace_to_a_fresh_key(client):
76
76
  "memberships": 1,
77
77
  "permissions": 1,
78
78
  "api_keys": 0,
79
+ "settings": 0,
79
80
  }
80
81
 
81
82
 
@@ -216,6 +217,7 @@ def test_rotation_reencrypts_bound_cells_when_encryption_is_on(app, monkeypatch)
216
217
  "memberships": 1,
217
218
  "permissions": 1,
218
219
  "api_keys": 1,
220
+ "settings": 0,
219
221
  }
220
222
 
221
223
  # Nothing left under the old key.
@@ -0,0 +1,318 @@
1
+ """Strict user identity, opt-in phase (SPEC 0008/0010) — HTTP-level.
2
+
3
+ The gate is tested in BOTH directions everywhere: strict mode blocks keyless
4
+ users AND releases them the moment a key is issued (or the mode is disabled),
5
+ and a tenant that never opts in behaves exactly like 2.4.1.
6
+ """
7
+
8
+ import uuid
9
+
10
+ import pytest
11
+
12
+ from auth.main import create_app
13
+
14
+
15
+ @pytest.fixture
16
+ def app():
17
+ application = create_app()
18
+ application.config["TESTING"] = True
19
+ return application
20
+
21
+
22
+ @pytest.fixture
23
+ def client(app):
24
+ return app.test_client()
25
+
26
+
27
+ def _h(key):
28
+ return {"Authorization": f"Bearer {key}"}
29
+
30
+
31
+ def _seed_rbac(client, key, user="alice"):
32
+ h = _h(key)
33
+ assert client.post("/api/role/engineers", headers=h).get_json()["result"] is True
34
+ assert (
35
+ client.post("/api/permission/engineers/deploy", headers=h).get_json()["result"]
36
+ is True
37
+ )
38
+ assert (
39
+ client.post(f"/api/membership/{user}/engineers", headers=h).get_json()["result"]
40
+ is True
41
+ )
42
+
43
+
44
+ def _has_permission(client, key, user="alice", perm="deploy"):
45
+ return client.get(f"/api/has_permission/{user}/{perm}", headers=_h(key)).get_json()[
46
+ "data"
47
+ ]
48
+
49
+
50
+ def test_settings_default_and_toggle_roundtrip(client):
51
+ key = str(uuid.uuid4())
52
+ h = _h(key)
53
+ assert client.get("/api/settings", headers=h).get_json()["data"] == {
54
+ "strict_users": False
55
+ }
56
+ on = client.put("/api/settings", headers=h, json={"strict_users": True})
57
+ assert on.status_code == 200
58
+ assert on.get_json()["data"] == {"strict_users": True}
59
+ assert client.get("/api/settings", headers=h).get_json()["data"] == {
60
+ "strict_users": True
61
+ }
62
+ # Both directions: it turns off again.
63
+ off = client.put("/api/settings", headers=h, json={"strict_users": False})
64
+ assert off.get_json()["data"] == {"strict_users": False}
65
+ assert client.get("/api/settings", headers=h).get_json()["data"] == {
66
+ "strict_users": False
67
+ }
68
+
69
+
70
+ def test_settings_input_validation(client):
71
+ key = str(uuid.uuid4())
72
+ h = _h(key)
73
+ assert client.put("/api/settings", headers=h).status_code == 400
74
+ assert (
75
+ client.put("/api/settings", headers=h, json={"strict_users": "yes"}).status_code
76
+ == 400
77
+ )
78
+ assert client.put("/api/settings", headers=h, json={}).status_code == 400
79
+ assert client.get("/api/settings").status_code == 401
80
+
81
+
82
+ def test_strict_blocks_and_releases_permission_checks(client):
83
+ key = str(uuid.uuid4())
84
+ h = _h(key)
85
+ _seed_rbac(client, key)
86
+
87
+ # GREEN baseline: keyless user answers true while strict is off (2.4.1
88
+ # behavior) — proves the block below is strict mode, not a broken seed.
89
+ assert _has_permission(client, key) == {"has_permission": True}
90
+
91
+ client.put("/api/settings", headers=h, json={"strict_users": True})
92
+
93
+ blocked = _has_permission(client, key)
94
+ assert blocked == {
95
+ "has_permission": False,
96
+ "reason": "user_not_key_backed",
97
+ }
98
+ # user_permissions and membership check and workflow can_run agree.
99
+ perms = client.get("/api/user_permissions/alice", headers=h).get_json()["data"]
100
+ assert perms["count"] == 0 and perms["reason"] == "user_not_key_backed"
101
+ member = client.get("/api/membership/alice/engineers", headers=h).get_json()["data"]
102
+ assert member == {"has_permission": False, "reason": "user_not_key_backed"}
103
+ wf = client.get(
104
+ "/api/workflow/user/alice/can_run/deploy", headers=h
105
+ ).get_json()["data"]
106
+ assert wf == {"has_permission": False, "reason": "user_not_key_backed"}
107
+
108
+ # RELEASE direction 1: issuing a key restores every answer.
109
+ client.post("/api/apikeys/user/alice", headers=h)
110
+ assert _has_permission(client, key) == {"has_permission": True}
111
+ assert (
112
+ client.get("/api/user_permissions/alice", headers=h).get_json()["data"]["count"]
113
+ == 1
114
+ )
115
+ assert client.get("/api/membership/alice/engineers", headers=h).get_json()["data"][
116
+ "has_permission"
117
+ ] is True
118
+
119
+ # Revoking the only key blocks again (revocation is now end-to-end).
120
+ key_id = client.get("/api/apikeys/user/alice", headers=h).get_json()["data"][
121
+ "keys"
122
+ ][0]["key_id"]
123
+ client.delete(f"/api/apikeys/user/alice/{key_id}", headers=h)
124
+ assert _has_permission(client, key)["has_permission"] is False
125
+
126
+ # RELEASE direction 2: disabling strict mode restores 2.4.1 behavior.
127
+ client.put("/api/settings", headers=h, json={"strict_users": False})
128
+ assert _has_permission(client, key) == {"has_permission": True}
129
+
130
+
131
+ def test_strict_blocks_membership_add_but_never_removal(client):
132
+ key = str(uuid.uuid4())
133
+ h = _h(key)
134
+ _seed_rbac(client, key, user="bob") # bob is a member while strict is off
135
+ client.put("/api/settings", headers=h, json={"strict_users": True})
136
+
137
+ # Adds for keyless users are refused with the 200-false convention.
138
+ add = client.post("/api/membership/carol/engineers", headers=h).get_json()
139
+ assert add == {"result": False, "reason": "user_not_key_backed"}
140
+
141
+ # Removal is NEVER strict-gated: revocation must always work.
142
+ rm = client.delete("/api/membership/bob/engineers", headers=h).get_json()
143
+ assert rm == {"result": True}
144
+ client.put("/api/settings", headers=h, json={"strict_users": False})
145
+ assert (
146
+ client.get("/api/membership/bob/engineers", headers=h).get_json()["data"][
147
+ "has_permission"
148
+ ]
149
+ is False
150
+ )
151
+
152
+ # Bootstrap order under strict mode: key first, then membership works.
153
+ client.put("/api/settings", headers=h, json={"strict_users": True})
154
+ client.post("/api/apikeys/user/carol", headers=h)
155
+ assert (
156
+ client.post("/api/membership/carol/engineers", headers=h).get_json()["result"]
157
+ is True
158
+ )
159
+
160
+
161
+ def test_strict_mode_is_tenant_scoped(client):
162
+ strict_tenant, normal_tenant = str(uuid.uuid4()), str(uuid.uuid4())
163
+ _seed_rbac(client, strict_tenant)
164
+ _seed_rbac(client, normal_tenant)
165
+ client.put(
166
+ "/api/settings", headers=_h(strict_tenant), json={"strict_users": True}
167
+ )
168
+
169
+ assert _has_permission(client, strict_tenant)["has_permission"] is False
170
+ # The other tenant is untouched — byte-identical 2.4.1 behavior.
171
+ assert _has_permission(client, normal_tenant) == {"has_permission": True}
172
+
173
+
174
+ def test_denial_reason_only_appears_when_strict_blocked(client):
175
+ key = str(uuid.uuid4())
176
+ h = _h(key)
177
+ _seed_rbac(client, key)
178
+ client.put("/api/settings", headers=h, json={"strict_users": True})
179
+ client.post("/api/apikeys/user/alice", headers=h)
180
+
181
+ # A key-backed user lacking the permission is a plain denial — no reason
182
+ # key, so callers can distinguish denial from strict-block.
183
+ denied = _has_permission(client, key, perm="launch_missiles")
184
+ assert denied == {"has_permission": False}
185
+
186
+
187
+ def test_check_permission_composite_endpoint(client):
188
+ key = str(uuid.uuid4())
189
+ h = _h(key)
190
+ _seed_rbac(client, key)
191
+ secret = client.post("/api/apikeys/user/alice", headers=h).get_json()["data"][
192
+ "api_key"
193
+ ]
194
+
195
+ ok = client.post(
196
+ "/api/apikeys/check_permission",
197
+ headers=h,
198
+ json={"api_key": secret, "permission": "deploy"},
199
+ ).get_json()["data"]
200
+ assert ok["valid"] is True
201
+ assert ok["user"] == "alice"
202
+ assert ok["has_permission"] is True
203
+
204
+ # Valid key, missing permission: valid stays true, answer is false.
205
+ no = client.post(
206
+ "/api/apikeys/check_permission",
207
+ headers=h,
208
+ json={"api_key": secret, "permission": "launch_missiles"},
209
+ ).get_json()["data"]
210
+ assert no == {
211
+ "valid": True,
212
+ "user": "alice",
213
+ "key_id": no["key_id"],
214
+ "has_permission": False,
215
+ }
216
+
217
+ # Works under strict mode too (the key IS the user's backing).
218
+ client.put("/api/settings", headers=h, json={"strict_users": True})
219
+ assert (
220
+ client.post(
221
+ "/api/apikeys/check_permission",
222
+ headers=h,
223
+ json={"api_key": secret, "permission": "deploy"},
224
+ ).get_json()["data"]["has_permission"]
225
+ is True
226
+ )
227
+
228
+ # Revoked key: validate-shaped red, no permission evaluation.
229
+ key_id = client.get("/api/apikeys/user/alice", headers=h).get_json()["data"][
230
+ "keys"
231
+ ][0]["key_id"]
232
+ client.delete(f"/api/apikeys/user/alice/{key_id}", headers=h)
233
+ revoked = client.post(
234
+ "/api/apikeys/check_permission",
235
+ headers=h,
236
+ json={"api_key": secret, "permission": "deploy"},
237
+ ).get_json()["data"]
238
+ assert revoked == {"valid": False, "reason": "revoked"}
239
+
240
+ # Input validation.
241
+ assert (
242
+ client.post(
243
+ "/api/apikeys/check_permission",
244
+ headers=h,
245
+ json={"api_key": secret, "permission": "bad name!"},
246
+ ).status_code
247
+ == 400
248
+ )
249
+ assert (
250
+ client.post(
251
+ "/api/apikeys/check_permission", headers=h, json={"api_key": "nope"}
252
+ ).status_code
253
+ == 400
254
+ )
255
+ assert client.post("/api/apikeys/check_permission", headers=h).status_code == 400
256
+
257
+
258
+ def test_rotation_carries_strict_setting(client):
259
+ key = str(uuid.uuid4())
260
+ h = _h(key)
261
+ _seed_rbac(client, key)
262
+ client.post("/api/apikeys/user/alice", headers=h)
263
+ client.put("/api/settings", headers=h, json={"strict_users": True})
264
+
265
+ rotated = client.post("/api/keys/rotate", headers=h).get_json()["data"]
266
+ assert rotated["migrated"]["settings"] == 1
267
+ h_new = _h(rotated["new_key"])
268
+ # Strict survives rotation; the key-backed user still answers true.
269
+ assert client.get("/api/settings", headers=h_new).get_json()["data"] == {
270
+ "strict_users": True
271
+ }
272
+ assert (
273
+ client.get("/api/has_permission/alice/deploy", headers=h_new).get_json()[
274
+ "data"
275
+ ]["has_permission"]
276
+ is True
277
+ )
278
+
279
+
280
+ def test_settings_update_is_audited(client):
281
+ from auth.audit import AuditLog
282
+ from auth.database import SessionLocal
283
+
284
+ key = str(uuid.uuid4())
285
+ client.put("/api/settings", headers=_h(key), json={"strict_users": True})
286
+ session = SessionLocal()
287
+ try:
288
+ actions = {row.action for row in session.query(AuditLog).all()}
289
+ assert "UPDATE_SETTINGS" in actions
290
+ finally:
291
+ session.close()
292
+
293
+
294
+ def test_in_process_service_override_param():
295
+ """Library callers pin strict behavior explicitly, DB row ignored."""
296
+ import uuid as _uuid
297
+
298
+ from auth.database import SessionLocal
299
+ from auth.main import create_app
300
+ from auth.services.service import AuthorizationService
301
+
302
+ create_app()
303
+ tenant = str(_uuid.uuid4())
304
+ db = SessionLocal()
305
+ try:
306
+ svc = AuthorizationService(db, tenant)
307
+ svc.set_strict_users(True)
308
+ assert AuthorizationService(db, tenant).strict_users_enabled() is True
309
+ # Explicit override beats the stored row, both ways.
310
+ assert (
311
+ AuthorizationService(db, tenant, strict_users=False).strict_users_enabled()
312
+ is False
313
+ )
314
+ off = AuthorizationService(db, tenant, strict_users=True)
315
+ svc.set_strict_users(False)
316
+ assert off.strict_users_enabled() is True
317
+ finally:
318
+ db.close()
@@ -1,45 +0,0 @@
1
- """The RETIRED Alembic migration stack still loads, applies, and reverses.
2
-
3
- Alembic is frozen at its single legacy revision (0001_widen_text, applied in
4
- production) and lives in migrations_legacy_alembic/; NEW migrations are
5
- migretti SQL files in migrations/ (see MIGRATIONS.md). These tests keep the
6
- legacy tree loadable so the recorded history stays reproducible.
7
-
8
- On SQLite the widening migration is a no-op (SQLite ignores varchar length), so
9
- these assert the framework wiring — env.py resolves the app's database, the
10
- revision imports, and upgrade/downgrade run without error. The real varchar->TEXT
11
- behaviour is exercised against PostgreSQL in the integration run.
12
- """
13
-
14
- import os
15
-
16
- import pytest
17
-
18
- pytest.importorskip("alembic")
19
-
20
- from alembic import command # noqa: E402
21
- from alembic.config import Config # noqa: E402
22
-
23
- from auth.database import create_tables # noqa: E402
24
-
25
- _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
-
27
-
28
- def _config() -> Config:
29
- cfg = Config(os.path.join(_ROOT, "alembic.ini"))
30
- cfg.set_main_option(
31
- "script_location", os.path.join(_ROOT, "migrations_legacy_alembic")
32
- )
33
- return cfg
34
-
35
-
36
- def test_upgrade_head_runs():
37
- # Alembic owns changes; create_tables owns creation. Tables must exist first.
38
- create_tables(raise_on_error=True)
39
- command.upgrade(_config(), "head") # must not raise
40
-
41
-
42
- def test_downgrade_then_upgrade_roundtrips():
43
- cfg = _config()
44
- command.downgrade(cfg, "base")
45
- command.upgrade(cfg, "head")
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