Spaces:
Sleeping
Sleeping
RBAC on key routes, otp to admin email on account creation
Browse files- docs/agent/research.md +272 -54
- src/app/api/v1/auth.py +211 -53
- src/app/api/v1/invitations.py +5 -0
- src/app/api/v1/users.py +9 -0
- src/app/core/permissions.py +197 -9
- src/app/services/invitation_service.py +2 -2
- src/app/services/otp_service.py +71 -0
docs/agent/research.md
CHANGED
|
@@ -1,74 +1,292 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
## How to Find Your Current Connection Strings ✅
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
```
|
| 19 |
-
postgresql://postgres:[YOUR-PASSWORD]@db.xxxxx.supabase.co:5432/postgres
|
| 20 |
-
```
|
| 21 |
-
- ⚠️ Requires IPv6 support
|
| 22 |
-
- Good for: VMs, long-running containers with IPv6
|
| 23 |
|
| 24 |
-
|
| 25 |
-
```
|
| 26 |
-
postgresql://postgres.xxxxx:[YOUR-PASSWORD]@aws-0-[region].pooler.supabase.com:5432/postgres
|
| 27 |
-
```
|
| 28 |
-
- ✅ Supports both IPv4 and IPv6
|
| 29 |
-
- Good for: Persistent servers, traditional backends
|
| 30 |
-
- **Use this for your FastAPI backend!**
|
| 31 |
|
| 32 |
-
|
| 33 |
-
```
|
| 34 |
-
|
| 35 |
-
``
|
| 36 |
-
-
|
| 37 |
-
-
|
| 38 |
-
-
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
DATABASE_URL=postgresql://postgres.YOUR_PROJECT_REF:[YOUR-PASSWORD]@aws-0-[YOUR-REGION].pooler.supabase.com:5432/postgres
|
| 46 |
-
```
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
-
|
| 71 |
-
- The password is your **database password**, not your Supabase account password
|
| 72 |
-
- If you don't remember your database password, you can reset it in Database Settings
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
I’ll review how user management works in this codebase (signup, auth, profile, roles, etc.), then give you a structured “grade” with what’s good, what’s weak, and concrete priority fixes.
|
| 2 |
|
| 3 |
+
First I need to scan the project (code + docs) for user/account/profile flows. I’ll set up a small todo list for this review, then search through `src` and relevant docs, and come back with findings and recommendations.
|
| 4 |
|
| 5 |
+
I’ll scan the code and docs for anything related to users, auth, accounts, and profiles, then summarize the end‑to‑end flow and grade it with concrete fixes you should do immediately. Let me search the repo first.
|
| 6 |
|
|
|
|
| 7 |
|
| 8 |
+
# High‑Level Summary: User Management Flow
|
| 9 |
|
| 10 |
+
From the code you’ve got a **pretty complete user lifecycle**:
|
| 11 |
|
| 12 |
+
- **Identity & Auth**
|
| 13 |
+
- Supabase Auth is the **source of truth** for authentication (email/password, tokens).
|
| 14 |
+
- Backend uses Supabase JWT (Bearer token) in `Authorization` header.
|
| 15 |
+
- `get_current_user` (in `api/deps.py`) validates token via `supabase_auth.get_user`, then loads the local `User` row.
|
| 16 |
+
- `get_current_active_user` additionally requires `user.is_active == True`.
|
| 17 |
|
| 18 |
+
- **Account Creation**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
There are **two main creation paths**:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
1. **Self‑Registration** (`POST /auth/register`)
|
| 23 |
+
- Calls `supabase_auth.sign_up` with `email`, `password`, and metadata (`first_name`, `last_name`, `phone`, `full_name`).
|
| 24 |
+
- Creates a local `User` mapped to `auth_user.id` with:
|
| 25 |
+
- `role = "platform_admin"` (**self‑registered users become platform admins**).
|
| 26 |
+
- `status = "invited"` (default from schema).
|
| 27 |
+
- `is_active = True`.
|
| 28 |
+
- Logs an audit event `register` and returns a token + basic user info.
|
| 29 |
|
| 30 |
+
2. **Invitation Flow** (`InvitationService.accept_invitation`)
|
| 31 |
+
- Admin (client/contractor/platform) invites users (not fully shown, but `accept_invitation` is).
|
| 32 |
+
- `accept_invitation`:
|
| 33 |
+
- Validates the invitation token.
|
| 34 |
+
- Ensures no existing user with the same email.
|
| 35 |
+
- Calls `supabase_auth.sign_up` and creates Supabase auth user.
|
| 36 |
+
- Creates local `User` with:
|
| 37 |
+
- `id = auth_user.id`.
|
| 38 |
+
- `email` from invitation.
|
| 39 |
+
- `name = first + last`.
|
| 40 |
+
- `phone` from acceptance or invitation.
|
| 41 |
+
- `role = invitation.invited_role`.
|
| 42 |
+
- `client_id` / `contractor_id` from invitation.
|
| 43 |
+
- `status = 'active'`, `is_active = True`.
|
| 44 |
+
- Marks invitation as accepted, commits, returns `access_token` + user details.
|
| 45 |
|
| 46 |
+
- **Login** (`POST /auth/login`)
|
| 47 |
+
- Calls `supabase_auth.sign_in` to validate credentials and get `access_token`.
|
| 48 |
+
- Loads local `User` by `auth_user.id`.
|
| 49 |
+
- If no local user → `404 "User profile not found"`.
|
| 50 |
+
- If `is_active == False` → audit failed login + `403 "Account is inactive"`.
|
| 51 |
+
- Otherwise, audit success + returns token + basic user info.
|
| 52 |
|
| 53 |
+
- **Profile Model & Building**
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
- `User` model (business profile) holds:
|
| 56 |
+
- Identity & contact: `name`, `email`, `phone`, `phone_alternate`, `display_name`, `id_number`.
|
| 57 |
+
- Org links: `client_id` or `contractor_id`.
|
| 58 |
+
- Role & status: `role`, `status`, `is_active`.
|
| 59 |
+
- Health & safety JSONB fields: `health_info`, `ppe_sizes`, plus emergency contacts.
|
| 60 |
+
- Location & geo: `current_location_*`, lat/long, updated timestamp.
|
| 61 |
+
- `additional_metadata` JSONB.
|
| 62 |
+
- Self‑service profile API (`api/v1/profile.py`):
|
| 63 |
+
- `GET /profile/me` returns:
|
| 64 |
+
- `basic_info` (from `BasicProfileResponse.from_orm(current_user)`).
|
| 65 |
+
- `health_info` (parsed from JSONB, with sensible defaults for missing fields).
|
| 66 |
+
- `ppe_sizes`.
|
| 67 |
+
- `location` details.
|
| 68 |
+
- Completion status: `ProfileService.calculate_profile_completion`.
|
| 69 |
+
- Counts: financial accounts, documents, asset assignments.
|
| 70 |
|
| 71 |
+
- **Profile Permissions & Editing**
|
| 72 |
|
| 73 |
+
- `ProfileService.check_edit_permission` encodes **hierarchical access**:
|
| 74 |
+
- Self can edit own profile (subject to field‑level checks elsewhere).
|
| 75 |
+
- `platform_admin` can edit anyone.
|
| 76 |
+
- `client_admin`/`contractor_admin` can edit users in their org.
|
| 77 |
+
- `project_manager` / `dispatcher` can edit `field_agent`s in shared projects.
|
| 78 |
+
- `ProfileService.get_profile_permissions` turns this into fine‑grained booleans:
|
| 79 |
+
- `can_edit_basic_info`, `can_edit_health_info`, `can_edit_ppe_sizes`,
|
| 80 |
+
`can_edit_location`, `can_edit_financial_accounts`, `can_upload_documents`,
|
| 81 |
+
`can_edit_role`, `can_edit_status`, `can_assign_assets`, and corresponding view permissions.
|
| 82 |
|
| 83 |
+
- **Roles & Authorization**
|
| 84 |
|
| 85 |
+
- `core/permissions.py` defines an `AppRole` enum and `ROLE_PERMISSIONS` map:
|
| 86 |
+
- Roles: `PLATFORM_ADMIN`, `CLIENT_ADMIN`, `CONTRACTOR_ADMIN`, `PROJECT_MANAGER`, `DISPATCHER`, `FIELD_AGENT`, `SALES_AGENT`, `SALES_MANAGER`.
|
| 87 |
+
- Each has a list of permission strings (e.g. `manage_projects`, `invite_users`, `view_team_performance`).
|
| 88 |
+
- `has_permission(role, permission)` supports generic checks.
|
| 89 |
+
- User update & admin actions (`UserService`):
|
| 90 |
+
- `check_update_permission`:
|
| 91 |
+
- `platform_admin` can change anyone.
|
| 92 |
+
- Org admins can update users in their org but cannot change org or promote to `platform_admin`.
|
| 93 |
+
- Self cannot use admin endpoints to change own role/status/org; must use profile APIs.
|
| 94 |
+
- `update_user`: handles basic fields, role, status, org changes, with audit‑style `changes` tracking.
|
| 95 |
+
- `change_user_status`: updates status + syncs `is_active` for `suspended`/`active`.
|
| 96 |
+
- `change_user_role`: validates and applies role changes.
|
| 97 |
+
- User admin endpoints (`api/v1/users.py` excerpts):
|
| 98 |
+
- `POST /users/{user_id}/status` → uses `UserService.change_user_status`, logs `status_change`.
|
| 99 |
+
- `POST /users/{user_id}/role` → uses `UserService.change_user_role`, logs `role_change`.
|
| 100 |
|
| 101 |
+
- **Statuses**
|
| 102 |
+
- Documented statuses: `invited`, `pending_setup`, `active`, `suspended`.
|
| 103 |
+
- `change_user_status` uses `status` string + maps `suspended` and `active` to `is_active False/True`.
|
| 104 |
|
| 105 |
+
---
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
# What You Did Well
|
| 108 |
+
|
| 109 |
+
- **Clear separation of auth vs business profile**
|
| 110 |
+
- Supabase handles auth (passwords, tokens).
|
| 111 |
+
- Local `User` table holds business & authorization data.
|
| 112 |
+
- `get_current_user` cleanly bridges the two.
|
| 113 |
+
|
| 114 |
+
- **Invitation‑based onboarding is solid**
|
| 115 |
+
- Token‑based invitations handled by `InvitationService`.
|
| 116 |
+
- Proper token validation, duplicate user check, and creation of both auth user + local profile.
|
| 117 |
+
- Good error handling and rollback on failure.
|
| 118 |
+
|
| 119 |
+
- **Strong admin & profile permission model**
|
| 120 |
+
- `UserService.check_update_permission` and `ProfileService.check_edit_permission` encode a realistic hierarchy:
|
| 121 |
+
- Platform admin → org admin → manager/dispatcher → self.
|
| 122 |
+
- Separation between **self profile editing** and **admin updates** is good design.
|
| 123 |
+
- Field‑level permission booleans in `ProfilePermissions` are explicit and easily testable.
|
| 124 |
+
|
| 125 |
+
- **Audit logging & rate limiting**
|
| 126 |
+
- Auth endpoints log audit events for `register` and `login` (both success and failure).
|
| 127 |
+
- Status/role changes log with old/new values and reason.
|
| 128 |
+
- Rate limits on `/auth/register` and `/auth/login` reduce brute force abuse.
|
| 129 |
+
|
| 130 |
+
- **Role definitions & permissions table**
|
| 131 |
+
- `AppRole` enum and `ROLE_PERMISSIONS` map give a centralized RBAC definition.
|
| 132 |
+
- `has_permission` helper is generic and allows future route‑level enforceable checks.
|
| 133 |
+
|
| 134 |
+
- **Good data modeling for field agents**
|
| 135 |
+
- Health info and PPE sizes as JSONB with nice mapping to Pydantic response models.
|
| 136 |
+
- Location fields + `current_location_updated_at` support live ops / dispatch use cases.
|
| 137 |
+
- `additional_metadata` JSONB gives you flexibility without schema churn.
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
# Weaknesses / Problems
|
| 142 |
+
|
| 143 |
+
## 1. Self‑registration as `platform_admin`
|
| 144 |
+
|
| 145 |
+
- `POST /auth/register` creates every self‑registered user as:
|
| 146 |
+
- `role = "platform_admin"`
|
| 147 |
+
- `is_active = True`
|
| 148 |
+
- `status = "invited"` (but effectively usable)
|
| 149 |
+
- This is **dangerous in production**. Anyone hitting `/auth/register` becomes the highest‑privilege user.
|
| 150 |
+
|
| 151 |
+
**Impact**
|
| 152 |
+
- Extremely high risk: if `/auth/register` is exposed, you’ve essentially got open platform‑admin account creation.
|
| 153 |
+
|
| 154 |
+
## 2. Status semantics are confusing / inconsistent
|
| 155 |
+
|
| 156 |
+
- For self‑registration:
|
| 157 |
+
- Status is set to `"invited"` while the user is immediately active and logged in.
|
| 158 |
+
- For invitation acceptance:
|
| 159 |
+
- Status is set to `"active"`.
|
| 160 |
+
- `login` uses only `is_active`, not `status`, so:
|
| 161 |
+
- A user with `status="invited"` but `is_active=True` can still log in.
|
| 162 |
+
- The `pending_setup` state is referenced in docs but I don’t see it enforced in this flow.
|
| 163 |
+
|
| 164 |
+
**Impact**
|
| 165 |
+
- Harder to reason about real lifecycle states (“invited but not registered”, “registered but not set up profile”, etc).
|
| 166 |
+
- Tooling and analytics relying on `status` may be incorrect.
|
| 167 |
+
|
| 168 |
+
## 3. RBAC implementation is only partially used
|
| 169 |
+
|
| 170 |
+
- `core/permissions.py` defines `ROLE_PERMISSIONS` and `has_permission`, but:
|
| 171 |
+
- I don’t see route‑level checks using `has_permission("role", "permission")`.
|
| 172 |
+
- Instead, some endpoints hand‑code role logic (e.g., profile and user service).
|
| 173 |
+
- This creates **duplication** and **risk of drift** between the central RBAC map and actual guards.
|
| 174 |
+
|
| 175 |
+
## 4. Suspended vs active inconsistencies at the auth provider
|
| 176 |
+
|
| 177 |
+
- `change_user_status` sets local `is_active` based on `status`, but:
|
| 178 |
+
- Supabase Auth account remains active; they can still get a valid token with correct password.
|
| 179 |
+
- You block them at the backend (`get_current_active_user`), which is good, but:
|
| 180 |
+
- You still incur auth load and leak some information (“account exists, but inactive”).
|
| 181 |
+
|
| 182 |
+
**Impact**
|
| 183 |
+
- Functionally safe at API level but not enforced at the auth provider.
|
| 184 |
+
- Not necessarily a bug, but security‑grade hardening could require aligning Supabase user’s `blocked`/`app_metadata` flags.
|
| 185 |
+
|
| 186 |
+
## 5. Inconsistent or legacy fields
|
| 187 |
+
|
| 188 |
+
- In `accept_invitation`, you set `activated_at=datetime.now(timezone.utc)`, yet the `User` model comments say:
|
| 189 |
+
- `invited_at` and `activated_at` were removed and tracked in `user_invitations`.
|
| 190 |
+
- If the DB column really is gone, this would fail at runtime; if it’s still there, comments are outdated and confusing.
|
| 191 |
+
|
| 192 |
+
## 6. Missing UX‑driven checks around profile completeness
|
| 193 |
+
|
| 194 |
+
- You compute `completion_status` in `ProfileService.calculate_profile_completion`, but:
|
| 195 |
+
- I haven’t seen it enforced anywhere (e.g., blocking certain actions until profile is complete).
|
| 196 |
+
- Users might stay in a partially configured state while still getting full access, depending on role.
|
| 197 |
+
|
| 198 |
+
## 7. Supabase helper edge cases
|
| 199 |
+
|
| 200 |
+
- `SupabaseAuth.sign_out` ignores the `access_token` parameter and just calls `self.client.auth.sign_out()` — works but:
|
| 201 |
+
- For multi‑session scenarios, you might want to specifically revoke that token.
|
| 202 |
+
- `update_user` sets session using the same access token as both access and refresh token (`set_session(access_token, access_token)`), which may not align perfectly with Supabase’s model.
|
| 203 |
+
- Could be fine but is brittle if Supabase changes expectations.
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
# What Needs Changing Right Away (High Priority)
|
| 208 |
+
|
| 209 |
+
1. **Stop self‑registration as platform admin**
|
| 210 |
+
- Change `register` flow so:
|
| 211 |
+
- Either:
|
| 212 |
+
- Self‑registration is disabled entirely in production, and all user creation goes through invitation / admin flows.
|
| 213 |
+
- Or: Self‑registered users get a **limited role** (e.g., `client_admin` for a new tenant, or `field_agent` for a specific app) with a safer onboarding path.
|
| 214 |
+
- Explicitly control who can become `platform_admin` via internal processes only.
|
| 215 |
+
|
| 216 |
+
2. **Clarify and normalize `status` & `is_active`**
|
| 217 |
+
- Decide on a consistent lifecycle like:
|
| 218 |
+
- `invited` → `pending_setup` → `active` → `suspended`.
|
| 219 |
+
- Enforce:
|
| 220 |
+
- `invited` and `pending_setup` users should either:
|
| 221 |
+
- Not be able to log in, or
|
| 222 |
+
- Be restricted to a setup flow only.
|
| 223 |
+
- Ensure `register` sets `status` appropriately (likely `active` or `pending_setup`, not `invited`).
|
| 224 |
+
|
| 225 |
+
3. **Wire route‑level checks to RBAC table**
|
| 226 |
+
- For endpoints that represent specific actions (e.g., manage users, manage projects, assign tickets):
|
| 227 |
+
- Use `has_permission(current_user.role, "<permission_name>")` instead of hard‑coding role checks everywhere.
|
| 228 |
+
- This will:
|
| 229 |
+
- Keep behavior consistent with `ROLE_PERMISSIONS`.
|
| 230 |
+
- Make it easier to adjust permissions centrally.
|
| 231 |
+
|
| 232 |
+
4. **Confirm `activated_at` / legacy fields**
|
| 233 |
+
- Either:
|
| 234 |
+
- Remove `activated_at=...` from `accept_invitation` if the column no longer exists.
|
| 235 |
+
- Or: Update `User` model + comments to reflect actual DB schema.
|
| 236 |
+
- Inconsistency here can cause subtle migration/runtime issues.
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
# Medium‑Priority Improvements
|
| 241 |
+
|
| 242 |
+
- **Supabase sync on suspension**
|
| 243 |
+
- Optionally, when suspending a user:
|
| 244 |
+
- Update their Supabase user (e.g., `user_metadata` or `app_metadata`) to mark them as suspended, or use any “blocked” mechanism available.
|
| 245 |
+
- This prevents them from even getting a token.
|
| 246 |
+
|
| 247 |
+
- **Enforce profile completeness for certain roles**
|
| 248 |
+
- For roles like `field_agent`:
|
| 249 |
+
- Require certain profile sections (PPE, emergency contacts, basic info) to be complete before:
|
| 250 |
+
- Accepting field tasks.
|
| 251 |
+
- Being assigned to risky operations.
|
| 252 |
+
- Gate important endpoints behind `completion_status` checks.
|
| 253 |
+
|
| 254 |
+
- **Lockout / abuse protection**
|
| 255 |
+
- You have rate limiting, which is good.
|
| 256 |
+
- Consider additional measures:
|
| 257 |
+
- Optional account lockout after N failed logins.
|
| 258 |
+
- Tracking login IP / device in metadata.
|
| 259 |
+
|
| 260 |
+
- **Better use of `ProfilePermissions`**
|
| 261 |
+
- Ensure all profile update endpoints (not just `GET /me`) consistently respect `ProfilePermissions` booleans to prevent accidental over‑editing by org roles.
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
# Low‑Priority / Nice to Have
|
| 266 |
+
|
| 267 |
+
- Use `ROLE_PERMISSIONS` not just for “system actions” but also for UI hints, so the frontend can hide/show features based on the same central map.
|
| 268 |
+
- Add tests for:
|
| 269 |
+
- `UserService.check_update_permission` across roles + org boundaries.
|
| 270 |
+
- `ProfileService.get_profile_permissions` permutations (self vs admin vs manager).
|
| 271 |
+
- Status transitions and how they affect `is_active` and login behavior.
|
| 272 |
+
|
| 273 |
+
---
|
| 274 |
+
|
| 275 |
+
# “Grade” of Your Work
|
| 276 |
+
|
| 277 |
+
If I had to assign letter grades per area:
|
| 278 |
+
|
| 279 |
+
- **Overall architecture (auth vs profile separation, use of services)**: **A**
|
| 280 |
+
- **RBAC design (roles, permissions, hierarchy)**: **B+**
|
| 281 |
+
- Solid structure, but not fully applied via `has_permission` yet.
|
| 282 |
+
- **Onboarding flows (invitation, profile creation)**: **A‑**
|
| 283 |
+
- Invitation flow is strong; self‑registration is the main issue.
|
| 284 |
+
- **Security hardening (roles for self‑registration, status semantics, suspension)**: **C**
|
| 285 |
+
- The platform_admin self‑registration is a serious concern and should be fixed before production exposure.
|
| 286 |
+
|
| 287 |
+
---
|
| 288 |
+
|
| 289 |
+
If you’d like, next step I can propose **concrete code changes** (files + specific modifications) to:
|
| 290 |
+
- Fix the self‑registration role.
|
| 291 |
+
- Normalize statuses and `is_active`.
|
| 292 |
+
- Introduce `has_permission` checks for a couple of key endpoints as a pattern.
|
src/app/api/v1/auth.py
CHANGED
|
@@ -20,77 +20,85 @@ logger = logging.getLogger(__name__)
|
|
| 20 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 21 |
|
| 22 |
|
| 23 |
-
@router.post("/register", response_model=
|
| 24 |
-
@limiter.limit("
|
| 25 |
async def register(request: Request, response: Response, user_data: UserCreate, db: Session = Depends(get_db)):
|
| 26 |
"""
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
try:
|
| 36 |
-
#
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
"first_name": user_data.first_name,
|
| 42 |
"last_name": user_data.last_name,
|
| 43 |
"phone": user_data.phone,
|
| 44 |
-
"
|
|
|
|
| 45 |
}
|
| 46 |
)
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
# Create user record in our users table
|
| 52 |
-
# Note: Self-registered users are created as platform_admin (no org link required)
|
| 53 |
-
# They can be assigned to client/contractor later by an admin
|
| 54 |
-
full_name = f"{user_data.first_name} {user_data.last_name}"
|
| 55 |
-
new_user = User(
|
| 56 |
-
id=auth_user.id, # Use Supabase Auth user ID
|
| 57 |
-
email=user_data.email,
|
| 58 |
-
name=full_name, # Store full name in 'name' field (matches schema)
|
| 59 |
-
phone=user_data.phone,
|
| 60 |
-
is_active=True,
|
| 61 |
-
role="platform_admin", # Self-registered users start as platform_admin
|
| 62 |
-
status="invited" # Default status from schema
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
db.add(new_user)
|
| 66 |
-
db.commit()
|
| 67 |
-
db.refresh(new_user)
|
| 68 |
-
|
| 69 |
-
# Audit log
|
| 70 |
-
AuditService.log_action(
|
| 71 |
db=db,
|
| 72 |
action='register',
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
description=f"New user registered: {user_data.email}",
|
| 76 |
-
user=new_user,
|
| 77 |
request=request,
|
| 78 |
-
|
| 79 |
)
|
| 80 |
|
| 81 |
-
logger.info(f"
|
| 82 |
|
| 83 |
return {
|
| 84 |
-
"
|
| 85 |
-
|
| 86 |
-
"user": {
|
| 87 |
-
"id": str(new_user.id),
|
| 88 |
-
"email": new_user.email,
|
| 89 |
-
"first_name": new_user.first_name,
|
| 90 |
-
"last_name": new_user.last_name,
|
| 91 |
-
"full_name": new_user.full_name,
|
| 92 |
-
"is_active": new_user.is_active
|
| 93 |
-
}
|
| 94 |
}
|
| 95 |
|
| 96 |
except Exception as e:
|
|
@@ -447,6 +455,156 @@ async def reset_password(
|
|
| 447 |
raise
|
| 448 |
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
@router.post("/logout", response_model=MessageResponse, status_code=status.HTTP_200_OK)
|
| 451 |
async def logout(
|
| 452 |
request: Request,
|
|
|
|
| 20 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 21 |
|
| 22 |
|
| 23 |
+
@router.post("/register", response_model=MessageResponse, status_code=status.HTTP_200_OK)
|
| 24 |
+
@limiter.limit("3/hour") # 3 registration attempts per hour per IP (reduced for security)
|
| 25 |
async def register(request: Request, response: Response, user_data: UserCreate, db: Session = Depends(get_db)):
|
| 26 |
"""
|
| 27 |
+
🔒 SECURE Platform Admin Registration - Requires OTP Verification
|
| 28 |
|
| 29 |
+
**SECURITY MODEL:**
|
| 30 |
+
- Self-registration is DISABLED for regular users (must use invitation flow)
|
| 31 |
+
- Platform admin creation requires OTP verification sent to authorized email
|
| 32 |
+
- Only lewiskimaru01@gmail.com can receive platform admin OTPs
|
| 33 |
+
|
| 34 |
+
**Process:**
|
| 35 |
+
1. Submit registration request
|
| 36 |
+
2. OTP sent to lewiskimaru01@gmail.com
|
| 37 |
+
3. Use OTP to verify via /otp/verify-public endpoint
|
| 38 |
+
4. After OTP verification, account is created as platform_admin
|
| 39 |
+
|
| 40 |
+
**Note:** Regular users (field agents, managers, etc.) must be invited by admins.
|
| 41 |
"""
|
| 42 |
+
# Security check: Only allow platform admin registration for authorized email
|
| 43 |
+
PLATFORM_ADMIN_EMAIL = "lewiskimaru01@gmail.com"
|
| 44 |
+
|
| 45 |
+
# Check if this is an attempt to create a platform admin account
|
| 46 |
+
# Platform admin accounts can only be created via OTP verification
|
| 47 |
try:
|
| 48 |
+
# Check if user already exists
|
| 49 |
+
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
| 50 |
+
if existing_user:
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 53 |
+
detail="Email already registered"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# For security, we don't create the account immediately
|
| 57 |
+
# Instead, we send OTP to the authorized platform admin email
|
| 58 |
+
# and store the registration request temporarily
|
| 59 |
+
|
| 60 |
+
# Import OTP service
|
| 61 |
+
from app.services.otp_service import OTPService
|
| 62 |
+
otp_service = OTPService.get_instance()
|
| 63 |
+
|
| 64 |
+
# Generate OTP and send to authorized admin email
|
| 65 |
+
otp_result = await otp_service.send_otp(
|
| 66 |
+
identifier=user_data.email, # Use registration email as identifier
|
| 67 |
+
channel="email", # Force email delivery
|
| 68 |
+
recipient=PLATFORM_ADMIN_EMAIL, # Always send to authorized admin
|
| 69 |
+
purpose=f"Platform Admin Registration: {user_data.email}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Store registration data in OTP metadata for later use
|
| 73 |
+
# When OTP is verified, we'll complete the registration
|
| 74 |
+
await otp_service.store_registration_data(
|
| 75 |
+
identifier=user_data.email,
|
| 76 |
+
data={
|
| 77 |
+
"email": user_data.email,
|
| 78 |
+
"password": user_data.password, # Temporarily stored, will be hashed by Supabase
|
| 79 |
"first_name": user_data.first_name,
|
| 80 |
"last_name": user_data.last_name,
|
| 81 |
"phone": user_data.phone,
|
| 82 |
+
"role": "platform_admin",
|
| 83 |
+
"registration_type": "platform_admin_otp"
|
| 84 |
}
|
| 85 |
)
|
| 86 |
|
| 87 |
+
# Audit log - registration request (not completed yet)
|
| 88 |
+
AuditService.log_auth_event(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
db=db,
|
| 90 |
action='register',
|
| 91 |
+
user_email=user_data.email,
|
| 92 |
+
success=False, # Not completed yet
|
|
|
|
|
|
|
| 93 |
request=request,
|
| 94 |
+
reason=f"OTP verification required - sent to {PLATFORM_ADMIN_EMAIL}"
|
| 95 |
)
|
| 96 |
|
| 97 |
+
logger.info(f"Platform admin registration OTP sent for: {user_data.email}")
|
| 98 |
|
| 99 |
return {
|
| 100 |
+
"message": f"Registration request received. OTP verification code sent to {PLATFORM_ADMIN_EMAIL}. "
|
| 101 |
+
f"Please check your email and verify using /otp/verify-public endpoint with identifier='{user_data.email}'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
except Exception as e:
|
|
|
|
| 455 |
raise
|
| 456 |
|
| 457 |
|
| 458 |
+
@router.post("/complete-registration", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
| 459 |
+
@limiter.limit("5/hour") # 5 completion attempts per hour per IP
|
| 460 |
+
async def complete_registration(
|
| 461 |
+
request: Request,
|
| 462 |
+
response: Response,
|
| 463 |
+
email: str,
|
| 464 |
+
otp_code: str,
|
| 465 |
+
db: Session = Depends(get_db)
|
| 466 |
+
):
|
| 467 |
+
"""
|
| 468 |
+
Complete platform admin registration after OTP verification
|
| 469 |
+
|
| 470 |
+
**Process:**
|
| 471 |
+
1. User submits registration via /auth/register
|
| 472 |
+
2. OTP sent to lewiskimaru01@gmail.com
|
| 473 |
+
3. User calls this endpoint with OTP code to complete registration
|
| 474 |
+
|
| 475 |
+
**Parameters:**
|
| 476 |
+
- **email**: Email address used during registration
|
| 477 |
+
- **otp_code**: 6-digit OTP code sent to admin email
|
| 478 |
+
|
| 479 |
+
**Security:** OTP must be verified before account creation
|
| 480 |
+
"""
|
| 481 |
+
try:
|
| 482 |
+
# Import OTP service
|
| 483 |
+
from app.services.otp_service import OTPService
|
| 484 |
+
otp_service = OTPService.get_instance()
|
| 485 |
+
|
| 486 |
+
# Verify OTP first
|
| 487 |
+
verification_result = otp_service.verify_otp(
|
| 488 |
+
email=email,
|
| 489 |
+
phone=None,
|
| 490 |
+
code=otp_code,
|
| 491 |
+
purpose="default", # Registration OTPs use default purpose
|
| 492 |
+
db=db
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
if not verification_result['verified']:
|
| 496 |
+
raise HTTPException(
|
| 497 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 498 |
+
detail=f"Invalid or expired OTP. {verification_result.get('attempts_remaining', 0)} attempts remaining."
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
# Retrieve stored registration data
|
| 502 |
+
registration_data = await otp_service.get_registration_data(email)
|
| 503 |
+
|
| 504 |
+
if not registration_data:
|
| 505 |
+
raise HTTPException(
|
| 506 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 507 |
+
detail="Registration data not found or expired. Please start registration process again."
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# Verify this is a platform admin registration
|
| 511 |
+
if registration_data.get('registration_type') != 'platform_admin_otp':
|
| 512 |
+
raise HTTPException(
|
| 513 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 514 |
+
detail="Invalid registration type"
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
# Check if user already exists (double-check)
|
| 518 |
+
existing_user = db.query(User).filter(User.email == email).first()
|
| 519 |
+
if existing_user:
|
| 520 |
+
raise HTTPException(
|
| 521 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 522 |
+
detail="Email already registered"
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# Now create the Supabase auth user
|
| 526 |
+
auth_response = await supabase_auth.sign_up(
|
| 527 |
+
email=registration_data['email'],
|
| 528 |
+
password=registration_data['password'],
|
| 529 |
+
user_metadata={
|
| 530 |
+
"first_name": registration_data['first_name'],
|
| 531 |
+
"last_name": registration_data['last_name'],
|
| 532 |
+
"phone": registration_data.get('phone'),
|
| 533 |
+
"full_name": f"{registration_data['first_name']} {registration_data['last_name']}"
|
| 534 |
+
}
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
auth_user = auth_response["user"]
|
| 538 |
+
session = auth_response["session"]
|
| 539 |
+
|
| 540 |
+
# Create user record in our database
|
| 541 |
+
full_name = f"{registration_data['first_name']} {registration_data['last_name']}"
|
| 542 |
+
new_user = User(
|
| 543 |
+
id=auth_user.id,
|
| 544 |
+
email=registration_data['email'],
|
| 545 |
+
name=full_name,
|
| 546 |
+
phone=registration_data.get('phone'),
|
| 547 |
+
is_active=True,
|
| 548 |
+
role="platform_admin", # Verified platform admin
|
| 549 |
+
status="active" # Active after OTP verification
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
db.add(new_user)
|
| 553 |
+
db.commit()
|
| 554 |
+
db.refresh(new_user)
|
| 555 |
+
|
| 556 |
+
# Audit log - successful registration
|
| 557 |
+
AuditService.log_action(
|
| 558 |
+
db=db,
|
| 559 |
+
action='create',
|
| 560 |
+
entity_type='user',
|
| 561 |
+
entity_id=str(new_user.id),
|
| 562 |
+
description=f"Platform admin account created (OTP verified): {registration_data['email']}",
|
| 563 |
+
user=new_user,
|
| 564 |
+
request=request,
|
| 565 |
+
additional_metadata={
|
| 566 |
+
'role': new_user.role,
|
| 567 |
+
'verification_method': 'otp_email'
|
| 568 |
+
}
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
logger.info(f"Platform admin account created successfully: {registration_data['email']}")
|
| 572 |
+
|
| 573 |
+
return {
|
| 574 |
+
"access_token": session.access_token,
|
| 575 |
+
"token_type": "bearer",
|
| 576 |
+
"user": {
|
| 577 |
+
"id": str(new_user.id),
|
| 578 |
+
"email": new_user.email,
|
| 579 |
+
"first_name": new_user.first_name,
|
| 580 |
+
"last_name": new_user.last_name,
|
| 581 |
+
"full_name": new_user.full_name,
|
| 582 |
+
"is_active": new_user.is_active
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
except HTTPException:
|
| 587 |
+
raise
|
| 588 |
+
except Exception as e:
|
| 589 |
+
logger.error(f"Complete registration error: {str(e)}")
|
| 590 |
+
db.rollback()
|
| 591 |
+
|
| 592 |
+
# Audit failed completion
|
| 593 |
+
AuditService.log_auth_event(
|
| 594 |
+
db=db,
|
| 595 |
+
action='register',
|
| 596 |
+
user_email=email,
|
| 597 |
+
success=False,
|
| 598 |
+
request=request,
|
| 599 |
+
reason=f"Registration completion failed: {str(e)}"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
raise HTTPException(
|
| 603 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 604 |
+
detail=f"Failed to complete registration: {str(e)}"
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
|
| 608 |
@router.post("/logout", response_model=MessageResponse, status_code=status.HTTP_200_OK)
|
| 609 |
async def logout(
|
| 610 |
request: Request,
|
src/app/api/v1/invitations.py
CHANGED
|
@@ -22,6 +22,7 @@ from app.schemas.invitation import (
|
|
| 22 |
)
|
| 23 |
from app.services.invitation_service import InvitationService
|
| 24 |
from app.schemas.auth import TokenResponse
|
|
|
|
| 25 |
import logging
|
| 26 |
import math
|
| 27 |
|
|
@@ -33,6 +34,7 @@ invitation_service = InvitationService()
|
|
| 33 |
|
| 34 |
|
| 35 |
@router.post("", response_model=InvitationResponse, status_code=status.HTTP_201_CREATED)
|
|
|
|
| 36 |
async def create_invitation(
|
| 37 |
invitation_data: InvitationCreate,
|
| 38 |
db: Session = Depends(get_db),
|
|
@@ -63,6 +65,7 @@ async def create_invitation(
|
|
| 63 |
|
| 64 |
|
| 65 |
@router.get("", response_model=InvitationListResponse)
|
|
|
|
| 66 |
async def list_invitations(
|
| 67 |
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
| 68 |
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
@@ -177,6 +180,7 @@ async def get_invitation(
|
|
| 177 |
|
| 178 |
|
| 179 |
@router.post("/{invitation_id}/resend", response_model=InvitationResponse)
|
|
|
|
| 180 |
async def resend_invitation(
|
| 181 |
invitation_id: UUID,
|
| 182 |
resend_data: InvitationResend,
|
|
@@ -201,6 +205,7 @@ async def resend_invitation(
|
|
| 201 |
|
| 202 |
|
| 203 |
@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
| 204 |
async def cancel_invitation(
|
| 205 |
invitation_id: UUID,
|
| 206 |
db: Session = Depends(get_db),
|
|
|
|
| 22 |
)
|
| 23 |
from app.services.invitation_service import InvitationService
|
| 24 |
from app.schemas.auth import TokenResponse
|
| 25 |
+
from app.core.permissions import require_permission
|
| 26 |
import logging
|
| 27 |
import math
|
| 28 |
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
@router.post("", response_model=InvitationResponse, status_code=status.HTTP_201_CREATED)
|
| 37 |
+
@require_permission("invite_users")
|
| 38 |
async def create_invitation(
|
| 39 |
invitation_data: InvitationCreate,
|
| 40 |
db: Session = Depends(get_db),
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
@router.get("", response_model=InvitationListResponse)
|
| 68 |
+
@require_permission("invite_users")
|
| 69 |
async def list_invitations(
|
| 70 |
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
| 71 |
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@router.post("/{invitation_id}/resend", response_model=InvitationResponse)
|
| 183 |
+
@require_permission("invite_users")
|
| 184 |
async def resend_invitation(
|
| 185 |
invitation_id: UUID,
|
| 186 |
resend_data: InvitationResend,
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
@router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 208 |
+
@require_permission("invite_users")
|
| 209 |
async def cancel_invitation(
|
| 210 |
invitation_id: UUID,
|
| 211 |
db: Session = Depends(get_db),
|
src/app/api/v1/users.py
CHANGED
|
@@ -17,6 +17,7 @@ from app.schemas.user import (
|
|
| 17 |
)
|
| 18 |
from app.services.user_service import UserService
|
| 19 |
from app.services.audit_service import AuditService
|
|
|
|
| 20 |
import logging
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
|
@@ -24,6 +25,7 @@ router = APIRouter(prefix="/users", tags=["Users"])
|
|
| 24 |
|
| 25 |
|
| 26 |
@router.get("", response_model=UserListResponse)
|
|
|
|
| 27 |
async def list_users(
|
| 28 |
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
| 29 |
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
@@ -156,6 +158,7 @@ async def get_user(
|
|
| 156 |
# ============================================
|
| 157 |
|
| 158 |
@router.put("/{user_id}", response_model=UserResponse)
|
|
|
|
| 159 |
async def update_user(
|
| 160 |
user_id: UUID,
|
| 161 |
data: UserUpdateAdmin,
|
|
@@ -210,6 +213,7 @@ async def update_user(
|
|
| 210 |
|
| 211 |
|
| 212 |
@router.post("/{user_id}/status", response_model=UserResponse)
|
|
|
|
| 213 |
async def change_user_status(
|
| 214 |
user_id: UUID,
|
| 215 |
data: UserStatusUpdate,
|
|
@@ -261,6 +265,7 @@ async def change_user_status(
|
|
| 261 |
|
| 262 |
|
| 263 |
@router.post("/{user_id}/role", response_model=UserResponse)
|
|
|
|
| 264 |
async def change_user_role(
|
| 265 |
user_id: UUID,
|
| 266 |
data: UserRoleUpdate,
|
|
@@ -313,6 +318,7 @@ async def change_user_role(
|
|
| 313 |
|
| 314 |
|
| 315 |
@router.post("/{user_id}/organization", response_model=UserResponse)
|
|
|
|
| 316 |
async def change_user_organization(
|
| 317 |
user_id: UUID,
|
| 318 |
data: UserOrganizationUpdate,
|
|
@@ -374,6 +380,7 @@ async def change_user_organization(
|
|
| 374 |
# ============================================
|
| 375 |
|
| 376 |
@router.post("/{user_id}/deactivate", response_model=UserResponse)
|
|
|
|
| 377 |
async def deactivate_user(
|
| 378 |
user_id: UUID,
|
| 379 |
reason: Optional[str] = Query(None, description="Reason for deactivation"),
|
|
@@ -420,6 +427,7 @@ async def deactivate_user(
|
|
| 420 |
|
| 421 |
|
| 422 |
@router.post("/{user_id}/activate", response_model=UserResponse)
|
|
|
|
| 423 |
async def activate_user(
|
| 424 |
user_id: UUID,
|
| 425 |
request: Request,
|
|
@@ -468,6 +476,7 @@ async def activate_user(
|
|
| 468 |
# ============================================
|
| 469 |
|
| 470 |
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
| 471 |
async def delete_user(
|
| 472 |
user_id: UUID,
|
| 473 |
reason: Optional[str] = Query(None, description="Reason for deletion"),
|
|
|
|
| 17 |
)
|
| 18 |
from app.services.user_service import UserService
|
| 19 |
from app.services.audit_service import AuditService
|
| 20 |
+
from app.core.permissions import require_permission, require_role, AppRole
|
| 21 |
import logging
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
@router.get("", response_model=UserListResponse)
|
| 28 |
+
@require_permission("view_users")
|
| 29 |
async def list_users(
|
| 30 |
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
| 31 |
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
|
|
| 158 |
# ============================================
|
| 159 |
|
| 160 |
@router.put("/{user_id}", response_model=UserResponse)
|
| 161 |
+
@require_permission("manage_org_users")
|
| 162 |
async def update_user(
|
| 163 |
user_id: UUID,
|
| 164 |
data: UserUpdateAdmin,
|
|
|
|
| 213 |
|
| 214 |
|
| 215 |
@router.post("/{user_id}/status", response_model=UserResponse)
|
| 216 |
+
@require_permission("manage_org_users")
|
| 217 |
async def change_user_status(
|
| 218 |
user_id: UUID,
|
| 219 |
data: UserStatusUpdate,
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
@router.post("/{user_id}/role", response_model=UserResponse)
|
| 268 |
+
@require_permission("manage_org_users")
|
| 269 |
async def change_user_role(
|
| 270 |
user_id: UUID,
|
| 271 |
data: UserRoleUpdate,
|
|
|
|
| 318 |
|
| 319 |
|
| 320 |
@router.post("/{user_id}/organization", response_model=UserResponse)
|
| 321 |
+
@require_role(AppRole.PLATFORM_ADMIN) # Only platform admins can move users between orgs
|
| 322 |
async def change_user_organization(
|
| 323 |
user_id: UUID,
|
| 324 |
data: UserOrganizationUpdate,
|
|
|
|
| 380 |
# ============================================
|
| 381 |
|
| 382 |
@router.post("/{user_id}/deactivate", response_model=UserResponse)
|
| 383 |
+
@require_permission("manage_org_users")
|
| 384 |
async def deactivate_user(
|
| 385 |
user_id: UUID,
|
| 386 |
reason: Optional[str] = Query(None, description="Reason for deactivation"),
|
|
|
|
| 427 |
|
| 428 |
|
| 429 |
@router.post("/{user_id}/activate", response_model=UserResponse)
|
| 430 |
+
@require_permission("manage_org_users")
|
| 431 |
async def activate_user(
|
| 432 |
user_id: UUID,
|
| 433 |
request: Request,
|
|
|
|
| 476 |
# ============================================
|
| 477 |
|
| 478 |
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 479 |
+
@require_role(AppRole.PLATFORM_ADMIN) # Only platform admins can delete users
|
| 480 |
async def delete_user(
|
| 481 |
user_id: UUID,
|
| 482 |
reason: Optional[str] = Query(None, description="Reason for deletion"),
|
src/app/core/permissions.py
CHANGED
|
@@ -2,7 +2,12 @@
|
|
| 2 |
Role-Based Access Control (RBAC) Definitions
|
| 3 |
"""
|
| 4 |
from enum import Enum
|
| 5 |
-
from typing import List, Dict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class AppRole(str, Enum):
|
|
@@ -21,44 +26,79 @@ class AppRole(str, Enum):
|
|
| 21 |
ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
|
| 22 |
AppRole.PLATFORM_ADMIN: ["*"], # All permissions
|
| 23 |
AppRole.CLIENT_ADMIN: [
|
| 24 |
-
|
|
|
|
| 25 |
"invite_users",
|
|
|
|
|
|
|
|
|
|
| 26 |
"view_reports",
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
],
|
| 29 |
AppRole.CONTRACTOR_ADMIN: [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"manage_agents",
|
|
|
|
| 31 |
"accept_projects",
|
| 32 |
"view_payroll",
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
],
|
| 35 |
AppRole.PROJECT_MANAGER: [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
"create_tickets",
|
| 37 |
"manage_inventory",
|
| 38 |
"approve_expenses",
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
],
|
| 41 |
AppRole.DISPATCHER: [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
"assign_tickets",
|
| 43 |
"issue_equipment",
|
| 44 |
"approve_expenses",
|
| 45 |
-
|
|
|
|
| 46 |
],
|
| 47 |
AppRole.FIELD_AGENT: [
|
|
|
|
| 48 |
"view_tickets",
|
| 49 |
"update_ticket_status",
|
| 50 |
"log_expenses",
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
],
|
| 53 |
AppRole.SALES_AGENT: [
|
|
|
|
| 54 |
"create_orders",
|
| 55 |
"view_customers",
|
| 56 |
"view_own_performance"
|
| 57 |
],
|
| 58 |
AppRole.SALES_MANAGER: [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
"view_team_performance",
|
| 60 |
-
"export_reports"
|
| 61 |
-
"manage_sales_agents"
|
| 62 |
]
|
| 63 |
}
|
| 64 |
|
|
@@ -66,9 +106,157 @@ ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
|
|
| 66 |
def has_permission(role: AppRole, permission: str) -> bool:
|
| 67 |
"""
|
| 68 |
Check if a role has a specific permission
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
"""
|
| 70 |
if role == AppRole.PLATFORM_ADMIN:
|
| 71 |
return True
|
| 72 |
|
| 73 |
role_perms = ROLE_PERMISSIONS.get(role, [])
|
| 74 |
return permission in role_perms or "*" in role_perms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
Role-Based Access Control (RBAC) Definitions
|
| 3 |
"""
|
| 4 |
from enum import Enum
|
| 5 |
+
from typing import List, Dict, Callable
|
| 6 |
+
from functools import wraps
|
| 7 |
+
from fastapi import HTTPException, status, Depends
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
|
| 12 |
|
| 13 |
class AppRole(str, Enum):
|
|
|
|
| 26 |
ROLE_PERMISSIONS: Dict[AppRole, List[str]] = {
|
| 27 |
AppRole.PLATFORM_ADMIN: ["*"], # All permissions
|
| 28 |
AppRole.CLIENT_ADMIN: [
|
| 29 |
+
# User Management
|
| 30 |
+
"view_users",
|
| 31 |
"invite_users",
|
| 32 |
+
"manage_org_users", # Can manage users in their organization
|
| 33 |
+
# Project Management
|
| 34 |
+
"manage_projects",
|
| 35 |
"view_reports",
|
| 36 |
+
# Sales
|
| 37 |
+
"manage_sales_orders",
|
| 38 |
+
# Documents
|
| 39 |
+
"view_documents",
|
| 40 |
+
"upload_documents"
|
| 41 |
],
|
| 42 |
AppRole.CONTRACTOR_ADMIN: [
|
| 43 |
+
# User Management
|
| 44 |
+
"view_users",
|
| 45 |
+
"invite_users",
|
| 46 |
+
"manage_org_users", # Can manage users in their organization
|
| 47 |
"manage_agents",
|
| 48 |
+
# Project Management
|
| 49 |
"accept_projects",
|
| 50 |
"view_payroll",
|
| 51 |
+
# Inventory
|
| 52 |
+
"manage_inventory",
|
| 53 |
+
# Documents
|
| 54 |
+
"view_documents",
|
| 55 |
+
"upload_documents"
|
| 56 |
],
|
| 57 |
AppRole.PROJECT_MANAGER: [
|
| 58 |
+
# User Management
|
| 59 |
+
"view_users",
|
| 60 |
+
"view_team_performance",
|
| 61 |
+
# Project Management
|
| 62 |
"create_tickets",
|
| 63 |
"manage_inventory",
|
| 64 |
"approve_expenses",
|
| 65 |
+
# Documents
|
| 66 |
+
"view_documents",
|
| 67 |
+
"upload_documents"
|
| 68 |
],
|
| 69 |
AppRole.DISPATCHER: [
|
| 70 |
+
# User Management
|
| 71 |
+
"view_users",
|
| 72 |
+
"view_agent_locations",
|
| 73 |
+
# Task Management
|
| 74 |
"assign_tickets",
|
| 75 |
"issue_equipment",
|
| 76 |
"approve_expenses",
|
| 77 |
+
# Documents
|
| 78 |
+
"view_documents"
|
| 79 |
],
|
| 80 |
AppRole.FIELD_AGENT: [
|
| 81 |
+
# Task Management
|
| 82 |
"view_tickets",
|
| 83 |
"update_ticket_status",
|
| 84 |
"log_expenses",
|
| 85 |
+
# Documents
|
| 86 |
+
"upload_photos",
|
| 87 |
+
"view_own_documents"
|
| 88 |
],
|
| 89 |
AppRole.SALES_AGENT: [
|
| 90 |
+
# Sales
|
| 91 |
"create_orders",
|
| 92 |
"view_customers",
|
| 93 |
"view_own_performance"
|
| 94 |
],
|
| 95 |
AppRole.SALES_MANAGER: [
|
| 96 |
+
# User Management
|
| 97 |
+
"view_users",
|
| 98 |
+
"manage_sales_agents",
|
| 99 |
+
# Sales & Reports
|
| 100 |
"view_team_performance",
|
| 101 |
+
"export_reports"
|
|
|
|
| 102 |
]
|
| 103 |
}
|
| 104 |
|
|
|
|
| 106 |
def has_permission(role: AppRole, permission: str) -> bool:
|
| 107 |
"""
|
| 108 |
Check if a role has a specific permission
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
role: User's role
|
| 112 |
+
permission: Permission to check (e.g., 'manage_projects', 'invite_users')
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
True if role has permission, False otherwise
|
| 116 |
"""
|
| 117 |
if role == AppRole.PLATFORM_ADMIN:
|
| 118 |
return True
|
| 119 |
|
| 120 |
role_perms = ROLE_PERMISSIONS.get(role, [])
|
| 121 |
return permission in role_perms or "*" in role_perms
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def require_permission(permission: str):
|
| 125 |
+
"""
|
| 126 |
+
Decorator to enforce permission checks on route handlers
|
| 127 |
+
|
| 128 |
+
Usage:
|
| 129 |
+
@router.get("/users")
|
| 130 |
+
@require_permission("manage_users")
|
| 131 |
+
async def list_users(current_user: User = Depends(get_current_active_user)):
|
| 132 |
+
...
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
permission: Required permission string
|
| 136 |
+
|
| 137 |
+
Raises:
|
| 138 |
+
HTTPException 403: If user doesn't have required permission
|
| 139 |
+
"""
|
| 140 |
+
def decorator(func: Callable):
|
| 141 |
+
@wraps(func)
|
| 142 |
+
async def wrapper(*args, **kwargs):
|
| 143 |
+
# Extract current_user from kwargs (injected by Depends(get_current_active_user))
|
| 144 |
+
current_user = kwargs.get('current_user')
|
| 145 |
+
|
| 146 |
+
if not current_user:
|
| 147 |
+
logger.error(f"Permission check failed: No current_user in kwargs for {func.__name__}")
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 150 |
+
detail="Authentication required"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Check if user has permission
|
| 154 |
+
user_role = current_user.role
|
| 155 |
+
if not has_permission(AppRole(user_role), permission):
|
| 156 |
+
logger.warning(
|
| 157 |
+
f"Permission denied: User {current_user.email} (role={user_role}) "
|
| 158 |
+
f"attempted {func.__name__} requiring '{permission}'"
|
| 159 |
+
)
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 162 |
+
detail=f"Insufficient permissions. Required: {permission}"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# User has permission, execute the route handler
|
| 166 |
+
return await func(*args, **kwargs)
|
| 167 |
+
|
| 168 |
+
return wrapper
|
| 169 |
+
return decorator
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def require_any_permission(*permissions: str):
|
| 173 |
+
"""
|
| 174 |
+
Decorator to enforce that user has at least one of the specified permissions
|
| 175 |
+
|
| 176 |
+
Usage:
|
| 177 |
+
@router.get("/reports")
|
| 178 |
+
@require_any_permission("view_reports", "export_reports")
|
| 179 |
+
async def get_reports(current_user: User = Depends(get_current_active_user)):
|
| 180 |
+
...
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
*permissions: List of acceptable permissions (user needs at least one)
|
| 184 |
+
|
| 185 |
+
Raises:
|
| 186 |
+
HTTPException 403: If user doesn't have any of the required permissions
|
| 187 |
+
"""
|
| 188 |
+
def decorator(func: Callable):
|
| 189 |
+
@wraps(func)
|
| 190 |
+
async def wrapper(*args, **kwargs):
|
| 191 |
+
current_user = kwargs.get('current_user')
|
| 192 |
+
|
| 193 |
+
if not current_user:
|
| 194 |
+
logger.error(f"Permission check failed: No current_user in kwargs for {func.__name__}")
|
| 195 |
+
raise HTTPException(
|
| 196 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 197 |
+
detail="Authentication required"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Check if user has any of the permissions
|
| 201 |
+
user_role = AppRole(current_user.role)
|
| 202 |
+
has_any = any(has_permission(user_role, perm) for perm in permissions)
|
| 203 |
+
|
| 204 |
+
if not has_any:
|
| 205 |
+
logger.warning(
|
| 206 |
+
f"Permission denied: User {current_user.email} (role={current_user.role}) "
|
| 207 |
+
f"attempted {func.__name__} requiring one of: {', '.join(permissions)}"
|
| 208 |
+
)
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 211 |
+
detail=f"Insufficient permissions. Required one of: {', '.join(permissions)}"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
return await func(*args, **kwargs)
|
| 215 |
+
|
| 216 |
+
return wrapper
|
| 217 |
+
return decorator
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def require_role(*roles: AppRole):
|
| 221 |
+
"""
|
| 222 |
+
Decorator to enforce specific role requirements (simpler than permission-based)
|
| 223 |
+
|
| 224 |
+
Usage:
|
| 225 |
+
@router.post("/admin/settings")
|
| 226 |
+
@require_role(AppRole.PLATFORM_ADMIN)
|
| 227 |
+
async def update_settings(current_user: User = Depends(get_current_active_user)):
|
| 228 |
+
...
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
*roles: List of acceptable roles
|
| 232 |
+
|
| 233 |
+
Raises:
|
| 234 |
+
HTTPException 403: If user doesn't have one of the required roles
|
| 235 |
+
"""
|
| 236 |
+
def decorator(func: Callable):
|
| 237 |
+
@wraps(func)
|
| 238 |
+
async def wrapper(*args, **kwargs):
|
| 239 |
+
current_user = kwargs.get('current_user')
|
| 240 |
+
|
| 241 |
+
if not current_user:
|
| 242 |
+
logger.error(f"Role check failed: No current_user in kwargs for {func.__name__}")
|
| 243 |
+
raise HTTPException(
|
| 244 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 245 |
+
detail="Authentication required"
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# Check if user has one of the required roles
|
| 249 |
+
if current_user.role not in [role.value for role in roles]:
|
| 250 |
+
logger.warning(
|
| 251 |
+
f"Role check failed: User {current_user.email} (role={current_user.role}) "
|
| 252 |
+
f"attempted {func.__name__} requiring role: {', '.join([r.value for r in roles])}"
|
| 253 |
+
)
|
| 254 |
+
raise HTTPException(
|
| 255 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 256 |
+
detail=f"Insufficient privileges. Required role: {', '.join([r.value for r in roles])}"
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
return await func(*args, **kwargs)
|
| 260 |
+
|
| 261 |
+
return wrapper
|
| 262 |
+
return decorator
|
src/app/services/invitation_service.py
CHANGED
|
@@ -187,8 +187,8 @@ class InvitationService:
|
|
| 187 |
role=invitation.invited_role,
|
| 188 |
status='active', # User is active after accepting invitation
|
| 189 |
client_id=invitation.client_id,
|
| 190 |
-
contractor_id=invitation.contractor_id
|
| 191 |
-
activated_at
|
| 192 |
)
|
| 193 |
|
| 194 |
db.add(new_user)
|
|
|
|
| 187 |
role=invitation.invited_role,
|
| 188 |
status='active', # User is active after accepting invitation
|
| 189 |
client_id=invitation.client_id,
|
| 190 |
+
contractor_id=invitation.contractor_id
|
| 191 |
+
# Note: activated_at removed - tracked in user_invitations.accepted_at
|
| 192 |
)
|
| 193 |
|
| 194 |
db.add(new_user)
|
src/app/services/otp_service.py
CHANGED
|
@@ -453,6 +453,77 @@ SwiftOps Team"""
|
|
| 453 |
detail="SMS delivery not yet implemented. Please use email or WhatsApp."
|
| 454 |
)
|
| 455 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
@staticmethod
|
| 457 |
def require_otp_for_purpose(purpose: str) -> bool:
|
| 458 |
"""Check if OTP is required for this purpose"""
|
|
|
|
| 453 |
detail="SMS delivery not yet implemented. Please use email or WhatsApp."
|
| 454 |
)
|
| 455 |
|
| 456 |
+
async def store_registration_data(
|
| 457 |
+
self,
|
| 458 |
+
identifier: str,
|
| 459 |
+
data: Dict[str, Any],
|
| 460 |
+
ttl: int = 3600 # 1 hour TTL for registration data
|
| 461 |
+
) -> None:
|
| 462 |
+
"""
|
| 463 |
+
Store registration data temporarily (used for OTP-verified registration)
|
| 464 |
+
|
| 465 |
+
Args:
|
| 466 |
+
identifier: Unique identifier (email address)
|
| 467 |
+
data: Registration data to store
|
| 468 |
+
ttl: Time to live in seconds (default 1 hour)
|
| 469 |
+
"""
|
| 470 |
+
key = f"registration:{identifier}"
|
| 471 |
+
value = json.dumps(data)
|
| 472 |
+
|
| 473 |
+
if self.storage_type == 'redis' and self.redis_client:
|
| 474 |
+
try:
|
| 475 |
+
self.redis_client.setex(key, ttl, value)
|
| 476 |
+
logger.info(f"Stored registration data for {identifier}")
|
| 477 |
+
except Exception as e:
|
| 478 |
+
logger.error(f"Failed to store registration data: {e}")
|
| 479 |
+
# Fallback to memory
|
| 480 |
+
self._memory_store[key] = {
|
| 481 |
+
'value': value,
|
| 482 |
+
'expires_at': datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
| 483 |
+
}
|
| 484 |
+
else:
|
| 485 |
+
# In-memory storage
|
| 486 |
+
self._memory_store[key] = {
|
| 487 |
+
'value': value,
|
| 488 |
+
'expires_at': datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
async def get_registration_data(self, identifier: str) -> Optional[Dict[str, Any]]:
|
| 492 |
+
"""
|
| 493 |
+
Retrieve and delete registration data after OTP verification
|
| 494 |
+
|
| 495 |
+
Args:
|
| 496 |
+
identifier: Unique identifier (email address)
|
| 497 |
+
|
| 498 |
+
Returns:
|
| 499 |
+
Registration data dict or None if not found/expired
|
| 500 |
+
"""
|
| 501 |
+
key = f"registration:{identifier}"
|
| 502 |
+
|
| 503 |
+
if self.storage_type == 'redis' and self.redis_client:
|
| 504 |
+
try:
|
| 505 |
+
value = self.redis_client.get(key)
|
| 506 |
+
if value:
|
| 507 |
+
# Delete after retrieval (one-time use)
|
| 508 |
+
self.redis_client.delete(key)
|
| 509 |
+
return json.loads(value)
|
| 510 |
+
return None
|
| 511 |
+
except Exception as e:
|
| 512 |
+
logger.error(f"Failed to retrieve registration data: {e}")
|
| 513 |
+
return None
|
| 514 |
+
else:
|
| 515 |
+
# In-memory storage
|
| 516 |
+
data = self._memory_store.get(key)
|
| 517 |
+
if data:
|
| 518 |
+
# Check if expired
|
| 519 |
+
if datetime.now(timezone.utc) > data['expires_at']:
|
| 520 |
+
del self._memory_store[key]
|
| 521 |
+
return None
|
| 522 |
+
# Delete after retrieval
|
| 523 |
+
del self._memory_store[key]
|
| 524 |
+
return json.loads(data['value'])
|
| 525 |
+
return None
|
| 526 |
+
|
| 527 |
@staticmethod
|
| 528 |
def require_otp_for_purpose(purpose: str) -> bool:
|
| 529 |
"""Check if OTP is required for this purpose"""
|