security(v3): harden multi-tenant isolation and enhance failure feedback
This commit is contained in:
@@ -113,7 +113,12 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
|
|||||||
except:
|
except:
|
||||||
has_col = False
|
has_col = False
|
||||||
|
|
||||||
|
if not has_col:
|
||||||
|
return forced
|
||||||
|
|
||||||
|
# CRITICAL: Always apply the filter. If account_id is None, it filters for NULL.
|
||||||
forced[target_col] = account.account_id
|
forced[target_col] = account.account_id
|
||||||
|
|
||||||
return forced
|
return forced
|
||||||
|
|
||||||
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:
|
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime, pytz
|
import datetime, pytz
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Set, Union
|
from typing import Dict, List, Optional, Set, Union
|
||||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||||
|
|
||||||
from app.db_sql import redis_lookup_id_random
|
from app.db_sql import redis_lookup_id_random
|
||||||
from app.lib_general import log, logging
|
from app.lib_general import log, logging
|
||||||
@@ -14,15 +14,39 @@ class Archive_Base(BaseModel):
|
|||||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||||
log.debug(locals())
|
log.debug(locals())
|
||||||
|
|
||||||
id_random: Optional[str] = Field(
|
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||||
**base_fields['archive_id_random'],
|
id: Optional[Union[int, str]] = Field(None, **base_fields['archive_id_random'])
|
||||||
alias = 'archive_id_random',
|
archive_id: Optional[Union[int, str]] = Field(None, **base_fields['archive_id_random'])
|
||||||
)
|
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||||
id: Optional[int] = Field(
|
|
||||||
alias = 'archive_id'
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
)
|
id_random: Optional[str] = Field(None, alias='archive_id_random', exclude=True)
|
||||||
account_id_random: Optional[str]
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
account_id: Optional[int]
|
|
||||||
|
@root_validator(pre=True)
|
||||||
|
def map_v3_ids(cls, values):
|
||||||
|
"""
|
||||||
|
Vision Transformer:
|
||||||
|
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||||
|
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||||
|
"""
|
||||||
|
# 1. Map Random Strings to Clean Names
|
||||||
|
rid = values.get('id_random') or values.get('archive_id_random')
|
||||||
|
if rid and isinstance(rid, str):
|
||||||
|
values['id'] = rid
|
||||||
|
values['archive_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||||
|
|
||||||
|
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||||
|
for k in ['id', 'archive_id', 'account_id']:
|
||||||
|
val = values.get(k)
|
||||||
|
if val is not None and not isinstance(val, str):
|
||||||
|
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||||
|
del values[k]
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
archive_type_id: Optional[int]
|
archive_type_id: Optional[int]
|
||||||
archive_type: Optional[str]
|
archive_type: Optional[str]
|
||||||
|
|
||||||
@@ -70,22 +94,8 @@ class Archive_Base(BaseModel):
|
|||||||
|
|
||||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||||
|
|
||||||
@validator('id', always=True)
|
|
||||||
def archive_id_lookup(cls, v, values, **kwargs):
|
|
||||||
if isinstance(v, int) and v > 0: return v
|
|
||||||
elif id_random := values.get('id_random'):
|
|
||||||
return redis_lookup_id_random(record_id_random=id_random, table_name='archive')
|
|
||||||
return None
|
|
||||||
|
|
||||||
@validator('account_id', always=True)
|
|
||||||
def account_id_lookup(cls, v, values, **kwargs):
|
|
||||||
if isinstance(v, int) and v > 0: return v
|
|
||||||
elif id_random := values.get('account_id_random'):
|
|
||||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
underscore_attrs_are_private = True
|
underscore_attrs_are_private = True
|
||||||
allow_population_by_field_name = True
|
allow_population_by_field_name = False
|
||||||
fields = base_fields
|
fields = base_fields
|
||||||
# ### END ### API Archive Models ### Archive_Base() ###
|
# ### END ### API Archive Models ### Archive_Base() ###
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ class AccountContext(BaseModel):
|
|||||||
super: bool = False
|
super: bool = False
|
||||||
auth_method: str = 'legacy_header'
|
auth_method: str = 'legacy_header'
|
||||||
token_payload: Optional[dict] = None
|
token_payload: Optional[dict] = None
|
||||||
|
auth_error: Optional[str] = None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Event_Badge_Base(BaseModel):
|
|||||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||||
id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
||||||
event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
||||||
|
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||||
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||||
|
|
||||||
# NOTE: This should only be used when the event_person record can not be created. And records before 2022.
|
# NOTE: This should only be used when the event_person record can not be created. And records before 2022.
|
||||||
@@ -30,6 +31,7 @@ class Event_Badge_Base(BaseModel):
|
|||||||
|
|
||||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
event_id_random_only: Optional[str] = Field(None, exclude=True)
|
event_id_random_only: Optional[str] = Field(None, exclude=True)
|
||||||
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
@@ -49,6 +51,7 @@ class Event_Badge_Base(BaseModel):
|
|||||||
values['id'] = rid
|
values['id'] = rid
|
||||||
values['event_badge_id'] = rid
|
values['event_badge_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||||
if eo_rid := values.get('event_id_random_only'): values['event_id_only'] = eo_rid
|
if eo_rid := values.get('event_id_random_only'): values['event_id_only'] = eo_rid
|
||||||
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
||||||
@@ -56,7 +59,7 @@ class Event_Badge_Base(BaseModel):
|
|||||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||||
|
|
||||||
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||||
for k in ['id', 'event_badge_id', 'event_id', 'event_id_only', 'event_badge_template_id', 'event_person_id', 'person_id']:
|
for k in ['id', 'event_badge_id', 'account_id', 'event_id', 'event_id_only', 'event_badge_template_id', 'event_person_id', 'person_id']:
|
||||||
val = values.get(k)
|
val = values.get(k)
|
||||||
if val is not None and not isinstance(val, str):
|
if val is not None and not isinstance(val, str):
|
||||||
values[k] = None
|
values[k] = None
|
||||||
@@ -201,11 +204,13 @@ class Event_Badge_Basic_Base(BaseModel):
|
|||||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||||
id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
||||||
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
||||||
|
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||||
event_badge_template_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_template_id_random'])
|
event_badge_template_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_template_id_random'])
|
||||||
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
||||||
|
|
||||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
|
||||||
@@ -222,11 +227,12 @@ class Event_Badge_Basic_Base(BaseModel):
|
|||||||
values['id'] = rid
|
values['id'] = rid
|
||||||
values['event_badge_id'] = rid
|
values['event_badge_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||||
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
||||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||||
|
|
||||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||||
for k in ['id', 'event_badge_id', 'event_badge_template_id', 'event_person_id']:
|
for k in ['id', 'event_badge_id', 'account_id', 'event_badge_template_id', 'event_person_id']:
|
||||||
val = values.get(k)
|
val = values.get(k)
|
||||||
if val is not None and not isinstance(val, str):
|
if val is not None and not isinstance(val, str):
|
||||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Event_File_Base(BaseModel):
|
|||||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||||
id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
||||||
event_file_id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
event_file_id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
||||||
|
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||||
hosted_file_id: Optional[Union[int, str]] = Field(**base_fields['hosted_file_id_random'])
|
hosted_file_id: Optional[Union[int, str]] = Field(**base_fields['hosted_file_id_random'])
|
||||||
|
|
||||||
# Generic Relational target
|
# Generic Relational target
|
||||||
@@ -34,6 +35,7 @@ class Event_File_Base(BaseModel):
|
|||||||
|
|
||||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
id_random: Optional[str] = Field(None, alias='event_file_id_random', exclude=True)
|
id_random: Optional[str] = Field(None, alias='event_file_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
hosted_file_id_random: Optional[str] = Field(None, exclude=True)
|
hosted_file_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
for_id_random: Optional[str] = Field(None, exclude=True)
|
for_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
@@ -60,6 +62,7 @@ class Event_File_Base(BaseModel):
|
|||||||
# 2. Map & Resolve Relational IDs
|
# 2. Map & Resolve Relational IDs
|
||||||
# (Field Name, Table Name)
|
# (Field Name, Table Name)
|
||||||
id_map = [
|
id_map = [
|
||||||
|
('account_id', 'account'),
|
||||||
('hosted_file_id', 'hosted_file'),
|
('hosted_file_id', 'hosted_file'),
|
||||||
('event_id', 'event'),
|
('event_id', 'event'),
|
||||||
('event_exhibit_id', 'event_exhibit'),
|
('event_exhibit_id', 'event_exhibit'),
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ class Event_Location_Base(BaseModel):
|
|||||||
id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||||
|
|
||||||
|
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||||
|
|
||||||
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
|
id_random: Optional[str] = Field(None, alias='event_location_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
|
||||||
code: Optional[str] = Field(
|
code: Optional[str] = Field(
|
||||||
# alias = 'event_location_code'
|
# alias = 'event_location_code'
|
||||||
)
|
)
|
||||||
@@ -108,13 +115,15 @@ class Event_Location_Base(BaseModel):
|
|||||||
values['id'] = rid
|
values['id'] = rid
|
||||||
values['event_location_id'] = rid
|
values['event_location_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'):
|
||||||
|
values['account_id'] = a_rid
|
||||||
if e_rid := values.get('event_id_random'):
|
if e_rid := values.get('event_id_random'):
|
||||||
values['event_id'] = e_rid
|
values['event_id'] = e_rid
|
||||||
if et_rid := values.get('event_track_id_random'):
|
if et_rid := values.get('event_track_id_random'):
|
||||||
values['event_track_id'] = et_rid
|
values['event_track_id'] = et_rid
|
||||||
|
|
||||||
# 2. Prevent "Collision Population"
|
# 2. Prevent "Collision Population"
|
||||||
for k in ['id', 'event_location_id', 'event_id', 'event_track_id']:
|
for k in ['id', 'event_location_id', 'account_id', 'event_id', 'event_track_id']:
|
||||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||||
del values[k]
|
del values[k]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class Event_Presenter_Base(BaseModel):
|
|||||||
alias = 'event_presenter_id'
|
alias = 'event_presenter_id'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
account_id_random: Optional[str]
|
||||||
|
account_id: Optional[int]
|
||||||
|
|
||||||
external_id: Optional[str]
|
external_id: Optional[str]
|
||||||
|
|
||||||
code: Optional[str]
|
code: Optional[str]
|
||||||
@@ -197,6 +200,13 @@ class Event_Presenter_Base(BaseModel):
|
|||||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presenter')
|
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presenter')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@validator('account_id', always=True)
|
||||||
|
def account_id_lookup(cls, v, values, **kwargs):
|
||||||
|
if isinstance(v, int) and v > 0: return v
|
||||||
|
elif id_random := values.get('account_id_random'):
|
||||||
|
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||||
|
return None
|
||||||
|
|
||||||
@validator('event_id', always=True)
|
@validator('event_id', always=True)
|
||||||
def event_id_lookup(cls, v, values, **kwargs):
|
def event_id_lookup(cls, v, values, **kwargs):
|
||||||
if isinstance(v, int) and v > 0: return v
|
if isinstance(v, int) and v > 0: return v
|
||||||
@@ -253,6 +263,9 @@ class Event_Presenter_Out_Base(BaseModel):
|
|||||||
alias = 'event_presenter_id'
|
alias = 'event_presenter_id'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
account_id_random: Optional[str]
|
||||||
|
account_id: Optional[int]
|
||||||
|
|
||||||
external_id: Optional[str]
|
external_id: Optional[str]
|
||||||
|
|
||||||
code: Optional[str]
|
code: Optional[str]
|
||||||
|
|||||||
@@ -23,12 +23,22 @@ class Event_Session_Base(BaseModel):
|
|||||||
id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||||
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||||
|
|
||||||
|
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||||
poc_event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
poc_event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||||
poc_person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
poc_person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||||
|
|
||||||
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
|
id_random: Optional[str] = Field(None, alias='event_session_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
event_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
poc_event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
poc_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
|
||||||
external_id: Optional[str] = Field(
|
external_id: Optional[str] = Field(
|
||||||
# alias = 'event_session_external_id'
|
# alias = 'event_session_external_id'
|
||||||
)
|
)
|
||||||
@@ -184,6 +194,8 @@ class Event_Session_Base(BaseModel):
|
|||||||
values['id'] = rid
|
values['id'] = rid
|
||||||
values['event_session_id'] = rid
|
values['event_session_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'):
|
||||||
|
values['account_id'] = a_rid
|
||||||
if e_rid := values.get('event_id_random'):
|
if e_rid := values.get('event_id_random'):
|
||||||
values['event_id'] = e_rid
|
values['event_id'] = e_rid
|
||||||
if el_rid := values.get('event_location_id_random'):
|
if el_rid := values.get('event_location_id_random'):
|
||||||
@@ -196,7 +208,7 @@ class Event_Session_Base(BaseModel):
|
|||||||
values['poc_person_id'] = pp_rid
|
values['poc_person_id'] = pp_rid
|
||||||
|
|
||||||
# 2. Prevent "Collision Population"
|
# 2. Prevent "Collision Population"
|
||||||
for k in ['id', 'event_session_id', 'event_id', 'event_location_id', 'event_track_id', 'poc_event_person_id', 'poc_person_id']:
|
for k in ['id', 'event_session_id', 'account_id', 'event_id', 'event_location_id', 'event_track_id', 'poc_event_person_id', 'poc_person_id']:
|
||||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||||
del values[k]
|
del values[k]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import datetime, pytz
|
import datetime, pytz
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Set, Union
|
from typing import Dict, List, Optional, Set, Union
|
||||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||||
|
|
||||||
from app.db_sql import redis_lookup_id_random
|
from app.db_sql import redis_lookup_id_random
|
||||||
from app.lib_general import log, logging
|
from app.lib_general import log, logging
|
||||||
@@ -15,16 +15,38 @@ class Site_Base(BaseModel):
|
|||||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||||
log.debug(locals())
|
log.debug(locals())
|
||||||
|
|
||||||
id_random: Optional[str] = Field(
|
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||||
**base_fields['site_id_random'],
|
id: Optional[Union[int, str]] = Field(None, **base_fields['site_id_random'])
|
||||||
alias = 'site_id_random',
|
site_id: Optional[Union[int, str]] = Field(None, **base_fields['site_id_random'])
|
||||||
)
|
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||||
id: Optional[int] = Field(
|
|
||||||
alias = 'site_id'
|
|
||||||
)
|
|
||||||
|
|
||||||
account_id_random: Optional[str]
|
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||||
account_id: Optional[int]
|
id_random: Optional[str] = Field(None, alias='site_id_random', exclude=True)
|
||||||
|
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||||
|
|
||||||
|
@root_validator(pre=True)
|
||||||
|
def map_v3_ids(cls, values):
|
||||||
|
"""
|
||||||
|
Vision Transformer:
|
||||||
|
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||||
|
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||||
|
"""
|
||||||
|
# 1. Map Random Strings to Clean Names
|
||||||
|
rid = values.get('id_random') or values.get('site_id_random')
|
||||||
|
if rid and isinstance(rid, str):
|
||||||
|
values['id'] = rid
|
||||||
|
values['site_id'] = rid
|
||||||
|
|
||||||
|
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||||
|
|
||||||
|
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||||
|
for k in ['id', 'site_id', 'account_id']:
|
||||||
|
val = values.get(k)
|
||||||
|
if val is not None and not isinstance(val, str):
|
||||||
|
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||||
|
del values[k]
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
code: Optional[str]
|
code: Optional[str]
|
||||||
|
|
||||||
@@ -101,35 +123,8 @@ class Site_Base(BaseModel):
|
|||||||
|
|
||||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||||
|
|
||||||
@validator('id', always=True)
|
|
||||||
def site_id_lookup(cls, v, values, **kwargs):
|
|
||||||
log.setLevel(logging.WARNING)
|
|
||||||
log.debug(locals())
|
|
||||||
|
|
||||||
if values['id_random']:
|
|
||||||
log.debug(values['id_random'])
|
|
||||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='site')
|
|
||||||
return None
|
|
||||||
|
|
||||||
@validator('account_id', always=True)
|
|
||||||
def account_id_lookup(cls, v, values, **kwargs):
|
|
||||||
log.setLevel(logging.WARNING)
|
|
||||||
log.debug(locals())
|
|
||||||
|
|
||||||
if isinstance(v, int) and v > 0: return v
|
|
||||||
elif id_random := values.get('account_id_random'):
|
|
||||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
|
||||||
return None
|
|
||||||
|
|
||||||
@validator('account_id_random', always=True)
|
|
||||||
def account_id_random_lookup(cls, v, values, **kwargs):
|
|
||||||
if isinstance(v, str) and len(v) >= 11: return v
|
|
||||||
elif account_id := values.get('account_id'):
|
|
||||||
return get_id_random(record_id=account_id, table_name='account')
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
underscore_attrs_are_private = True
|
underscore_attrs_are_private = True
|
||||||
allow_population_by_field_name = True
|
allow_population_by_field_name = False
|
||||||
fields = base_fields
|
fields = base_fields
|
||||||
# ### END ### API Site Models ### Site_Base() ###
|
# ### END ### API Site Models ### Site_Base() ###
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ cms_obj_li = {
|
|||||||
'searchable_fields': [
|
'searchable_fields': [
|
||||||
'id', 'account_id', 'site_id',
|
'id', 'account_id', 'site_id',
|
||||||
'id_random', 'account_id_random', 'site_id_random',
|
'id_random', 'account_id_random', 'site_id_random',
|
||||||
'fqdn', 'enable', 'created_on', 'updated_on'
|
'fqdn', 'access_key', 'site_access_key',
|
||||||
|
'enable', 'created_on', 'updated_on'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async def get_obj(
|
|||||||
obj_type_l1: str = Path(min_length=2, max_length=50),
|
obj_type_l1: str = Path(min_length=2, max_length=50),
|
||||||
obj_id: str = Path(min_length=11, max_length=22),
|
obj_id: str = Path(min_length=11, max_length=22),
|
||||||
view: str = Query('default'),
|
view: str = Query('default'),
|
||||||
account: AccountContext = Depends(get_account_context),
|
account: AccountContext = Depends(get_account_context_optional),
|
||||||
serialization: SerializationParams = Depends(),
|
serialization: SerializationParams = Depends(),
|
||||||
delay: DelayParams = Depends(),
|
delay: DelayParams = Depends(),
|
||||||
):
|
):
|
||||||
@@ -143,8 +143,13 @@ async def get_obj(
|
|||||||
|
|
||||||
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 obj_cfg.get('public_read', False):
|
if not obj_cfg.get('public_read', False):
|
||||||
|
# Strict context check for non-public objects
|
||||||
|
if account.auth_method == 'guest' or (account.account_id is None and not account.super):
|
||||||
|
reason = account.auth_error or "Account context required."
|
||||||
|
return mk_resp(data=False, status_code=403, response=response, status_message=reason)
|
||||||
|
|
||||||
if not check_account_access(sql_result, account, obj_name):
|
if not check_account_access(sql_result, account, obj_name):
|
||||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.")
|
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied. Record belongs to another account.")
|
||||||
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:
|
||||||
@@ -334,7 +339,7 @@ async def search_obj_li(
|
|||||||
if not account.super and for_obj_id != account.account_id_random:
|
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.")
|
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.")
|
||||||
|
|
||||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
if not is_public_read and not account.super and account.auth_method != 'bypass':
|
||||||
if search_query.and_filters is None: search_query.and_filters = []
|
if search_query.and_filters is None: search_query.and_filters = []
|
||||||
if obj_name == 'account':
|
if obj_name == 'account':
|
||||||
search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id))
|
search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ def get_account_context_optional(
|
|||||||
resolved_token_payload = None
|
resolved_token_payload = None
|
||||||
auth_method = 'guest'
|
auth_method = 'guest'
|
||||||
api_key_authorized = False
|
api_key_authorized = False
|
||||||
|
auth_error = None
|
||||||
|
|
||||||
# 1. Mandatory Machine Auth (API Key)
|
# 1. Mandatory Machine Auth (API Key)
|
||||||
# Prefer header, fallback to query param
|
# Prefer header, fallback to query param
|
||||||
@@ -56,16 +57,22 @@ def get_account_context_optional(
|
|||||||
if (not enable_from or enable_from <= now) and (not enable_to or now <= enable_to):
|
if (not enable_from or enable_from <= now) and (not enable_to or now <= enable_to):
|
||||||
api_key_authorized = True
|
api_key_authorized = True
|
||||||
else:
|
else:
|
||||||
log.error(f"Security: API Key {key_to_check} expired/not yet valid.")
|
auth_error = "API Key expired or not yet valid."
|
||||||
|
log.error(f"Security: {auth_error} Key: {key_to_check}")
|
||||||
else:
|
else:
|
||||||
log.error(f"Security: API Key {key_to_check} is disabled.")
|
auth_error = "API Key is disabled."
|
||||||
|
log.error(f"Security: {auth_error} Key: {key_to_check}")
|
||||||
else:
|
else:
|
||||||
log.error(f"Security: API Key {key_to_check} not found.")
|
auth_error = "API Key not found or invalid."
|
||||||
|
log.error(f"Security: {auth_error} Key: {key_to_check}")
|
||||||
|
else:
|
||||||
|
auth_error = "Mandatory API Key missing."
|
||||||
|
|
||||||
# 2. Context Resolution (Only if API Key is valid)
|
# 2. Context Resolution (Only if API Key is valid)
|
||||||
if api_key_authorized:
|
if api_key_authorized:
|
||||||
# Default to machine auth if no account context is provided
|
# Default to machine auth if no account context is provided
|
||||||
auth_method = 'api_key'
|
auth_method = 'api_key'
|
||||||
|
auth_error = "Account context required for this operation."
|
||||||
|
|
||||||
# A. Resolve via Account ID Header
|
# A. Resolve via Account ID Header
|
||||||
if x_account_id:
|
if x_account_id:
|
||||||
@@ -73,6 +80,9 @@ def get_account_context_optional(
|
|||||||
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id):
|
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id):
|
||||||
resolved_account_id = looked_up_id
|
resolved_account_id = looked_up_id
|
||||||
auth_method = 'account_header'
|
auth_method = 'account_header'
|
||||||
|
auth_error = None
|
||||||
|
else:
|
||||||
|
auth_error = f"Account ID '{x_account_id}' not found."
|
||||||
|
|
||||||
# B. Resolve via JWT / Token Query Param
|
# B. Resolve via JWT / Token Query Param
|
||||||
elif x_no_account_id_token:
|
elif x_no_account_id_token:
|
||||||
@@ -88,23 +98,34 @@ def get_account_context_optional(
|
|||||||
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=resolved_account_id_random):
|
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=resolved_account_id_random):
|
||||||
resolved_account_id = looked_up_id
|
resolved_account_id = looked_up_id
|
||||||
auth_method = 'jwt_token'
|
auth_method = 'jwt_token'
|
||||||
|
auth_error = None
|
||||||
|
else:
|
||||||
|
auth_error = f"Account ID '{resolved_account_id_random}' from token not found."
|
||||||
|
else:
|
||||||
|
# JWT is valid but has no account_id (e.g. platform-wide guest)
|
||||||
|
# We keep auth_method as 'jwt_token' but account_id as None.
|
||||||
|
auth_method = 'jwt_token'
|
||||||
|
auth_error = "Valid token provided, but no account context found in payload."
|
||||||
else:
|
else:
|
||||||
log.warning("Security: Failed to decode JWT token.")
|
auth_error = "Failed to decode JWT token."
|
||||||
|
log.warning(f"Security: {auth_error}")
|
||||||
|
|
||||||
# Legacy Fallback (just a raw random ID string)
|
# Legacy Fallback (just a raw random ID string)
|
||||||
if auth_method in ['guest', 'api_key']:
|
if auth_method in ['guest', 'api_key', 'jwt_token'] and auth_error:
|
||||||
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):
|
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
|
resolved_account_id = looked_up_id
|
||||||
|
resolved_account_id_random = x_no_account_id_token
|
||||||
auth_method = 'token_query'
|
auth_method = 'token_query'
|
||||||
|
auth_error = None
|
||||||
|
|
||||||
# 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']:
|
||||||
resolved_account_id = 1
|
resolved_account_id = 1
|
||||||
resolved_account_id_random = '--- NO ACCOUNT ---'
|
resolved_account_id_random = '--- NO ACCOUNT ---'
|
||||||
auth_method = 'bypass'
|
auth_method = 'bypass'
|
||||||
|
auth_error = None
|
||||||
|
|
||||||
log.info(f"V3 Auth: method={auth_method}, authorized={api_key_authorized}, account={resolved_account_id_random}")
|
log.info(f"V3 Auth: method={auth_method}, authorized={api_key_authorized}, account={resolved_account_id_random}, error={auth_error}")
|
||||||
|
|
||||||
is_admin = (auth_method == 'bypass')
|
is_admin = (auth_method == 'bypass')
|
||||||
is_manager = (auth_method == 'bypass')
|
is_manager = (auth_method == 'bypass')
|
||||||
@@ -122,7 +143,8 @@ def get_account_context_optional(
|
|||||||
administrator=is_admin,
|
administrator=is_admin,
|
||||||
manager=is_manager,
|
manager=is_manager,
|
||||||
super=is_super,
|
super=is_super,
|
||||||
token_payload=resolved_token_payload
|
token_payload=resolved_token_payload,
|
||||||
|
auth_error=auth_error
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_account_context(
|
def get_account_context(
|
||||||
@@ -134,8 +156,9 @@ def get_account_context(
|
|||||||
) -> AccountContext:
|
) -> AccountContext:
|
||||||
"""Strict version of account context resolution."""
|
"""Strict version of account context resolution."""
|
||||||
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token, x_aether_api_key, x_aether_api_key_query)
|
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token, x_aether_api_key, x_aether_api_key_query)
|
||||||
if ctx.auth_method == 'guest':
|
if ctx.auth_method == 'guest' or (ctx.account_id is None and not ctx.super):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.')
|
reason = ctx.auth_error or "Account context required."
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=reason)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,266 +1,70 @@
|
|||||||
# Aether API V3 Frontend Integration Guide (Svelte/TypeScript)
|
# Aether API V3 Frontend Integration Guide (Svelte/TypeScript)
|
||||||
|
|
||||||
This guide explains how to update or create frontend functions to interact with the new **Aether API V3 CRUD** and **Action** endpoints. V3 introduces a nested URL structure, a powerful POST-based search, and specialized "Action" routes for binary data.
|
This guide defines the standards for interacting with the **Aether API V3 CRUD** and **Action** endpoints.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Key Differences (V2 vs V3)
|
## 1. Authentication and Security (Mandatory)
|
||||||
|
|
||||||
| Feature | CRUD V2 (Legacy) | CRUD V3 (Modern) |
|
V3 architecture enforces strict **Multi-Tenant Isolation** and **Machine Authorization**. Requests require two levels of validation.
|
||||||
| ------------------ | --------------------- | ---------------------------------------------------- |
|
|
||||||
| **Base Prefix** | `/v2/crud` | `/v3/crud` |
|
|
||||||
| **List Suffix** | Uses `/list` | **No suffix** (e.g., `/v3/crud/journal/`) |
|
|
||||||
| **Nested Path** | Not supported in URL | **Supported** (e.g., `/v3/crud/journal/{id}/entry/`) |
|
|
||||||
| **Object Aliases** | Limited | **Supported** (e.g., `entry` maps to `journal_entry`)|
|
|
||||||
| **View Selection** | `tbl_alt`, `mdl_alt` | **`view` parameter** (e.g., `?view=enriched`) |
|
|
||||||
| **Complex Search** | Limited to GET `jp` | **POST `/search`** (Unlimited size + Hybrid params) |
|
|
||||||
| **Full-Text** | Manual column names | **Reserved `q` property** in SearchQuery |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Authentication and Security (Mandatory)
|
|
||||||
|
|
||||||
As of January 2026, the V3 architecture enforces strict **Multi-Tenant Isolation** and **Machine Authorization**. Most requests require two levels of validation.
|
|
||||||
|
|
||||||
### A. The "Entry Ticket" (API Key)
|
### A. The "Entry Ticket" (API Key)
|
||||||
**Mandatory for all requests.** Every request must provide a valid `x-aether-api-key` in the header. This identifies the application or script.
|
**Mandatory for all requests.** identifies the application or client.
|
||||||
|
|
||||||
* **Header:** `x-aether-api-key: <your_app_key>`
|
* **Header:** `x-aether-api-key: <your_app_key>`
|
||||||
* **Status Code:** `403 Forbidden` if missing, invalid, or expired.
|
* **Status Code:** `403 Forbidden` if missing or invalid.
|
||||||
|
|
||||||
### B. The "Visa" (Account Context)
|
### B. The "Visa" (Account Context)
|
||||||
Once the API Key is validated, you must specify the context of your request.
|
Required for any non-public data (Journals, Badges, Users, etc.).
|
||||||
|
1. **Standard Access**: Provide the `x-account-id` (the random string ID).
|
||||||
1. **Standard User Access**: Provide the `x-account-id` (the random string ID).
|
|
||||||
* **Header:** `x-account-id: <account_id_random>`
|
* **Header:** `x-account-id: <account_id_random>`
|
||||||
2. **Administrative Bypass**: For authorized scripts needing global access.
|
2. **Administrative Bypass**: For authorized scripts needing global access.
|
||||||
* **Header:** `x-no-account-id: bypass`
|
* **Header:** `x-no-account-id: bypass`
|
||||||
3. **Guest / Anonymous Access**: Provide a **Safe Guest JWT**.
|
3. **Token Access**: Provide a **JWT** in the query string.
|
||||||
* **Header:** `x-aether-api-key: <your_app_key>` (No Account Header)
|
* **Query Param:** `?jwt=<token>`
|
||||||
* **Query Param:** `?jwt=<guest_token>`
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Implementing V3 CRUD Functions
|
## 2. Bootstrapping (The FQDN Handshake)
|
||||||
|
|
||||||
### A. List & Single Object (GET)
|
When the frontend first loads and doesn't know the `account_id`, it performs a "handshake" using its domain name.
|
||||||
```ts
|
|
||||||
// GET /v3/crud/journal/{id}?view=enriched
|
**Endpoint:** `POST /v3/crud/site_domain/search`
|
||||||
export async function get_ae_obj_v3({ api_cfg, obj_type, obj_id, view = 'default' }) {
|
**Body:**
|
||||||
const endpoint = `/v3/crud/${obj_type}/${obj_id}`;
|
```json
|
||||||
return await get_object({ api_cfg, endpoint, params: { view } });
|
{
|
||||||
}
|
"and": [
|
||||||
```
|
{ "field": "fqdn", "op": "eq", "value": "demo.oneskyit.com" }
|
||||||
|
]
|
||||||
### B. Advanced & Hybrid Search (POST)
|
|
||||||
```ts
|
|
||||||
// POST /v3/crud/{obj_type}/search
|
|
||||||
export async function search_ae_obj_v3({ api_cfg, obj_type, search_query, enabled = 'enabled' }) {
|
|
||||||
const endpoint = `/v3/crud/${obj_type}/search`;
|
|
||||||
return await post_object({ api_cfg, endpoint, params: { enabled }, data: search_query });
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
**Results:**
|
||||||
|
* Returns 200 + a list containing the `account_id` and `site_id` random strings.
|
||||||
|
* ** ڈیزائن Choice:** If the domain is not found, it returns **200 OK with an empty list `[]`**. It is NOT a 404.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Hosted File Management (Modern V3 Actions)
|
## 3. Standard CRUD Patterns
|
||||||
|
|
||||||
V3 uses specialized **"Action"** routes for binary operations to separate processing logic from standard metadata CRUD.
|
### A. GET by ID
|
||||||
|
Used when the ID is known.
|
||||||
|
* **Endpoint:** `GET /v3/crud/{obj_type}/{id_random}`
|
||||||
|
* **Security:** Returns 403 if the record doesn't belong to your `x-account-id`.
|
||||||
|
|
||||||
### A. Upload Action
|
### B. POST Search
|
||||||
**Path**: `POST /v3/action/hosted_file/upload`
|
The primary way to retrieve data.
|
||||||
**Format**: `multipart/form-data`
|
* **Endpoint:** `POST /v3/crud/{obj_type}/search`
|
||||||
|
* **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects.
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `file_list` | File[] | Yes | One or more files to upload. |
|
|
||||||
| `account_id` | String | Yes | Random ID of the owner account. |
|
|
||||||
| `link_to_type`| String | Yes | Object type to link (e.g., `archive_content`). |
|
|
||||||
| `link_to_id` | String | Yes | Random ID of the object to link. |
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `allowed_extensions`: Whitelist check (e.g., `?allowed_extensions=png&allowed_extensions=jpg`).
|
|
||||||
- `delay_ms`: Simulate network delay for UI testing.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Automatic Deduplication:** API checks SHA256 hashes. If a file exists on the server, it reuses the record and creates a new link instead of a duplicate upload.
|
|
||||||
- **Relational Integrity:** Automatically creates `hosted_file_link` records.
|
|
||||||
|
|
||||||
### B. Download & Streaming Action
|
|
||||||
**Path**: `GET /v3/action/hosted_file/{id}/download`
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `key` | String | **Temporary V3.0 Auth:** Pass any valid `account_id_random` to bypass headers. |
|
|
||||||
| `site_key` | String | Bypass headers via `access_key` from the `site` table. |
|
|
||||||
| `filename` | String | Override the response filename. |
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **ID Vision:** Automatically resolves `{id}` if it belongs to a container object (e.g., `event_file`) instead of a direct `hosted_file`.
|
|
||||||
- **Streaming:** Supports standard `Range` headers for large files and video seeking.
|
|
||||||
- **Testing:** Supports `delay_ms` query parameter.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> **TEMPORARY SOLUTION (V3.0):** The `?key=` and `?site_key=` unauthenticated access patterns are intended to unblock the frontend for inline images and direct links where custom headers are not possible. This will be replaced by a standardized Signed URL or Read-Token system in **Version 3.1**. Please do not rely on this pattern for long-term security architecture.
|
|
||||||
|
|
||||||
### C. Hash-Based Download (Content-Addressable)
|
|
||||||
**Path**: `GET /v3/action/hosted_file/hash/{sha256}/download`
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Local Caching:** Ideal for systems like the Events Launcher that cache files locally by hash.
|
|
||||||
- **Flexible Auth:** Supports `api_key` in the query parameter (e.g., `?api_key=<key>`) for simple machine-to-machine requests.
|
|
||||||
- **Zero DB Lookup:** Resolves the physical path deterministically from the hash, bypassing database latency.
|
|
||||||
|
|
||||||
### D. Deletion & Cleanup Action
|
|
||||||
**Path**: `DELETE /v3/action/hosted_file/{id}`
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `link_to_type`| Query | null | The type of the link to remove. |
|
|
||||||
| `link_to_id` | Query | null | The random ID of the link to remove. |
|
|
||||||
| `rm_orphan` | Query | false | If true, physically delete file if no links remain. |
|
|
||||||
| `method` | Query | `hide` | Cleanup method: `hide`, `disable`, or `delete` (hard). |
|
|
||||||
| `fake_delete` | Query | false | **Testing Mode:** Verifies file/record existence without modification. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Event File Management (Specialized V3 Actions)
|
## 4. Troubleshooting 403 Forbidden
|
||||||
|
|
||||||
While `hosted_file` handles generic storage, `event_file` actions are context-aware and atomic for the Event module.
|
If you receive a 403 on a valid ID:
|
||||||
|
1. Verify `x-aether-api-key` is correct.
|
||||||
### A. Atomic Upload Action
|
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
|
||||||
**Path**: `POST /v3/action/event_file/upload`
|
3. Verify the record actually belongs to the account ID you are sending.
|
||||||
**Format**: `multipart/form-data`
|
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `file_list` | File[] | Yes | Files to upload. |
|
|
||||||
| `account_id`| String | Yes | Owner account. |
|
|
||||||
| `for_type` | String | Yes | Parent object type (e.g., `event_session`). |
|
|
||||||
| `for_id` | String | Yes | Random ID of the parent object. |
|
|
||||||
| `event_id` | String | No | Optional event context. |
|
|
||||||
| `title` | String | No | Display title for the file. |
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Atomic Creation:** Automatically creates the `hosted_file`, the `hosted_file_link`, AND the `event_file` association in one request.
|
|
||||||
- **Intelligent Updates:** If the same file is uploaded again for the same object, it updates the metadata instead of creating a duplicate association.
|
|
||||||
- **Enriched Return:** Returns full `event_file` objects with nested `hosted_file` data.
|
|
||||||
|
|
||||||
### B. Specialized Download
|
|
||||||
**Path**: `GET /v3/action/event_file/{id}/download`
|
|
||||||
*Semantic alias for the universal hosted_file downloader.*
|
|
||||||
|
|
||||||
### C. Link Existing Hosted File
|
|
||||||
**Path**: `POST /v3/action/event_file/from_hosted_file/{hosted_file_id}`
|
|
||||||
|
|
||||||
Use this when a file is already in standard storage (e.g., from a previous session or general archive) and you want to link it to an Event object without re-uploading.
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `hosted_file_id` | Path | Yes | String ID of the physical file. |
|
|
||||||
| `for_type` | Body | Yes | Target object type (e.g., `event_session`). |
|
|
||||||
| `for_id` | Body | Yes | String ID of the target object. |
|
|
||||||
| `event_id` | Body | No | Optional event context. |
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Vision IDs:** Accepts string IDs in both the path and JSON body.
|
|
||||||
- **Relational Integrity:** Automatically creates both `hosted_file_link` and `event_file` records.
|
|
||||||
- **Enriched Return:** Returns the full `event_file` object.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Hosted File Management (Legacy)
|
|
||||||
|
|
||||||
The following endpoints are maintained for backward compatibility but should be migrated to V3 Actions.
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `POST` | `/hosted_file/upload_files` | Legacy multi-upload. |
|
|
||||||
| `GET` | `/hosted_file/{id}/download` | Legacy download. |
|
|
||||||
| `GET` | `/hosted_file/{id}/stream` | Legacy buffered streamer. |
|
|
||||||
| `DELETE` | `/hosted_file/{id}` | Legacy deletion. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The "ID Vision" Standard (2026)
|
|
||||||
|
|
||||||
V3 uses a **String-Only ID Vision**. The frontend NEVER handles or stores database integers.
|
|
||||||
|
|
||||||
1. **Automatic Mapping:** Internal `id_random` fields are mapped to clean names (e.g., `id`, `event_id`, `account_id`) in JSON responses.
|
|
||||||
2. **Intelligent Resolution:** You can send random string IDs back to the API in any `*_id` field; the API resolves them to integers before database insertion.
|
|
||||||
3. **Suffix Compatibility:** The `_random` suffix (e.g., `event_id_random`) is maintained for backward compatibility with V2 clients. Standard V3 frontend integration should prioritize fields without the suffix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Real-Time Communication (V3 WebSockets)
|
|
||||||
|
|
||||||
V3 WebSockets provide a granular, high-performance messaging layer.
|
|
||||||
|
|
||||||
- **Guide**: [Aether API V3 WebSocket Integration Guide](./GUIDE__V3_FRONTEND_WEBSOCKETS.md)
|
|
||||||
- **Endpoint**: `ws://[api_domain]/v3/ws/group/{group_id}/client/{client_id}`
|
|
||||||
- **Key Requirement**: All messages must conform to the `WS_Message_V3` schema.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Structured Error Handling
|
|
||||||
|
|
||||||
V3 returns machine-readable error objects in `meta.details` for failures.
|
|
||||||
|
|
||||||
### HTTP Status Codes
|
|
||||||
- **`400 Bad Request`**: Used for client-driven errors including invalid search fields, validation failures, and constraint violations.
|
|
||||||
- **`403 Forbidden`**: Missing or invalid API Key / Account Context.
|
|
||||||
- **`404 Not Found`**: Object ID does not exist.
|
|
||||||
- **`500 Internal Server Error`**: Unexpected server crash or database connection failure.
|
|
||||||
|
|
||||||
### Common Error Categories
|
|
||||||
Found in `meta.details.category`:
|
|
||||||
- `database_duplicate`: Non-unique value (Code 1062). -> **400**
|
|
||||||
- `database_constraint`: Foreign key violation (Codes 1451, 1452). -> **400**
|
|
||||||
- `database_schema`: Invalid column name or missing field in the requested `view` (Codes 1054, 1146). -> **400**
|
|
||||||
- `validation`: Pydantic validation failed (Check `details` for field-specific errors). -> **400**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Data Store Cascading Lookup (V3)
|
|
||||||
|
|
||||||
V3 provides a specialized endpoint for retrieving configuration or content snippets by a human-friendly `code`. This uses a **hierarchical fallback** logic to find the "best fit" record for the current user and object context.
|
|
||||||
|
|
||||||
### A. The V3 Lookup Endpoint
|
|
||||||
**Path:** `GET /v3/data_store/code/{code}`
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| `for_type` | String | No | Parent object type (e.g., `event`, `person`). |
|
|
||||||
| `for_id` | String | No | Parent object random ID (Vision ID). |
|
|
||||||
| `limit` | Integer | No | **Dynamic Return:** Default `1` (returns single object). If `> 1`, returns a list. |
|
|
||||||
|
|
||||||
### B. Cascading Logic (The Hierarchy of Truth)
|
|
||||||
The API automatically resolves the most specific record available using the following priority:
|
|
||||||
**Object Override > Account Override > Global System Default.**
|
|
||||||
|
|
||||||
| Specificity | `account_id` | `for_type` | `for_id` | `code` | `Result` |
|
|
||||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
||||||
| **Global** | `NULL` | `NULL` | `NULL` | `'site_config'` | System default (blue theme). |
|
|
||||||
| **Account** | `'abc123_rd'` | `NULL` | `NULL` | `'site_config'` | Acme Corp default (green theme). |
|
|
||||||
| **Object** | `'abc123_rd'` | `'event'` | `'evt_xyz_rd'` | `'site_config'` | Main Conference override (gold theme). |
|
|
||||||
|
|
||||||
### C. Rules for Frontend Agents
|
|
||||||
1. **Vision IDs Only:** Always use random string IDs (e.g., `evt_xyz_rd`) for `for_id`. Never use database integers.
|
|
||||||
2. **Explicit Context:** If you are within an Event or Person context, always provide `for_type` and `for_id`. The API will handle the fallback if a specific record doesn't exist.
|
|
||||||
3. **Automatic JSON Parsing:** If the record `type` is `'json'`, the API returns a structured object/list under the `json` key. You do not need to call `JSON.parse()`.
|
|
||||||
4. **Handling the Return:**
|
|
||||||
* `limit=1` (Default): Returns a single **Object** in `data`.
|
|
||||||
* `limit>1`: Returns a **List** of objects in `data`.
|
|
||||||
|
|
||||||
### D. Example Implementation
|
|
||||||
```ts
|
|
||||||
// GET /v3/data_store/code/event_launcher_main_info?for_type=event&for_id=nmBfuGFeR0k&limit=1
|
|
||||||
export async function get_data_store_v3({ api_cfg, code, for_type, for_id, limit = 1 }) {
|
|
||||||
const endpoint = `/v3/data_store/code/${code}`;
|
|
||||||
const params = { for_type, for_id, limit };
|
|
||||||
return await get_object({ api_cfg, endpoint, params });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
95
tests/e2e/test_e2e_v3_security_audit.py
Normal file
95
tests/e2e/test_e2e_v3_security_audit.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
API_ROOT = "https://dev-api.oneskyit.com"
|
||||||
|
API_KEY = "dFP6J9DVj9hUgIMn-fNIqg"
|
||||||
|
|
||||||
|
# Test Matrix: (obj_type, id_random, is_public)
|
||||||
|
TEST_OBJECTS = [
|
||||||
|
("journal", "SWFK-48-89-90", False),
|
||||||
|
("journal_entry", "w1p-OE-Hm-zz", False),
|
||||||
|
("event", "aJ0KmgvU62Q", True),
|
||||||
|
("post", "-qTmbMlEjAY", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Primary Account Context for Auth tests (Account 1)
|
||||||
|
ACCOUNT_A_RAND = "_XY7DXtc9MY"
|
||||||
|
# Secondary Account Context (Account 2 - assume this exists and doesn't own Account 1 records)
|
||||||
|
ACCOUNT_B_RAND = "nqOzejLCDXM"
|
||||||
|
|
||||||
|
def print_result(label, success, message=""):
|
||||||
|
status = "✅ PASS" if success else "❌ FAIL"
|
||||||
|
print(f"[{status}] {label} {message}")
|
||||||
|
|
||||||
|
def check_id_vision(data, path=""):
|
||||||
|
"""Recursively check that no integers are leaked in ID-named fields."""
|
||||||
|
leaks = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for k, v in data.items():
|
||||||
|
current_path = f"{path}.{k}" if path else k
|
||||||
|
# Check fields that look like IDs
|
||||||
|
if k == "id" or k.endswith("_id") or k.endswith("_id_random"):
|
||||||
|
if isinstance(v, int):
|
||||||
|
leaks.append(f"{current_path}: {v} (Type: int)")
|
||||||
|
# Recurse
|
||||||
|
leaks.extend(check_id_vision(v, current_path))
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for i, item in enumerate(data):
|
||||||
|
leaks.extend(check_id_vision(item, f"{path}[{i}]"))
|
||||||
|
return leaks
|
||||||
|
|
||||||
|
def run_security_audit():
|
||||||
|
print("====================================================")
|
||||||
|
print(" V3 COMPREHENSIVE SECURITY & VISION AUDIT ")
|
||||||
|
print(f" Target: {API_ROOT}")
|
||||||
|
print("====================================================")
|
||||||
|
|
||||||
|
for obj_type, obj_id, is_public in TEST_OBJECTS:
|
||||||
|
print(f"\n--- Testing Object Type: {obj_type} ---")
|
||||||
|
|
||||||
|
# 1. READ LEAK CHECK (No context)
|
||||||
|
url = f"{API_ROOT}/v3/crud/{obj_type}/{obj_id}"
|
||||||
|
headers_unauth = {"x-aether-api-key": API_KEY}
|
||||||
|
resp_unauth = requests.get(url, headers=headers_unauth)
|
||||||
|
|
||||||
|
if is_public:
|
||||||
|
print_result(f"Unauth GET {obj_type}", resp_unauth.status_code == 200)
|
||||||
|
else:
|
||||||
|
print_result(f"Unauth GET {obj_type}", resp_unauth.status_code == 403, f"- Blocked: {resp_unauth.json().get('meta', {}).get('status_message')}")
|
||||||
|
|
||||||
|
# 2. VISION COMPLIANCE CHECK (With context)
|
||||||
|
headers_auth = {"x-aether-api-key": API_KEY, "x-account-id": ACCOUNT_A_RAND}
|
||||||
|
resp_auth = requests.get(url, headers=headers_auth)
|
||||||
|
|
||||||
|
if resp_auth.status_code == 200:
|
||||||
|
data = resp_auth.json().get('data', {})
|
||||||
|
leaks = check_id_vision(data)
|
||||||
|
print_result(f"Vision Compliance {obj_type}", len(leaks) == 0, f"- Leaks: {leaks if leaks else 'None'}")
|
||||||
|
else:
|
||||||
|
print_result(f"Auth GET {obj_type}", False, f"- Failed to get record for Vision check: {resp_auth.status_code}")
|
||||||
|
|
||||||
|
# 3. WRITE ISOLATION CHECK (PATCH someone else's record)
|
||||||
|
if not is_public:
|
||||||
|
headers_wrong_account = {"x-aether-api-key": API_KEY, "x-account-id": ACCOUNT_B_RAND}
|
||||||
|
# Attempt to rename a journal that doesn't belong to Account B
|
||||||
|
resp_patch = requests.patch(url, headers=headers_wrong_account, json={"notes": "Hacked!"})
|
||||||
|
print_result(f"Cross-Account Write Block {obj_type}", resp_patch.status_code == 403, f"- Status: {resp_patch.status_code}")
|
||||||
|
|
||||||
|
# 4. SEARCH LEAK CHECK (Wide open search)
|
||||||
|
print("\n--- Search Leakage Audit ---")
|
||||||
|
resp_search = requests.post(f"{API_ROOT}/v3/crud/journal/search", headers=headers_unauth, json={"and": []})
|
||||||
|
count = len(resp_search.json().get('data', []))
|
||||||
|
print_result("Search Leakage (Journal)", count == 0, f"- Found {count} records (Expected 0)")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
run_security_audit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ AUDIT CRASHED: {e}")
|
||||||
|
|
||||||
|
print("\n====================================================")
|
||||||
|
print(f"Audit completed in {time.time() - start_time:.2f}s")
|
||||||
|
print("====================================================")
|
||||||
90
tests/e2e/verify_v3_security_hardening.py
Normal file
90
tests/e2e/verify_v3_security_hardening.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
API_ROOT = "https://dev-api.oneskyit.com"
|
||||||
|
# Using the key provided in your examples
|
||||||
|
API_KEY = "dFP6J9DVj9hUgIMn-fNIqg"
|
||||||
|
# A known private journal ID from account 1
|
||||||
|
PRIVATE_JOURNAL_ID = "SWFK-48-89-90"
|
||||||
|
# A known public object type/ID
|
||||||
|
PUBLIC_FQDN = "dev-app.oneskyit.com"
|
||||||
|
# A known valid account ID random for testing restoration of access
|
||||||
|
VALID_ACCOUNT_ID_RAND = "_XY7DXtc9MY"
|
||||||
|
|
||||||
|
def print_result(label, success, message=""):
|
||||||
|
status = "✅ PASS" if success else "❌ FAIL"
|
||||||
|
print(f"[{status}] {label} {message}")
|
||||||
|
|
||||||
|
def test_hardened_search_leak():
|
||||||
|
"""Verify that search NO LONGER leaks private data when account_id is missing."""
|
||||||
|
print("\n--- Test 1: Global Leak Prevention (Search) ---")
|
||||||
|
url = f"{API_ROOT}/v3/crud/journal/search"
|
||||||
|
headers = {"x-aether-api-key": API_KEY}
|
||||||
|
# NO account header, NO JWT
|
||||||
|
payload = {"and": []}
|
||||||
|
|
||||||
|
resp = requests.post(url, headers=headers, json=payload)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json().get('data', [])
|
||||||
|
# Should be 0 because all journals in DB have an account_id,
|
||||||
|
# and we are now strictly filtering for account_id IS NULL.
|
||||||
|
success = (len(data) == 0)
|
||||||
|
print_result("Leak Blocked (Journal Search)", success, f"- Found {len(data)} records (Expected 0)")
|
||||||
|
else:
|
||||||
|
print_result("Leak Blocked (Journal Search)", False, f"- Unexpected Status: {resp.status_code}")
|
||||||
|
|
||||||
|
def test_strict_id_block():
|
||||||
|
"""Verify that GET ID correctly blocks private records when account_id is missing."""
|
||||||
|
print("\n--- Test 2: Strict Access Control (GET ID) ---")
|
||||||
|
url = f"{API_ROOT}/v3/crud/journal/{PRIVATE_JOURNAL_ID}"
|
||||||
|
headers = {"x-aether-api-key": API_KEY}
|
||||||
|
# NO account header, NO JWT
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
success = (resp.status_code == 403)
|
||||||
|
print_result("Access Denied (Journal GET ID)", success, f"- Status: {resp.status_code} (Expected 403)")
|
||||||
|
|
||||||
|
def test_bootstrap_exception():
|
||||||
|
"""Verify that public_read objects still work for bootstrapping."""
|
||||||
|
print("\n--- Test 3: Bootstrap Exception (Public Read) ---")
|
||||||
|
url = f"{API_ROOT}/v3/crud/site_domain/search"
|
||||||
|
headers = {"x-aether-api-key": API_KEY}
|
||||||
|
payload = {"and": [{"field": "fqdn", "op": "eq", "value": PUBLIC_FQDN}]}
|
||||||
|
|
||||||
|
resp = requests.post(url, headers=headers, json=payload)
|
||||||
|
|
||||||
|
success = (resp.status_code == 200 and len(resp.json().get('data', [])) > 0)
|
||||||
|
print_result("Bootstrap Allowed (Site Domain)", success, f"- Status: {resp.status_code}")
|
||||||
|
|
||||||
|
def test_restored_access():
|
||||||
|
"""Verify that providing the correct x-account-id restores access."""
|
||||||
|
print("\n--- Test 4: Restored Access (With Context) ---")
|
||||||
|
url = f"{API_ROOT}/v3/crud/journal/{PRIVATE_JOURNAL_ID}"
|
||||||
|
headers = {
|
||||||
|
"x-aether-api-key": API_KEY,
|
||||||
|
"x-account-id": VALID_ACCOUNT_ID_RAND
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
success = (resp.status_code == 200)
|
||||||
|
print_result("Access Restored (Journal with Header)", success, f"- Status: {resp.status_code}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Starting V3 Security Hardening Verification")
|
||||||
|
print(f"Target: {API_ROOT}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_hardened_search_leak()
|
||||||
|
test_strict_id_block()
|
||||||
|
test_bootstrap_exception()
|
||||||
|
test_restored_access()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ ERROR during test execution: {e}")
|
||||||
|
|
||||||
|
print("\nVerification completed.")
|
||||||
Reference in New Issue
Block a user