security(v3): harden multi-tenant isolation and enhance failure feedback

This commit is contained in:
Scott Idem
2026-02-13 18:45:20 -05:00
parent 61e17f1efa
commit 2266f149f7
15 changed files with 389 additions and 317 deletions

View File

@@ -113,7 +113,12 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
except:
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
return forced
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:

View File

@@ -1,7 +1,7 @@
import datetime, pytz
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.lib_general import log, logging
@@ -14,15 +14,39 @@ class Archive_Base(BaseModel):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
id_random: Optional[str] = Field(
**base_fields['archive_id_random'],
alias = 'archive_id_random',
)
id: Optional[int] = Field(
alias = 'archive_id'
)
account_id_random: Optional[str]
account_id: Optional[int]
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['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'])
# --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='archive_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('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: Optional[str]
@@ -70,22 +94,8 @@ class Archive_Base(BaseModel):
_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:
underscore_attrs_are_private = True
allow_population_by_field_name = True
allow_population_by_field_name = False
fields = base_fields
# ### END ### API Archive Models ### Archive_Base() ###

View File

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

View File

@@ -19,6 +19,7 @@ class Event_Badge_Base(BaseModel):
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
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'])
# 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) ---
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_only: 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['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 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
@@ -56,7 +59,7 @@ class Event_Badge_Base(BaseModel):
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
# 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)
if val is not None and not isinstance(val, str):
values[k] = None
@@ -201,11 +204,13 @@ class Event_Badge_Basic_Base(BaseModel):
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
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_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) ---
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_person_id_random: Optional[str] = Field(None, exclude=True)
@@ -222,11 +227,12 @@ class Event_Badge_Basic_Base(BaseModel):
values['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 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
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)
if val is not None and not isinstance(val, str):
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):

View File

@@ -19,6 +19,7 @@ class Event_File_Base(BaseModel):
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
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'])
# Generic Relational target
@@ -34,6 +35,7 @@ class Event_File_Base(BaseModel):
# --- Standardized Legacy / Internal IDs (Excluded) ---
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)
for_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
# (Field Name, Table Name)
id_map = [
('account_id', 'account'),
('hosted_file_id', 'hosted_file'),
('event_id', 'event'),
('event_exhibit_id', 'event_exhibit'),

View File

@@ -19,9 +19,16 @@ class Event_Location_Base(BaseModel):
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_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(
# alias = 'event_location_code'
)
@@ -108,13 +115,15 @@ class Event_Location_Base(BaseModel):
values['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'):
values['event_id'] = e_rid
if et_rid := values.get('event_track_id_random'):
values['event_track_id'] = et_rid
# 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:
del values[k]

View File

@@ -27,6 +27,9 @@ class Event_Presenter_Base(BaseModel):
alias = 'event_presenter_id'
)
account_id_random: Optional[str]
account_id: Optional[int]
external_id: 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 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)
def event_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
@@ -253,6 +263,9 @@ class Event_Presenter_Out_Base(BaseModel):
alias = 'event_presenter_id'
)
account_id_random: Optional[str]
account_id: Optional[int]
external_id: Optional[str]
code: Optional[str]

View File

@@ -23,12 +23,22 @@ class Event_Session_Base(BaseModel):
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_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'])
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'])
# --- 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(
# alias = 'event_session_external_id'
)
@@ -184,6 +194,8 @@ class Event_Session_Base(BaseModel):
values['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'):
values['event_id'] = e_rid
if el_rid := values.get('event_location_id_random'):
@@ -196,7 +208,7 @@ class Event_Session_Base(BaseModel):
values['poc_person_id'] = pp_rid
# 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:
del values[k]

View File

@@ -1,7 +1,7 @@
import datetime, pytz
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.lib_general import log, logging
@@ -15,16 +15,38 @@ class Site_Base(BaseModel):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
id_random: Optional[str] = Field(
**base_fields['site_id_random'],
alias = 'site_id_random',
)
id: Optional[int] = Field(
alias = 'site_id'
)
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['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'])
account_id_random: Optional[str]
account_id: Optional[int]
# --- Standardized Legacy / Internal IDs (Excluded) ---
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]
@@ -101,35 +123,8 @@ class Site_Base(BaseModel):
_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:
underscore_attrs_are_private = True
allow_population_by_field_name = True
allow_population_by_field_name = False
fields = base_fields
# ### END ### API Site Models ### Site_Base() ###

View File

@@ -125,7 +125,8 @@ cms_obj_li = {
'searchable_fields': [
'id', 'account_id', 'site_id',
'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'
],
},
}

View File

@@ -110,7 +110,7 @@ async def get_obj(
obj_type_l1: str = Path(min_length=2, max_length=50),
obj_id: str = Path(min_length=11, max_length=22),
view: str = Query('default'),
account: AccountContext = Depends(get_account_context),
account: AccountContext = Depends(get_account_context_optional),
serialization: SerializationParams = 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 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):
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)
return mk_resp(data=resp_data, response=response)
else:
@@ -334,7 +339,7 @@ async def search_obj_li(
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 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 obj_name == 'account':
search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id))

View File

@@ -38,6 +38,7 @@ def get_account_context_optional(
resolved_token_payload = None
auth_method = 'guest'
api_key_authorized = False
auth_error = None
# 1. Mandatory Machine Auth (API Key)
# 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):
api_key_authorized = True
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:
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:
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)
if api_key_authorized:
# Default to machine auth if no account context is provided
auth_method = 'api_key'
auth_error = "Account context required for this operation."
# A. Resolve via Account ID Header
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):
resolved_account_id = looked_up_id
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
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):
resolved_account_id = looked_up_id
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:
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)
if auth_method in ['guest', 'api_key']:
resolved_account_id_random = x_no_account_id_token
if auth_method in ['guest', 'api_key', 'jwt_token'] and auth_error:
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_random = x_no_account_id_token
auth_method = 'token_query'
auth_error = None
# 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']:
resolved_account_id = 1
resolved_account_id_random = '--- NO ACCOUNT ---'
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_manager = (auth_method == 'bypass')
@@ -122,7 +143,8 @@ def get_account_context_optional(
administrator=is_admin,
manager=is_manager,
super=is_super,
token_payload=resolved_token_payload
token_payload=resolved_token_payload,
auth_error=auth_error
)
def get_account_context(
@@ -134,8 +156,9 @@ def get_account_context(
) -> AccountContext:
"""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)
if ctx.auth_method == 'guest':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.')
if ctx.auth_method == 'guest' or (ctx.account_id is None and not ctx.super):
reason = ctx.auth_error or "Account context required."
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=reason)
return ctx