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'):
|
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:
|
||||||
|
|||||||
@@ -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'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -75,6 +75,65 @@ def test_restored_access():
|
|||||||
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}")
|
||||||
@@ -84,6 +143,7 @@ if __name__ == "__main__":
|
|||||||
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}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user