diff --git a/GEMINI.md b/GEMINI.md index 86f7b60..b0182d2 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -11,55 +11,43 @@ I am the **primary orchestrator and main helper** for the development of the **U - **Owner/Developer:** Scott Idem (user). - **System Name:** Aether (AE). - **Purpose:** Events Presentation Management, Events Badge Printing, Leads, Attendee Tracking, Presentation Launcher, Journals, Archives, Posts. -- **Started:** Mid-2018. -- **Frontend History:** Python Flask -> Svelte (current: SvelteKit). - **Current API Version (FastAPI):** v4.9.0. - **V3 Implementation:** Modern parallel CRUD and Search endpoints under `/v3/crud`. -### API Versioning & Strategy - -- `/crud` (v1): Legacy, still used by older frontend parts. -- `/v2/crud` (v2.5): Modern, preferred, and mostly functional endpoint. -- `/v3/crud`: The goal of this project phase. A new, parallel implementation with a refined structure and advanced search. **Runs alongside v1 and v2.** - ### Technical Learnings -- **Startup Errors & Logging:** The "worker failed to boot" error is often an import-time error or a logging configuration failure. - - **Root Cause:** If `logging.config.dictConfig` fails (e.g., due to missing `/logs` directories in Docker), the entire application crashes. -- **Circular Dependencies during Refactoring:** Even deferred imports can trigger boot failures during FastAPI's introspection phase if the module structure is fragile. "Isolation Mode" (local definitions in routers) is a confirmed temporary fix. -- **V3 API Dependencies:** Standardized `Response` injection should use plain type hints (e.g., `response: Response`) to avoid router initialization failures. +- **Circular Dependencies Fixed**: Successfully resolved the fragile startup dependency chain by isolating Auth models and using strictly deferred DB imports in a dedicated `dependencies_v3.py` module. +- **Bootstrap Paradox Solved**: Implemented a guest-access exception for `site_domain` search, allowing the frontend to resolve site context without a JWT. +- **VS Code Optimization**: Configured workspace settings to suppress markdownlint noise and enforce 4-space indentation for cleaner documentation. -### V3 Architectural Progress (Jan 2026) +## Session Learnings & Progress (Jan 7, 2026) - MILESTONE -- **Modular Object Definitions:** Monolithic `ae_obj_types_def.py` refactored into domain-specific files in `app/object_definitions/`. -- **Advanced Search (POST):** Implemented `POST /v3/crud/{obj}/search` supporting recursive AND/OR grouping and standardized full-text search via the `q` property. -- **Security Hardening:** Implemented a 5-level recursion depth limit and a field allowlist (`searchable_fields`) for the Search API. +### 1. Stability & Architecture +- [x] **Permanent Dependency Fix**: Migrated `AccountContext` and Auth logic to dedicated modules (`auth_models.py`, `dependencies_v3.py`). This permanently resolved the "Worker failed to boot" issues. +- [x] **Modularized `lib_general.py`**: Successfully extracted core functionalities into specialized modules: + - `lib_email.py` (SMTP/Email) + - `lib_export.py` (CSV/Excel Exports) + - `lib_jwt.py` (JWT encoding/decoding) + - `lib_hash.py` (Argon2 hashing) -## Session Learnings & Progress (Jan 2-7, 2026) +### 2. V3 Search & Security +- [x] **Site Domain Exception**: Implemented and verified unauthenticated search for `site_domain`. +- [x] **Strict Multi-Tenant Isolation**: Enforced `account_id` filtering at the database level for all other V3 endpoints. -### V3 API Security Hardening (Jan 7, 2026) - MILESTONE -- **Mandatory JWT Authentication**: Successfully implemented strict multi-tenant isolation across all V3 CRUD and Search endpoints. - - **Account Isolation**: results are automatically filtered by `account_id` from the JWT. - - **Bootstrap Paradox Exception**: `site_domain` search is explicitly allowed for unauthenticated guests to unblock site context resolution. - -### Unified Agent Architecture -- **Refined Specification**: Incorporated feedback from the Frontend Svelte agent. The Unified Agent will handle **Automated Schema Synchronization**, **Log Stream Aggregation**, and **Automated Lifecycle Management**. - -### Infrastructure & Progress -- [x] **Modularize `lib_general.py`**: Successfully extracted Email, Export, JWT, and Hash functions into specialized modules (`lib_email.py`, `lib_export.py`, `lib_jwt.py`, `lib_hash.py`). +### 3. Unified Agent Platform +- [x] **Initialized `aether_platform`**: Created the orchestrator root at `/home/scott/OSIT_dev/aether_platform/`. +- [x] **Established Meta-Structure**: Linked `ae_api`, `ae_app`, and `ae_env` into the platform root via symbolic links. +- [x] **Unified Agent Specification**: Published and refined the `UNIFIED_AGENT_ARCH.md` incorporating frontend agent feedback. ## Current To-Do List ### 1. High Priority & Urgent -- [ ] **Initialize `aether_platform` Project** (Priority: High): Create the root directory at `/home/scott/OSIT_dev/aether_platform/` and establish the initial meta-structure. -- [ ] **Unified Agent Architecture Document** (Priority: High): Refine and synchronize the final spec (Draft Done). -- [ ] **Permanent Dependency Fix** (Priority: Urgent): Migrate `AccountContext` and Auth logic to a dedicated module. +- [ ] **Unified Agent Core Logic**: Plan the implementation of the orchestrator's cross-stack diagnostic tools. +- [ ] **Docker MCP Integration**: Re-attempt environment diagnostics using the correct python path (`./env_mcp/bin/python`). -### 2. Infrastructure & Environment -- [ ] **Docker MCP Integration**: Re-attempt diagnostics using the correct python path (`./env_mcp/bin/python`). -- [ ] **Agent Bridge Repair**: Resolve the `psutil` or syntax issues in `app/routers/agent_bridge.py`. -- [ ] **Nginx Configuration**: Resolve 404 errors on Port 8888 routes. +### 2. Infrastructure & Technical Debt +- [ ] **Agent Bridge Repair**: Fix the `psutil` or syntax issues in `agent_bridge.py`. +- [ ] **Nginx Configuration**: Finalize Port 8888 route proxying if needed. ### Workflow & Collaboration -- **`GEMINI.md` Strategy:** Context flows up the tree. -- **Agents Sync (rsync):** Shared documentation and notifications pushed to `~/agents_sync/`. -- **Home Server:** Remote proxy at `https://dev-api.oneskyit.com`.- [x] **Establish Symbolic Links**: Linked API, App, and Env into aether_platform. +- **Storage**: Critical assets at `/home/scott/OSIT/hosted_files/` (Synced via Syncthing). Files are often accessed directly via API download endpoints. +- **Agents Sync**: Shared documentation and notifications pushed to `~/agents_sync/`. \ No newline at end of file diff --git a/app/lib_general_v3.py b/app/lib_general_v3.py index 53328a0..1d5534f 100644 --- a/app/lib_general_v3.py +++ b/app/lib_general_v3.py @@ -1,11 +1,8 @@ """ This file contains general utility functions and helpers specifically for API v3. -It aims to provide a clean slate for new methods and refactor existing ones from lib_general.py -that are relevant to the v3 API, while removing unused or outdated functionalities. +Refactored 2026-01-07 to move Auth logic to dependencies_v3.py to fix circular dependencies. """ -# Standard library imports -import time import logging from typing import ( Any, @@ -15,7 +12,6 @@ from typing import ( Union, ) -# Third-party imports from fastapi import ( APIRouter, Depends, @@ -29,143 +25,23 @@ from fastapi import ( from pydantic import ( BaseModel, Field, - ValidationError, - computed_field, - model_validator, ) -# Internal imports (from this project) +# Re-import from the new central auth models +from app.models.auth_models import AccountContext +# Import the dependency functions for backward compatibility in existing v3 routes +from app.routers.dependencies_v3 import ( + get_account_context, + get_account_context_optional, + PaginationParams, + StatusFilterParams, + SerializationParams, + DelayParams +) + from app.config import settings -from app.db_sql import redis_lookup_id_random from app.log import get_logger logger = get_logger(__name__) - -# --- Pydantic Model for Account Context --- -class AccountContext(BaseModel): - account_id: Optional[int] - account_id_random: Optional[str] - - -# --- Dependency Function for Account Context --- -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), # Assuming 'bypass' or similar string - x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), -) -> AccountContext: - """ - Resolves the account context from headers/query parameters with defined precedence. - Precedence: x_account_id (header) > x_no_account_id_token (query) > x_no_account_id (header flag) - Raises HTTPException 403 if no valid account is found and no bypass is indicated. - """ - logger.setLevel(logging.DEBUG) # Adjust as needed - logger.debug(locals()) - - resolved_account_id = None - resolved_account_id_random = None - - if x_account_id: - # Primary check: x_account_id header - 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 - logger.info(f'Found account from x_account_id header: {resolved_account_id}') - else: - logger.warning(f'Invalid x_account_id header provided: {x_account_id}') - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-Account-ID header.') - elif x_no_account_id_token: - # Secondary check: x_no_account_id_token query parameter - 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 - logger.info(f'Found account from x_no_account_id_token query: {resolved_account_id}') - else: - logger.warning(f'Invalid x_no_account_id_token query provided: {x_no_account_id_token}') - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-No-Account-ID-Token query parameter.') - elif x_no_account_id: - # Tertiary check: x_no_account_id header for bypass - # For now, just presence indicates bypass. Can add a specific value check later if needed. - logger.info(f'X-No-Account-ID header found: {x_no_account_id}. Proceeding without specific account context.') - resolved_account_id = None # Explicitly None for "no specific account" - resolved_account_id_random = '--- NO ACCOUNT ---' - else: - logger.warning('No valid account context provided via X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.') - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required. Please provide X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.') - - return AccountContext(account_id=resolved_account_id, account_id_random=resolved_account_id_random) - - -# --- Pydantic Model for Pagination --- -class PaginationParams(BaseModel): - limit: int = 100 # Default limit - offset: int = 0 - -# --- Dependency Function for Pagination --- -def get_pagination_params( - limit: int = Query(100, ge=0, description="Maximum number of items to return"), - offset: int = Query(0, ge=0, description="Number of items to skip (for pagination)"), -) -> PaginationParams: - return PaginationParams(limit=limit, offset=offset) - - -# --- Pydantic Model for Status Filtering --- -class StatusFilterParams(BaseModel): - enabled: str = 'enabled' # 'enabled', 'disabled', 'all' - hidden: str = 'not_hidden' # 'hidden', 'not_hidden', 'all' - -# --- Dependency Function for Status Filtering --- -def get_status_filter_params( - enabled: str = Query('enabled', description="Filter by object enabled status ('enabled', 'disabled', 'all')"), - hidden: str = Query('not_hidden', description="Filter by object hidden status ('hidden', 'not_hidden', 'all')"), -) -> StatusFilterParams: - allowed_enabled_values = {'enabled', 'disabled', 'all'} - allowed_hidden_values = {'hidden', 'not_hidden', 'all'} - - if enabled not in allowed_enabled_values: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid value for 'enabled'. Must be one of {list(allowed_enabled_values)}." - ) - if hidden not in allowed_hidden_values: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid value for 'hidden'. Must be one of {list(allowed_hidden_values)}." - ) - return StatusFilterParams(enabled=enabled, hidden=hidden) - - -# --- Pydantic Model for Serialization Options --- -class SerializationParams(BaseModel): - by_alias: bool = True - exclude_unset: bool = False - exclude_defaults: bool = False # Added based on common_route_params - exclude_none: bool = False # Added based on common_route_params - -# --- Dependency Function for Serialization Options --- -def get_serialization_params( - by_alias: bool = Query(True, description="Whether to use field aliases for serialization"), - exclude_unset: bool = Query(False, description="Whether to exclude unset fields from the response"), - exclude_defaults: bool = Query(False, description="Whether to exclude fields with their default values from the response"), - exclude_none: bool = Query(False, description="Whether to exclude fields that are None from the response"), -) -> SerializationParams: - return SerializationParams( - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) - - -# --- Pydantic Model for Delay --- -class DelayParams(BaseModel): - sleep_time_ms: int = 0 # Raw delay value in ms - sleep_time_s: float = 0.0 # Converted to seconds for time.sleep() - -# --- Dependency Function for Delay --- -def get_delay_params( - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms', description="Delay response for X milliseconds (header)"), - delay_ms: Optional[int] = Query(0, description="Delay response for X milliseconds (query parameter)"), -) -> DelayParams: - calculated_delay_ms = max(x_delay_ms or 0, delay_ms or 0) - return DelayParams(sleep_time_ms=calculated_delay_ms, sleep_time_s=calculated_delay_ms / 1000.0) \ No newline at end of file +# Note: Dependency function implementations have moved to app/routers/dependencies_v3.py diff --git a/app/models/auth_models.py b/app/models/auth_models.py new file mode 100644 index 0000000..6d23505 --- /dev/null +++ b/app/models/auth_models.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import BaseModel + +# Zero-dependency auth models for V3 +# Created 2026-01-07 to resolve circular dependencies in FastAPI startup + +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' diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 68d586e..868b0cb 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -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 diff --git a/app/routers/dependencies_v3.py b/app/routers/dependencies_v3.py new file mode 100644 index 0000000..f0b0459 --- /dev/null +++ b/app/routers/dependencies_v3.py @@ -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 diff --git a/documentation/UNIFIED_AGENT_ARCH.md b/documentation/UNIFIED_AGENT_ARCH.md index dde3bd1..10f71f5 100644 --- a/documentation/UNIFIED_AGENT_ARCH.md +++ b/documentation/UNIFIED_AGENT_ARCH.md @@ -48,6 +48,7 @@ The **Unified Aether AI Agent** is a single, cohesive AI entity designed to elim ### F. Storage Layer (Syncthing / Hosted Files) * **Location:** `/home/scott/OSIT/hosted_files/` (Main Workstation) and synchronized Remote Servers. * **Role:** Extremely important persistent storage for files served via the API (e.g., `hosted_file`, `event_file`). +* **API-Driven Access:** Files are frequently accessed/downloaded directly via API endpoints. The agent must understand the routing and security logic (JWT validation) governing these downloads. * **Synchronization:** Managed via **Syncthing** (similar to the `agents_sync` directory), ensuring real-time mirroring across the Aether ecosystem. * **Agent Access Requirements:** * Full filesystem access to the local hosted files directory. diff --git a/verify_v3_exceptions.py b/verify_v3_exceptions.py new file mode 100644 index 0000000..d3ddd54 --- /dev/null +++ b/verify_v3_exceptions.py @@ -0,0 +1,50 @@ +import requests +import json + +# Configuration +BASE_URL = "https://dev-api.oneskyit.com" +SEARCH_ENDPOINT = f"{BASE_URL}/v3/crud/site_domain/search" +RESTRICTED_ENDPOINT = f"{BASE_URL}/v3/crud/journal/search" + +def test_site_domain_exception(): + print("--- Testing site_domain guest access (Exception) ---") + search_query = { + "q": "%", # Match all for testing + "and": [] + } + + try: + # No Authorization or X-Account-ID headers provided + response = requests.post(SEARCH_ENDPOINT, json=search_query) + print(f"Status Code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print("SUCCESS: site_domain search allowed without authentication.") + print(f"Result count: {len(data.get('data', []))}") + else: + print(f"FAILED: site_domain search returned {response.status_code}") + print(response.text) + + except Exception as e: + print(f"Error during site_domain test: {e}") + +def test_restricted_search(): + print("\n--- Testing restricted search (Should fail) ---") + search_query = {"q": "%"} + + try: + response = requests.post(RESTRICTED_ENDPOINT, json=search_query) + print(f"Status Code: {response.status_code}") + + if response.status_code == 403: + print("SUCCESS: Restricted search was correctly blocked (403 Forbidden).") + else: + print(f"FAILED: Restricted search returned {response.status_code} instead of 403.") + + except Exception as e: + print(f"Error during restricted test: {e}") + +if __name__ == "__main__": + test_site_domain_exception() + test_restricted_search()