admin felület különválasztva
This commit is contained in:
265
docs/p0_3d_capability_matrix_audit_report.md
Normal file
265
docs/p0_3d_capability_matrix_audit_report.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# P0 Audit: 3D Capability Matrix (RBAC) — SQLAlchemy Model Audit Report
|
||||
|
||||
**Author:** Service Finder Rendszer-Architect
|
||||
**Date:** 2026-06-24
|
||||
**Gitea Issue:** #283
|
||||
**Scope:** Read-only model analysis for 3-Dimensional RBAC system (Global Roles × Tenant Roles × Subscription Capabilities)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report audits 4 critical dimensions against the existing SQLAlchemy model layer. The goal is to determine **what exists**, **what is missing**, and what **database migration steps** are needed to implement a full 3D Capability Matrix.
|
||||
|
||||
**Files examined:**
|
||||
- `backend/app/models/identity/identity.py` — User/Person models
|
||||
- `backend/app/models/marketplace/organization.py` — Organization, OrganizationMember, OrgRole models
|
||||
- `backend/app/models/core_logic.py` — SubscriptionTier, OrganizationSubscription, UserSubscription models
|
||||
- `backend/app/models/fleet_finance/models.py` — CostCategory model
|
||||
- `backend/scripts/seed_finance_dictionaries.py` — CostCategory seed data
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 1: Global / System Roles
|
||||
|
||||
**Model:** `User` — `identity.users`
|
||||
**Enum:** `UserRole`
|
||||
|
||||
### What EXISTS
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `role` | `PG_ENUM(UserRole)` | System-level role: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER |
|
||||
| `is_superuser` | **NOT FOUND** | There is no is_superuser boolean. Role-based only. |
|
||||
| `custom_permissions` | `JSON` | Per-user permission overrides — `{}::jsonb` default |
|
||||
| `scope_level` | `String(30)` | "individual" default — hierarchical scope context |
|
||||
| `scope_id` | `String(50)` | The ID of the scope context |
|
||||
| `is_vip` | `Boolean` | VIP flag |
|
||||
| `subscription_plan` | `String(30)` | "FREE" default |
|
||||
| `subscription_expires_at` | `DateTime` | Subscription expiry |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Dimension 1 is sufficiently covered.** The `UserRole` enum provides 6 system-level roles. The `custom_permissions` JSON column allows per-user capability overrides. However, there is **no standardized capability/feature matrix** at the global level — everything is ad-hoc JSON.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 2: Tenant / Local Roles
|
||||
|
||||
**Model:** `OrganizationMember` — `fleet.organization_members`
|
||||
**Enum:** `OrgUserRole`
|
||||
|
||||
### What EXISTS
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `role` | `PG_ENUM(OrgUserRole)` | OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER |
|
||||
| `permissions` | `JSON` | Per-member capability flags — `{}::jsonb` |
|
||||
| `is_permanent` | `Boolean` | Permanent member flag |
|
||||
| `is_verified` | `Boolean` | Verified member flag |
|
||||
| `status` | `String(20)` | "active" default |
|
||||
|
||||
### Bonus: Dynamic OrgRole RBAC System
|
||||
|
||||
**Model:** `OrgRole` — `fleet.org_roles`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `name_key` | `String(50)` | Unique role identifier (e.g., "OWNER") |
|
||||
| `display_name` | `String(100)` | Human-readable name |
|
||||
| `permissions` | `JSONB` | **Capability flags** per role — e.g. {"can_add_expense": true, "can_approve_expense": false} |
|
||||
| `is_system` | `Boolean` | System role (cannot be deleted) |
|
||||
| `priority` | `Integer` | Role hierarchy priority |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Dimension 2 is well-covered.** The `OrgRole` model with its `permissions` JSONB column is exactly what Phase 2 RBAC needs. Each `OrganizationMember` also has an individual `permissions` JSON column for user-level overrides above the role.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 3: Subscription Tier & Feature Capabilities
|
||||
|
||||
### 3A. SubscriptionTier Model
|
||||
|
||||
**Model:** `SubscriptionTier` — `system.subscription_tiers`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `name` | `String` | e.g., "free", "premium", "vip" |
|
||||
| `rules` | `JSONB` | **Generic limits**: {"max_vehicles": 5} — NO standardized feature capability format |
|
||||
| `is_custom` | `Boolean` | Custom tier flag |
|
||||
|
||||
### 3B. Organization Model (fleet schema)
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `subscription_plan` | `String(30)` | "FREE" default — denormalized quick-ref |
|
||||
| `subscription_expires_at` | `DateTime` | Subscription expiry |
|
||||
| `subscription_tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
|
||||
| `base_asset_limit` | `Integer` | Base vehicle limit (1 default) |
|
||||
| `purchased_extra_slots` | `Integer` | Extra purchased vehicle slots |
|
||||
| `settings` | `JSONB` | **Operational settings only** — NOT feature capabilities |
|
||||
|
||||
### 3C. OrganizationSubscription Model
|
||||
|
||||
**Model:** `OrganizationSubscription` — `finance.org_subscriptions`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `extra_allowances` | `JSONB` | Extra quotas: {"extra_vehicles": 1, "extra_garages": 1} |
|
||||
| `tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
|
||||
|
||||
### CRITICAL GAP: Missing custom_features / capabilities_override on Organization
|
||||
|
||||
**There is NO dedicated JSONB column** on the `Organization` model to store feature-level capability overrides like:
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_fuel_logging": true,
|
||||
"feature_gps_tracking": false,
|
||||
"feature_analytics": true,
|
||||
"feature_multi_driver": true,
|
||||
"feature_service_booking": false,
|
||||
"feature_tco_reports": false
|
||||
}
|
||||
```
|
||||
|
||||
The existing `settings` JSONB column is for *operational* settings, not *feature capability* gates.
|
||||
|
||||
Similarly, the `SubscriptionTier.rules` JSONB has **no standardized format** for feature capability flags — it only stores numeric limits.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 4: Cost Category Feature Linkage
|
||||
|
||||
**Model:** `CostCategory` — `fleet_finance.cost_categories`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `min_tier` | `String(20)` | "free" default — minimum subscription tier to access this category |
|
||||
| `visibility` | `String(20)` | "both" default — visibility scope |
|
||||
| `is_system` | `Boolean` | System category flag |
|
||||
| `required_feature` | **NOT FOUND** | No column to tie category to a specific subscription feature |
|
||||
| `min_capability` | **NOT FOUND** | No column for minimum capability requirement |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Partially covered.** The `min_tier` field gates categories by subscription tier (e.g., "free", "premium"), but there is **no way to gate by individual feature capability**. For example, we cannot say "this category requires the feature_fuel_logging capability."
|
||||
|
||||
---
|
||||
|
||||
## 3D Capability Matrix — Current vs. Required
|
||||
|
||||
| Dimension | What We Have | What We Need |
|
||||
|---|---|---|
|
||||
| D1: Global Roles | UserRole enum with 6 values + custom_permissions JSON | Sufficient. No DB changes needed. |
|
||||
| D2: Tenant Roles | OrgUserRole enum + OrgRole.permissions JSONB + per-member permissions JSON | Sufficient. No DB changes needed. |
|
||||
| D3a: Subscription Tiers | SubscriptionTier.rules JSONB (generic limits) | Need standardized feature capability format |
|
||||
| D3b: Org Feature Overrides | Organization.settings JSONB (operational only) | **NEED custom_features JSONB column** |
|
||||
| D4: Cost Category Gating | CostCategory.min_tier (tier-level only) | Need required_feature column + feature-based gating |
|
||||
|
||||
---
|
||||
|
||||
## Proposed Database Migration Steps
|
||||
|
||||
The following migrations must be executed **in order** to build the 3D Capability Matrix architecture:
|
||||
|
||||
### Migration 1: Add feature_capabilities JSONB to SubscriptionTier
|
||||
|
||||
**Target:** `SubscriptionTier` — `system.subscription_tiers`
|
||||
|
||||
```sql
|
||||
ALTER TABLE system.subscription_tiers
|
||||
ADD COLUMN feature_capabilities JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
**Purpose:** Define which features each tier enables. Example:
|
||||
|
||||
| Tier | Feature Capabilities |
|
||||
|---|---|
|
||||
| free | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_service_reminders": false, "feature_gps_tracking": false} |
|
||||
| premium | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_analytics": true, "feature_gps_tracking": true, "feature_multi_driver": true} |
|
||||
| vip | All features enabled |
|
||||
|
||||
### Migration 2: Add custom_features JSONB to Organization
|
||||
|
||||
**Target:** `Organization` — `fleet.organizations`
|
||||
|
||||
```sql
|
||||
ALTER TABLE fleet.organizations
|
||||
ADD COLUMN custom_features JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
**Purpose:** Allow per-organization feature overrides. These overrides **merge on top of** the tier defaults. A `false` value can disable a tier feature for a specific org.
|
||||
|
||||
### Migration 3: Add required_feature to CostCategory
|
||||
|
||||
**Target:** `CostCategory` — `fleet_finance.cost_categories`
|
||||
|
||||
```sql
|
||||
ALTER TABLE fleet_finance.cost_categories
|
||||
ADD COLUMN required_feature VARCHAR(50) DEFAULT NULL;
|
||||
```
|
||||
|
||||
**Purpose:** Tie a cost category to a specific feature capability.
|
||||
|
||||
| Category | required_feature |
|
||||
|---|---|
|
||||
| OPERATION_FUEL | NULL (free for all) |
|
||||
| SERVICE_SCHEDULED | NULL (free for all) |
|
||||
| FINANCING_LEASE | "feature_financing" |
|
||||
| INSURANCE_CASCO | "feature_insurance" |
|
||||
| ANALYTICS_TCO | "feature_analytics" |
|
||||
|
||||
### Migration 4 (Optional): Create FeatureCapability Enum
|
||||
|
||||
```sql
|
||||
CREATE TYPE system.feature_capability AS ENUM (
|
||||
'feature_fuel_logging',
|
||||
'feature_basic_reports',
|
||||
'feature_analytics',
|
||||
'feature_gps_tracking',
|
||||
'feature_multi_driver',
|
||||
'feature_service_booking',
|
||||
'feature_tco_reports',
|
||||
'feature_financing',
|
||||
'feature_insurance',
|
||||
'feature_ad_free'
|
||||
);
|
||||
```
|
||||
|
||||
**Purpose:** Standardize feature names across the system.
|
||||
|
||||
---
|
||||
|
||||
## Capability Resolution Algorithm (Proposed)
|
||||
|
||||
The final capability check for any feature at runtime follows this resolution chain:
|
||||
|
||||
```
|
||||
1. Organization.custom_features["feature_xxx"] <- explicit override (highest priority)
|
||||
2. SubscriptionTier.feature_capabilities["feature_xxx"] <- tier default
|
||||
3. false <- implicit default (feature not available)
|
||||
```
|
||||
|
||||
**For CostCategory visibility:**
|
||||
```
|
||||
If category.min_tier > org.subscription_plan -> HIDE
|
||||
If category.required_feature IS NOT NULL AND NOT resolved_feature_capability -> HIDE
|
||||
Otherwise -> SHOW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Status | Action Required |
|
||||
|---|---|---|
|
||||
| D1: Global Roles | Complete | None |
|
||||
| D2: Tenant Roles | Complete | None |
|
||||
| D3: Subscription Tier Features | Partial | Add feature_capabilities JSONB to SubscriptionTier |
|
||||
| D3: Org Feature Overrides | Missing | Add custom_features JSONB to Organization |
|
||||
| D4: Cost Category Feature Gate | Partial | Add required_feature column to CostCategory |
|
||||
| Standardization | Missing | (Optional) Create FeatureCapability Enum |
|
||||
|
||||
**Total proposed DB changes: 3 ALTER TABLE statements + 1 optional ENUM type.**
|
||||
266
docs/p0_backoffice_sso_router_audit_report.md
Normal file
266
docs/p0_backoffice_sso_router_audit_report.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# P0 Architect Report: Backoffice SSO & Router Architecture
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Scope:** Cross-subdomain cookie SSO, backend RBAC dependencies, frontend admin routing
|
||||
**Auditor:** Fast Coder (Core Developer)
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Cross-Subdomain Cookies (Backend Auth)
|
||||
|
||||
### File: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py)
|
||||
|
||||
#### Current State (Lines 127–134, 221–228)
|
||||
|
||||
Both the `/login` and `/verify-email` endpoints set the `refresh_token` cookie like this:
|
||||
|
||||
```python
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=max_age_sec
|
||||
)
|
||||
```
|
||||
|
||||
#### ❌ Finding: `domain` parameter is MISSING
|
||||
|
||||
The `domain` parameter is **not set** on either `set_cookie()` call. This means the cookie is scoped to the **exact origin** that issued it.
|
||||
|
||||
**Impact for SSO:**
|
||||
- If the user logs in at `app.servicefinder.hu`, the `refresh_token` cookie is only sent to `app.servicefinder.hu`.
|
||||
- When the user navigates to `admin.servicefinder.hu`, the browser **will not send** the cookie because the domain doesn't match.
|
||||
- This breaks cross-subdomain SSO entirely — the admin subdomain cannot silently refresh the access token.
|
||||
|
||||
#### Required Fix
|
||||
|
||||
Add `domain=".servicefinder.hu"` (with leading dot for subdomain wildcard) to both `set_cookie()` calls:
|
||||
|
||||
```python
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
domain=".servicefinder.hu", # ← ADD THIS
|
||||
max_age=max_age_sec
|
||||
)
|
||||
```
|
||||
|
||||
**Considerations:**
|
||||
- The domain should be **dynamic** based on environment (e.g., `.servicefinder.hu` for production, `localhost` for dev).
|
||||
- Use `settings.COOKIE_DOMAIN` or derive it from `settings.FRONTEND_BASE_URL`.
|
||||
- `samesite="lax"` is correct — it allows the cookie to be sent on top-level navigations between subdomains.
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Backend Dependencies (Deps for Staff)
|
||||
|
||||
### File: [`backend/app/api/deps.py`](backend/app/api/deps.py)
|
||||
|
||||
#### 2a. Existing `get_current_admin` (Lines 160–177)
|
||||
|
||||
```python
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
allowed_roles = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
}
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(status_code=403, detail="...")
|
||||
return current_user
|
||||
```
|
||||
|
||||
**✅ Good:** This already exists and allows `SUPERADMIN`, `ADMIN`, and `MODERATOR`.
|
||||
|
||||
**❌ Gap:** It does **not** include `SALES_REP` or `SERVICE_MGR`. The Architect's vision is that the Backoffice (`admin.servicefinder.hu`) should admit all "staff" roles — not just superadmins.
|
||||
|
||||
#### 2b. Existing `RequireSystemCapability` (Lines 184–218)
|
||||
|
||||
```python
|
||||
def RequireSystemCapability(capability_name: str):
|
||||
async def system_capability_checker(current_user: User = Depends(get_current_user)) -> bool:
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
role_key = current_user.role.value
|
||||
if not role_has_capability(role_key, capability_name):
|
||||
raise HTTPException(status_code=403, detail=f"...")
|
||||
return True
|
||||
return system_capability_checker
|
||||
```
|
||||
|
||||
**✅ Good:** This is the scalable, fine-grained approach. Each endpoint can declare exactly which capability it needs.
|
||||
|
||||
**❌ Gap:** There is no **middle-ground** dependency — something between `get_current_admin` (hardcoded role set) and `RequireSystemCapability` (fine-grained). We need a `RequireRole` dependency that accepts a list of allowed roles.
|
||||
|
||||
#### 2c. Proposed: `RequireRole` Dependency
|
||||
|
||||
Add to [`backend/app/api/deps.py`](backend/app/api/deps.py):
|
||||
|
||||
```python
|
||||
def RequireRole(allowed_roles: List[UserRole]):
|
||||
"""
|
||||
🎯 Flexible role-checking dependency.
|
||||
|
||||
Accepts a list of UserRole values. If the current user's role is
|
||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/sales")
|
||||
async def sales_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||
):
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||
)
|
||||
return True
|
||||
return role_checker
|
||||
```
|
||||
|
||||
And a **convenience** `get_current_staff` that admits all internal staff:
|
||||
|
||||
```python
|
||||
STAFF_ROLES = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
UserRole.SALES_REP,
|
||||
UserRole.SERVICE_MGR,
|
||||
}
|
||||
|
||||
async def get_current_staff(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
if current_user.role not in STAFF_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.STAFF_ONLY"
|
||||
)
|
||||
return current_user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AUDIT: Frontend Architecture (Admin Domain Routing)
|
||||
|
||||
### File: [`frontend/src/router/index.ts`](frontend/src/router/index.ts)
|
||||
|
||||
#### 3a. Current Admin Route (Lines 112–137)
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{ path: '', name: 'admin-dashboard', component: () => import('../views/admin/AdminDashboardView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('../views/admin/AdminUsersView.vue') },
|
||||
{ path: 'packages', name: 'admin-packages', component: () => import('../views/admin/AdminPackagesView.vue') },
|
||||
{ path: 'services', name: 'admin-services', component: () => import('../views/admin/AdminServicesView.vue') },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Good:** The admin routes exist under a single `/admin` path with `requiresAdmin` meta.
|
||||
|
||||
#### 3b. Current Route Guard (Lines 166–177)
|
||||
|
||||
```typescript
|
||||
if (to.meta.requiresAdmin) {
|
||||
const role = authStore.user?.role
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
if (!role || !adminRoles.includes(role.toUpperCase())) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Finding: The guard is too restrictive.** It only admits `SUPERADMIN`, `ADMIN`, and `MODERATOR`. `SALES_REP` and `SERVICE_MGR` are blocked from the admin panel entirely.
|
||||
|
||||
**❌ Finding: No separate subdomain routing.** The admin UI is served under the same SPA at `/admin` path, not as a separate deployment for `admin.servicefinder.hu`. This means:
|
||||
- The same Vite build serves both `app.servicefinder.hu` and `admin.servicefinder.hu`.
|
||||
- The Nginx reverse proxy must route `admin.servicefinder.hu` → same SPA, but the router handles the `/admin` prefix.
|
||||
- This is **fine for Phase 1** but limits independent scaling/deployment.
|
||||
|
||||
#### 3c. Auth Store `isAdmin` Getter (Lines 103–110)
|
||||
|
||||
```typescript
|
||||
const isAdmin = computed(() => {
|
||||
const role = user.value?.role
|
||||
if (!role) return false
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
return adminRoles.includes(role.toUpperCase())
|
||||
})
|
||||
```
|
||||
|
||||
**❌ Same gap:** `SALES_REP` and `SERVICE_MGR` are not recognized as admin/staff.
|
||||
|
||||
#### 3d. AdminLayout Component
|
||||
|
||||
**File:** [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue)
|
||||
|
||||
**✅ Good:** The layout has a proper sidebar with navigation links, user profile section, and slot for header controls. It references `LanguageSwitcher`, `ModeSwitcher`, and `HeaderProfile`.
|
||||
|
||||
**❌ Finding:** The sidebar links include routes that don't exist in the router (`/admin/vehicles`, `/admin/translations`, `/admin/system`, `/admin/audit`). These will produce 404s when clicked.
|
||||
|
||||
---
|
||||
|
||||
## 4. ACTION PLAN
|
||||
|
||||
### Phase 1: Fix Cookie Domain for SSO (Backend)
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 1.1 | Add `COOKIE_DOMAIN` to settings | [`backend/app/core/config.py`](backend/app/core/config.py) | Add `COOKIE_DOMAIN: str = ".servicefinder.hu"` field |
|
||||
| 1.2 | Add `domain` param to `/login` cookie | [`backend/app/api/v1/endpoints/auth.py:127`](backend/app/api/v1/endpoints/auth.py:127) | `response.set_cookie(..., domain=settings.COOKIE_DOMAIN)` |
|
||||
| 1.3 | Add `domain` param to `/verify-email` cookie | [`backend/app/api/v1/endpoints/auth.py:221`](backend/app/api/v1/endpoints/auth.py:221) | Same fix |
|
||||
|
||||
### Phase 2: Create Scalable Backend Dependencies
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 2.1 | Add `RequireRole` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Generic role-list checker |
|
||||
| 2.2 | Add `get_current_staff` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Admits SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR |
|
||||
| 2.3 | Update `get_current_admin` to use STAFF_ROLES | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Or keep as-is for superadmin-only endpoints |
|
||||
|
||||
### Phase 3: Update Frontend Router Guard & Auth Store
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 3.1 | Update `requiresAdmin` guard to admit all staff roles | [`frontend/src/router/index.ts:168`](frontend/src/router/index.ts:168) | Change `adminRoles` to include `SALES_REP`, `SERVICE_MGR` |
|
||||
| 3.2 | Update `isAdmin` getter in auth store | [`frontend/src/stores/auth.ts:103`](frontend/src/stores/auth.ts:103) | Same role expansion |
|
||||
| 3.3 | Add missing admin routes or remove dead sidebar links | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | Add routes for vehicles, translations, system, audit — or remove sidebar links |
|
||||
| 3.4 | Consider role-based sidebar visibility | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | SALES_REP should only see sales-related nav items |
|
||||
|
||||
### Phase 4: Nginx / Reverse Proxy (Infrastructure)
|
||||
|
||||
| # | Task | Details |
|
||||
|---|------|---------|
|
||||
| 4.1 | Ensure `admin.servicefinder.hu` → same SPA | Nginx config must serve the same frontend build for both subdomains |
|
||||
| 4.2 | Ensure CORS allows `admin.servicefinder.hu` | Already in [`backend/app/core/config.py:93`](backend/app/core/config.py:93) ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Findings
|
||||
|
||||
| Area | Status | Issue |
|
||||
|------|--------|-------|
|
||||
| Cookie `domain` | ❌ Missing | SSO broken between subdomains |
|
||||
| `get_current_admin` | ⚠️ Too narrow | Missing SALES_REP, SERVICE_MGR |
|
||||
| `RequireSystemCapability` | ✅ Good | Fine-grained, scalable |
|
||||
| `RequireRole` | ❌ Missing | No flexible role-list dependency |
|
||||
| Frontend `requiresAdmin` guard | ⚠️ Too narrow | Blocks SALES_REP, SERVICE_MGR |
|
||||
| Frontend `isAdmin` getter | ⚠️ Too narrow | Same gap |
|
||||
| AdminLayout sidebar links | ❌ Dead links | Routes for vehicles/translations/system/audit don't exist |
|
||||
| CORS origins | ✅ Good | `admin.servicefinder.hu` already whitelisted |
|
||||
366
docs/p0_tenant_isolation_vehicle_mapping_audit_report.md
Normal file
366
docs/p0_tenant_isolation_vehicle_mapping_audit_report.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 🚨 P0 READ-ONLY AUDIT - Tenant Isolation & Vehicle Mapping Report
|
||||
|
||||
**Dátum:** 2026-06-23
|
||||
**Auditor:** Rendszer-Architect (DeepSeek Reasoner)
|
||||
**Státusz:** Elkészült
|
||||
**Scope:** Full-stack (Database → Backend API → Frontend)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Executive Summary
|
||||
|
||||
A vizsgálat célja a `CostsView.vue` jármű legördülő menüjében (vehicle dropdown) tapasztalt **tenant izolációs hiba** (Garage Isolation) kivizsgálása volt. A hibajelenség: amikor a felhasználó garázst/szervezetet vált, a költségek helyesen frissülnek, de a jármű legördülő lista a **régi garázs összes járművét** mutatja továbbra is.
|
||||
|
||||
**Verdikt:** A backend API helyesen szűr (`current_organization_id` alapján, JWT `scope_id`-n keresztül). A hiba gyökere **kizárólag a frontend cache elavulásában** (cache staleness) rejlik: a járművek csak egyszer töltődnek be (`onMounted`), és soha nem frissülnek garázsváltáskor.
|
||||
|
||||
---
|
||||
|
||||
## 📊 STEP 1: Database Data Mapping (tester_pro, User ID: 28)
|
||||
|
||||
### 1.1 User Adatok
|
||||
|
||||
| Mező | Érték |
|
||||
|------|-------|
|
||||
| `id` | 28 |
|
||||
| `email` | `tester_pro@profibot.hu` |
|
||||
| `role` | `ADMIN` |
|
||||
| `scope_id` | `null` (personal mode) |
|
||||
| `scope_level` | `individual` |
|
||||
| `subscription_plan` | `PREMIUM` |
|
||||
| `ui_mode` | `personal` |
|
||||
| `is_active` | `true` |
|
||||
|
||||
### 1.2 Organization Tagságok
|
||||
|
||||
| Org ID | Név | Típus | Szerepkör | Státusz |
|
||||
|--------|-----|-------|-----------|---------|
|
||||
| **1** | `Test Company` | `individual` | `OWNER` | `active`, `verified` |
|
||||
| **21** | `Private_28` | `individual` | `OWNER` | `active`, `verified` |
|
||||
|
||||
### 1.3 Járművek (Asset-ek) Szervezetenként
|
||||
|
||||
#### 🏢 **Org 1 - Test Company** (19 jármű)
|
||||
|
||||
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|
||||
|----------|-------|--------|-----|---------|-------------|
|
||||
| `BMW123` | BMW | BMWM3 | - | `active` | `verified` |
|
||||
| `QWE432` | BMW | R 1250 GS ADVENTURE | - | `active` | `verified` |
|
||||
| `TEST-000` | Ford | Focus | TESTVIN1111111111 | `active` | `verified` |
|
||||
| `TEST-111` | Ford | Focus | TESTVIN1111111119 | `active` | `verified` |
|
||||
| `TEST-123` | Ford | Focus | TESTVIN1234567890 | `active` | `draft` |
|
||||
| `TEST-555` | Ford | Focus | TESTVIN5555555559 | `active` | `verified` |
|
||||
| `TEST-777` | Ford | Focus | TESTVIN7777777779 | `active` | `verified` |
|
||||
| `TEST-888` | Ford | Focus | TESTVIN8888888889 | `active` | `draft` |
|
||||
| `TEST-999` | Ford | Focus | TESTVIN9999999999 | `active` | `draft` |
|
||||
| `TEST-API-999` | TestBrand | TestModel | - | `active` | `draft` |
|
||||
| `DRAFT-000` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-111` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-456` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-555` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-777` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-888` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-999` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `TEST123` | TestBrand | TestModel | TESTVIN123456789 | `draft` | `draft` |
|
||||
| `HGV-TEST-01` | MERCEDES-BENZ | ACTROS | - | `archived` | `verified` |
|
||||
|
||||
#### 🏠 **Org 21 - Private_28** (6 jármű)
|
||||
|
||||
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|
||||
|----------|-------|--------|-----|---------|-------------|
|
||||
| `ABC-123` | Toyota | Corolla | 1HGCM82633A123456 | `active` | `verified` |
|
||||
| `AIML519` | Skoda | Citigo e | - | `active` | `enriched` |
|
||||
| `PKT215` | Mazda | 2 | - | `active` | `draft` |
|
||||
| `QWE123` | APRILIA | af1 | - | `active` | `draft` |
|
||||
| `TTRFGHZT` | BAYLINER | 1750 CAPRY BOWRIDER | - | `active` | `draft` |
|
||||
| `UOK795` | Honda | CB1000R | ZDCSC60C0FF091488 | `active` | `verified` |
|
||||
|
||||
**Összesen:** **25 jármű** a két szervezetben (10+6 aktív, 8 draft, 1 archived).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ STEP 2: Backend API Endpoint Audit
|
||||
|
||||
### Végpont: `GET /assets/vehicles`
|
||||
|
||||
**Fájl:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:229)
|
||||
|
||||
**Szignatúra:**
|
||||
```python
|
||||
@router.get("/vehicles", response_model=List[AssetResponse])
|
||||
async def get_user_vehicles(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthContext = Depends(get_current_user)
|
||||
):
|
||||
```
|
||||
|
||||
**Függőségek:** A végpont **nem** fogad el `organization_id` query paramétert a frontendtől. Kizárólag a JWT tokenben tárolt `current_user.scope_id` alapján szűr.
|
||||
|
||||
### Szűrési Logika
|
||||
|
||||
#### 1️⃣ Personal Mode (nincs scope_id beállítva) - [`assets.py:259`](backend/app/api/v1/endpoints/assets.py:259)
|
||||
|
||||
```python
|
||||
if current_user.scope_id is None:
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in result.fetchall()]
|
||||
|
||||
stmt = select(Asset).where(
|
||||
Asset.current_organization_id.in_(user_org_ids),
|
||||
Asset.status == "active"
|
||||
)
|
||||
```
|
||||
|
||||
**Mit csinál:** Personal módban lekéri az összes szervezet ID-t, ahol a user tag, majd visszaadja az összes olyan szervezet összes aktív járművét. Ez a **teljes flotta** a user szemszögéből.
|
||||
|
||||
#### 2️⃣ Corporate Mode (scope_id beállítva) - [`assets.py:286`](backend/app/api/v1/endpoints/assets.py:286)
|
||||
|
||||
```python
|
||||
else:
|
||||
scope_org_id = int(current_user.scope_id)
|
||||
stmt = select(Asset).where(
|
||||
Asset.current_organization_id == scope_org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
```
|
||||
|
||||
**Mit csinál:** Corporate módban kizárólag a `scope_id`-ban megadott szervezet járműveit adja vissza. Ez a helyes, izolált viselkedés.
|
||||
|
||||
### Backend Audit Verdict: ✅ **Helyes**
|
||||
|
||||
A backend API logikája megfelelő. A probléma **nem** a backendben van.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ STEP 3: Frontend Audit - CostsView.vue & Stores
|
||||
|
||||
### 3.1 A Hibás Működés Rekonstrukciója
|
||||
|
||||
#### ✅ Amit a `cost.ts` store jól csinál - [`cost.ts:71`](frontend/src/stores/cost.ts:71)
|
||||
|
||||
A költség létrehozásánál defense-in-depth jelleggel injektálja az `active_organization_id`-t:
|
||||
```typescript
|
||||
const userOrgId = useAuthStore().user?.active_organization_id
|
||||
if (userOrgId != null) {
|
||||
body.organization_id = userOrgId
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ Amit a `CostsView.vue` jól csinál - [`CostsView.vue:435`](frontend/src/views/costs/CostsView.vue:435)
|
||||
|
||||
A `fetchCosts()` függvény helyesen adja át az `active_organization_id`-t a backendnek:
|
||||
```typescript
|
||||
const params: Record<string, any> = { page: currentPage.value, per_page: itemsPerPage.value }
|
||||
const userOrgId = authStore.user?.active_organization_id
|
||||
if (userOrgId != null) {
|
||||
params.organization_id = userOrgId
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 A Hiba Gyökere: Cache Staleness
|
||||
|
||||
#### ❌ **1. hiba:** A járművek csak egyszer töltődnek be - [`CostsView.vue:559`](frontend/src/views/costs/CostsView.vue:559)
|
||||
|
||||
```typescript
|
||||
onMounted(() => {
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
vehicleStore.fetchVehicles() // <-- CSAK egyszer, onMounted-ben!
|
||||
}
|
||||
fetchCosts()
|
||||
})
|
||||
```
|
||||
|
||||
A járművek **csak akkor** töltődnek be, ha a store még üres. Ez azt jelenti, hogy ha a user már járt a Costs oldalon, a járművek a Pinia store-ban cache-elődnek, és **soha többé nem frissülnek**.
|
||||
|
||||
#### ❌ **2. hiba:** Nincs watch az org váltásra - [`CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
|
||||
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
fetchCosts() // <-- Csak költségeket frissíti
|
||||
// 🔴 HIÁNYZIK: vehicleStore.fetchVehicles() !
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Amikor a user garázst vált, a `watch` csak a költségeket frissíti. A járművek **nem** frissülnek.
|
||||
|
||||
#### ❌ **3. hiba:** `fetchVehicles()` nem fogad el org_id paramétert - [`vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
|
||||
|
||||
```typescript
|
||||
async function fetchVehicles() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await api.get('/assets/vehicles') // <-- NINCS org_id param
|
||||
vehicles.value = res.data as Vehicle[]
|
||||
} catch (err: any) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Még ha meg is hívnánk a `fetchVehicles()`-t org váltáskor, a függvény jelenleg nem tud szűrni - minden esetben a teljes (scope-alapú) listát kéri le. Szerencsére a backend JWT `scope_id`-t használ, így ha újrahívjuk, a backend a helyes (már frissített scope) alapján adja vissza az adatokat.
|
||||
|
||||
### 3.3 A Szivárgás Vizualizációja
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CostsView
|
||||
participant vehicleStore
|
||||
participant authStore
|
||||
participant Backend
|
||||
|
||||
Note over User,Backend: === Initial Load (onMounted) ===
|
||||
User->>CostsView: Navigates to Costs page
|
||||
CostsView->>vehicleStore: fetchVehicles() [no org filter]
|
||||
vehicleStore->>Backend: GET /assets/vehicles [scope_id=null]
|
||||
Backend-->>vehicleStore: [25 vehicles from ALL orgs]
|
||||
vehicleStore->>CostsView: vehicles cached in Pinia
|
||||
|
||||
Note over User,Backend: === Org Switch ===
|
||||
User->>CostsView: Switches to Org 21 (Private_28)
|
||||
CostsView->>authStore: switchOrganization(21)
|
||||
authStore->>Backend: PATCH /users/me/active-organization
|
||||
|
||||
Note over CostsView: WATCH triggers
|
||||
CostsView->>CostsView: fetchCosts() with org_id=21 ✅
|
||||
CostsView->>CostsView: 🔴 DOES NOT refresh vehicles!
|
||||
|
||||
Note over CostsView: Vehicle dropdown shows ALL 25 vehicles<br/>(from Test Company AND Private_28)
|
||||
Note over CostsView: But expenses are correctly filtered to Org 21 only!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 STEP 4: Javítási Javaslat (Fix Recommendation)
|
||||
|
||||
### Hiba Kategorizálás
|
||||
|
||||
| Kritérium | Érték |
|
||||
|-----------|-------|
|
||||
| **Típus** | Frontend Cache Staleness (Pinia store nem frissül garázsváltáskor) |
|
||||
| **Súlyosság** | **P0 - Kritikus** (adat szivárgás: user látja más garázs járműveit) |
|
||||
| **Érintett fájlok** | `CostsView.vue`, `vehicle.ts` |
|
||||
| **Backend érintettség** | ❌ Nincs (backend helyesen szűr) |
|
||||
| **Adatbázis érintettség** | ❌ Nincs (adatok helyesen vannak tárolva) |
|
||||
|
||||
### Javítási Terv
|
||||
|
||||
#### 1. Kötelező javítás: `CostsView.vue` watch kiegészítése
|
||||
|
||||
Fájl: [`frontend/src/views/costs/CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
|
||||
|
||||
**Jelenleg:**
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
fetchCosts()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Javítás után:**
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
vehicleStore.fetchVehicles() // <-- ÚJ: Járművek frissítése org váltáskor
|
||||
fetchCosts()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. Ajánlott javítás: `vehicle.ts` - add org_id paraméter támogatás
|
||||
|
||||
Fájl: [`frontend/src/stores/vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
|
||||
|
||||
**Javaslat:** Bár a backend JWT scope alapján is szűr, érdemes explicit `organization_id` query paraméter támogatást hozzáadni:
|
||||
|
||||
```typescript
|
||||
async function fetchVehicles(organizationId?: number | null) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
if (organizationId != null) {
|
||||
params.organization_id = organizationId
|
||||
}
|
||||
const res = await api.get('/assets/vehicles', { params })
|
||||
vehicles.value = res.data as Vehicle[]
|
||||
} catch (err: any) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Opcionális javítás: `vehicleStore` automatikus cache invalidáció
|
||||
|
||||
Fájl: [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts)
|
||||
|
||||
Adj hozzá egy `watch`-t a `vehicle` store-ban, ami automatikusan frissíti a járműveket, ha a user `active_organization_id`-ja megváltozik:
|
||||
|
||||
```typescript
|
||||
// vehicle.ts store-ban
|
||||
watch(
|
||||
() => useAuthStore().user?.active_organization_id,
|
||||
() => {
|
||||
fetchVehicles() // Automatikus cache invalidáció org váltáskor
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 4. Opcionális javítás: Backend `GET /assets/vehicles` endpoint bővítése
|
||||
|
||||
Fájl: [`backend/app/api/v1/endpoints/assets.py:229`](backend/app/api/v1/endpoints/assets.py:229)
|
||||
|
||||
Adj hozzá egy opcionális `organization_id` query paramétert, ami felülbírálja a JWT `scope_id`-t (admin/jogosultság ellenőrzéssel):
|
||||
|
||||
```python
|
||||
@router.get("/vehicles", response_model=List[AssetResponse])
|
||||
async def get_user_vehicles(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
organization_id: Optional[int] = Query(None), # <-- ÚJ paraméter
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthContext = Depends(get_current_user)
|
||||
):
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Összefoglalás
|
||||
|
||||
### Valóság vs. Elvárt Működés
|
||||
|
||||
| Aspektus | Elvárt | Valóság |
|
||||
|----------|--------|---------|
|
||||
| `tester_pro` garázsai | Csak az aktív garázs adatai | Mindkét garázs (Test Company + Private_28) járművei látszanak |
|
||||
| Költségek szűrése | Csak az aktív garázsra | ✅ Helyes - org_id alapján szűr |
|
||||
| Járművek szűrése | Csak az aktív garázsra | ❌ Hibás - nincs org_id filter, cache-ből jön |
|
||||
| Backend API | Helyes scope alapú szűrés | ✅ Helyes - JWT scope_id alapján dolgozik |
|
||||
| Adatbázis | Helyes org-hozzárendelés | ✅ Helyes - minden asset.current_organization_id be van állítva |
|
||||
|
||||
### Következtetés
|
||||
|
||||
A tenant izolációs hiba **kizárólag frontend oldali**. A backend API és az adatbázis séma megfelelő. A javításhoz elegendő a `CostsView.vue` `watch` blokkját kiegészíteni egy `vehicleStore.fetchVehicles()` hívással. A többi javítási javaslat (backend paraméter, store szintű watch) opcionális, de ajánlott a robosztusság növeléséhez.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referenciák
|
||||
|
||||
- [CostsView.vue](frontend/src/views/costs/CostsView.vue) - A hibás watch blokk (549-556) és onMounted (558-565)
|
||||
- [vehicle.ts store](frontend/src/stores/vehicle.ts) - A fetchVehicles() függvény (141-158)
|
||||
- [auth.ts store](frontend/src/stores/auth.ts) - switchOrganization() (687) és active_organization_id (70)
|
||||
- [cost.ts store](frontend/src/stores/cost.ts) - Defense-in-depth org_id injektálás (71)
|
||||
- [assets.py (backend)](backend/app/api/v1/endpoints/assets.py) - GET /assets/vehicles (229-318)
|
||||
|
||||
---
|
||||
|
||||
*Audit lezárva: 2026-06-23 22:12 UTC*
|
||||
301
docs/p0_ui_switcher_roles_subscription_audit_report.md
Normal file
301
docs/p0_ui_switcher_roles_subscription_audit_report.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# P0 Audit Report: UI, Switcher, Roles & Subscription Visibility
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Fast Coder (Core Developer)
|
||||
**Status:** Read-Only Audit Complete — No Code Modified
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Garage Switcher (`HeaderCompanySwitcher.vue`)
|
||||
|
||||
### File: [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)
|
||||
|
||||
### Current Behavior
|
||||
|
||||
The switcher fetches organizations via `authStore.fetchMyOrganizations()` which calls `GET /api/v1/organizations/my`.
|
||||
|
||||
**Filtering logic (line 144-151):**
|
||||
```typescript
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type &&
|
||||
org.org_type !== 'individual' &&
|
||||
org.org_type !== 'service_provider' &&
|
||||
org.org_type !== 'service'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Root Cause of Missing Private Garages:**
|
||||
- The `org_type` field in the backend response (`GET /organizations/my`) returns the actual `OrgType` enum value.
|
||||
- The filter **excludes** `individual` type organizations — but a user's **private/personal garage** IS of type `individual`.
|
||||
- The switcher only shows the **"Private Garage" button** (line 40-58) as a hardcoded navigation to `/dashboard`, but does NOT list individual-type organizations in the dropdown.
|
||||
- If a user has **multiple** private/individual garages (e.g., "Saját garázs" + "Családi garázs"), only the first one is accessible via the hardcoded button. The others are invisible.
|
||||
|
||||
**Why company garages appear correctly:**
|
||||
- Business/fleet_owner type organizations pass the filter and are listed dynamically.
|
||||
|
||||
### Required Fix
|
||||
|
||||
1. **Remove the `org_type !== 'individual'` filter** — or change the approach to show ALL organizations.
|
||||
2. **Add visual separation** in the dropdown: group by `org_type` (e.g., "Saját garázsok" vs "Céges garázsok").
|
||||
3. **Ensure `org_type` is always populated** in the backend response (currently it's optional in the `OrganizationItem` type).
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Subscription & Limit Visibility
|
||||
|
||||
### 2a. Backend: `GET /api/v1/organizations/my`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:180)
|
||||
|
||||
**Current response includes:**
|
||||
- `subscription_plan` (string, e.g. "test_privát")
|
||||
- `subscription_expires_at` (ISO datetime)
|
||||
- `max_vehicles` (P0: from subscription tier JSONB rules)
|
||||
- `max_garages` (P0: from subscription tier JSONB rules)
|
||||
- `user_role` (OWNER/ADMIN/etc.)
|
||||
|
||||
**Verdict:** ✅ Backend already returns all necessary subscription data per organization.
|
||||
|
||||
### 2b. Backend: `GET /auth/me` (User Profile)
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:48)
|
||||
|
||||
**Current response includes:**
|
||||
- `subscription_plan` (user-level)
|
||||
- `max_vehicles`, `max_garages` (from subscription tier)
|
||||
- `system_capabilities`, `org_capabilities`
|
||||
|
||||
**Verdict:** ✅ Backend already returns user-level subscription data.
|
||||
|
||||
### 2c. Frontend: `SubscriptionStatusWidget.vue`
|
||||
|
||||
**File:** [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue)
|
||||
|
||||
**Current display:**
|
||||
- Shows plan name (`subscriptionPlan`)
|
||||
- Shows days remaining
|
||||
- Shows a progress bar (but only for time remaining, NOT for vehicle count)
|
||||
|
||||
**Missing:**
|
||||
- ❌ **No vehicle count display** (e.g., "16/100 jármű")
|
||||
- ❌ **No max_vehicles limit progress bar**
|
||||
- The widget only shows the **time-based** progress, not the **usage-based** progress.
|
||||
|
||||
### 2d. Frontend: `SubscriptionInfoModal.vue`
|
||||
|
||||
**File:** [`frontend/src/components/subscription/SubscriptionInfoModal.vue`](frontend/src/components/subscription/SubscriptionInfoModal.vue)
|
||||
|
||||
**Current display:**
|
||||
- ✅ Shows plan name
|
||||
- ✅ Shows expiry date + days remaining
|
||||
- ✅ Shows vehicle limit progress bar (`vehicleCount / maxVehicles`)
|
||||
- ✅ Shows garage limit progress bar (`garageCount / maxGarages`)
|
||||
|
||||
**Verdict:** ✅ The modal already has full subscription visibility. The issue is that this modal is only accessible via a button click, not visible on the dashboard by default.
|
||||
|
||||
### 2e. Frontend: `DashboardView.vue`
|
||||
|
||||
**File:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:78-94)
|
||||
|
||||
**Current display:**
|
||||
- ✅ `SubscriptionStatusWidget` is rendered in the dashboard (line 81)
|
||||
- ❌ But it only shows plan name + days remaining, NOT vehicle usage
|
||||
|
||||
### Required Fixes
|
||||
|
||||
1. **Enhance `SubscriptionStatusWidget.vue`** to also show:
|
||||
- Vehicle count vs limit (e.g., "🚗 16/100")
|
||||
- A secondary progress bar for vehicle usage
|
||||
- The plan name should be more prominent
|
||||
2. **Alternatively**, replace the widget with a richer component that includes both time and usage data.
|
||||
|
||||
---
|
||||
|
||||
## 3. AUDIT: Role Logic & Ownership Transfer
|
||||
|
||||
### 3a. Backend: Role-Based Access Control
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:329-357)
|
||||
|
||||
**`_check_org_admin_access()` function:**
|
||||
- Allows both `OWNER` and `ADMIN` roles to access admin endpoints
|
||||
- Used by: `GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`
|
||||
- ✅ ADMINs already have full operational rights (member management, invitations)
|
||||
|
||||
**Subscription management endpoint:**
|
||||
- `PUT /{org_id}/subscription` (line 775) requires `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` — this is a **system-level** capability, not org-level.
|
||||
- ❌ **ADMINs cannot change subscription** via this endpoint unless they also have the system capability (which typically only SUPERADMIN has).
|
||||
- The Architect's requirement says ADMINs should manage billing/subscription. This needs a separate org-level permission check.
|
||||
|
||||
### 3b. Backend: Ownership Transfer
|
||||
|
||||
**File:** [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:196)
|
||||
|
||||
- `is_ownership_transferable` field exists on the `Organization` model (boolean, default `true`)
|
||||
- `can_transfer_ownership` capability exists in `seed_org_roles.py` (line 35: OWNER=true, ADMIN=false)
|
||||
- ❌ **No dedicated backend endpoint** for "Transfer Ownership" exists yet
|
||||
- The `PATCH /{org_id}/members/{member_id}` endpoint (line 486) can change a member's role to OWNER, but there's no complete transfer flow (e.g., reassign `owner_id` on the Organization record, log the transfer, notify parties)
|
||||
|
||||
### 3c. Frontend: Members/Team Page
|
||||
|
||||
- ❌ **No frontend page** found for managing team members (search for "members", "team", "munkatárs", "tagok" returned 0 results in `.vue` files)
|
||||
- The backend has all the APIs (`GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`), but there's no UI to call them
|
||||
|
||||
### Required Fixes
|
||||
|
||||
1. **Create a Team Members page** (frontend) that uses the existing backend APIs
|
||||
2. **Add org-level subscription management** for ADMINs (separate permission check from system-level)
|
||||
3. **Create a Transfer Ownership endpoint** + UI flow
|
||||
|
||||
---
|
||||
|
||||
## 4. AUDIT: Mobile Menu Alignment (Hamburger Menu)
|
||||
|
||||
### File: [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75)
|
||||
|
||||
**Current "Költségek" button styling:**
|
||||
```html
|
||||
<button
|
||||
@click="navigateToCard('costs')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" ...>
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- The button uses `flex items-center gap-3` — this is a flex row with the icon and text.
|
||||
- The text is **not** wrapped in any alignment utility — it uses default flex alignment.
|
||||
- The icon + text are left-aligned within the flex container (default `justify-start`).
|
||||
- The `text-sm` class applies to the text, which is fine.
|
||||
- **There is NO `text-center` class** on this button or its text. The Architect's report mentions `text-center` causing misalignment, but the current code uses `flex items-center gap-3` which is correct for left-aligned menu items.
|
||||
|
||||
**However**, looking at the `OrganizationLayout.vue` hamburger menu (line 40-72), the same pattern is used — all buttons are `flex w-full items-center gap-3`. The text alignment is consistent.
|
||||
|
||||
**Possible issue:** The problem might be in a different component or a different version of the file. The current code appears correctly aligned. If there's a visual bug, it could be:
|
||||
1. A CSS conflict from a parent component
|
||||
2. An i18n translation string that's too long causing text wrapping
|
||||
3. A different mobile menu component not found in this search
|
||||
|
||||
### Required Fix
|
||||
- If the issue is confirmed, ensure all hamburger menu buttons use `flex items-center gap-3` (already the case)
|
||||
- Check if any parent CSS overrides the alignment
|
||||
- Verify the `menu.finance` translation key produces the expected text
|
||||
|
||||
---
|
||||
|
||||
## 5. ARCHITECT REPORT & ACTION PLAN
|
||||
|
||||
### Summary of Findings
|
||||
|
||||
| # | Area | Status | Priority |
|
||||
|---|------|--------|----------|
|
||||
| 1 | Garage Switcher: individual orgs filtered out | ❌ Bug | **P0** |
|
||||
| 2 | SubscriptionStatusWidget: missing vehicle usage | ❌ Missing | **P0** |
|
||||
| 3 | Team Members UI page | ❌ Missing | **P1** |
|
||||
| 4 | Ownership Transfer endpoint | ❌ Missing | **P1** |
|
||||
| 5 | ADMIN subscription management | ⚠️ Partial | **P1** |
|
||||
| 6 | Mobile menu alignment | ✅ OK (current code) | **P2** |
|
||||
|
||||
### Detailed Action Plan
|
||||
|
||||
#### 🔴 P0 — Must Fix
|
||||
|
||||
**Task 1: Fix Garage Switcher to show ALL organizations**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144) | Remove the `org_type !== 'individual'` filter. Instead, show ALL organizations grouped by type (individual vs business). Add a section header "Saját garázsok" for individual orgs. |
|
||||
| [`frontend/src/types/organization.ts`](frontend/src/types/organization.ts:24) | Make `org_type` required (remove `?`) since the backend now sends it reliably |
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:239-269) | Verify `org_type` is always populated in the response (currently uses `o.org_type.value if hasattr(...)` — ensure it never returns null) |
|
||||
|
||||
**Task 2: Enhance SubscriptionStatusWidget with vehicle usage**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue) | Add vehicle count vs limit display (e.g., "🚗 16/100"), add secondary progress bar for vehicle usage, make plan name more prominent |
|
||||
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue:72-81) | Add `maxVehicles` and `vehicleCount` computed properties (already available from `authStore.user?.max_vehicles` and `vehicleStore.vehicles.length`) |
|
||||
|
||||
#### 🟡 P1 — Should Fix
|
||||
|
||||
**Task 3: Create Team Members UI page**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/views/organization/TeamMembersView.vue` | **NEW FILE** — Create a full team management page with: member list table, role change dropdown (OWNER/ADMIN/ACCOUNTANT/DRIVER/VIEWER), invitation form, remove member button |
|
||||
| `frontend/src/router/index.ts` | Add route `/organization/:id/team` pointing to the new view |
|
||||
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:52-60) | Add "Csapat" menu item to the hamburger menu |
|
||||
|
||||
**Task 4: Create Ownership Transfer endpoint + UI**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py) | **NEW ENDPOINT** `POST /{org_id}/transfer-ownership` — requires OWNER role, accepts `new_owner_user_id`, updates `owner_id` on Organization, creates audit log, notifies both parties |
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:486-569) | The existing `PATCH /{org_id}/members/{member_id}` already supports changing role to OWNER, but doesn't update `Organization.owner_id`. Add that logic. |
|
||||
| Frontend team page | Add "Tulajdonjog átruházása" button (visible only to OWNERs) |
|
||||
|
||||
**Task 5: ADMIN subscription management**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:775-822) | Add an alternative permission check for `PUT /{org_id}/subscription` that allows org ADMINs (not just system-level) to manage subscriptions. Use `_check_org_admin_access()` instead of `RequireSystemCapability`. |
|
||||
|
||||
#### 🟢 P2 — Nice to Fix
|
||||
|
||||
**Task 6: Mobile menu alignment verification**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75) | Verify the "Költségek" button alignment. If the issue persists, add `text-left` explicitly to the button or wrap the text in a `<span class="text-left">`. |
|
||||
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:40-72) | Same verification for the company hamburger menu |
|
||||
|
||||
### Data Flow Diagram (Current vs Proposed)
|
||||
|
||||
```
|
||||
Current:
|
||||
GET /organizations/my
|
||||
→ Returns all orgs including individual
|
||||
→ HeaderCompanySwitcher FILTERS OUT individual
|
||||
→ User sees only company orgs + hardcoded "Private Garage" button
|
||||
→ Multiple private garages = invisible
|
||||
|
||||
Proposed:
|
||||
GET /organizations/my
|
||||
→ Returns all orgs including individual
|
||||
→ HeaderCompanySwitcher shows ALL orgs grouped:
|
||||
┌─────────────────────────────┐
|
||||
│ 👤 Saját garázsok │
|
||||
│ ✓ Első garázs │
|
||||
│ Második garázs │
|
||||
│─────────────────────────────│
|
||||
│ 🏢 Céges garázsok │
|
||||
│ Alpha Kft. │
|
||||
│ Beta Kft. │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Subscription Visibility Enhancement
|
||||
|
||||
```
|
||||
Current SubscriptionStatusWidget:
|
||||
┌─────────────────────────────┐
|
||||
│ test_privát 16 nap │
|
||||
│ ████████░░░░░░ │ ← only time progress
|
||||
└─────────────────────────────┘
|
||||
|
||||
Proposed SubscriptionStatusWidget:
|
||||
┌─────────────────────────────┐
|
||||
│ 💎 test_privát Aktív │
|
||||
│ Lejárat: 2026.07.09 │
|
||||
│ ████████░░░░░░ 16 nap │ ← time progress
|
||||
│ 🚗 Járművek: 16/100 │
|
||||
│ ██████████░░░░ 16% │ ← usage progress
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**End of Audit Report.** Ready for Architect review and approval before implementation.
|
||||
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# P0 READ & COMPARE — Vendor and Expense Architecture Audit Report
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Fast Coder (Core Developer)
|
||||
**Scope:** Vendor creation flow + Expense creation flow
|
||||
**Status:** COMPLETE — No code modified, pure audit
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Vendor Creation Flow
|
||||
|
||||
### 1.1. Endpoint: `POST /providers/quick-add`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/providers.py:241`](../backend/app/api/v1/endpoints/providers.py:241)
|
||||
|
||||
This is the **primary** vendor creation endpoint used by the frontend's CostEntryWizard when a user adds a new service provider (vendor) via crowdsourcing.
|
||||
|
||||
**Actual Code Flow** (in [`backend/app/services/provider_service.py:468`](../backend/app/services/provider_service.py:468)):
|
||||
|
||||
| Step | What happens | Line |
|
||||
|------|-------------|------|
|
||||
| 1 | Organization created with `org_type=OrgType.service_provider` | `:526` |
|
||||
| 2 | `owner_id=None` — explicitly set to None | `:541` |
|
||||
| 3 | `status="pending_verification"`, `is_verified=False` | `:527-528` |
|
||||
| 4 | ServiceProfile created with `status="ghost"` | `:561-570` |
|
||||
| 5 | Branch created with address data | `:618-630` |
|
||||
| 6 | **OrganizationMember created with `role=OrgUserRole.ADMIN`** | `:639-646` |
|
||||
|
||||
### 1.2. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `org_type` | `SERVICE_PROVIDER` | `OrgType.service_provider` ✅ | **CORRECT** |
|
||||
| `owner_id` | `NULL` (ideally) | `owner_id=None` ✅ | **CORRECT** |
|
||||
| Creator in members? | **NO** — creator should NOT be a member | **BUG:** Creator IS added as `OrganizationMember` with `role=ADMIN` ❌ | **DEVIATION** |
|
||||
|
||||
### 1.3. The Member Problem
|
||||
|
||||
In [`backend/app/services/provider_service.py:639-646`](../backend/app/services/provider_service.py:639-646):
|
||||
|
||||
```python
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN, # <-- ADMIN, not OWNER
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
```
|
||||
|
||||
The code comment at line 635 says:
|
||||
> *"A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER). Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég nem jelenik meg a 'Cégeim' garázsválasztóban."*
|
||||
|
||||
**However**, the expected business logic says:
|
||||
> *"A létrehozó felhasználó NEM kerülhet be a tagok (organization_members) közé!"*
|
||||
|
||||
This is a **deliberate design decision** — the current code adds the creator as ADMIN. The expected logic says NO membership at all. This needs Architect clarification.
|
||||
|
||||
### 1.4. Secondary Endpoint: `POST /organizations/onboard`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py:38`](../backend/app/api/v1/endpoints/organizations.py:38)
|
||||
|
||||
This endpoint is for **business onboarding** (not vendor creation). It creates:
|
||||
- `org_type=OrgType.business` (line 84)
|
||||
- `owner_id` is NOT set (implicitly NULL)
|
||||
- Creator is added as `OrganizationMember` with `role="OWNER"` (line 116-121)
|
||||
|
||||
This is **correct** for business onboarding — the creator SHOULD be the owner.
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Expense Creation Flow
|
||||
|
||||
### 2.1. Endpoint: `POST /expenses/`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/expenses.py:345`](../backend/app/api/v1/endpoints/expenses.py:345)
|
||||
|
||||
### 2.2. How `organization_id` is Resolved
|
||||
|
||||
**Actual Code** (lines 398-429):
|
||||
|
||||
```python
|
||||
# CRITICAL FIX: organization_id MUST be the user's active organization context,
|
||||
# NOT blindly taken from the payload.
|
||||
#
|
||||
# Resolution strategy:
|
||||
# 1. First, try to resolve the user's active organization from their membership
|
||||
# 2. Fallback to asset.current_organization_id or asset.owner_org_id
|
||||
# 3. Ignore expense.organization_id from payload to prevent vendor-org pollution
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id # Priority 1
|
||||
elif asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id # Priority 2
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id # Priority 3
|
||||
else:
|
||||
# B2C fallback: any membership (even unverified)
|
||||
...
|
||||
```
|
||||
|
||||
### 2.3. How `vendor_organization_id` is Set
|
||||
|
||||
**Actual Code** (line 495):
|
||||
|
||||
```python
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
```
|
||||
|
||||
The payload's `vendor_organization_id` is passed **directly** to the model. This is the vendor's organization ID.
|
||||
|
||||
### 2.4. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `organization_id` source | Asset's garage (`asset.current_organization_id`) | User's active org membership (Priority 1), then `asset.current_organization_id` (Priority 2) | **PARTIAL DEVIATION** |
|
||||
| Payload `organization_id` | Should be **IGNORED** | IS ignored — backend resolves from user context ✅ | **CORRECT** |
|
||||
| `vendor_organization_id` | The invoice issuer (vendor) | Set from `expense.vendor_organization_id` ✅ | **CORRECT** |
|
||||
|
||||
### 2.5. The organization_id Deviation Explained
|
||||
|
||||
The expected logic says:
|
||||
> *"A költség organization_id mezőjének (a költség gazdájának) a jármű saját garázsát (asset.current_organization_id) kell tükröznie."*
|
||||
|
||||
The actual code uses **Priority 1: user's active org membership**. This means if a user belongs to Organization A (their fleet manager org) but the asset is currently assigned to Organization B (a different garage), the expense will be saved under Organization A, not Organization B.
|
||||
|
||||
**This is a potential bug** in multi-org scenarios where a user manages vehicles across multiple organizations. The asset's `current_organization_id` should be the authoritative source.
|
||||
|
||||
### 2.6. Frontend Payload Analysis
|
||||
|
||||
**File:** [`frontend/src/components/cost/CostEntryWizard.vue:690-723`](../frontend/src/components/cost/CostEntryWizard.vue:690)
|
||||
|
||||
The frontend:
|
||||
1. Does **NOT** send `vendor_organization_id` in the payload (line 690-714)
|
||||
2. Sends `organization_id` = user's `active_organization_id` (line 720-723)
|
||||
3. Stores vendor info inside `data.vendor_id` and `data.vendor_name` (JSONB) (line 707-710)
|
||||
|
||||
**File:** [`frontend/src/stores/cost.ts:64-73`](../frontend/src/stores/cost.ts:64)
|
||||
|
||||
The cost store also injects `organization_id` from `authStore.user?.active_organization_id`.
|
||||
|
||||
**This means:** The frontend never sends `vendor_organization_id` to the backend! The vendor info is stored in the JSONB `data` field, but the dedicated `vendor_organization_id` column on `AssetCost` remains NULL.
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary of Findings
|
||||
|
||||
### 🟢 CORRECT (Matches Expected Logic)
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | `POST /providers/quick-add` creates org with `org_type=service_provider` | [`provider_service.py:526`](../backend/app/services/provider_service.py:526) |
|
||||
| 2 | `owner_id=None` for crowdsourced providers | [`provider_service.py:541`](../backend/app/services/provider_service.py:541) |
|
||||
| 3 | Backend `POST /expenses/` ignores payload `organization_id` | [`expenses.py:398-429`](../backend/app/api/v1/endpoints/expenses.py:398) |
|
||||
| 4 | `vendor_organization_id` is correctly mapped from schema | [`expenses.py:495`](../backend/app/api/v1/endpoints/expenses.py:495) |
|
||||
|
||||
### 🟡 DEVIATION (Needs Architect Decision)
|
||||
|
||||
| # | Finding | Location | Impact |
|
||||
|---|---------|----------|--------|
|
||||
| 1 | **Vendor creator IS added as OrganizationMember (ADMIN)** — expected: NO membership | [`provider_service.py:639-646`](../backend/app/services/provider_service.py:639) | Vendor appears in user's org list? |
|
||||
| 2 | **`organization_id` resolved from user's active org (Priority 1)** — expected: `asset.current_organization_id` | [`expenses.py:406-414`](../backend/app/api/v1/endpoints/expenses.py:406) | Wrong org in multi-org scenarios |
|
||||
| 3 | **Frontend never sends `vendor_organization_id`** — vendor info only in JSONB `data` | [`CostEntryWizard.vue:690-714`](../frontend/src/components/cost/CostEntryWizard.vue:690) | `vendor_organization_id` column always NULL |
|
||||
|
||||
### 🔴 CRITICAL GAP
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | Frontend `CostEntryWizard` does NOT populate `vendor_organization_id` in the POST payload | [`CostEntryWizard.vue:707`](../frontend/src/components/cost/CostEntryWizard.vue:707) — vendor_id stored only in `data.vendor_id` |
|
||||
| 2 | The `cost.ts` store also doesn't map `vendor_organization_id` | [`cost.ts:53-73`](../frontend/src/stores/cost.ts:53) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Actions
|
||||
|
||||
1. **Architect Decision Needed:** Should `quick_add_provider` add the creator as `OrganizationMember` (current behavior) or NOT (expected logic)? The current ADMIN role prevents the org from appearing in "My Companies" garage selector, but the user still has a membership record.
|
||||
|
||||
2. **Fix `organization_id` Resolution:** Change Priority 1 from "user's active org membership" to `asset.current_organization_id` to match the expected business logic exactly.
|
||||
|
||||
3. **Fix Frontend:** Add `vendor_organization_id` to the POST `/expenses/` payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONB `data`.
|
||||
|
||||
---
|
||||
|
||||
*End of Audit Report — No code was modified during this analysis.*
|
||||
Reference in New Issue
Block a user