Compare commits
5 Commits
c837d465ca
...
0ecc5a97d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ecc5a97d5 | ||
|
|
516865b7d8 | ||
|
|
7f9666dc1e | ||
|
|
f9f588ddf2 | ||
|
|
ea25bf78d4 |
@@ -21,10 +21,21 @@ router = APIRouter()
|
|||||||
|
|
||||||
# --- Passcode Authentication ---
|
# --- Passcode Authentication ---
|
||||||
|
|
||||||
|
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||||
|
|
||||||
|
ROLE_TTL = {
|
||||||
|
'super': 8 * 3600, # 8 hours
|
||||||
|
'manager': 24 * 3600, # 24 hours
|
||||||
|
'administrator': 48 * 3600, # 48 hours
|
||||||
|
'trusted': 48 * 3600, # 48 hours
|
||||||
|
'public': 24 * 3600, # 24 hours
|
||||||
|
'authenticated': 12 * 3600, # 12 hours
|
||||||
|
}
|
||||||
|
|
||||||
class PasscodeAuthRequest(BaseModel):
|
class PasscodeAuthRequest(BaseModel):
|
||||||
"""Request model for site-based passcode authentication."""
|
"""Request model for site-based passcode authentication."""
|
||||||
site_id: str = Field(..., description="The random string ID of the site")
|
site_id: str = Field(..., description="The random string ID of the site")
|
||||||
passcode: str = Field(..., description="The passcode to verify")
|
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||||
|
|
||||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||||
async def authenticate_passcode(
|
async def authenticate_passcode(
|
||||||
@@ -54,10 +65,11 @@ async def authenticate_passcode(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||||
|
|
||||||
# 3. Verify Passcode and Resolve Role
|
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||||
matched_role = None
|
matched_role = None
|
||||||
for role, code in access_codes.items():
|
for role in ROLE_PRIORITY:
|
||||||
if str(code) == str(passcode):
|
code = access_codes.get(role)
|
||||||
|
if code and str(code) == str(passcode):
|
||||||
matched_role = role
|
matched_role = role
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -70,22 +82,25 @@ async def authenticate_passcode(
|
|||||||
if account_id_int := record.get('account_id'):
|
if account_id_int := record.get('account_id'):
|
||||||
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||||
|
|
||||||
# 5. Mint JWT
|
# 5. Mint JWT with complete role flags and per-role TTL
|
||||||
payload = {
|
payload = {
|
||||||
'account_id': account_id_random,
|
'account_id': account_id_random,
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
'administrator': (matched_role == 'administrator'),
|
'administrator': (matched_role == 'administrator'),
|
||||||
'manager': (matched_role == 'manager'),
|
'trusted': (matched_role == 'trusted'),
|
||||||
'super': (matched_role == 'super'),
|
'public': (matched_role == 'public'),
|
||||||
|
'authenticated': (matched_role == 'authenticated'),
|
||||||
'json_str': json.dumps({
|
'json_str': json.dumps({
|
||||||
'auth_type': 'passcode',
|
'auth_type': 'passcode',
|
||||||
'site_id': site_id,
|
'site_id': site_id,
|
||||||
'role': matched_role
|
'role': matched_role
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
token = sign_jwt(
|
token = sign_jwt(
|
||||||
secret_key=settings.JWT_KEY,
|
secret_key=settings.JWT_KEY,
|
||||||
ttl=3600 * 24, # 24 hour session
|
ttl=ROLE_TTL[matched_role],
|
||||||
**payload
|
**payload
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -313,15 +313,6 @@ async def post_child_obj(
|
|||||||
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
||||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||||
|
|
||||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
|
||||||
if 'account_id' in input_model.__fields__:
|
|
||||||
obj_data['account_id'] = account.account_id
|
|
||||||
|
|
||||||
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
|
||||||
|
|
||||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
|
||||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validated_obj = input_model(**obj_data)
|
validated_obj = input_model(**obj_data)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
@@ -332,8 +323,21 @@ async def post_child_obj(
|
|||||||
|
|
||||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||||
|
|
||||||
# Re-inject parent FK after model serialization. Some model root_validators strip
|
# Sanitize AFTER serialization so that:
|
||||||
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
|
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||||
|
# 2. ID resolution (string → integer) happens on the dict going to the DB,
|
||||||
|
# avoiding the root_validator's integer-stripping anti-leakage guard.
|
||||||
|
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
|
||||||
|
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||||
|
|
||||||
|
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes
|
||||||
|
# straight to the DB without conflicting with Vision ID string constraints in the model.
|
||||||
|
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||||
|
if 'account_id' in input_model.__fields__:
|
||||||
|
data_to_insert['account_id'] = account.account_id
|
||||||
|
|
||||||
|
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
|
||||||
|
# set — ensuring the child is always linked to the correct parent.
|
||||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||||
|
|
||||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||||
|
|||||||
@@ -656,6 +656,44 @@ async def event_id_badge_import_zoom_csv(
|
|||||||
if badge_type_code:
|
if badge_type_code:
|
||||||
log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'")
|
log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'")
|
||||||
|
|
||||||
|
# Parse marketing consent column (if present) and map to badge fields.
|
||||||
|
# Expected values: "Opt-in" => agree_to_tc=True, allow_tracking=True
|
||||||
|
# "Opt-out" => agree_to_tc=False, allow_tracking=False
|
||||||
|
# "N/A" => None/NULL
|
||||||
|
marketing_raw = None
|
||||||
|
for _k in ('Agree to receive marketing communication?', 'Agree to receive marketing communication', 'Agree to TC', 'agree_to_tc'):
|
||||||
|
if _k in record and str(record.get(_k)).strip() != '':
|
||||||
|
marketing_raw = str(record.get(_k)).strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
agree_to_tc_val = None
|
||||||
|
allow_tracking_val = None
|
||||||
|
if marketing_raw is not None:
|
||||||
|
m = marketing_raw.strip()
|
||||||
|
m_low = m.lower()
|
||||||
|
if m_low in ('n/a', 'na'):
|
||||||
|
agree_to_tc_val = None
|
||||||
|
allow_tracking_val = None
|
||||||
|
elif m_low in ('opt-in', 'optin', 'opt in'):
|
||||||
|
agree_to_tc_val = True
|
||||||
|
allow_tracking_val = True
|
||||||
|
elif m_low in ('opt-out', 'optout', 'opt out'):
|
||||||
|
agree_to_tc_val = False
|
||||||
|
allow_tracking_val = False
|
||||||
|
else:
|
||||||
|
if m_low in ('yes', 'y', 'true', '1'):
|
||||||
|
agree_to_tc_val = True
|
||||||
|
allow_tracking_val = True
|
||||||
|
elif m_low in ('no', 'n', 'false', '0'):
|
||||||
|
agree_to_tc_val = False
|
||||||
|
allow_tracking_val = False
|
||||||
|
else:
|
||||||
|
agree_to_tc_val = None
|
||||||
|
allow_tracking_val = None
|
||||||
|
|
||||||
|
# Need to deal with this special field/column for Axonius
|
||||||
|
# "Agree to receive marketing communication?"
|
||||||
|
|
||||||
event_person_data = {
|
event_person_data = {
|
||||||
'account_id': account_id,
|
'account_id': account_id,
|
||||||
'event_id': event_id,
|
'event_id': event_id,
|
||||||
@@ -712,6 +750,8 @@ async def event_id_badge_import_zoom_csv(
|
|||||||
'event_badge_template_id_random': 'RKYp2HcQm9o',
|
'event_badge_template_id_random': 'RKYp2HcQm9o',
|
||||||
'badge_type': ticket_name,
|
'badge_type': ticket_name,
|
||||||
'badge_type_code': badge_type_code,
|
'badge_type_code': badge_type_code,
|
||||||
|
'agree_to_tc': agree_to_tc_val,
|
||||||
|
'allow_tracking': allow_tracking_val,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,73 @@ Frontend guidance:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Axonius Zoom CSV Upload (Temporary — Apr 2026)
|
||||||
|
|
||||||
|
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
|
||||||
|
|
||||||
|
- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
|
||||||
|
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
|
||||||
|
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
|
||||||
|
- `begin_at` (int, default `0`)
|
||||||
|
- `end_at` (int, default `20000`)
|
||||||
|
- `return_detail` (bool, default `false`)
|
||||||
|
- Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).
|
||||||
|
|
||||||
|
Behavior / notes:
|
||||||
|
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
|
||||||
|
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
|
||||||
|
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
|
||||||
|
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
|
||||||
|
- Rows missing `Registrant email` are skipped.
|
||||||
|
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.
|
||||||
|
|
||||||
|
Frontend guidance:
|
||||||
|
- UI must be staff-only and should validate an `event_id` is selected.
|
||||||
|
- For large files, use `begin_at`/`end_at` to process in chunks.
|
||||||
|
- Prefer `return_detail=false` for large imports to reduce payload size.
|
||||||
|
|
||||||
|
Common errors:
|
||||||
|
- `403` — missing/invalid account context or API key.
|
||||||
|
- `404` — event not found.
|
||||||
|
- `500` — file save or processing error.
|
||||||
|
|
||||||
|
Example curl (replace placeholders):
|
||||||
|
```bash
|
||||||
|
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
|
||||||
|
-H "x-aether-api-key: <API_KEY>" \
|
||||||
|
-H "x-account-id: <ACCOUNT_ID>" \
|
||||||
|
-F "file=@/path/to/zoom_export.csv"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sample success (summary mode, `return_detail=false`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"event_id": "xK9mP3qRtL2",
|
||||||
|
"event_id_random": "xK9mP3qRtL2",
|
||||||
|
"external_id": "alice@example.com",
|
||||||
|
"given_name": "Alice",
|
||||||
|
"family_name": "Smith",
|
||||||
|
"email": "alice@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"status_code": 200,
|
||||||
|
"status_name": "OK",
|
||||||
|
"success": true,
|
||||||
|
"data_type": "list",
|
||||||
|
"data_list_count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).
|
||||||
|
|
||||||
|
Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 7. User Actions (`/v3/action/user/`)
|
## 7. User Actions (`/v3/action/user/`)
|
||||||
|
|
||||||
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
|
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
|
||||||
|
|||||||
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
# PROJECT: Site Passcode Security — API-Verified Auth
|
||||||
|
|
||||||
|
**Last updated:** 2026-04-10
|
||||||
|
**Status:** Backend work in progress — frontend pending backend completion
|
||||||
|
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
|
||||||
|
|
||||||
|
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
|
||||||
|
|
||||||
|
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
|
||||||
|
|
||||||
|
### Source of Truth
|
||||||
|
|
||||||
|
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model
|
||||||
|
|
||||||
|
| Threat | Current | After Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
|
||||||
|
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
|
||||||
|
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
|
||||||
|
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
|
||||||
|
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
|
||||||
|
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
|
||||||
|
|
||||||
|
### The fundamental constraint
|
||||||
|
|
||||||
|
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Solution: API-Verified Passcode + JWT Session
|
||||||
|
|
||||||
|
### Core idea
|
||||||
|
|
||||||
|
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
|
||||||
|
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
|
||||||
|
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
|
||||||
|
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
|
||||||
|
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
|
||||||
|
|
||||||
|
### Session restore on reload
|
||||||
|
|
||||||
|
- `access_type` still persists in localStorage (no change here)
|
||||||
|
- The JWT is the **proof** that the access was legitimately granted and is still valid
|
||||||
|
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
|
||||||
|
- If JWT expired → reset `access_type` to anonymous, clear JWT
|
||||||
|
- If JWT valid → no action needed, `access_type` is already correct
|
||||||
|
|
||||||
|
This gives session expiry without a network call on every page load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TTL Per Role — Decided
|
||||||
|
|
||||||
|
| Access Level | JWT TTL | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `super` | 8 hours | Highest privilege |
|
||||||
|
| `manager` | 24 hours | |
|
||||||
|
| `administrator` | 48 hours | |
|
||||||
|
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
|
||||||
|
| `public` | 24 hours | |
|
||||||
|
| `authenticated` | 12 hours | |
|
||||||
|
| `anonymous` | N/A | No passcode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caching Decision
|
||||||
|
|
||||||
|
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Changes Required
|
||||||
|
|
||||||
|
**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
|
||||||
|
|
||||||
|
**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.
|
||||||
|
|
||||||
|
**File:** `aether_api_fastapi/app/routers/api.py`
|
||||||
|
|
||||||
|
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
|
||||||
|
|
||||||
|
### Fix 1: Passcode matching must use explicit priority order
|
||||||
|
|
||||||
|
**Current (wrong):**
|
||||||
|
```python
|
||||||
|
for role, code in access_codes.items(): # dict insertion order — not guaranteed
|
||||||
|
if str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||||
|
|
||||||
|
matched_role = None
|
||||||
|
for role in ROLE_PRIORITY:
|
||||||
|
code = access_codes.get(role)
|
||||||
|
if code and str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
|
||||||
|
|
||||||
|
### Fix 2: JWT payload must include all six role flags
|
||||||
|
|
||||||
|
**Current (incomplete):**
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
# trusted / public / authenticated missing
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'trusted': (matched_role == 'trusted'),
|
||||||
|
'public': (matched_role == 'public'),
|
||||||
|
'authenticated': (matched_role == 'authenticated'),
|
||||||
|
'json_str': json.dumps({
|
||||||
|
'auth_type': 'passcode', # distinguishes from user login JWTs
|
||||||
|
'site_id': site_id,
|
||||||
|
'role': matched_role # canonical role string — frontend uses this
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
|
||||||
|
|
||||||
|
### Fix 3: Per-role TTL
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```python
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=3600 * 24, # hardcoded 24h for all roles
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
ROLE_TTL = {
|
||||||
|
'super': 8 * 3600, # 8 hours
|
||||||
|
'manager': 24 * 3600, # 24 hours
|
||||||
|
'administrator': 48 * 3600, # 48 hours
|
||||||
|
'trusted': 48 * 3600, # 48 hours
|
||||||
|
'public': 24 * 3600, # 24 hours
|
||||||
|
'authenticated': 12 * 3600, # 12 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=ROLE_TTL[matched_role],
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 4: Add minimum length validation to `passcode` field
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```python
|
||||||
|
passcode: str = Field(..., description="The passcode to verify")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
```python
|
||||||
|
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
|
||||||
|
|
||||||
|
### Complete corrected endpoint (for reference)
|
||||||
|
|
||||||
|
```python
|
||||||
|
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||||
|
|
||||||
|
ROLE_TTL = {
|
||||||
|
'super': 8 * 3600,
|
||||||
|
'manager': 24 * 3600,
|
||||||
|
'administrator': 48 * 3600,
|
||||||
|
'trusted': 48 * 3600,
|
||||||
|
'public': 24 * 3600,
|
||||||
|
'authenticated': 12 * 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
class PasscodeAuthRequest(BaseModel):
|
||||||
|
"""Request model for site-based passcode authentication."""
|
||||||
|
site_id: str = Field(..., description="Random string ID of the site")
|
||||||
|
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||||
|
|
||||||
|
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||||
|
async def authenticate_passcode(
|
||||||
|
auth_req: PasscodeAuthRequest,
|
||||||
|
response: Response = Response,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Passcode-to-JWT Endpoint.
|
||||||
|
Verifies a passcode against site.access_code_kv_json (single source of truth —
|
||||||
|
v_site_domain joins from the same site record).
|
||||||
|
Returns a signed JWT with the site's account context, full role flags, and
|
||||||
|
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
|
||||||
|
this token from a user login JWT.
|
||||||
|
"""
|
||||||
|
site_id = auth_req.site_id
|
||||||
|
passcode = auth_req.passcode
|
||||||
|
|
||||||
|
# 1. Look up the site record
|
||||||
|
search_data = {'id_random': site_id}
|
||||||
|
if record := sql_select(table_name='site', data=search_data):
|
||||||
|
# 2. Parse access codes
|
||||||
|
access_codes_raw = record.get('access_code_kv_json')
|
||||||
|
access_codes = {}
|
||||||
|
if access_codes_raw:
|
||||||
|
try:
|
||||||
|
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||||
|
|
||||||
|
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||||
|
matched_role = None
|
||||||
|
for role in ROLE_PRIORITY:
|
||||||
|
code = access_codes.get(role)
|
||||||
|
if code and str(code) == str(passcode):
|
||||||
|
matched_role = role
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_role:
|
||||||
|
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
|
||||||
|
|
||||||
|
# 4. Resolve account context
|
||||||
|
account_id_random = record.get('account_id_random')
|
||||||
|
if not account_id_random:
|
||||||
|
if account_id_int := record.get('account_id'):
|
||||||
|
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||||
|
|
||||||
|
# 5. Mint JWT with complete role flags and per-role TTL
|
||||||
|
payload = {
|
||||||
|
'account_id': account_id_random,
|
||||||
|
'super': (matched_role == 'super'),
|
||||||
|
'manager': (matched_role == 'manager'),
|
||||||
|
'administrator': (matched_role == 'administrator'),
|
||||||
|
'trusted': (matched_role == 'trusted'),
|
||||||
|
'public': (matched_role == 'public'),
|
||||||
|
'authenticated': (matched_role == 'authenticated'),
|
||||||
|
'json_str': json.dumps({
|
||||||
|
'auth_type': 'passcode',
|
||||||
|
'site_id': site_id,
|
||||||
|
'role': matched_role
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
token = sign_jwt(
|
||||||
|
secret_key=settings.JWT_KEY,
|
||||||
|
ttl=ROLE_TTL[matched_role],
|
||||||
|
**payload
|
||||||
|
)
|
||||||
|
|
||||||
|
return mk_resp(
|
||||||
|
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
|
||||||
|
response=response
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
|
||||||
|
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
|
||||||
|
else:
|
||||||
|
log.warning(f"Auth Failed: Site {site_id} not found.")
|
||||||
|
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Phase 2 (follow-up — not blocking frontend)
|
||||||
|
|
||||||
|
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Changes Required
|
||||||
|
|
||||||
|
**These depend on the backend fixes above being deployed first.**
|
||||||
|
|
||||||
|
### 1a. `src/lib/app_components/e_app_access_type.svelte`
|
||||||
|
|
||||||
|
Replace `handle_check_access_type_passcode` entirely. The new version:
|
||||||
|
|
||||||
|
- Is `async`
|
||||||
|
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
|
||||||
|
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
|
||||||
|
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
|
||||||
|
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
|
||||||
|
- On network error: shows inline connection error
|
||||||
|
- Clears `auth_error` when `entered_passcode` changes
|
||||||
|
|
||||||
|
API call shape:
|
||||||
|
```http
|
||||||
|
POST /authenticate_passcode
|
||||||
|
Content-Type: application/json
|
||||||
|
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
|
||||||
|
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to template (near the passcode input):
|
||||||
|
```svelte
|
||||||
|
{#if auth_pending}
|
||||||
|
<Loader size="1em" class="animate-spin text-gray-400" />
|
||||||
|
{/if}
|
||||||
|
{#if auth_error}
|
||||||
|
<span class="text-error-500 text-xs">{auth_error}</span>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1b. `src/routes/+layout.ts`
|
||||||
|
|
||||||
|
**Stop caching passcodes from bootstrap** — remove line ~394:
|
||||||
|
```ts
|
||||||
|
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
|
||||||
|
```ts
|
||||||
|
// Enforce passcode JWT TTL on page load.
|
||||||
|
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
|
||||||
|
// User login JWTs (auth_type !== 'passcode') are left untouched.
|
||||||
|
if (ae_loc_json?.jwt) {
|
||||||
|
try {
|
||||||
|
const parts = ae_loc_json.jwt.split('.');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const jwt_payload = JSON.parse(atob(parts[1]));
|
||||||
|
const json_str = typeof jwt_payload.json_str === 'string'
|
||||||
|
? JSON.parse(jwt_payload.json_str)
|
||||||
|
: jwt_payload.json_str;
|
||||||
|
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
|
||||||
|
// Passcode JWT has expired — revoke access
|
||||||
|
ae_loc_json.jwt = null;
|
||||||
|
ae_loc_json.access_type = 'anonymous';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JWT — leave untouched, let existing handling deal with it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
|
||||||
|
|
||||||
|
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
|
||||||
|
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
|
||||||
|
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Affected
|
||||||
|
|
||||||
|
| File | Repo | Change |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
|
||||||
|
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
|
||||||
|
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
|
||||||
|
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
|
||||||
|
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
|
||||||
|
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |
|
||||||
@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
|||||||
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
||||||
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
||||||
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
||||||
|
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
|
||||||
|
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
|
||||||
|
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
|
||||||
|
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
|
||||||
|
|
||||||
# Test Targets: (Object Type, Valid ID Random)
|
# Test Targets: (Object Type, Valid ID Random)
|
||||||
# Note: These IDs are extracted from real active records.
|
# Note: These IDs are extracted from real active records.
|
||||||
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
|
||||||
|
"""
|
||||||
|
Regression test for secondary FK resolution in nested POST create.
|
||||||
|
|
||||||
|
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
|
||||||
|
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
|
||||||
|
sanitize_payload resolved the string → integer, then the model's root_validator
|
||||||
|
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
|
||||||
|
survived only because it was explicitly re-injected; secondary FKs were silently lost.
|
||||||
|
|
||||||
|
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
|
||||||
|
model serialization, matching the flat V3 POST pattern.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
1. POST returns 200.
|
||||||
|
2. Each field in required_fk_fields is present AND non-None in the response.
|
||||||
|
3. All *_id fields are strings (Vision Standard).
|
||||||
|
4. Cleanup: DELETE the created record.
|
||||||
|
"""
|
||||||
|
label = f"Nested Secondary FK ({parent_type}/{child_type})"
|
||||||
|
print(f"\n--- Regression: {label} ---")
|
||||||
|
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
|
||||||
|
headers = get_headers()
|
||||||
|
|
||||||
|
resp = requests.post(url, headers=headers, json=payload)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = resp.json().get('data', {})
|
||||||
|
new_id = data.get('id') or data.get('obj_id_random')
|
||||||
|
if not new_id or not isinstance(new_id, str):
|
||||||
|
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
|
||||||
|
|
||||||
|
# Check required secondary FK fields are present and non-None
|
||||||
|
for field in required_fk_fields:
|
||||||
|
val = data.get(field)
|
||||||
|
if val is None:
|
||||||
|
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
|
||||||
|
# Still attempt cleanup
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
if not isinstance(val, str):
|
||||||
|
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
|
||||||
|
|
||||||
|
# Vision compliance: all *_id fields must be strings
|
||||||
|
for key, val in data.items():
|
||||||
|
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
|
||||||
|
if val is not None and not isinstance(val, str):
|
||||||
|
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
|
||||||
|
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
return False
|
||||||
|
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||||
|
if del_resp.status_code == 200:
|
||||||
|
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def test_nested_alias_resolution():
|
def test_nested_alias_resolution():
|
||||||
"""
|
"""
|
||||||
Verifies that the 'entry' alias and nested resolution works for journals.
|
Verifies that the 'entry' alias and nested resolution works for journals.
|
||||||
@@ -171,6 +244,21 @@ if __name__ == "__main__":
|
|||||||
child_type='event_session',
|
child_type='event_session',
|
||||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||||
))
|
))
|
||||||
|
# Secondary FK regression: event_badge_template_id must survive nested POST
|
||||||
|
# (was silently dropped as NULL before the sanitize_payload order fix)
|
||||||
|
results.append(test_nested_create_secondary_fk(
|
||||||
|
parent_type='event_person',
|
||||||
|
parent_id=EVENT_PERSON_PARENT_ID,
|
||||||
|
child_type='event_badge',
|
||||||
|
payload={
|
||||||
|
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
|
||||||
|
'given_name': '[e2e-test]',
|
||||||
|
'family_name': 'secondary-fk-regression',
|
||||||
|
'enable': False,
|
||||||
|
'hide': True,
|
||||||
|
},
|
||||||
|
required_fk_fields=['event_badge_template_id'],
|
||||||
|
))
|
||||||
|
|
||||||
elapsed = time.time() - suite_start
|
elapsed = time.time() - suite_start
|
||||||
if all(results):
|
if all(results):
|
||||||
|
|||||||
Reference in New Issue
Block a user