auth 2.5.0__tar.gz → 2.5.2__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.5.0 → auth-2.5.2}/PKG-INFO +9 -5
  2. {auth-2.5.0 → auth-2.5.2}/README.md +8 -4
  3. {auth-2.5.0 → auth-2.5.2}/auth/__init__.py +36 -3
  4. {auth-2.5.0 → auth-2.5.2}/auth/database.py +25 -4
  5. {auth-2.5.0 → auth-2.5.2}/auth/docs_page.py +18 -7
  6. {auth-2.5.0 → auth-2.5.2}/auth/routes.py +7 -1
  7. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/PKG-INFO +9 -5
  8. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/SOURCES.txt +1 -0
  9. {auth-2.5.0 → auth-2.5.2}/pyproject.toml +1 -1
  10. auth-2.5.2/tests/test_database_sslmode.py +47 -0
  11. {auth-2.5.0 → auth-2.5.2}/tests/test_strict_users.py +58 -3
  12. {auth-2.5.0 → auth-2.5.2}/LICENSE +0 -0
  13. {auth-2.5.0 → auth-2.5.2}/auth/api_keys.py +0 -0
  14. {auth-2.5.0 → auth-2.5.2}/auth/audit.py +0 -0
  15. {auth-2.5.0 → auth-2.5.2}/auth/circuit_breaker.py +0 -0
  16. {auth-2.5.0 → auth-2.5.2}/auth/client.py +0 -0
  17. {auth-2.5.0 → auth-2.5.2}/auth/cmd/__init__.py +0 -0
  18. {auth-2.5.0 → auth-2.5.2}/auth/cmd/server.py +0 -0
  19. {auth-2.5.0 → auth-2.5.2}/auth/config.py +0 -0
  20. {auth-2.5.0 → auth-2.5.2}/auth/core/REST/__init__.py +0 -0
  21. {auth-2.5.0 → auth-2.5.2}/auth/core/REST/client.py +0 -0
  22. {auth-2.5.0 → auth-2.5.2}/auth/core/__init__.py +0 -0
  23. {auth-2.5.0 → auth-2.5.2}/auth/core/models/__init__.py +0 -0
  24. {auth-2.5.0 → auth-2.5.2}/auth/decorators.py +0 -0
  25. {auth-2.5.0 → auth-2.5.2}/auth/encryption.py +0 -0
  26. {auth-2.5.0 → auth-2.5.2}/auth/logging_config.py +0 -0
  27. {auth-2.5.0 → auth-2.5.2}/auth/main.py +0 -0
  28. {auth-2.5.0 → auth-2.5.2}/auth/models/sql.py +0 -0
  29. {auth-2.5.0 → auth-2.5.2}/auth/response_format.py +0 -0
  30. {auth-2.5.0 → auth-2.5.2}/auth/sanitizer.py +0 -0
  31. {auth-2.5.0 → auth-2.5.2}/auth/server.py +0 -0
  32. {auth-2.5.0 → auth-2.5.2}/auth/services/service.py +0 -0
  33. {auth-2.5.0 → auth-2.5.2}/auth/validation.py +0 -0
  34. {auth-2.5.0 → auth-2.5.2}/auth/workflow_checker.py +0 -0
  35. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/dependency_links.txt +0 -0
  36. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/entry_points.txt +0 -0
  37. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/requires.txt +0 -0
  38. {auth-2.5.0 → auth-2.5.2}/auth.egg-info/top_level.txt +0 -0
  39. {auth-2.5.0 → auth-2.5.2}/setup.cfg +0 -0
  40. {auth-2.5.0 → auth-2.5.2}/tests/test_api_keys.py +0 -0
  41. {auth-2.5.0 → auth-2.5.2}/tests/test_api_keys_encryption.py +0 -0
  42. {auth-2.5.0 → auth-2.5.2}/tests/test_audit_integrity.py +0 -0
  43. {auth-2.5.0 → auth-2.5.2}/tests/test_client.py +0 -0
  44. {auth-2.5.0 → auth-2.5.2}/tests/test_client_rest.py +0 -0
  45. {auth-2.5.0 → auth-2.5.2}/tests/test_cmd_server.py +0 -0
  46. {auth-2.5.0 → auth-2.5.2}/tests/test_config.py +0 -0
  47. {auth-2.5.0 → auth-2.5.2}/tests/test_correctness_hardening.py +0 -0
  48. {auth-2.5.0 → auth-2.5.2}/tests/test_docs_page.py +0 -0
  49. {auth-2.5.0 → auth-2.5.2}/tests/test_encryption.py +0 -0
  50. {auth-2.5.0 → auth-2.5.2}/tests/test_encryption_integration.py +0 -0
  51. {auth-2.5.0 → auth-2.5.2}/tests/test_flask.py +0 -0
  52. {auth-2.5.0 → auth-2.5.2}/tests/test_key_rotation.py +0 -0
  53. {auth-2.5.0 → auth-2.5.2}/tests/test_log_redaction.py +0 -0
  54. {auth-2.5.0 → auth-2.5.2}/tests/test_phase_a_hardening.py +0 -0
  55. {auth-2.5.0 → auth-2.5.2}/tests/test_reencryption.py +0 -0
  56. {auth-2.5.0 → auth-2.5.2}/tests/test_routes_errors.py +0 -0
  57. {auth-2.5.0 → auth-2.5.2}/tests/test_server.py +0 -0
  58. {auth-2.5.0 → auth-2.5.2}/tests/test_service_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 2.5.0
