diff --git a/app/lib_sql_search.py b/app/lib_sql_search.py index 83f5aa6..8768085 100644 --- a/app/lib_sql_search.py +++ b/app/lib_sql_search.py @@ -199,7 +199,11 @@ def sql_search_qry_part( if hasattr(item, 'field'): clause, item_data = process_filter(item) node_clauses.append(clause); data.update(item_data) - else: node_clauses.append(f"({process_node(item, current_depth + 1)})") + else: + # Recurse into nested SearchQuery; only append if non-empty + sub_clause = process_node(item, current_depth + 1) + if sub_clause: + node_clauses.append(f"({sub_clause})") if node_clauses: joiner = ' AND ' if 'and' in filter_attr else ' OR ' clauses.append(f"({joiner.join(node_clauses)})") @@ -261,6 +265,18 @@ def sql_search_qry_part( except Exception as e: log.warning(f"Failed to resolve random ID for field {target_field}: {e}") + # site_domain: 'access_key' is a virtual field. + # site_access_key (site-level) takes priority; fall back to site_domain_access_key + # when site_access_key is not set (NULL or empty). + if target_field == 'access_key' and table_name and 'site_domain' in table_name: + sql_op = operator_map.get(f.op.lower()) + if not sql_op: raise HTTPException(status_code=400, detail=f"Unsupported operator: {f.op}") + p1, p2 = get_param_name(), get_param_name() + return ( + f"(site_access_key {sql_op} :{p1} OR " + f"((site_access_key IS NULL OR site_access_key = '') AND site_domain_access_key {sql_op} :{p2}))" + ), {p1: f.value, p2: f.value} + if searchable_fields is not None and target_field not in searchable_fields: # Fallback check for original field just in case if f.field not in searchable_fields: diff --git a/app/object_definitions/cms.py b/app/object_definitions/cms.py index c78aef8..d21cc59 100644 --- a/app/object_definitions/cms.py +++ b/app/object_definitions/cms.py @@ -124,7 +124,7 @@ cms_obj_li = { 'searchable_fields': [ 'id', 'account_id', 'site_id', 'id_random', 'account_id_random', 'site_id_random', - 'fqdn', 'access_key', 'site_access_key', + 'fqdn', 'access_key', 'site_access_key', 'site_domain_access_key', 'enable', 'created_on', 'updated_on' ], }, diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 5ddd6c3..c7c5a0d 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -61,16 +61,16 @@ async def get_obj_schema( ): """ Dynamic Schema Introspection. - + Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly. Returns: - Database column definitions (types, defaults, nullability). - Pydantic model field definitions (validation rules, aliases). - + This enables dynamic form generation without hardcoding schemas in the frontend. """ schema_info = get_object_schema_info(obj_type, view, variant) - + if "error" in schema_info: status_code = 400 if "not found" in schema_info["error"] else 500 return mk_resp(data=False, status_code=status_code, response=response, status_message=schema_info["error"]) @@ -87,7 +87,7 @@ async def validate_obj_payload( ): """ Dry-Run Payload Validation. - + Verifies that a payload is valid according to the Pydantic model without performing any database operations. """ @@ -118,7 +118,7 @@ async def get_obj( ): """ Retrieve a Single Object. - + 1. Resolves the public `id_random` (string) to the internal `id` (integer). 2. Performs a SQL SELECT. 3. Enforces Multi-Tenant access checks. @@ -149,10 +149,10 @@ async def get_obj( 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. Record belongs to another account.") - + # Pass inc_hosted_file to the Pydantic model if applicable if obj_name == 'event_file' and inc_hosted_file: sql_result['inc_hosted_file'] = True @@ -181,7 +181,7 @@ async def get_obj_li( ): """ List Objects (Pagination & Filtering). - + Supports: - Standard filtering (enabled/hidden). - Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries). @@ -199,7 +199,7 @@ async def get_obj_li( and_like_dict_obj = None or_like_dict_obj = None and_in_dict_li_obj = None - + jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None if jp_obj: if jp_obj.get('qry'): qry_dict_li = jp_obj['qry'] @@ -213,7 +213,7 @@ async def get_obj_li( obj_name = obj_type_l1 if obj_name 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.") - + obj_cfg = obj_type_kv_li[obj_name] if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): @@ -232,7 +232,7 @@ async def get_obj_li( order_by_li = filter_order_by(order_by_li, base_name, table_name) status_filter = get_supported_filters(base_name, status_filter) - + if not obj_cfg.get('public_read', False): and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name) @@ -278,10 +278,10 @@ async def get_obj_li( if sql_result is False: # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) - + # If it's a schema error (like Unknown Column), it's a 400 Bad Request status_code = 400 if db_err.category == "database_schema" else 500 - + return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict()) if sql_result: @@ -308,7 +308,7 @@ async def search_obj_li( ): """ Search Objects (POST). - + Advanced search endpoint using `SearchQuery` body. - Security: Guests can access specific objects (e.g., site_domain) if permitted. - Filtering: Supports dynamic AND/OR filters built from the frontend. @@ -343,6 +343,31 @@ async def search_obj_li( status_filter = get_supported_filters(base_name, status_filter) searchable_fields = obj_cfg.get('searchable_fields') + # site_domain access-key enforcement: + # - site_access_key (site-level) takes priority; site_domain_access_key used as fallback. + # - A domain is public only if site_domain_access_key is NULL/empty (and site_access_key is also unset). + # - Falsy access_key values (empty string, None) are stripped — treated as "no key". + # - When a key IS provided, lib_sql_search handles the SQL expansion (see process_filter). + if obj_name == 'site_domain': + # Sanity check: drop access_key filters with falsy values + if search_query.and_filters: + search_query.and_filters = [ + f for f in search_query.and_filters + if not (isinstance(f, SearchFilter) and f.field == 'access_key' and not f.value) + ] + key_fields = {'access_key', 'site_access_key', 'site_domain_access_key'} + has_key_filter = any( + isinstance(f, SearchFilter) and f.field in key_fields + for f in (search_query.and_filters or []) + ) + if not has_key_filter: + if search_query.and_filters is None: + search_query.and_filters = [] + for col in ('site_access_key', 'site_domain_access_key'): + search_query.and_filters.append(SearchQuery.parse_obj({ + 'or': [{'field': col, 'op': 'is_null'}, {'field': col, 'op': 'eq', 'value': ''}] + })) + 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.") @@ -388,10 +413,10 @@ async def search_obj_li( if sql_result is False: # Standardized rich error bubbling db_err = format_db_error(get_last_sql_error()) - + # If it's a schema error (like Unknown Column), it's a 400 Bad Request status_code = 400 if db_err.category == "database_schema" else 500 - + return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict()) if sql_result: @@ -414,7 +439,7 @@ async def post_obj( ): """ Create Object. - + 1. Injects `account_id` for ownership. 2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields. - If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped. @@ -496,7 +521,7 @@ async def patch_obj( ): """ Update Object (Partial). - + 1. Resolves ID and checks access permissions. 2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields. - If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped. @@ -557,7 +582,7 @@ async def delete_obj( ): """ Delete Object. - + Supports: - Soft Delete: `method='hide'` or `method='disable'`. - Hard Delete: `method='delete'`. diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index ae480be..b7e32d2 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -44,6 +44,37 @@ When the frontend first loads and doesn't know the `account_id`, it performs a " * Returns 200 + a list containing the `account_id` (random string ID) and `site_id` (random string ID). * ** デザイン Choice:** If the domain is not found, it returns **200 OK with an empty list `[]`**. It is NOT a 404. +> **Access Key Support** +> +> Some client deployments restrict their domain via an access key passed in the browser URL (e.g. `?key=abc123`). The frontend reads this param and forwards it as `access_key` in the POST body. +> +> **How to pass the key:** +> ```json +> { +> "and": [ +> { "field": "fqdn", "op": "eq", "value": "client.example.com" }, +> { "field": "access_key", "op": "eq", "value": "abc123" } +> ] +> } +> ``` +> If `key` is absent, empty, or falsy — **omit `access_key` from the payload entirely**. Do not send `"access_key": ""`. +> +> **Server behavior:** +> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it. +> - `site_domain_access_key` (domain-level key) is used as fallback when `site_access_key` is not set. +> - A domain is **public** only when **both** key columns are NULL/empty. +> - Falsy `access_key` values are ignored server-side as a safety net. +> - Match → `200` with the record. No match → `200` with empty list `[]`. +> - Do **not** use `access_code_kv_json` for this — that field is for UI features only. +> +> | Browser URL | `access_key` in payload | Result | +> |---|---|---| +> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public) | +> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record | +> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) | +> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) | +> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) | +> --- ## 3. Standard CRUD Patterns diff --git a/tests/e2e/verify_v3_security_hardening.py b/tests/e2e/verify_v3_security_hardening.py index 1e640db..7adac40 100644 --- a/tests/e2e/verify_v3_security_hardening.py +++ b/tests/e2e/verify_v3_security_hardening.py @@ -6,13 +6,13 @@ import os # --- Configuration --- API_ROOT = "https://dev-api.oneskyit.com" # Using the key provided in your examples -API_KEY = "dFP6J9DVj9hUgIMn-fNIqg" +API_KEY = "dFP6J9DVj9hUgIMn-fNIqg" # A known private journal ID from account 1 PRIVATE_JOURNAL_ID = "SWFK-48-89-90" # A known public object type/ID PUBLIC_FQDN = "dev-app.oneskyit.com" # A known valid account ID random for testing restoration of access -VALID_ACCOUNT_ID_RAND = "_XY7DXtc9MY" +VALID_ACCOUNT_ID_RAND = "_XY7DXtc9MY" def print_result(label, success, message=""): status = "✅ PASS" if success else "❌ FAIL" @@ -24,13 +24,13 @@ def test_hardened_search_leak(): url = f"{API_ROOT}/v3/crud/journal/search" headers = {"x-aether-api-key": API_KEY} # NO account header, NO JWT - payload = {"and": []} - + payload = {"and": []} + resp = requests.post(url, headers=headers, json=payload) - + if resp.status_code == 200: data = resp.json().get('data', []) - # Should be 0 because all journals in DB have an account_id, + # Should be 0 because all journals in DB have an account_id, # and we are now strictly filtering for account_id IS NULL. success = (len(data) == 0) print_result("Leak Blocked (Journal Search)", success, f"- Found {len(data)} records (Expected 0)") @@ -43,9 +43,9 @@ def test_strict_id_block(): url = f"{API_ROOT}/v3/crud/journal/{PRIVATE_JOURNAL_ID}" headers = {"x-aether-api-key": API_KEY} # NO account header, NO JWT - + resp = requests.get(url, headers=headers) - + success = (resp.status_code == 403) print_result("Access Denied (Journal GET ID)", success, f"- Status: {resp.status_code} (Expected 403)") @@ -55,9 +55,9 @@ def test_bootstrap_exception(): url = f"{API_ROOT}/v3/crud/site_domain/search" headers = {"x-aether-api-key": API_KEY} payload = {"and": [{"field": "fqdn", "op": "eq", "value": PUBLIC_FQDN}]} - + resp = requests.post(url, headers=headers, json=payload) - + success = (resp.status_code == 200 and len(resp.json().get('data', [])) > 0) print_result("Bootstrap Allowed (Site Domain)", success, f"- Status: {resp.status_code}") @@ -69,22 +69,82 @@ def test_restored_access(): "x-aether-api-key": API_KEY, "x-account-id": VALID_ACCOUNT_ID_RAND } - + resp = requests.get(url, headers=headers) - + success = (resp.status_code == 200) print_result("Access Restored (Journal with Header)", success, f"- Status: {resp.status_code}") + +def test_site_domain_access_key(): + """ + Verify site_domain lookup respects access_key. + + The frontend reads the 'key' query param from the browser URL and forwards it + as 'access_key' in the POST body. No key means a public domain is expected. + + Valid (should return a result): + https://dev-demo.oneskyit.com — public, no key needed + http://idaa.localhost:5173/?key=restricted — correct key + https://dev-idaa.oneskyit.com/?key=restricted-access — correct key + https://sk-idaa.oneskyit.com/?key=8VTOJ0X5hvT6JdiTJsGEzQ — correct key + + Invalid (should return empty): + http://idaa.localhost:5173/ — key required, none given + http://idaa.localhost:5173/?key=bad-key-example — wrong key + https://dev-idaa.oneskyit.com/ — key required, none given + https://dev-idaa.oneskyit.com/?key= — empty key treated as none + https://dev-idaa.oneskyit.com/?key=any-wrong-key — wrong key + https://sk-idaa.oneskyit.com/ — key required, none given + https://sk-idaa.oneskyit.com/?key=another-bad-key-example — wrong key + """ + print("\n--- Test 5: Site Domain Access Key Behavior ---") + url = f"{API_ROOT}/v3/crud/site_domain/search" + headers = {"x-aether-api-key": API_KEY} + + cases = [ + # (fqdn, key, should_pass, label) + # --- valid --- + ("dev-demo.oneskyit.com", None, True, "public domain, no key"), + ("idaa.localhost:5173", "restricted", True, "correct key"), + ("dev-idaa.oneskyit.com", "restricted-access", True, "correct key"), + ("sk-idaa.oneskyit.com", "8VTOJ0X5hvT6JdiTJsGEzQ", True, "correct key"), + # --- invalid --- + ("idaa.localhost:5173", None, False, "key required, none given"), + ("idaa.localhost:5173", "bad-key-example", False, "wrong key"), + ("dev-idaa.oneskyit.com", None, False, "key required, none given"), + ("dev-idaa.oneskyit.com", "", False, "empty key treated as none"), + ("dev-idaa.oneskyit.com", "any-wrong-key", False, "wrong key"), + ("sk-idaa.oneskyit.com", None, False, "key required, none given"), + ("sk-idaa.oneskyit.com", "another-bad-key-example", False, "wrong key"), + ] + + for fqdn, key, should_pass, label in cases: + payload = {"and": [{"field": "fqdn", "op": "eq", "value": fqdn}]} + # Omit access_key entirely when None (no key in URL); send it when present (even if empty) + if key is not None: + payload["and"].append({"field": "access_key", "op": "eq", "value": key}) + + try: + resp = requests.post(url, headers=headers, json=payload) + data = resp.json().get('data', []) if resp.status_code == 200 else [] + success = (resp.status_code == 200 and ((len(data) > 0) == should_pass)) + tag = "VALID " if should_pass else "INVALID" + print_result(f"[{tag}] {fqdn} key={key!r:30} ({label})", success, f"- Count: {len(data)}") + except Exception as e: + print_result(f"[{'VALID ' if should_pass else 'INVALID'}] {fqdn} key={key!r}", False, f"- Exception: {e}") + if __name__ == "__main__": print(f"Starting V3 Security Hardening Verification") print(f"Target: {API_ROOT}") - + try: test_hardened_search_leak() test_strict_id_block() test_bootstrap_exception() test_restored_access() + test_site_domain_access_key() except Exception as e: print(f"\n❌ ERROR during test execution: {e}") - + print("\nVerification completed.")