Security: Enforce mandatory API Keys for V3, fix search logic, and update frontend guide

This commit is contained in:
Scott Idem
2026-01-19 14:11:13 -05:00
parent d8b0c3b0a4
commit cad0d2e867
5 changed files with 325 additions and 43 deletions

View File

@@ -166,11 +166,23 @@ def sql_search_qry_part(
data[p_name] = query_node.query_string
elif searchable_fields:
like_clauses = []
# Fields to exclude from a generic text 'q' search (numeric, technical, or date fields)
exclude_patterns = [
'enable', 'hide', 'priority', 'sort', 'group',
'created_on', 'updated_on'
]
for field in searchable_fields:
if not any(x in field for x in ['_id', 'enable', 'hide', 'priority', 'sort', 'group', 'created_on', 'updated_on']):
f_p_name = get_param_name()
like_clauses.append(f"`{field}` LIKE :{f_p_name}")
data[f_p_name] = f"%{query_node.query_string}%"
# Exclude exact internal integer IDs (ending in _id)
if field.endswith('_id') or field == 'id':
continue
# Exclude other technical/meta fields
if any(x == field for x in exclude_patterns):
continue
f_p_name = get_param_name()
like_clauses.append(f"`{field}` LIKE :{f_p_name}")
data[f_p_name] = f"%{query_node.query_string}%"
if like_clauses: clauses.append(f"({' OR '.join(like_clauses)})")
for filter_attr in ['and_filters', 'or_filters']:
if hasattr(query_node, filter_attr) and getattr(query_node, filter_attr):

View File

@@ -12,35 +12,63 @@ log = logging.getLogger(__name__)
def get_account_context_optional(
x_account_id: Optional[str] = Header(None, min_length=11, max_length=22),
x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100),
x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22),
x_no_account_id_token: Optional[str] = Query(None, alias='jwt', min_length=11, max_length=22),
x_aether_api_key: Optional[str] = Header(None, min_length=11, max_length=22),
) -> AccountContext:
"""
Resolves the account context but does not raise 403 on failure.
Resolves the account context and enforces API Key validation.
Uses DEFERRED imports to prevent circular dependency at startup.
"""
from app.db_sql import redis_lookup_id_random
from app.db_sql import redis_lookup_id_random, sql_select
from datetime import datetime
resolved_account_id = None
resolved_account_id_random = None
auth_method = 'guest'
api_key_authorized = False
if x_account_id:
resolved_account_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
auth_method = 'legacy_header'
elif x_no_account_id_token:
resolved_account_id_random = x_no_account_id_token
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token):
resolved_account_id = looked_up_id
auth_method = 'token_query'
elif x_no_account_id:
resolved_account_id = None
resolved_account_id_random = '--- NO ACCOUNT ---'
auth_method = 'bypass'
# 1. Mandatory Machine Auth (API Key)
# This identifies the script/app, regardless of the user/account context.
if x_aether_api_key:
sql = "SELECT * FROM api_key WHERE (public_key = :key OR secret_key = :key) LIMIT 1"
if api_key_rec := sql_select(sql=sql, data={'key': x_aether_api_key}):
if api_key_rec.get('enable'):
now = datetime.now()
enable_from = api_key_rec.get('enable_from')
enable_to = api_key_rec.get('enable_to')
if (not enable_from or enable_from <= now) and (not enable_to or now <= enable_to):
api_key_authorized = True
else:
log.warning(f"Security: API Key {x_aether_api_key} expired/not yet valid.")
else:
log.warning(f"Security: API Key {x_aether_api_key} is disabled.")
else:
log.warning(f"Security: API Key {x_aether_api_key} not found.")
# 2. Context Resolution (Only if API Key is valid)
if api_key_authorized:
# A. Resolve via Account ID Header
if x_account_id:
resolved_account_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
auth_method = 'legacy_header'
# B. Resolve via JWT / Token Query Param
elif x_no_account_id_token:
resolved_account_id_random = x_no_account_id_token
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token):
resolved_account_id = looked_up_id
auth_method = 'token_query'
# C. Resolve via Administrative Bypass
elif x_no_account_id and x_no_account_id.lower() not in ['false', '0', 'null', 'undefined', 'none', 'no_account_id_here']:
resolved_account_id = None
resolved_account_id_random = '--- NO ACCOUNT ---'
auth_method = 'bypass'
return AccountContext(
account_id=resolved_account_id,
account_id=resolved_account_id,
account_id_random=resolved_account_id_random,
auth_method=auth_method,
administrator=(auth_method == 'bypass'),
@@ -51,10 +79,67 @@ def get_account_context_optional(
def get_account_context(
x_account_id: Optional[str] = Header(None, min_length=11, max_length=22),
x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100),
x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22),
x_no_account_id_token: Optional[str] = Query(None, alias='jwt', min_length=11, max_length=22),
x_aether_api_key: Optional[str] = Header(None, min_length=11, max_length=22),
) -> AccountContext:
"""Strict version of account context resolution."""
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token)
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token, x_aether_api_key)
if ctx.auth_method == 'guest':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.')
return ctx
# --- Shared Pagination & Status Dependencies ---
class PaginationParams:
def __init__(
self,
limit: int = Query(100, ge=0),
offset: int = Query(0, ge=0),
):
self.limit = limit
self.offset = offset
class StatusFilterParams:
def __init__(
self,
enabled: str = Query('enabled'),
hidden: str = Query('not_hidden'),
):
self.enabled = enabled
self.hidden = hidden
class SerializationParams:
def __init__(
self,
by_alias: bool = Query(True),
exclude_unset: bool = Query(False),
exclude_defaults: bool = Query(False),
exclude_none: bool = Query(False),
):
self.by_alias = by_alias
self.exclude_unset = exclude_unset
self.exclude_defaults = exclude_defaults
self.exclude_none = exclude_none
class DelayParams:
def __init__(
self,
x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'),
delay_ms: Optional[int] = Query(0),
):
val = max(x_delay_ms or 0, delay_ms or 0)
self.sleep_time_ms = val
self.sleep_time_s = val / 1000.0
def get_account_context(
x_account_id: Optional[str] = Header(None, min_length=11, max_length=22),
x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100),
x_no_account_id_token: Optional[str] = Query(None, alias='jwt', min_length=11, max_length=22),
x_aether_api_key: Optional[str] = Header(None, min_length=11, max_length=22),
) -> 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)
if ctx.auth_method == 'guest':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.')
return ctx