Files
service-finder/docs/p0_backoffice_sso_router_audit_report.md
2026-06-24 11:29:45 +00:00

11 KiB
Raw Blame History

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

Current State (Lines 127134, 221228)

Both the /login and /verify-email endpoints set the refresh_token cookie like this:

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:

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

2a. Existing get_current_admin (Lines 160177)

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 184218)

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:

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:

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

3a. Current Admin Route (Lines 112137)

{
  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 166177)

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 103110)

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

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

# Task File Details
1.1 Add COOKIE_DOMAIN to settings 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 response.set_cookie(..., domain=settings.COOKIE_DOMAIN)
1.3 Add domain param to /verify-email cookie 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 Generic role-list checker
2.2 Add get_current_staff dependency 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 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 Change adminRoles to include SALES_REP, SERVICE_MGR
3.2 Update isAdmin getter in auth store 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 Add routes for vehicles, translations, system, audit — or remove sidebar links
3.4 Consider role-based sidebar visibility 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

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