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

@@ -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.")

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

@@ -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}")