kamau1 commited on
Commit
62c6607
·
1 Parent(s): dd886a4

RBAC on key routes, otp to admin email on account creation

Browse files
docs/agent/research.md CHANGED
@@ -1,74 +1,292 @@
1
- Great question! Based on my research, here's what you need to know:
2
 
3
- ## Yes, the Old Connection String Format is Deprecated!
4
 
5
- The old `postgresql://postgres:YOUR_PASSWORD@db.xxxxx.supabase.co:5432/postgres` direct connection format still works but has important limitations - it now uses IPv6 only by default, and the old PgBouncer pooler was deprecated in January 2024.
6
 
7
- ## How to Find Your Current Connection Strings ✅
8
 
9
- Supabase now provides three types of connection strings, all accessible from your dashboard by clicking the "Connect" button at the top of the page:
10
 
11
- ### Step-by-Step to Get Your Connection Strings:
12
 
13
- 1. **Go to your Supabase Dashboard**
14
- 2. **Click the "Connect" button** at the top of your project page (you should see it prominently displayed)
15
- 3. You'll see three connection string options:
 
 
16
 
17
- ### 1. **Direct Connection** (IPv6 only)
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
- ### 2. **Session Mode Pooler** (Recommended for FastAPI)
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
- ### 3. **Transaction Mode Pooler** (For serverless)
33
- ```
34
- postgresql://postgres.xxxxx:[YOUR-PASSWORD]@aws-0-[region].pooler.supabase.com:6543/postgres
35
- ```
36
- - Supports both IPv4 and IPv6
37
- - Good for: Edge functions, serverless, Lambda
38
- - ⚠️ Does not support prepared statements
39
 
40
- ## Which One Should You Use for FastAPI?
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
- **Use the Session Mode Pooler** (port 5432 with pooler URL):
 
 
 
 
 
43
 
44
- ```env
45
- DATABASE_URL=postgresql://postgres.YOUR_PROJECT_REF:[YOUR-PASSWORD]@aws-0-[YOUR-REGION].pooler.supabase.com:5432/postgres
46
- ```
47
 
48
- ## Visual Guide to Find It:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
- Since I saw your Supabase dashboard earlier, here's exactly what to do:
51
 
52
- 1. **Look at the TOP of your Supabase project page**
53
- 2. You should see a **"Connect"** button (it's usually near the project name)
54
- 3. Click it
55
- 4. A modal will pop up with connection string options
56
- 5. Choose **"Session"** tab
57
- 6. Copy the entire connection string
58
- 7. Replace `[YOUR-PASSWORD]` with your actual database password
 
 
59
 
60
- ## If You Can't Find the Connect Button:
61
 
62
- Alternative path:
63
- 1. Go to **Project Settings** (gear icon bottom left)
64
- 2. Click **"Database"** in the sidebar
65
- 3. Scroll down to **"Connection string"** section
66
- 4. You'll see the three options there
 
 
 
 
 
 
 
 
 
 
67
 
68
- ## Important Notes:
 
 
69
 
70
- - The new Supavisor connection strings include your project reference in the username (e.g., `postgres.YOUR_PROJECT_REF`)
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
- Would you like me to help you configure this connection string in your FastAPI app once you get it?
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=TokenResponse, status_code=status.HTTP_201_CREATED)
24
- @limiter.limit("5/hour") # 5 registrations per hour per IP
25
  async def register(request: Request, response: Response, user_data: UserCreate, db: Session = Depends(get_db)):
26
  """
27
- Register a new user account via Supabase Auth
28
 
29
- - **email**: User's email address (must be unique)
30
- - **password**: Strong password (min 8 chars, 1 digit, 1 uppercase)
31
- - **first_name**: User's first name
32
- - **last_name**: User's last name
33
- - **phone**: Optional phone number
 
 
 
 
 
 
 
34
  """
 
 
 
 
 
35
  try:
36
- # Register user with Supabase Auth
37
- auth_response = await supabase_auth.sign_up(
38
- email=user_data.email,
39
- password=user_data.password,
40
- user_metadata={
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  "first_name": user_data.first_name,
42
  "last_name": user_data.last_name,
43
  "phone": user_data.phone,
44
- "full_name": f"{user_data.first_name} {user_data.last_name}"
 
45
  }
46
  )
47
 
48
- auth_user = auth_response["user"]
49
- session = auth_response["session"]
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
- entity_type='user',
74
- entity_id=str(new_user.id),
75
- description=f"New user registered: {user_data.email}",
76
- user=new_user,
77
  request=request,
78
- additional_metadata={'role': new_user.role}
79
  )
80
 
81
- logger.info(f"User registered successfully: {user_data.email}")
82
 
83
  return {
84
- "access_token": session.access_token,
85
- "token_type": "bearer",
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
- "manage_projects",
 
25
  "invite_users",
 
 
 
26
  "view_reports",
27
- "manage_sales_orders"
 
 
 
 
28
  ],
29
  AppRole.CONTRACTOR_ADMIN: [
 
 
 
 
30
  "manage_agents",
 
31
  "accept_projects",
32
  "view_payroll",
33
- "manage_inventory"
 
 
 
 
34
  ],
35
  AppRole.PROJECT_MANAGER: [
 
 
 
 
36
  "create_tickets",
37
  "manage_inventory",
38
  "approve_expenses",
39
- "view_team_performance"
 
 
40
  ],
41
  AppRole.DISPATCHER: [
 
 
 
 
42
  "assign_tickets",
43
  "issue_equipment",
44
  "approve_expenses",
45
- "view_agent_locations"
 
46
  ],
47
  AppRole.FIELD_AGENT: [
 
48
  "view_tickets",
49
  "update_ticket_status",
50
  "log_expenses",
51
- "upload_photos"
 
 
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=datetime.now(timezone.utc)
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"""