3
+ Version: 2.5.2
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -119,14 +119,18 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
119
119
  mints `rak_...` secrets for *your users*, shows each exactly once, stores only
120
120
  a hash, and validates them inside your namespace — an identity UI fronts the
121
121
  lifecycle, backends validate then use the RBAC checks. Client methods:
122
- `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
122
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`,
123
+ `check_api_key_permission` (validate + permission in one round trip), plus
124
+ `get_settings`/`set_strict_users`. All of these also exist on the in-process
125
+ `Authorization` wrapper for embedded consumers.
123
126
  - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
124
127
  key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
125
128
  `PUT /api/settings {"strict_users": true}` makes keyless users answer
126
129
  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).
130
+ does validate + permission in one round trip. **3.0.0 makes strict identity
131
+ the default** (the audited per-tenant opt-out survives for validated
132
+ machine-subject architectures) — and ships only after every consuming
133
+ platform confirms. Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
130
134
 
131
135
  ## Documentation
132
136
 
@@ -63,14 +63,18 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
63
63
  mints `rak_...` secrets for *your users*, shows each exactly once, stores only
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
- `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
66
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`,
67
+ `check_api_key_permission` (validate + permission in one round trip), plus
68
+ `get_settings`/`set_strict_users`. All of these also exist on the in-process
69
+ `Authorization` wrapper for embedded consumers.
67
70
  - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
68
71
  key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
69
72
  `PUT /api/settings {"strict_users": true}` makes keyless users answer
70
73
  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).
74
+ does validate + permission in one round trip. **3.0.0 makes strict identity
75
+ the default** (the audited per-tenant opt-out survives for validated
76
+ machine-subject architectures) — and ships only after every consuming
77
+ platform confirms. Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
74
78
 
75
79
  ## Documentation
76
80
 
@@ -38,12 +38,21 @@ api = app
38
38
  class Authorization:
39
39
  """Compatibility wrapper for old Authorization interface"""
40
40
 
41
- def __init__(self, client: str, db_session=None):
41
+ def __init__(
42
+ self,
43
+ client: str,
44
+ db_session=None,
45
+ strict_users: Optional[bool] = None,
46
+ ):
42
47
  self.client = client
43
48
  # Use provided session or create a new one
44
49
  self.db = db_session if db_session else SessionLocal()
45
- # Use legacy mode (no client validation) for backward compatibility
46
- self.service = AuthorizationService(self.db, client, validate_client=False)
50
+ # Use legacy mode (no client validation) for backward compatibility.
51
+ # strict_users: None reads the tenant's stored setting (identical
52
+ # semantics to the REST layer); an explicit bool pins it per instance.
53
+ self.service = AuthorizationService(
54
+ self.db, client, validate_client=False, strict_users=strict_users
55
+ )
47
56
 
48
57
  @property
49
58
  def roles(self):
@@ -94,6 +103,30 @@ class Authorization:
94
103
  def which_users_can(self, name: str):
95
104
  return self.service.which_users_can(name)
96
105
 
