feat(site_domain): restore access_key enforcement for FQDN lookups

- api_crud_v3: strip falsy access_key values; restrict keyless queries
  to public domains (both site_access_key and site_domain_access_key
  must be NULL/empty); 75-line recursive block replaced with ~16 lines
- lib_sql_search: expand virtual 'access_key' field into priority SQL —
  site_access_key first, site_domain_access_key as fallback
- cms.py: add site_domain_access_key to site_domain searchable_fields
- docs: update frontend guide with access key behavior and examples
- e2e test: expand to cover all valid/invalid access key scenarios (15/15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-31 14:46:33 -04:00
parent 1f9cbb0a1f
commit 4629e1ec63
5 changed files with 167 additions and 35 deletions

View File

@@ -199,7 +199,11 @@ def sql_search_qry_part(
if hasattr(item, 'field'): if hasattr(item, 'field'):
clause, item_data = process_filter(item) clause, item_data = process_filter(item)
node_clauses.append(clause); data.update(item_data) 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: if node_clauses:
joiner = ' AND ' if 'and' in filter_attr else ' OR ' joiner = ' AND ' if 'and' in filter_attr else ' OR '
clauses.append(f"({joiner.join(node_clauses)})") clauses.append(f"({joiner.join(node_clauses)})")
@@ -261,6 +265,18 @@ def sql_search_qry_part(
except Exception as e: except Exception as e:
log.warning(f"Failed to resolve random ID for field {target_field}: {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: if searchable_fields is not None and target_field not in searchable_fields:
# Fallback check for original field just in case # Fallback check for original field just in case
if f.field not in searchable_fields: if f.field not in searchable_fields:

View File

@@ -124,7 +124,7 @@ cms_obj_li = {
'searchable_fields': [ 'searchable_fields': [
'id', 'account_id', 'site_id', 'id', 'account_id', 'site_id',
'id_random', 'account_id_random', 'site_id_random', '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' 'enable', 'created_on', 'updated_on'
], ],
}, },

View File

@@ -61,16 +61,16 @@ async def get_obj_schema(
): ):
""" """
Dynamic Schema Introspection. Dynamic Schema Introspection.
Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly. Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly.
Returns: Returns:
- Database column definitions (types, defaults, nullability). - Database column definitions (types, defaults, nullability).
- Pydantic model field definitions (validation rules, aliases). - Pydantic model field definitions (validation rules, aliases).
This enables dynamic form generation without hardcoding schemas in the frontend. This enables dynamic form generation without hardcoding schemas in the frontend.
""" """
schema_info = get_object_schema_info(obj_type, view, variant) schema_info = get_object_schema_info(obj_type, view, variant)
if "error" in schema_info: if "error" in schema_info:
status_code = 400 if "not found" in schema_info["error"] else 500 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"]) 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. Dry-Run Payload Validation.
Verifies that a payload is valid according to the Pydantic model Verifies that a payload is valid according to the Pydantic model
without performing any database operations. without performing any database operations.
""" """
@@ -118,7 +118,7 @@ async def get_obj(
): ):
""" """
Retrieve a Single Object. Retrieve a Single Object.
1. Resolves the public `id_random` (string) to the internal `id` (integer). 1. Resolves the public `id_random` (string) to the internal `id` (integer).
2. Performs a SQL SELECT. 2. Performs a SQL SELECT.
3. Enforces Multi-Tenant access checks. 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): if account.auth_method == 'guest' or (account.account_id is None and not account.super):
reason = account.auth_error or "Account context required." reason = account.auth_error or "Account context required."
return mk_resp(data=False, status_code=403, response=response, status_message=reason) return mk_resp(data=False, status_code=403, response=response, status_message=reason)
if not check_account_access(sql_result, account, obj_name): 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.") 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 # Pass inc_hosted_file to the Pydantic model if applicable
if obj_name == 'event_file' and inc_hosted_file: if obj_name == 'event_file' and inc_hosted_file:
sql_result['inc_hosted_file'] = True sql_result['inc_hosted_file'] = True
@@ -181,7 +181,7 @@ async def get_obj_li(
): ):
""" """
List Objects (Pagination & Filtering). List Objects (Pagination & Filtering).
Supports: Supports:
- Standard filtering (enabled/hidden). - Standard filtering (enabled/hidden).
- Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries). - 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 and_like_dict_obj = None
or_like_dict_obj = None or_like_dict_obj = None
and_in_dict_li_obj = None and_in_dict_li_obj = None
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
if jp_obj: if jp_obj:
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry'] 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 obj_name = obj_type_l1
if obj_name not in obj_type_kv_li: 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.") 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] obj_cfg = obj_type_kv_li[obj_name]
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):
@@ -232,7 +232,7 @@ 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)
if not obj_cfg.get('public_read', False): 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) 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: if sql_result is False:
# Standardized rich error bubbling # Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error()) db_err = format_db_error(get_last_sql_error())
# If it's a schema error (like Unknown Column), it's a 400 Bad Request # 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 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()) 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: if sql_result:
@@ -308,7 +308,7 @@ async def search_obj_li(
): ):
""" """
Search Objects (POST). Search Objects (POST).
Advanced search endpoint using `SearchQuery` body. Advanced search endpoint using `SearchQuery` body.
- Security: Guests can access specific objects (e.g., site_domain) if permitted. - Security: Guests can access specific objects (e.g., site_domain) if permitted.
- Filtering: Supports dynamic AND/OR filters built from the frontend. - 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) status_filter = get_supported_filters(base_name, status_filter)
searchable_fields = obj_cfg.get('searchable_fields') 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 for_obj_type == 'account' and for_obj_id:
if not account.super and for_obj_id != account.account_id_random: 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.") 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: if sql_result is False:
# Standardized rich error bubbling # Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error()) db_err = format_db_error(get_last_sql_error())
# If it's a schema error (like Unknown Column), it's a 400 Bad Request # 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 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()) 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: if sql_result:
@@ -414,7 +439,7 @@ async def post_obj(
): ):
""" """
Create Object. Create Object.
1. Injects `account_id` for ownership. 1. Injects `account_id` for ownership.
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields. 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. - 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). Update Object (Partial).
1. Resolves ID and checks access permissions. 1. Resolves ID and checks access permissions.
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields. 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. - If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
@@ -557,7 +582,7 @@ async def delete_obj(
): ):
""" """
Delete Object. Delete Object.
Supports: Supports:
- Soft Delete: `method='hide'` or `method='disable'`. - Soft Delete: `method='hide'` or `method='disable'`.
- Hard Delete: `method='delete'`. - Hard Delete: `method='delete'`.

