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:
Scott Idem
2026-01-20 14:56:56 -05:00
parent 8a22ac324c
commit dc7732ab5f
11 changed files with 179 additions and 16 deletions

View File

@@ -11,3 +11,4 @@ class AccountContext(BaseModel):
manager: bool = False
super: bool = False
auth_method: str = 'legacy_header'
token_payload: Optional[dict] = None

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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

View File

@@ -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')))

View File

@@ -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(