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.
- {auth-2.4.0 → auth-2.5.0}/PKG-INFO +9 -3
- {auth-2.4.0 → auth-2.5.0}/README.md +7 -0
- {auth-2.4.0 → auth-2.5.0}/auth/audit.py +3 -0
- {auth-2.4.0 → auth-2.5.0}/auth/client.py +56 -0
- {auth-2.4.0 → auth-2.5.0}/auth/docs_page.py +30 -3
- {auth-2.4.0 → auth-2.5.0}/auth/models/sql.py +22 -0
- {auth-2.4.0 → auth-2.5.0}/auth/routes.py +117 -5
- {auth-2.4.0 → auth-2.5.0}/auth/services/service.py +99 -1
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/PKG-INFO +9 -3
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/SOURCES.txt +2 -2
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/requires.txt +1 -2
- {auth-2.4.0 → auth-2.5.0}/pyproject.toml +4 -6
- {auth-2.4.0 → auth-2.5.0}/tests/test_api_keys_encryption.py +0 -2
- {auth-2.4.0 → auth-2.5.0}/tests/test_client.py +38 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_key_rotation.py +2 -0
- auth-2.5.0/tests/test_strict_users.py +318 -0
- auth-2.4.0/tests/test_migrations.py +0 -45
- {auth-2.4.0 → auth-2.5.0}/LICENSE +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/__init__.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/api_keys.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/circuit_breaker.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/cmd/__init__.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/cmd/server.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/config.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/core/REST/__init__.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/core/REST/client.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/core/__init__.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/core/models/__init__.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/database.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/decorators.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/encryption.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/logging_config.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/main.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/response_format.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/sanitizer.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/server.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/validation.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth/workflow_checker.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-2.4.0 → auth-2.5.0}/auth.egg-info/top_level.txt +0 -0
- {auth-2.4.0 → auth-2.5.0}/setup.cfg +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_api_keys.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_audit_integrity.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_client_rest.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_cmd_server.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_config.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_correctness_hardening.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_docs_page.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_encryption.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_encryption_integration.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_flask.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_log_redaction.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_reencryption.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_routes_errors.py +0 -0
- {auth-2.4.0 → auth-2.5.0}/tests/test_server.py +0 -0
- {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.
|
|
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:
|
|
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`,
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
62
|
-
#
|
|
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
|
-
"
|
|
69
|
+
"migretti>=0.10.0",
|
|
72
70
|
"ruff>=0.0.260",
|
|
73
71
|
"mypy>=1.0",
|
|
74
72
|
"types-requests",
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|