View File

@@ -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). * 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. * ** デザイン 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 ## 3. Standard CRUD Patterns

View File

@@ -6,13 +6,13 @@ import os
# --- Configuration --- # --- Configuration ---
API_ROOT = "https://dev-api.oneskyit.com" API_ROOT = "https://dev-api.oneskyit.com"
# Using the key provided in your examples # Using the key provided in your examples
API_KEY = "dFP6J9DVj9hUgIMn-fNIqg" API_KEY = "dFP6J9DVj9hUgIMn-fNIqg"
# A known private journal ID from account 1 # A known private journal ID from account 1
PRIVATE_JOURNAL_ID = "SWFK-48-89-90" PRIVATE_JOURNAL_ID = "SWFK-48-89-90"
# A known public object type/ID # A known public object type/ID
PUBLIC_FQDN = "dev-app.oneskyit.com" PUBLIC_FQDN = "dev-app.oneskyit.com"
# A known valid account ID random for testing restoration of access # 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=""): def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL" status = "✅ PASS" if success else "❌ FAIL"
@@ -24,13 +24,13 @@ def test_hardened_search_leak():
url = f"{API_ROOT}/v3/crud/journal/search" url = f"{API_ROOT}/v3/crud/journal/search"
headers = {"x-aether-api-key": API_KEY} headers = {"x-aether-api-key": API_KEY}
# NO account header, NO JWT # NO account header, NO JWT
payload = {"and": []} payload = {"and": []}
resp = requests.post(url, headers=headers, json=payload) resp = requests.post(url, headers=headers, json=payload)
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json().get('data', []) 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. # and we are now strictly filtering for account_id IS NULL.
success = (len(data) == 0) success = (len(data) == 0)
print_result("Leak Blocked (Journal Search)", success, f"- Found {len(data)} records (Expected 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}" url = f"{API_ROOT}/v3/crud/journal/{PRIVATE_JOURNAL_ID}"
headers = {"x-aether-api-key": API_KEY} headers = {"x-aether-api-key": API_KEY}
# NO account header, NO JWT # NO account header, NO JWT
resp = requests.get(url, headers=headers) resp = requests.get(url, headers=headers)
success = (resp.status_code == 403) success = (resp.status_code == 403)
print_result("Access Denied (Journal GET ID)", success, f"- Status: {resp.status_code} (Expected 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" url = f"{API_ROOT}/v3/crud/site_domain/search"
headers = {"x-aether-api-key": API_KEY} headers = {"x-aether-api-key": API_KEY}
payload = {"and": [{"field": "fqdn", "op": "eq", "value": PUBLIC_FQDN}]} payload = {"and": [{"field": "fqdn", "op": "eq", "value": PUBLIC_FQDN}]}
resp = requests.post(url, headers=headers, json=payload) resp = requests.post(url, headers=headers, json=payload)
success = (resp.status_code == 200 and len(resp.json().get('data', [])) > 0) success = (resp.status_code == 200 and len(resp.json().get('data', [])) > 0)
print_result("Bootstrap Allowed (Site Domain)", success, f"- Status: {resp.status_code}") 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-aether-api-key": API_KEY,
"x-account-id": VALID_ACCOUNT_ID_RAND "x-account-id": VALID_ACCOUNT_ID_RAND
} }
resp = requests.get(url, headers=headers) resp = requests.get(url, headers=headers)
success = (resp.status_code == 200) success = (resp.status_code == 200)
print_result("Access Restored (Journal with Header)", success, f"- Status: {resp.status_code}") 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__": if __name__ == "__main__":
print(f"Starting V3 Security Hardening Verification") print(f"Starting V3 Security Hardening Verification")
print(f"Target: {API_ROOT}") print(f"Target: {API_ROOT}")
try: try:
test_hardened_search_leak() test_hardened_search_leak()
test_strict_id_block() test_strict_id_block()
test_bootstrap_exception() test_bootstrap_exception()
test_restored_access() test_restored_access()
test_site_domain_access_key()
except Exception as e: except Exception as e:
print(f"\n❌ ERROR during test execution: {e}") print(f"\n❌ ERROR during test execution: {e}")
print("\nVerification completed.") print("\nVerification completed.")