Arch: Finalize V3 Auth modularization and Unified Agent spec.

- Integrated zero-dependency Auth models and dependencies_v3.py.
- Successfully resolved circular dependency boot loops.
- Verified site_domain search exception via verify_v3_exceptions.py.
- Refined Unified Agent Architecture with Storage Layer and API-driven access details.
- Updated project roadmap and milestones in GEMINI.md.
This commit is contained in:
Scott Idem
2026-01-07 19:07:21 -05:00
parent 90c6b914fa
commit 59d5b81da0
7 changed files with 245 additions and 307 deletions

View File

@@ -4,38 +4,15 @@ import json
import urllib.parse
import time
import asyncio
from pydantic import BaseModel
import logging
log = logging.getLogger(__name__)
# NOTE: We are defining these locally to avoid circular dependency hell with lib_general_v3 for now.
class AccountContext(BaseModel):
account_id: Optional[int]
account_id_random: Optional[str]
administrator: bool = False
manager: bool = False
super: bool = False
auth_method: str = 'legacy_header'
class PaginationParams(BaseModel):
limit: int = 100
offset: int = 0
class StatusFilterParams(BaseModel):
enabled: str = 'enabled'
hidden: str = 'not_hidden'
class SerializationParams(BaseModel):
by_alias: bool = True
exclude_unset: bool = False
exclude_defaults: bool = False
exclude_none: bool = False
class DelayParams(BaseModel):
sleep_time_ms: int = 0
sleep_time_s: float = 0.0
from app.lib_general_v3 import (
AccountContext, get_account_context, get_account_context_optional,
PaginationParams, StatusFilterParams,
SerializationParams, DelayParams
)
from app.models.response_models import *
from app.models.api_crud_models import SearchFilter, SearchQuery
from app.ae_obj_types_def import obj_type_kv_li
@@ -43,82 +20,6 @@ from app.ae_obj_types_def import obj_type_kv_li
router = APIRouter()
# --- Local Dependencies ---
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),
) -> AccountContext:
from app.db_sql import redis_lookup_id_random
resolved_account_id = None
resolved_account_id_random = None
auth_method = 'guest'
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'
return AccountContext(
account_id=resolved_account_id,
account_id_random=resolved_account_id_random,
auth_method=auth_method,
administrator=(auth_method == 'bypass'),
manager=(auth_method == 'bypass'),
super=(auth_method == 'bypass')
)
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),
) -> AccountContext:
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token)
if ctx.auth_method == 'guest':
# Raise strict 403 if required
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.')
return ctx
def get_pagination_params(
limit: int = Query(100, ge=0),
offset: int = Query(0, ge=0),
) -> PaginationParams:
return PaginationParams(limit=limit, offset=offset)
def get_status_filter_params(
enabled: str = Query('enabled'),
hidden: str = Query('not_hidden'),
) -> StatusFilterParams:
return StatusFilterParams(enabled=enabled, hidden=hidden)
def get_serialization_params(
by_alias: bool = Query(True),
exclude_unset: bool = Query(False),
exclude_defaults: bool = Query(False),
exclude_none: bool = Query(False),
) -> SerializationParams:
return SerializationParams(by_alias=by_alias, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none)
def get_delay_params(
x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'),
delay_ms: Optional[int] = Query(0),
) -> DelayParams:
val = max(x_delay_ms or 0, delay_ms or 0)
return DelayParams(sleep_time_ms=val, sleep_time_s=val / 1000.0)
# --- Helpers ---
def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool:
@@ -176,7 +77,12 @@ def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Opt
def get_supported_filters(model: Any, status_filter: StatusFilterParams) -> StatusFilterParams:
if not model or not hasattr(model, "__fields__"):
return status_filter
adjusted = StatusFilterParams(enabled=status_filter.enabled, hidden=status_filter.hidden)
# We create a new instance to avoid side effects on the dependency object
from app.routers.dependencies_v3 import StatusFilterParams as SF
adjusted = SF()
adjusted.enabled = status_filter.enabled
adjusted.hidden = status_filter.hidden
if 'enable' not in model.__fields__:
adjusted.enabled = 'all'
if 'hide' not in model.__fields__:
@@ -192,7 +98,7 @@ def safe_json_loads(json_str: Optional[str]) -> Any:
# --- Routes ---
@router.get("/health", response_model=Resp_Body_Base)
async def health_check(delay: DelayParams = Depends(get_delay_params)):
async def health_check(delay: DelayParams = Depends()):
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
return mk_resp(data={"status": "V3 API is healthy!"})
@@ -252,8 +158,8 @@ async def get_obj(
obj_id: str = Path(min_length=11, max_length=22),
view: str = Query('default'),
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select
@@ -293,10 +199,10 @@ async def get_obj_li(
order_by_li: Optional[str] = None,
jp: Optional[Union[str, None]] = None,
account: AccountContext = Depends(get_account_context),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: StatusFilterParams = Depends(get_status_filter_params),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
pagination: PaginationParams = Depends(),
status_filter: StatusFilterParams = Depends(),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select
@@ -397,10 +303,10 @@ async def search_obj_li(
view: str = Query('default'),
order_by_li: Optional[str] = Query(None),
account: AccountContext = Depends(get_account_context_optional),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: StatusFilterParams = Depends(get_status_filter_params),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
pagination: PaginationParams = Depends(),
status_filter: StatusFilterParams = Depends(),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select
@@ -487,8 +393,8 @@ async def post_obj(
obj_type_l1: str = Path(min_length=2, max_length=50),
return_obj: Optional[bool] = True,
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import sql_insert, get_id_random, sql_select
@@ -542,8 +448,8 @@ async def patch_obj(
obj_id: str = Path(min_length=11, max_length=22),
return_obj: Optional[bool] = True,
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
@@ -590,7 +496,7 @@ async def delete_obj(
obj_id: str = Path(min_length=11, max_length=22),
method: str = Query('delete', regex='^(delete|hide|disable)$'),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(get_delay_params),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete
@@ -638,10 +544,10 @@ async def get_child_obj_li(
order_by_li: Optional[str] = None,
jp: Optional[Union[str, None]] = None,
account: AccountContext = Depends(get_account_context),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: StatusFilterParams = Depends(get_status_filter_params),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
pagination: PaginationParams = Depends(),
status_filter: StatusFilterParams = Depends(),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select
@@ -727,8 +633,8 @@ async def post_child_obj(
child_obj_type: str = Path(min_length=2, max_length=50),
return_obj: Optional[bool] = True,
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, get_id_random
@@ -791,8 +697,8 @@ async def get_child_obj(
child_obj_type: str = Path(min_length=2, max_length=50),
child_obj_id: str = Path(min_length=11, max_length=22),
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select
@@ -827,8 +733,8 @@ async def patch_child_obj(
child_obj_id: str = Path(min_length=11, max_length=22),
return_obj: Optional[bool] = True,
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
@@ -870,7 +776,7 @@ async def delete_child_obj(
child_obj_id: str = Path(min_length=11, max_length=22),
method: str = Query('delete', regex='^(delete|hide|disable)$'),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(get_delay_params),
delay: DelayParams = Depends(),
):
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete

View File

@@ -0,0 +1,104 @@
from fastapi import Depends, Header, HTTPException, Query, Response, status
from typing import Optional, Union
import logging
import asyncio
from app.models.auth_models import AccountContext
log = logging.getLogger(__name__)
# --- Account Context Dependencies ---
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),
) -> AccountContext:
"""
Resolves the account context but does not raise 403 on failure.
Uses DEFERRED imports to prevent circular dependency at startup.
"""
from app.db_sql import redis_lookup_id_random
resolved_account_id = None
resolved_account_id_random = None
auth_method = 'guest'
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'
return AccountContext(
account_id=resolved_account_id,
account_id_random=resolved_account_id_random,
auth_method=auth_method,
administrator=(auth_method == 'bypass'),
manager=(auth_method == 'bypass'),
super=(auth_method == 'bypass')
)
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),
) -> AccountContext:
"""Strict version of account context resolution."""
ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token)
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