Implement V3 API security hardening and multi-tenant data isolation

- Enhanced AuthContext with role-aware fields (administrator, manager, super).
- Implemented deferred database lookups for user roles in get_v3_auth_context.
- Added global account isolation in api_crud_v3.py using check_account_access and apply_forced_account_filter.
- Hardened all V3 CRUD endpoints (GET, POST, PATCH, DELETE) and nested routes with ownership verification.
- Enforced forced account filtering at the SQL level for Listing and Searching.
- Updated documentation with details on the new security and data isolation architecture.
This commit is contained in:
Scott Idem
2026-01-07 13:34:38 -05:00
parent 270712f905
commit 6d13b952c4
3 changed files with 157 additions and 5 deletions

View File

@@ -64,6 +64,9 @@ class AuthContext(BaseModel):
account_id_random: Optional[str] = None account_id_random: Optional[str] = None
user_id: Optional[int] = None user_id: Optional[int] = None
person_id: Optional[int] = None person_id: Optional[int] = None
administrator: bool = False
manager: bool = False
super: bool = False
auth_method: str = 'none' # 'jwt_header', 'jwt_query', 'legacy_header', 'bypass' auth_method: str = 'none' # 'jwt_header', 'jwt_query', 'legacy_header', 'bypass'
# Alias for backward compatibility with initial V3 implementation # Alias for backward compatibility with initial V3 implementation
@@ -83,8 +86,9 @@ def get_v3_auth_context(
Supports JWT in Authorization header (Bearer) OR 'jwt' query parameter. Supports JWT in Authorization header (Bearer) OR 'jwt' query parameter.
Falls back to legacy headers for backward compatibility. Falls back to legacy headers for backward compatibility.
""" """
# Defer import to break circular dependency # Defer imports to break circular dependency
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
from app.methods.user_methods import load_user_obj
# 1. Check for JWT (Header preferred, then Query for downloads) # 1. Check for JWT (Header preferred, then Query for downloads)
token = None token = None
@@ -101,13 +105,28 @@ def get_v3_auth_context(
payload = decode_jwt(settings.JWT_KEY, token) payload = decode_jwt(settings.JWT_KEY, token)
if payload: if payload:
logger.info(f"JWT Validated ({method}). User: {payload.get('user_id')}, Account: {payload.get('account_id')}") logger.info(f"JWT Validated ({method}). User: {payload.get('user_id')}, Account: {payload.get('account_id')}")
return AuthContext(
# Initialize AuthContext
ctx = AuthContext(
account_id=payload.get('account_id'), account_id=payload.get('account_id'),
account_id_random=payload.get('public_key'), # existing sign_jwt uses public_key for id_random account_id_random=payload.get('public_key'), # existing sign_jwt uses public_key for id_random
user_id=payload.get('user_id'), user_id=payload.get('user_id'),
person_id=payload.get('person_id'), person_id=payload.get('person_id'),
auth_method=method auth_method=method
) )
# Populate roles if user_id is present
if ctx.user_id:
try:
user = load_user_obj(user_id=ctx.user_id)
if user:
ctx.administrator = getattr(user, 'administrator', False)
ctx.manager = getattr(user, 'manager', False)
ctx.super = getattr(user, 'super', False)
except Exception as e:
logger.warning(f"Failed to load user roles for user {ctx.user_id}: {e}")
return ctx
else: else:
logger.warning(f"Invalid or expired JWT provided via {method}") logger.warning(f"Invalid or expired JWT provided via {method}")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired authentication token.") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired authentication token.")
@@ -129,7 +148,10 @@ def get_v3_auth_context(
logger.info("Authentication bypassed via X-No-Account-ID") logger.info("Authentication bypassed via X-No-Account-ID")
return AuthContext( return AuthContext(
account_id_random='--- NO ACCOUNT ---', account_id_random='--- NO ACCOUNT ---',
auth_method='bypass' auth_method='bypass',
administrator=True, # Bypass usually implies admin for dev/utility
manager=True,
super=True
) )
# 4. No Auth Found # 4. No Auth Found

View File

@@ -22,6 +22,42 @@ from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_updat
router = APIRouter() router = APIRouter()
def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool:
"""
Verifies if the current account context has access to the given SQL result.
Always returns True for super users.
"""
if account.super or account.auth_method == 'bypass':
return True
if not account.account_id:
return False
res_account_id = None
if isinstance(sql_result, dict):
if obj_name == 'account':
res_account_id = sql_result.get('id')
else:
res_account_id = sql_result.get('account_id')
if res_account_id is not None and res_account_id != account.account_id:
return False
return True
def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountContext, model: Any, obj_name: str) -> Dict:
"""
Adds a forced account_id filter to the query dictionary if the user is not a super user.
"""
forced = and_qry_dict or {}
if account.super or account.auth_method == 'bypass':
return forced
if obj_name == 'account':
forced['id'] = account.account_id
elif model and hasattr(model, '__fields__') and 'account_id' in model.__fields__:
forced['account_id'] = account.account_id
return forced
def safe_json_loads(json_str: Optional[str]) -> Any: def safe_json_loads(json_str: Optional[str]) -> Any:
""" """
Safely load a JSON string, handling 'undefined' or invalid formats Safely load a JSON string, handling 'undefined' or invalid formats
@@ -226,6 +262,10 @@ async def get_obj(
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.")
if sql_result := sql_select(table_name=table_name, record_id=record_id): if sql_result := sql_select(table_name=table_name, record_id=record_id):
# Security Check
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( resp_data = base_name(**sql_result).dict(
by_alias=serialization.by_alias, by_alias=serialization.by_alias,
exclude_unset=serialization.exclude_unset, exclude_unset=serialization.exclude_unset,
@@ -287,6 +327,11 @@ async def get_obj_li(
if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id):
return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.") return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.")
# Explicit Account isolation
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.")
obj_cfg = obj_type_kv_li[obj_name] obj_cfg = obj_type_kv_li[obj_name]
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
@@ -296,6 +341,9 @@ async def get_obj_li(
order_by_li = filter_order_by(order_by_li, base_name, table_name) order_by_li = filter_order_by(order_by_li, base_name, table_name)
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
# Forced account isolation
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name)
if for_obj_type and for_obj_id: if for_obj_type and for_obj_id:
resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type)
@@ -395,6 +443,19 @@ async def search_obj_li(
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
searchable_fields = obj_cfg.get('searchable_fields') searchable_fields = obj_cfg.get('searchable_fields')
# Explicit Account isolation
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.")
# Forced account isolation for the search query itself
# We inject the account_id into the SearchQuery object if it's not a super user
if not account.super and account.auth_method != 'bypass' and account.account_id:
if obj_name == 'account':
search_query.and_filter['id'] = account.account_id
elif base_name and hasattr(base_name, '__fields__') and 'account_id' in base_name.__fields__:
search_query.and_filter['account_id'] = account.account_id
if for_obj_type and for_obj_id: if for_obj_type and for_obj_id:
resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type)
if not resolved_for_obj_id: if not resolved_for_obj_id:
@@ -474,6 +535,16 @@ async def post_obj(
if not table_name_insert or not input_model or not table_name_select or not output_model: if not table_name_insert or not input_model or not table_name_select or not output_model:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.")
# Security: Force account_id for non-super users if model supports it
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
elif obj_name == 'account':
# Users can't create accounts unless they are super (usually)
# But if they can, it should be their own ID?
# Actually, account creation is usually a super-admin task.
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted to super administrators.")
try: try:
validated_obj = input_model(**obj_data) validated_obj = input_model(**obj_data)
except Exception as e: except Exception as e:
@@ -537,6 +608,13 @@ async def patch_obj(
if not record_id: if not record_id:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.")
# Security Check
if existing_obj := sql_select(table_name=table_name_select, record_id=record_id):
if not check_account_access(existing_obj, account, obj_name):
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.")
else:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.")
data_to_update = obj_data data_to_update = obj_data
if sql_update_result := sql_update(data=data_to_update, table_name=table_name_update, record_id=record_id): if sql_update_result := sql_update(data=data_to_update, table_name=table_name_update, record_id=record_id):
@@ -576,6 +654,7 @@ async def delete_obj(
obj_cfg = obj_type_kv_li[obj_name] obj_cfg = obj_type_kv_li[obj_name]
table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
if not table_name_delete: if not table_name_delete:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.")
@@ -584,6 +663,13 @@ async def delete_obj(
if not record_id: if not record_id:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.")
# Security Check
if existing_obj := sql_select(table_name=table_name_select, record_id=record_id):
if not check_account_access(existing_obj, account, obj_name):
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.")
else:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.")
if method == 'hide': if method == 'hide':
if sql_update(table_name=table_name_delete, record_id=record_id, data={'hide': True}): if sql_update(table_name=table_name_delete, record_id=record_id, data={'hide': True}):
return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' hidden successfully.") return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' hidden successfully.")
@@ -645,8 +731,8 @@ async def get_child_obj_li(
order_by_li = safe_json_loads(order_by_li) order_by_li = safe_json_loads(order_by_li)
obj_name = child_obj_type obj_name = child_obj_type
if obj_name not in obj_type_kv_li: if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).")
obj_cfg = obj_type_kv_li[obj_name] obj_cfg = obj_type_kv_li[obj_name]
table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
@@ -662,6 +748,18 @@ async def get_child_obj_li(
if not resolved_parent_id: if not resolved_parent_id:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{parent_obj_type}' with ID '{parent_obj_id}' not found.") return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{parent_obj_type}' with ID '{parent_obj_id}' not found.")
# Parent Security Check
parent_cfg = obj_type_kv_li[parent_obj_type]
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
if not check_account_access(parent_sql_res, account, parent_obj_type):
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent object.")
else:
return mk_resp(data=False, status_code=404, response=response, status_message="Parent object not found.")
# Child security: Forced account isolation if child supports it
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name)
sql_result = sql_select( sql_result = sql_select(
table_name=table_name, table_name=table_name,
field_name=f'{parent_obj_type}_id', field_name=f'{parent_obj_type}_id',
@@ -724,12 +822,26 @@ async def post_child_obj(
if not resolved_parent_id: if not resolved_parent_id:
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.") return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
# Parent Security Check
parent_cfg = obj_type_kv_li[parent_obj_type]
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
if not check_account_access(parent_sql_res, account, parent_obj_type):
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent object.")
else:
return mk_resp(data=False, status_code=404, response=response, status_message="Parent object not found.")
obj_cfg = obj_type_kv_li[child_obj_type] obj_cfg = obj_type_kv_li[child_obj_type]
table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl')) input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
# Security: Force account_id for non-super users if child supports it
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
try: try:

View File

@@ -55,3 +55,21 @@ The following objects have been migrated to the modern V3 configuration and are
- `hosted_file`, `post`, `post_comment` - `hosted_file`, `post`, `post_comment`
- `person`, `user` - `person`, `user`
- `lu_country`, `lu_country_subdivision`, `lu_time_zone` - `lu_country`, `lu_country_subdivision`, `lu_time_zone`
## 5. Security and Data Isolation
Implemented in Jan 2026, the V3 architecture enforces strict data isolation and role-aware authorization.
### Role-Aware Authorization (`AuthContext`)
- **Deferred User Lookup**: When a JWT is provided, the API performs a deferred database lookup via `load_user_obj` to populate `administrator`, `manager`, and `super` flags.
- **Role Scoping**: These flags are used to bypass account isolation for support and system maintenance tasks.
### Multi-Tenant Isolation
- **Forced Account Filtering**: For all non-super users, the API automatically injects an `account_id` filter into every list and search query. This is enforced at the SQL level via `apply_forced_account_filter`.
- **Post-Retrieval Verification**: Single object retrievals (`GET`), updates (`PATCH`), and deletions (`DELETE`) include a secondary ownership check (`check_account_access`). If a user attempts to access an ID belonging to another account, they receive a `403 Forbidden` response.
- **Hierarchical Verification**: Child/Nested endpoints verify the ownership of the parent object before allowing operations on children, preventing cross-tenant "sideways" traversal.
- **Creation Guard**: When creating records (`POST`), the user's `account_id` is automatically forced onto the new record, preventing a user from creating data for another account.
### Bypass and Utility Access
- **X-No-Account-ID**: A specialized header used by development utilities and administrative scripts to bypass standard account isolation. This header grants `super` permissions and should only be used in trusted internal environments.
- **JWT Query Parameter**: Supported for direct file downloads and sharing links where custom headers cannot be provided.