106
+ # Tenant settings & strict user identity (SPEC 0010) — embedded consumers
107
+ # get the same semantics the REST layer serves.
108
+ def get_settings(self):
109
+ return self.service.get_settings()
110
+
111
+ def set_strict_users(self, enabled: bool):
112
+ return self.service.set_strict_users(enabled)
113
+
114
+ # Per-user API keys (SPEC 0004)
115
+ def create_api_key(self, user: str, label: Optional[str] = None):
116
+ return self.service.create_api_key(user, label)
117
+
118
+ def list_api_keys(self, user: str):
119
+ return self.service.list_api_keys(user)
120
+
121
+ def revoke_api_key(self, user: str, key_id: str):
122
+ return self.service.revoke_api_key(user, key_id)
123
+
124
+ def validate_api_key(self, api_key: str):
125
+ return self.service.validate_api_key(api_key)
126
+
127
+ def check_api_key_permission(self, api_key: str, permission: str):
128
+ return self.service.check_api_key_permission(api_key, permission)
129
+
97
130
 
98
131
  # Export the new client for users who want enhanced features
99
132
  __all__ = [
@@ -3,18 +3,39 @@
3
3
  SQLAlchemy database session management with enterprise-grade connection pooling
4
4
  """
5
5
  import logging
6
+ import os
6
7
  import sqlite3
7
8
  import threading
8
9
  from contextlib import contextmanager
9
10
  from typing import Generator, Optional
10
11
 
11
12
  from sqlalchemy import create_engine, event, pool
12
- from sqlalchemy.engine import Engine
13
+ from sqlalchemy.engine import Engine, make_url
13
14
  from sqlalchemy.orm import Session, sessionmaker
14
15
  from sqlalchemy.pool import Pool
15
16
 
16
17
  from auth.config import DatabaseType, get_settings
17
18
 
19
+
20
+ def _forced_sslmode(database_url: str) -> Optional[str]:
21
+ """The sslmode to force via connect_args, or None to leave it alone.
22
+
23
+ Secure-by-default SSL for remote hosts — but never overriding an explicit
24
+ caller choice. connect_args beat URL conninfo in psycopg, so forcing
25
+ sslmode here would silently discard a ``?sslmode=...`` the caller wrote
26
+ (highway report, agent-mail thr-7745c815fd0a425cabac). Precedence:
27
+ URL sslmode param > PGSSLMODE env > require-for-remote. The host is
28
+ decided by component comparison, not URL substring — a URL carrying
29
+ ``?fallback_application_name=localhost`` must not skip SSL.
30
+ """
31
+ url = make_url(database_url)
32
+ if "sslmode" in url.query or os.environ.get("PGSSLMODE"):
33
+ return None
34
+ host = (url.host or "").lower()
35
+ if host in ("localhost", "127.0.0.1", "::1"):
36
+ return None
37
+ return "require"
38
+
18
39
  logger = logging.getLogger(__name__)
19
40
 
20
41
 
@@ -86,9 +107,9 @@ class DatabaseEngine(metaclass=SingletonMeta):
86
107
  "options": "-c statement_timeout=30000", # 30 seconds
87
108
  }
88
109
 
89
- # Enable SSL for remote connections
90
- if "localhost" not in database_url and "127.0.0.1" not in database_url:
91
- connect_args["sslmode"] = "require"
110
+ forced = _forced_sslmode(database_url)
111
+ if forced:
112
+ connect_args["sslmode"] = forced
92
113
 
93
114
  return create_engine(
94
115
  database_url,
@@ -147,13 +147,24 @@ secrets keep validating afterwards.
147
147
  That is being retired. **Since 2.5.0 the opt-in phase is live**: enable strict
148
148
  mode with `PUT /api/settings` `{{"strict_users": true}}` and authorization
149
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`.
150
+ shapes, additive reason `user_not_key_backed`). Gated decisions: has_permission,
151
+ the membership check, user_permissions (answers `count: 0` + reason), workflow
152
+ can_run. NOT gated: user_roles, members and every other listing. Membership
153
+ adds for key-less subjects answer **409** `{{"result": false, "reason":
154
+ "user_not_key_backed"}}` (since 2.5.1 — a refused grant must not look like
155
+ success; check `result` on writes regardless). Create the key first, then grant
156
+ roles: key creation committing before the grant is a contract, with no eventual
157
+ consistency in between. Strict mode never blocks key issuance or any
158
+ delete/revoke path. The `reason` field is **stable contract** (only ever
159
+ present on strict blocks). A strict block on read decisions is an **HTTP
160
+ 200** — transport-failure handling (retries, breakers, fallbacks) will not
161
+ fire on it; tenants that deliberately hold `strict_users: false` should assert
162
+ that value in their deploy/health checks so an unexpected flip alarms instead
163
+ of silently zeroing entitlements. 3.0.0 makes strict identity the default — the audited
164
+ per-tenant opt-out survives for platforms that authenticate their own callers —
165
+ and ships only after every consuming platform has confirmed readiness. Migrate
166
+ now: issue keys to your users, derive the user from `/api/apikeys/validate` —
167
+ or do both steps in one round trip with `POST /api/apikeys/check_permission`.
157
168
 
158
169
  ## 4. Endpoints
159
170
 
@@ -194,7 +194,13 @@ def register_routes(app):
194
194
  if not result:
195
195
  reason = _strict_reason(auth_service, user)
196
196
  if reason:
197
- return jsonify({"result": result, "reason": reason})
197
+ # 409, not 200-with-false: two consumers independently showed
198
+ # that a refused grant answering 200 gets written past
199
+ # (raise_for_status passes, result goes unchecked) and turns
200
+ # strict mode into silent dead-key provisioning. Strict mode is
201
+ # opt-in, so the shape may differ there; the documented
202
+ # missing-role 200-false below is untouched.
203
+ return jsonify({"result": False, "reason": reason}), 409
198
204
 
199
205
  return jsonify({"result": result})
200
206
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 2.5.0
3
+ Version: 2.5.2
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -119,14 +119,18 @@ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
119
119
  mints `rak_...` secrets for *your users*, shows each exactly once, stores only
120
120
  a hash, and validates them inside your namespace — an identity UI fronts the
121
121
  lifecycle, backends validate then use the RBAC checks. Client methods:
122
- `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
122
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`,
123
+ `check_api_key_permission` (validate + permission in one round trip), plus
124
+ `get_settings`/`set_strict_users`. All of these also exist on the in-process
125
+ `Authorization` wrapper for embedded consumers.
123
126
  - **DEPRECATED — bare user strings.** Asserting a `<user>` that no validated API
124
127
  key backs is scheduled for decommission. The opt-in phase is live (2.5.0):
125
128
  `PUT /api/settings {"strict_users": true}` makes keyless users answer
126
129
  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).
130
+ does validate + permission in one round trip. **3.0.0 makes strict identity
131
+ the default** (the audited per-tenant opt-out survives for validated
132
+ machine-subject architectures) — and ships only after every consuming
133
+ platform confirms. Details: [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md).
130
134
 
131
135
  ## Documentation
132
136
 
@@ -41,6 +41,7 @@ tests/test_client_rest.py
41
41
  tests/test_cmd_server.py
42
42
  tests/test_config.py
43
43
  tests/test_correctness_hardening.py
44
+ tests/test_database_sslmode.py
44
45
  tests/test_docs_page.py
45
46
  tests/test_encryption.py
46
47
  tests/test_encryption_integration.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth"
7
- version = "2.5.0"
7
+ version = "2.5.2"
8
8
  description = "Authorization for humans"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,47 @@
1
+ """sslmode precedence for the PostgreSQL engine factory (SPEC 0011, highway
2
+ report thr-7745c815fd0a425cabac).
3
+
4
+ Contract: explicit URL sslmode wins, then PGSSLMODE, then secure-by-default
5
+ ``require`` for genuinely remote hosts — decided by host component, never by
6
+ URL substring.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from auth.database import _forced_sslmode
12
+
13
+ REMOTE = "postgresql+psycopg://app:pw@db:5432/x"
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _no_pgsslmode(monkeypatch):
18
+ monkeypatch.delenv("PGSSLMODE", raising=False)
19
+
20
+
21
+ def test_remote_host_defaults_to_require():
22
+ # Known-positive first: the default CAN fire.
23
+ assert _forced_sslmode(REMOTE) == "require"
24
+ assert _forced_sslmode("postgresql://u@10.0.0.5/db") == "require"
25
+
26
+
27
+ def test_explicit_url_sslmode_is_never_overridden():
28
+ assert _forced_sslmode(REMOTE + "?sslmode=disable") is None
29
+ assert _forced_sslmode(REMOTE + "?sslmode=verify-full") is None
30
+
31
+
32
+ def test_pgsslmode_env_is_respected(monkeypatch):
33
+ monkeypatch.setenv("PGSSLMODE", "disable")
34
+ assert _forced_sslmode(REMOTE) is None
35
+
36
+
37
+ def test_local_hosts_get_no_forced_sslmode():
38
+ assert _forced_sslmode("postgresql://u@localhost/db") is None
39
+ assert _forced_sslmode("postgresql://u@127.0.0.1:5432/db") is None
40
+
41
+
42
+ def test_host_is_compared_as_component_not_substring():
43
+ # The old substring test skipped SSL for this remote URL; it must not.
44
+ tricky = "postgresql://app@db:5432/x?fallback_application_name=localhost"
45
+ assert _forced_sslmode(tricky) == "require"
46
+ # And a host merely CONTAINING 'localhost' is remote.
47
+ assert _forced_sslmode("postgresql://u@localhost.attacker.net/db") == "require"
@@ -134,9 +134,19 @@ def test_strict_blocks_membership_add_but_never_removal(client):
134
134
  _seed_rbac(client, key, user="bob") # bob is a member while strict is off
135
135
  client.put("/api/settings", headers=h, json={"strict_users": True})
136
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"}
137
+ # Adds for keyless users are refused with an unmissable 409 (a 200 here
138
+ # was shown by two consumers to be silently written past).
139
+ add = client.post("/api/membership/carol/engineers", headers=h)
140
+ assert add.status_code == 409
141
+ assert add.get_json() == {"result": False, "reason": "user_not_key_backed"}
142
+ # The documented missing-role no-op keeps its 200-false shape even in
143
+ # strict tenants — only the key-less refusal is a 409.
144
+ ghost_role = client.post("/api/membership/carol/ghosts", headers=h)
145
+ assert ghost_role.status_code == 409 # carol is still key-less: strict wins
146
+ client.post("/api/apikeys/user/dave", headers=h)
147
+ keyed_ghost = client.post("/api/membership/dave/ghosts", headers=h)
148
+ assert keyed_ghost.status_code == 200
149
+ assert keyed_ghost.get_json() == {"result": False}
140
150
 
141
151
  # Removal is NEVER strict-gated: revocation must always work.
142
152
  rm = client.delete("/api/membership/bob/engineers", headers=h).get_json()
@@ -291,6 +301,51 @@ def test_settings_update_is_audited(client):
291
301
  session.close()
292
302
 
293
303
 
304
+ def test_authorization_wrapper_embedded_strict_flow():
305
+ """Embedded consumers (Authorization wrapper) get REST-identical strict
306
+ semantics: same setting row, same gates, same release-on-key-issuance."""
307
+ import uuid as _uuid
308
+
309
+ from auth import Authorization
310
+ from auth.database import SessionLocal
311
+ from auth.main import create_app
312
+
313
+ create_app()
314
+ tenant = str(_uuid.uuid4())
315
+ db = SessionLocal()
316
+ try:
317
+ wrapper = Authorization(client=tenant, db_session=db)
318
+ assert wrapper.add_role("ops") is True
319
+ assert wrapper.add_permission("ops", "deploy") is True
320
+ assert wrapper.add_membership("erin", "ops") is True
321
+ assert wrapper.user_has_permission("erin", "deploy") is True
322
+
323
+ assert wrapper.get_settings() == {"strict_users": False}
324
+ wrapper.set_strict_users(True)
325
+ # Fresh wrapper (no per-instance cache) sees the flip and blocks.
326
+ strict = Authorization(client=tenant, db_session=db)
327
+ assert strict.user_has_permission("erin", "deploy") is False
328
+
329
+ created = strict.create_api_key("erin", label="svc erin")
330
+ assert created is not None and created["api_key"].startswith("rak_")
331
+ assert strict.user_has_permission("erin", "deploy") is True
332
+ checked = strict.check_api_key_permission(created["api_key"], "deploy")
333
+ assert checked["valid"] is True and checked["has_permission"] is True
334
+ assert strict.list_api_keys("erin")[0]["label"] == "svc erin"
335
+ assert (
336
+ strict.revoke_api_key("erin", created["key_id"]) == "revoked"
337
+ )
338
+ assert strict.validate_api_key(created["api_key"]) == {
339
+ "valid": False,
340
+ "reason": "revoked",
341
+ }
342
+ # Explicit per-instance override pins behavior regardless of the row.
343
+ pinned_off = Authorization(client=tenant, db_session=db, strict_users=False)
344
+ assert pinned_off.user_has_permission("erin", "deploy") is True
345
+ finally:
346
+ db.close()
347
+
348
+
294
349
  def test_in_process_service_override_param():
295
350
  """Library callers pin strict behavior explicitly, DB row ignored."""
296
351
  import uuid as _uuid
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