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
This commit is contained in:
@@ -11,3 +11,4 @@ class AccountContext(BaseModel):
|
|||||||
manager: bool = False
|
manager: bool = False
|
||||||
super: bool = False
|
super: bool = False
|
||||||
auth_method: str = 'legacy_header'
|
auth_method: str = 'legacy_header'
|
||||||
|
token_payload: Optional[dict] = None
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ cms_obj_li = {
|
|||||||
'table_name_alt': 'v_post_detail',
|
'table_name_alt': 'v_post_detail',
|
||||||
'tbl_name_update': 'post',
|
'tbl_name_update': 'post',
|
||||||
'base_name': Post_Base,
|
'base_name': Post_Base,
|
||||||
|
'public_read': True,
|
||||||
'exp_default': [
|
'exp_default': [
|
||||||
'post_id_random',
|
'post_id_random',
|
||||||
'account_id_random',
|
'account_id_random',
|
||||||
@@ -70,6 +71,7 @@ cms_obj_li = {
|
|||||||
'table_name_alt': 'v_post_comment_detail',
|
'table_name_alt': 'v_post_comment_detail',
|
||||||
'tbl_name_update': 'post_comment',
|
'tbl_name_update': 'post_comment',
|
||||||
'base_name': Post_Comment_Base,
|
'base_name': Post_Comment_Base,
|
||||||
|
'public_read': True,
|
||||||
'exp_default': [
|
'exp_default': [
|
||||||
'post_comment_id_random',
|
'post_comment_id_random',
|
||||||
'account_id_random', 'post_id_random',
|
'account_id_random', 'post_id_random',
|
||||||
@@ -118,6 +120,7 @@ cms_obj_li = {
|
|||||||
'tbl_name_update': 'site_domain',
|
'tbl_name_update': 'site_domain',
|
||||||
'base_name': Site_Domain_Base,
|
'base_name': Site_Domain_Base,
|
||||||
'base_name_alt': Site_Domain_FQDN_ID_Base,
|
'base_name_alt': Site_Domain_FQDN_ID_Base,
|
||||||
|
'public_read': True,
|
||||||
# V3 Search Security:
|
# V3 Search Security:
|
||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'id', 'account_id', 'site_id',
|
'id', 'account_id', 'site_id',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ events_general_obj_li = {
|
|||||||
'tbl_name_update': 'event',
|
'tbl_name_update': 'event',
|
||||||
'base_name': Event_Base,
|
'base_name': Event_Base,
|
||||||
'base_name_alt': Event_Meeting_Flat_Base,
|
'base_name_alt': Event_Meeting_Flat_Base,
|
||||||
|
'public_read': True,
|
||||||
'exp_default': [
|
'exp_default': [
|
||||||
'event_id_random',
|
'event_id_random',
|
||||||
'conference', 'type',
|
'conference', 'type',
|
||||||
@@ -46,7 +47,7 @@ events_general_obj_li = {
|
|||||||
'event_id_random', 'account_id_random', 'event_code', 'conference',
|
'event_id_random', 'account_id_random', 'event_code', 'conference',
|
||||||
'type', 'name', 'summary', 'description', 'format', 'timezone',
|
'type', 'name', 'summary', 'description', 'format', 'timezone',
|
||||||
'location_text', 'status', 'enable', 'hide', 'priority', 'sort',
|
'location_text', 'status', 'enable', 'hide', 'priority', 'sort',
|
||||||
'group', 'notes', 'created_on', 'updated_on'
|
'group', 'notes', 'created_on', 'updated_on', 'default_qry_str'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'event_file': {
|
'event_file': {
|
||||||
@@ -63,6 +64,7 @@ events_general_obj_li = {
|
|||||||
'table_name_alt': 'v_event_file',
|
'table_name_alt': 'v_event_file',
|
||||||
'tbl_name_update': 'event_file',
|
'tbl_name_update': 'event_file',
|
||||||
'base_name': Event_File_Base,
|
'base_name': Event_File_Base,
|
||||||
|
'public_read': True,
|
||||||
# V3 Search Security:
|
# V3 Search Security:
|
||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'event_file_id_random', 'hosted_file_id_random', 'event_id_random',
|
'event_file_id_random', 'hosted_file_id_random', 'event_id_random',
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ events_presentation_obj_li = {
|
|||||||
'table_name_alt': 'v_event_presentation_w_file_count',
|
'table_name_alt': 'v_event_presentation_w_file_count',
|
||||||
'tbl_name_update': 'event_presentation',
|
'tbl_name_update': 'event_presentation',
|
||||||
'base_name': Event_Presentation_Base,
|
'base_name': Event_Presentation_Base,
|
||||||
|
'public_read': True,
|
||||||
# V3 Search Security:
|
# V3 Search Security:
|
||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'event_presentation_id_random', 'event_id_random',
|
'event_presentation_id_random', 'event_id_random',
|
||||||
@@ -85,6 +86,7 @@ events_presentation_obj_li = {
|
|||||||
'table_name_alt': 'v_event_presenter_w_file_count',
|
'table_name_alt': 'v_event_presenter_w_file_count',
|
||||||
'tbl_name_update': 'event_presenter',
|
'tbl_name_update': 'event_presenter',
|
||||||
'base_name': Event_Presenter_Base,
|
'base_name': Event_Presenter_Base,
|
||||||
|
'public_read': True,
|
||||||
'exp_default': [
|
'exp_default': [
|
||||||
'event_presenter_id_random',
|
'event_presenter_id_random',
|
||||||
'title_names', 'given_name', 'middle_name', 'family_name', 'designations',
|
'title_names', 'given_name', 'middle_name', 'family_name', 'designations',
|
||||||
@@ -119,6 +121,7 @@ events_presentation_obj_li = {
|
|||||||
'table_name': 'v_event_session',
|
'table_name': 'v_event_session',
|
||||||
'tbl_name_update': 'event_session',
|
'tbl_name_update': 'event_session',
|
||||||
'base_name': Event_Session_Base,
|
'base_name': Event_Session_Base,
|
||||||
|
'public_read': True,
|
||||||
# V3 Search Security:
|
# V3 Search Security:
|
||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'event_session_id_random', 'event_id_random',
|
'event_session_id_random', 'event_id_random',
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ other_obj_li = {
|
|||||||
'table_name': 'v_archive_content',
|
'table_name': 'v_archive_content',
|
||||||
'tbl_name_update': 'archive_content',
|
'tbl_name_update': 'archive_content',
|
||||||
'base_name': Archive_Content_Base,
|
'base_name': Archive_Content_Base,
|
||||||
|
'public_read': True,
|
||||||
# V3 Search Security:
|
# V3 Search Security:
|
||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'archive_content_id_random', 'account_id_random', 'archive_id_random',
|
'archive_content_id_random', 'account_id_random', 'archive_id_random',
|
||||||
@@ -122,6 +123,7 @@ other_obj_li = {
|
|||||||
'table_name': 'v_hosted_file',
|
'table_name': 'v_hosted_file',
|
||||||
'tbl_name_update': 'hosted_file',
|
'tbl_name_update': 'hosted_file',
|
||||||
'base_name': Hosted_File_Base,
|
'base_name': Hosted_File_Base,
|
||||||
|
'public_read': True,
|
||||||
'exp_default': [
|
'exp_default': [
|
||||||
'hosted_file_id_random',
|
'hosted_file_id_random',
|
||||||
'hash_sha256',
|
'hash_sha256',
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ async def request_jwt(
|
|||||||
log.error('No key found to sign the JWT with!')
|
log.error('No key found to sign the JWT with!')
|
||||||
return mk_resp(data=False, status_code=400, response=response) # Bad Request
|
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 = {}
|
||||||
payload['account_id'] = account_id
|
payload['account_id'] = account_id
|
||||||
|
|||||||
@@ -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.")
|
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 sql_result := sql_select(table_name=table_name, record_id=record_id):
|
||||||
if not check_account_access(sql_result, account, obj_name):
|
if not obj_cfg.get('public_read', False):
|
||||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.")
|
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)
|
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)
|
return mk_resp(data=resp_data, response=response)
|
||||||
else:
|
else:
|
||||||
@@ -199,15 +200,17 @@ async def get_obj_li(
|
|||||||
obj_name = obj_type_l1
|
obj_name = obj_type_l1
|
||||||
if obj_name not in obj_type_kv_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.")
|
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):
|
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.")
|
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 for_obj_type == 'account' and for_obj_id:
|
||||||
if not account.super and for_obj_id != account.account_id_random:
|
if not obj_cfg.get('public_read', False):
|
||||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.")
|
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')))
|
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')))
|
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)
|
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
||||||
status_filter = get_supported_filters(base_name, status_filter)
|
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:
|
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)
|
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:
|
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.")
|
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')
|
obj_cfg = obj_type_kv_li[obj_name]
|
||||||
is_site_domain_lookup = (obj_name == 'site_domain')
|
|
||||||
|
|
||||||
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.")
|
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):
|
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.")
|
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')))
|
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')))
|
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def get_account_context_optional(
|
|||||||
|
|
||||||
resolved_account_id = None
|
resolved_account_id = None
|
||||||
resolved_account_id_random = None
|
resolved_account_id_random = None
|
||||||
|
resolved_token_payload = None
|
||||||
auth_method = 'guest'
|
auth_method = 'guest'
|
||||||
api_key_authorized = False
|
api_key_authorized = False
|
||||||
|
|
||||||
@@ -61,6 +62,9 @@ def get_account_context_optional(
|
|||||||
# Check if it's a real JWT (contains dots)
|
# Check if it's a real JWT (contains dots)
|
||||||
if '.' in x_no_account_id_token:
|
if '.' in x_no_account_id_token:
|
||||||
if decoded := decode_jwt(secret_key=settings.JWT_KEY, token=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
|
# In Aether, JWTs store the RANDOM string IDs to prevent exposure
|
||||||
resolved_account_id_random = decoded.get('account_id')
|
resolved_account_id_random = decoded.get('account_id')
|
||||||
if resolved_account_id_random:
|
if resolved_account_id_random:
|
||||||
@@ -72,10 +76,12 @@ def get_account_context_optional(
|
|||||||
|
|
||||||
# Legacy Fallback (just a raw random ID string)
|
# Legacy Fallback (just a raw random ID string)
|
||||||
if auth_method == 'guest':
|
if auth_method == 'guest':
|
||||||
resolved_account_id_random = x_no_account_id_token
|
# Only treat as random ID if it looks like one (not a malformed JWT)
|
||||||
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token):
|
if '.' not in x_no_account_id_token:
|
||||||
resolved_account_id = looked_up_id
|
resolved_account_id_random = x_no_account_id_token
|
||||||
auth_method = 'token_query'
|
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
|
# 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']:
|
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,
|
auth_method=auth_method,
|
||||||
administrator=(auth_method == 'bypass'),
|
administrator=(auth_method == 'bypass'),
|
||||||
manager=(auth_method == 'bypass'),
|
manager=(auth_method == 'bypass'),
|
||||||
super=(auth_method == 'bypass')
|
super=(auth_method == 'bypass'),
|
||||||
|
token_payload=resolved_token_payload
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_account_context(
|
def get_account_context(
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ This directory contains the automated and manual test scripts for the Aether Fas
|
|||||||
| Script | Description |
|
| Script | Description |
|
||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| `test_e2e_agent_bridge.py` | Verifies the `/agent` diagnostics and log streaming endpoints. |
|
| `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_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_site_bootstrap.py` | Verifies the unauthenticated FQDN lookup for site initialization. |
|
||||||
| `test_e2e_v3_accounts.py` | CRUD verification for the Account object via network. |
|
| `test_e2e_v3_accounts.py` | CRUD verification for the Account object via network. |
|
||||||
|
|||||||
94
tests/e2e/test_e2e_jwt_guest_auth.py
Normal file
94
tests/e2e/test_e2e_jwt_guest_auth.py
Normal file
@@ -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.")
|
||||||
34
tests/test_permissive_mode.py
Normal file
34
tests/test_permissive_mode.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user