From dc7732ab5f7cf87331c71e8b45854279d16c6ad0 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 20 Jan 2026 14:56:56 -0500 Subject: [PATCH] feat(security): implement safe guest auth flow and harden request_jwt - Patched request_jwt to strip privileged IDs when signing with public keys - Updated AccountContext and V3 dependencies to preserve JWT payloads for guests - Whitelisted Archive, Post, Event, and other core objects for public read access - Added 'default_qry_str' to Event searchable fields - Added test_e2e_jwt_guest_auth.py for security verification --- app/models/auth_models.py | 1 + app/object_definitions/cms.py | 3 + app/object_definitions/events_general.py | 4 +- app/object_definitions/events_presentation.py | 3 + app/object_definitions/other.py | 2 + app/routers/api.py | 10 ++ app/routers/api_crud_v3.py | 26 +++-- app/routers/dependencies_v3.py | 17 +++- tests/README.md | 1 + tests/e2e/test_e2e_jwt_guest_auth.py | 94 +++++++++++++++++++ tests/test_permissive_mode.py | 34 +++++++ 11 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/test_e2e_jwt_guest_auth.py create mode 100644 tests/test_permissive_mode.py diff --git a/app/models/auth_models.py b/app/models/auth_models.py index 6d23505..ed2bc29 100644 --- a/app/models/auth_models.py +++ b/app/models/auth_models.py @@ -11,3 +11,4 @@ class AccountContext(BaseModel): manager: bool = False super: bool = False auth_method: str = 'legacy_header' + token_payload: Optional[dict] = None diff --git a/app/object_definitions/cms.py b/app/object_definitions/cms.py index d855ea5..d5fdd3b 100644 --- a/app/object_definitions/cms.py +++ b/app/object_definitions/cms.py @@ -40,6 +40,7 @@ cms_obj_li = { 'table_name_alt': 'v_post_detail', 'tbl_name_update': 'post', 'base_name': Post_Base, + 'public_read': True, 'exp_default': [ 'post_id_random', 'account_id_random', @@ -70,6 +71,7 @@ cms_obj_li = { 'table_name_alt': 'v_post_comment_detail', 'tbl_name_update': 'post_comment', 'base_name': Post_Comment_Base, + 'public_read': True, 'exp_default': [ 'post_comment_id_random', 'account_id_random', 'post_id_random', @@ -118,6 +120,7 @@ cms_obj_li = { 'tbl_name_update': 'site_domain', 'base_name': Site_Domain_Base, 'base_name_alt': Site_Domain_FQDN_ID_Base, + 'public_read': True, # V3 Search Security: 'searchable_fields': [ 'id', 'account_id', 'site_id', diff --git a/app/object_definitions/events_general.py b/app/object_definitions/events_general.py index fa5b793..e01475f 100644 --- a/app/object_definitions/events_general.py +++ b/app/object_definitions/events_general.py @@ -20,6 +20,7 @@ events_general_obj_li = { 'tbl_name_update': 'event', 'base_name': Event_Base, 'base_name_alt': Event_Meeting_Flat_Base, + 'public_read': True, 'exp_default': [ 'event_id_random', 'conference', 'type', @@ -46,7 +47,7 @@ events_general_obj_li = { 'event_id_random', 'account_id_random', 'event_code', 'conference', 'type', 'name', 'summary', 'description', 'format', 'timezone', 'location_text', 'status', 'enable', 'hide', 'priority', 'sort', - 'group', 'notes', 'created_on', 'updated_on' + 'group', 'notes', 'created_on', 'updated_on', 'default_qry_str' ], }, 'event_file': { @@ -63,6 +64,7 @@ events_general_obj_li = { 'table_name_alt': 'v_event_file', 'tbl_name_update': 'event_file', 'base_name': Event_File_Base, + 'public_read': True, # V3 Search Security: 'searchable_fields': [ 'event_file_id_random', 'hosted_file_id_random', 'event_id_random', diff --git a/app/object_definitions/events_presentation.py b/app/object_definitions/events_presentation.py index 8ad0d10..4f0dd04 100644 --- a/app/object_definitions/events_presentation.py +++ b/app/object_definitions/events_presentation.py @@ -61,6 +61,7 @@ events_presentation_obj_li = { 'table_name_alt': 'v_event_presentation_w_file_count', 'tbl_name_update': 'event_presentation', 'base_name': Event_Presentation_Base, + 'public_read': True, # V3 Search Security: 'searchable_fields': [ 'event_presentation_id_random', 'event_id_random', @@ -85,6 +86,7 @@ events_presentation_obj_li = { 'table_name_alt': 'v_event_presenter_w_file_count', 'tbl_name_update': 'event_presenter', 'base_name': Event_Presenter_Base, + 'public_read': True, 'exp_default': [ 'event_presenter_id_random', 'title_names', 'given_name', 'middle_name', 'family_name', 'designations', @@ -119,6 +121,7 @@ events_presentation_obj_li = { 'table_name': 'v_event_session', 'tbl_name_update': 'event_session', 'base_name': Event_Session_Base, + 'public_read': True, # V3 Search Security: 'searchable_fields': [ 'event_session_id_random', 'event_id_random', diff --git a/app/object_definitions/other.py b/app/object_definitions/other.py index 2326cc2..51d727a 100644 --- a/app/object_definitions/other.py +++ b/app/object_definitions/other.py @@ -102,6 +102,7 @@ other_obj_li = { 'table_name': 'v_archive_content', 'tbl_name_update': 'archive_content', 'base_name': Archive_Content_Base, + 'public_read': True, # V3 Search Security: 'searchable_fields': [ 'archive_content_id_random', 'account_id_random', 'archive_id_random', @@ -122,6 +123,7 @@ other_obj_li = { 'table_name': 'v_hosted_file', 'tbl_name_update': 'hosted_file', 'base_name': Hosted_File_Base, + 'public_read': True, 'exp_default': [ 'hosted_file_id_random', 'hash_sha256', diff --git a/app/routers/api.py b/app/routers/api.py index 0ea73d1..d5636a7 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -93,6 +93,16 @@ async def request_jwt( log.error('No key found to sign the JWT with!') return mk_resp(data=False, status_code=400, response=response) # Bad Request + # SECURITY PATCH: Prevent public API key from minting privileged tokens + # If we are using the default system key (settings.JWT_KEY) but NO external signing key was provided + # (i.e. access via public API Key), we must NOT allow minting account-level privileges. + if not x_aether_signing_key: + if account_id or person_id or user_id: # Check params from function signature (not payload dict yet) + log.warning("Security: Attempt to mint privileged JWT without signing key. Downgrading to Guest.") + account_id = None + person_id = None + user_id = None + # We allow json_str and b64_str to pass through for session context payload = {} payload['account_id'] = account_id diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index b19155c..3fc0c36 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -142,8 +142,9 @@ async def get_obj( return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") if sql_result := sql_select(table_name=table_name, record_id=record_id): - if not check_account_access(sql_result, account, obj_name): - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") + if not obj_cfg.get('public_read', False): + if not check_account_access(sql_result, account, obj_name): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) return mk_resp(data=resp_data, response=response) else: @@ -199,15 +200,17 @@ async def get_obj_li( obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") + + obj_cfg = obj_type_kv_li[obj_name] if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.") if for_obj_type == 'account' and for_obj_id: - if not account.super and for_obj_id != account.account_id_random: - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") + if not obj_cfg.get('public_read', False): + if not account.super and for_obj_id != account.account_id_random: + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") - obj_cfg = obj_type_kv_li[obj_name] table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) @@ -216,7 +219,9 @@ async def get_obj_li( order_by_li = filter_order_by(order_by_li, base_name, table_name) status_filter = get_supported_filters(base_name, status_filter) - and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name) + + if not obj_cfg.get('public_read', False): + and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name) if for_obj_type and for_obj_id: resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) @@ -295,16 +300,17 @@ async def search_obj_li( if obj_name not in obj_type_kv_li: return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") - is_guest = (account.auth_method == 'guest') - is_site_domain_lookup = (obj_name == 'site_domain') + obj_cfg = obj_type_kv_li[obj_name] - if is_guest and not is_site_domain_lookup: + is_guest = (account.auth_method == 'guest') + is_public_read = obj_cfg.get('public_read', False) + + if is_guest and not is_public_read: return mk_resp(data=False, status_code=403, response=response, status_message="Authentication required for this search.") if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.") - obj_cfg = obj_type_kv_li[obj_name] table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) diff --git a/app/routers/dependencies_v3.py b/app/routers/dependencies_v3.py index 9ee641f..19c7470 100644 --- a/app/routers/dependencies_v3.py +++ b/app/routers/dependencies_v3.py @@ -26,6 +26,7 @@ def get_account_context_optional( resolved_account_id = None resolved_account_id_random = None + resolved_token_payload = None auth_method = 'guest' api_key_authorized = False @@ -61,6 +62,9 @@ def get_account_context_optional( # Check if it's a real JWT (contains dots) if '.' in x_no_account_id_token: if decoded := decode_jwt(secret_key=settings.JWT_KEY, token=x_no_account_id_token): + # Capture the full payload for session context (even for guests) + resolved_token_payload = decoded + # In Aether, JWTs store the RANDOM string IDs to prevent exposure resolved_account_id_random = decoded.get('account_id') if resolved_account_id_random: @@ -72,10 +76,12 @@ def get_account_context_optional( # Legacy Fallback (just a raw random ID string) if auth_method == 'guest': - resolved_account_id_random = x_no_account_id_token - if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): - resolved_account_id = looked_up_id - auth_method = 'token_query' + # Only treat as random ID if it looks like one (not a malformed JWT) + if '.' not in x_no_account_id_token: + resolved_account_id_random = x_no_account_id_token + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): + resolved_account_id = looked_up_id + auth_method = 'token_query' # C. Resolve via Administrative Bypass elif x_no_account_id and x_no_account_id.lower() not in ['false', '0', 'null', 'undefined', 'none', 'no_account_id_here']: @@ -89,7 +95,8 @@ def get_account_context_optional( auth_method=auth_method, administrator=(auth_method == 'bypass'), manager=(auth_method == 'bypass'), - super=(auth_method == 'bypass') + super=(auth_method == 'bypass'), + token_payload=resolved_token_payload ) def get_account_context( diff --git a/tests/README.md b/tests/README.md index 6a7e899..0186aff 100644 --- a/tests/README.md +++ b/tests/README.md @@ -44,6 +44,7 @@ This directory contains the automated and manual test scripts for the Aether Fas | Script | Description | | :--- | :--- | | `test_e2e_agent_bridge.py` | Verifies the `/agent` diagnostics and log streaming endpoints. | +| `test_e2e_jwt_guest_auth.py` | **Security Test**: Verifies safe guest token minting and whitelisted access. | | `test_e2e_legacy_remote_schema.py` | Remote check for legacy schema compatibility. | | `test_e2e_site_bootstrap.py` | Verifies the unauthenticated FQDN lookup for site initialization. | | `test_e2e_v3_accounts.py` | CRUD verification for the Account object via network. | diff --git a/tests/e2e/test_e2e_jwt_guest_auth.py b/tests/e2e/test_e2e_jwt_guest_auth.py new file mode 100644 index 0000000..6928936 --- /dev/null +++ b/tests/e2e/test_e2e_jwt_guest_auth.py @@ -0,0 +1,94 @@ +import requests +import json +import jwt # Used only for local decoding/verification of the received token +import sys + +# Configuration +BASE_URL = "https://dev-api.oneskyit.com" +API_KEY = "IDF68Em5X4HTZlswRNgepQ" + +def test_request_jwt_security(): + print("\n--- Test 1: request_jwt Security (Over the Network) ---") + + # Attempt to request a token with an injected account_id + # The backend should now strip this account_id because we aren't using a signing key + params = { + "account_id": "999999", + "json_str": json.dumps({"mode": "guest", "test": True}) + } + headers = { + "X-Aether-API-Key": API_KEY + } + + url = f"{BASE_URL}/api/request_jwt" + print(f"Calling: {url}") + + try: + response = requests.get(url, params=params, headers=headers) + print(f"Status: {response.status_code}") + + data = response.json() + token = data.get('data', {}).get('jwt') + + if not token: + print(f"❌ No token returned. Response: {json.dumps(data, indent=2)}") + return None + + print(f"Token Received: {token[:30]}...") + + # We can't verify the signature without the secret, but we can inspect the payload + # using unverified decode to see if the server stripped the ID before signing. + decoded = jwt.decode(token, options={"verify_signature": False}) + print(f"Unverified Payload: {decoded}") + + if decoded.get('account_id') is None: + print("✅ SUCCESS: 'account_id' was successfully stripped by the server.") + else: + print(f"❌ FAILURE: 'account_id' was present! Security Patch not active. Value: {decoded.get('account_id')}") + sys.exit(1) + + return token + + except Exception as e: + print(f"❌ Error during request: {e}") + return None + +def test_guest_access(guest_token): + print("\n--- Test 2: Guest Access with Token ---") + + headers = { + "X-Aether-API-Key": API_KEY + } + params = {"jwt": guest_token} + + # 1. Test Public Object (site_domain) - Should succeed (200) + print("\n[A] Testing Public Read (site_domain)...") + url_public = f"{BASE_URL}/v3/crud/site_domain/search" + resp_public = requests.post(url_public, json={"q": "%"}, headers=headers, params=params) + print(f"Status: {resp_public.status_code}") + + if resp_public.status_code == 200: + print("✅ SUCCESS: Guest token allowed access to public object.") + else: + print(f"❌ FAILURE: Status {resp_public.status_code}. Msg: {resp_public.text}") + + # 2. Test Private Object (journal) - Should be blocked (403) + print("\n[B] Testing Private Read (journal)...") + url_private = f"{BASE_URL}/v3/crud/journal/search" + resp_private = requests.post(url_private, json={"q": "%"}, headers=headers, params=params) + print(f"Status: {resp_private.status_code}") + + if resp_private.status_code == 403: + print("✅ SUCCESS: Guest correctly blocked from private object (403 Forbidden).") + else: + print(f"❌ FAILURE: Guest was NOT blocked. Status: {resp_private.status_code}") + +if __name__ == "__main__": + print(f"Starting E2E JWT Guest Auth Tests against {BASE_URL}\n") + token = test_request_jwt_security() + if token: + test_guest_access(token) + else: + print("❌ Token request failed, skipping access tests.") + + print("\nTests Complete.") diff --git a/tests/test_permissive_mode.py b/tests/test_permissive_mode.py new file mode 100644 index 0000000..3449314 --- /dev/null +++ b/tests/test_permissive_mode.py @@ -0,0 +1,34 @@ +import requests +import json + +API_BASE = "https://dev-api.oneskyit.com/v3/crud" +API_KEY = "IDF68Em5X4HTZlswRNgepQ" +JOURNAL_ID = "OGQK-02-04-94" + +# We'll try to patch this journal with an extra field that shouldn't be there +payload = { + "name": "Permissive Test Name", + "unauthorized_field": "I should be ignored", + "created_on": "2026-01-01T00:00:00" # Technical field usually forbidden +} + +def test_permissive_mode(): + headers = { + "x-aether-api-key": API_KEY, + "x-no-account-id": "bypass", + "Content-Type": "application/json" + } + + print("\n--- Test 1: Standard Mode (Should FAIL) ---") + resp = requests.patch(f"{API_BASE}/journal/{JOURNAL_ID}", headers=headers, json=payload) + print(f"Status: {resp.status_code}") + print(f"Response: {resp.text}") + + print("\n--- Test 2: Permissive Mode (Should SUCCEED) ---") + headers["x-ae-ignore-extra-fields"] = "true" + resp = requests.patch(f"{API_BASE}/journal/{JOURNAL_ID}", headers=headers, json=payload) + print(f"Status: {resp.status_code}") + print(f"Response: {resp.text}") + +if __name__ == "__main__": + test_permissive_mode()