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:
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user