@cryptotaxi247 / CoPilot / commits / d8a87ae9

feat(customer-portal): Agents + sub-filter tabs scoping; fix cross-tenant alerts leak (#891)

* feat(customer-portal): scope Agents + alerts/cases sub-filter tabs; fix cross-tenant leak (#873) Follow-up slice extending the multi-customer filter to the Agents page and the alerts/cases sub-filter tabs, and fixing a pre-existing cross-tenant disclosure found along the way. Security fix (cross-tenant disclosure): - The alerts variant list endpoints (by status/source/asset/assigned-to/title/ tag/ioc and /alerts/filter) passed customer_code=accessible[0] if len==1 else None to list_alerts_multiple_filters. For a customer_user assigned to MORE THAN ONE customer this meant no customer filter at all, so the list returned every customer's alerts of that status/source/etc. (the count badges were scoped, so it looked subtly wrong). Now these endpoints pass customer_codes=accessible_ customers (the caller's full accessible set) and list_alerts_multiple_filters gained a customer_codes list filter. Admin/analyst (wildcard) paths unchanged. Feature (multi-customer filter, follow-up): - Agents: GET /agents takes an optional customer_codes query param (via filter_query_by_customer_access requested_customers); portal getAgents passes the selection and the Agents list re-fetches when it changes. - Alerts/Cases sub-filter tabs: the by-status/source/asset/tag (alerts) and by-status/assigned-to (cases) endpoints take optional customer_codes (resolved against the user's access) and the portal wrappers + List.vue pass the selection so those tabs stay scoped to the chosen customer(s). All params are optional, so the analyst frontend is unaffected. Portal type-check + eslint pass; backend compiles. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(customer-portal): scope Dashboards page to the customer filter (#873) The Dashboards page was hardwired to the user's primary customer (authStore.userCustomerCode) via the single-customer endpoint /siem/dashboards/enabled/{customer_code}, so multi-customer users could not see their other customers' dashboards. - Backend: new GET /siem/dashboards/enabled?customer_codes=... that resolves the requested subset against the user's access (resolve_effective_customers) and returns the merged enabled dashboards (all accessible when none requested). The existing per-customer endpoint is unchanged. - Frontend: the Dashboards list now follows the global customer filter (selected subset, or all accessible), adds a Customer column, and re-fetches on change. The dashboard viewer is keyed by dashboard id and is unaffected. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(customer-portal): let Event Search choose any accessible customer; follow the filter (#873) Event Search is inherently single-customer (it queries one customer + one source), and the Customer field was a disabled input locked to the user's primary customer, so multi-customer users could not search their other customers' events. Turn the Customer field into a selector of the user's accessible customers, seeded from the global customer filter when it resolves to a single customer (otherwise the primary/first accessible). Changing the customer reloads its event sources, and the form follows the global filter when it narrows to one customer. No backend change: the SIEM events / event_sources / field-mappings endpoints already enforce check_customer_access, so selecting only ever works within the user's own access. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylor_socfortress committed May 27, 2026 at 17:05 UTC d8a87ae9b33c00dae2e8c5cbd7e5d8881ed0b92a
14 files changed +232 -72
backend/app/agents/routes/agents.py
+14 -3
@@ -14,6 +14,7 @@ from fastapi import Depends
14 from fastapi import Header
15 from fastapi import HTTPException
16 from fastapi import Path
17 +from fastapi import Query
18 from fastapi import Security
19 from fastapi.responses import StreamingResponse
20 from loguru import logger
@@ -233,7 +234,11 @@ async def delete_agent_from_database(db: AsyncSession, agent_id: str):
234 description="Get all agents currently synced to the database",
235 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
236 )
236 -async def get_agents(current_user: User = Depends(AuthHandler().get_current_user), db: AsyncSession = Depends(get_db)) -> AgentsResponse:
237 +async def get_agents(
238 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
239 + current_user: User = Depends(AuthHandler().get_current_user),
240 + db: AsyncSession = Depends(get_db),
241 +) -> AgentsResponse:
242 """
243 Retrieve all agents currently synced to the database.
244 Results are filtered based on user's customer access permissions.
@@ -246,9 +251,15 @@ async def get_agents(current_user: User = Depends(AuthHandler().get_current_user
251 """
252 logger.info("Fetching all agents")
253 try:
249 - # Apply customer access filtering
254 + # Apply customer access filtering (optionally narrowed to a requested subset)
255 base_query = select(Agents)
251 - filtered_query = await customer_access_handler.filter_query_by_customer_access(current_user, db, base_query, Agents.customer_code)
256 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
257 + current_user,
258 + db,
259 + base_query,
260 + Agents.customer_code,
261 + requested_customers=customer_codes,
262 + )
263
264 result = await db.execute(filtered_query)
265 agents = result.scalars().all()
backend/app/incidents/routes/db_operations.py
+30 -21
@@ -833,7 +833,7 @@ async def list_alerts_by_ioc_value_endpoint(
833 # Customer user - filter by accessible customers
834 alerts = await list_alerts_multiple_filters(
835 ioc_value=ioc_value,
836 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
836 + customer_codes=accessible_customers,
837 db=db,
838 page=page,
839 page_size=page_size,
@@ -886,14 +886,15 @@ async def list_alerts_by_tag_endpoint(
886 tag: str,
887 page: int = Query(1, ge=1),
888 page_size: int = Query(25, ge=1),
889 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
890 current_user: User = Depends(AuthHandler().get_current_user),
891 db: AsyncSession = Depends(get_db),
892 ):
893 """List alerts by tag with customer access filtering"""
894 logger.info(f"Listing alerts by tag {tag} for user: {current_user.username} with role_id: {current_user.role_id}")
895
895 - # Get customer access filtering
896 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
896 + # Get customer access filtering (optionally narrowed to a requested subset)
897 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
898
899 if "*" in accessible_customers:
900 # Admin/analyst - no filtering needed
@@ -906,7 +907,7 @@ async def list_alerts_by_tag_endpoint(
907 # Customer user - filter by accessible customers
908 alerts = await list_alerts_multiple_filters(
909 tags=[tag],
909 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
910 + customer_codes=accessible_customers,
911 db=db,
912 page=page,
913 page_size=page_size,
@@ -1403,6 +1404,7 @@ async def list_alerts_by_status_endpoint(
1404 page: int = Query(1, ge=1),
1405 page_size: int = Query(25, ge=1),
1406 order: str = Query("desc", pattern="^(asc|desc)$"),
1407 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1408 current_user: User = Depends(AuthHandler().get_current_user),
1409 db: AsyncSession = Depends(get_db),
1410 ):
@@ -1412,8 +1414,8 @@ async def list_alerts_by_status_endpoint(
1414
1415 logger.info(f"Listing alerts by status {status} for user: {current_user.username} with role_id: {current_user.role_id}")
1416
1415 - # Get customer access filtering
1416 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1417 + # Get customer access filtering (optionally narrowed to a requested subset)
1418 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1419
1420 if "*" in accessible_customers:
1421 # Admin/analyst - no filtering needed
@@ -1428,7 +1430,7 @@ async def list_alerts_by_status_endpoint(
1430 # For now, let's use the multiple filters function with customer codes
1431 alerts = await list_alerts_multiple_filters(
1432 status=status.value,
1431 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1433 + customer_codes=accessible_customers,
1434 db=db,
1435 page=page,
1436 page_size=page_size,
@@ -1480,7 +1482,7 @@ async def list_alerts_by_assigned_to_endpoint(
1482 # Customer user - filter by accessible customers
1483 alerts = await list_alerts_multiple_filters(
1484 assigned_to=assigned_to,
1483 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1485 + customer_codes=accessible_customers,
1486 db=db,
1487 page=page,
1488 page_size=page_size,
@@ -1512,14 +1514,15 @@ async def list_alerts_by_asset_name_endpoint(
1514 page: int = Query(1, ge=1),
1515 page_size: int = Query(25, ge=1),
1516 order: str = Query("desc", pattern="^(asc|desc)$"),
1517 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1518 current_user: User = Depends(AuthHandler().get_current_user),
1519 db: AsyncSession = Depends(get_db),
1520 ):
1521 """List alerts by asset name with customer access filtering"""
1522 logger.info(f"Listing alerts by asset {asset_name} for user: {current_user.username} with role_id: {current_user.role_id}")
1523
1521 - # Get customer access filtering
1522 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1524 + # Get customer access filtering (optionally narrowed to a requested subset)
1525 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1526
1527 if "*" in accessible_customers:
1528 # Admin/analyst - no filtering needed
@@ -1532,7 +1535,7 @@ async def list_alerts_by_asset_name_endpoint(
1535 # Customer user - filter by accessible customers
1536 alerts = await list_alerts_multiple_filters(
1537 asset_name=asset_name,
1535 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1538 + customer_codes=accessible_customers,
1539 db=db,
1540 page=page,
1541 page_size=page_size,
@@ -1584,7 +1587,7 @@ async def list_alerts_by_title_endpoint(
1587 # Customer user - filter by accessible customers
1588 alerts = await list_alerts_multiple_filters(
1589 alert_title=title,
1587 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1590 + customer_codes=accessible_customers,
1591 db=db,
1592 page=page,
1593 page_size=page_size,
@@ -1645,14 +1648,15 @@ async def list_alerts_by_source_endpoint(
1648 page: int = Query(1, ge=1),
1649 page_size: int = Query(25, ge=1),
1650 order: str = Query("desc", pattern="^(asc|desc)$"),
1651 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1652 current_user: User = Depends(AuthHandler().get_current_user),
1653 db: AsyncSession = Depends(get_db),
1654 ):
1655 """List alerts by source with customer access filtering"""
1656 logger.info(f"Listing alerts by source {source} for user: {current_user.username} with role_id: {current_user.role_id}")
1657
1654 - # Get customer access filtering
1655 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
1658 + # Get customer access filtering (optionally narrowed to a requested subset)
1659 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
1660
1661 if "*" in accessible_customers:
1662 # Admin/analyst - no filtering needed
@@ -1665,7 +1669,7 @@ async def list_alerts_by_source_endpoint(
1669 # Customer user - filter by accessible customers
1670 alerts = await list_alerts_multiple_filters(
1671 source=source,
1668 - customer_code=accessible_customers[0] if len(accessible_customers) == 1 else None,
1672 + customer_codes=accessible_customers,
1673 db=db,
1674 page=page,
1675 page_size=page_size,
@@ -1739,6 +1743,9 @@ async def list_alerts_multiple_filters_endpoint(
1743 page=page,
1744 page_size=page_size,
1745 order=order,
1746 + # Constrain scoped users to their accessible customers (prevents cross-tenant
1747 + # disclosure when the user has >1 customer and no explicit customer_code).
1748 + customer_codes=None if "*" in accessible_customers else accessible_customers,
1749 user=current_user, # Pass user for tag filtering
1750 )
1751
@@ -2134,6 +2141,7 @@ async def list_cases_by_status_endpoint(
2141 page: int = Query(1, ge=1),
2142 page_size: int = Query(25, ge=1),
2143 order: str = Query("desc", pattern="^(asc|desc)$"),
2144 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
2145 current_user: User = Depends(AuthHandler().get_current_user),
2146 db: AsyncSession = Depends(get_db),
2147 ):
@@ -2151,13 +2159,13 @@ async def list_cases_by_status_endpoint(
2159 cases = await list_cases_by_status(status.value, db, page=page, page_size=page_size, order=order)
2160 else:
2161 # Customer user - get paginated cases and filter by status
2154 - all_user_cases = await list_cases_for_user(current_user, db, page, page_size, order)
2162 + all_user_cases = await list_cases_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
2163 cases = [case for case in all_user_cases if case.case_status == status.value]
2164
2157 - total = await case_total_for_user(current_user, db)
2158 - open_cases = await cases_open_for_user(current_user, db)
2159 - in_progress = await cases_in_progress_for_user(current_user, db)
2160 - closed = await cases_closed_for_user(current_user, db)
2165 + total = await case_total_for_user(current_user, db, customer_codes=customer_codes)
2166 + open_cases = await cases_open_for_user(current_user, db, customer_codes=customer_codes)
2167 + in_progress = await cases_in_progress_for_user(current_user, db, customer_codes=customer_codes)
2168 + closed = await cases_closed_for_user(current_user, db, customer_codes=customer_codes)
2169
2170 return CaseOutResponse(
2171 cases=cases,
@@ -2177,6 +2185,7 @@ async def list_cases_by_status_endpoint(
2185 )
2186 async def list_cases_by_assigned_to_endpoint(
2187 assigned_to: str,
2188 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
2189 current_user: User = Depends(AuthHandler().get_current_user),
2190 db: AsyncSession = Depends(get_db),
2191 ):
@@ -2191,7 +2200,7 @@ async def list_cases_by_assigned_to_endpoint(
2200 cases = await list_cases_by_assigned_to(assigned_to, db)
2201 else:
2202 # Customer user - filter by accessible customers
2194 - all_user_cases = await list_cases_for_user(current_user, db)
2203 + all_user_cases = await list_cases_for_user(current_user, db, customer_codes=customer_codes)
2204 cases = [case for case in all_user_cases if case.assigned_to == assigned_to]
2205
2206 return CaseOutResponse(cases=cases, success=True, message="Cases retrieved successfully")
backend/app/incidents/services/db_operations.py
+9 -1
@@ -2466,6 +2466,7 @@ async def list_alerts_multiple_filters(
2466 assigned_to: Optional[str] = None,
2467 alert_title: Optional[str] = None,
2468 customer_code: Optional[str] = None,
2469 + customer_codes: Optional[List[str]] = None,
2470 source: Optional[str] = None,
2471 asset_name: Optional[str] = None,
2472 status: Optional[str] = None,
@@ -2476,7 +2477,12 @@ async def list_alerts_multiple_filters(
2477 order: str = "desc",
2478 user: Optional[User] = None, # New parameter for tag filtering
2479 ) -> List[AlertOut]:
2479 - """List alerts with multiple filters including tag-based RBAC"""
2480 + """List alerts with multiple filters including tag-based RBAC.
2481 +
2482 + ``customer_code`` filters to a single customer; ``customer_codes`` filters to
2483 + a set (used to constrain scoped users to their accessible customers — passing
2484 + the caller's full accessible set prevents cross-tenant disclosure).
2485 + """
2486 from sqlalchemy import and_
2487 from sqlalchemy import exists
2488 from sqlalchemy import or_
@@ -2492,6 +2498,8 @@ async def list_alerts_multiple_filters(
2498 filters.append(Alert.alert_name.like(f"%{alert_title}%"))
2499 if customer_code:
2500 filters.append(Alert.customer_code == customer_code)
2501 + if customer_codes:
2502 + filters.append(Alert.customer_code.in_(customer_codes))
2503 if source:
2504 filters.append(Alert.source == source)
2505 if asset_name:
backend/app/siem/routes/dashboards.py
+26
@@ -1,6 +1,10 @@
1 +from typing import List
2 +from typing import Optional
3 +
4 from fastapi import APIRouter
5 from fastapi import Depends
6 from fastapi import HTTPException
7 +from fastapi import Query
8 from fastapi import Security
9 from loguru import logger
10 from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,6 +28,7 @@ from app.siem.services.dashboards import disable_dashboard
28 from app.siem.services.dashboards import enable_dashboard
29 from app.siem.services.dashboards import get_category_detail
30 from app.siem.services.dashboards import get_enabled_dashboards
31 +from app.siem.services.dashboards import get_enabled_dashboards_for_customers
32 from app.siem.services.dashboards import get_panel_data
33 from app.siem.services.dashboards import list_categories
34
@@ -79,6 +84,27 @@ async def get_dashboard_category(category_id: str) -> DashboardCategoryDetailRes
84 # ── Enabled dashboards (per-customer, DB-backed) ────────────────
85
86
87 +@dashboards_router.get(
88 + "/enabled",
89 + response_model=EnabledDashboardsListResponse,
90 + description="List enabled dashboards across the user's accessible customers (optionally narrowed to a subset via customer_codes).",
91 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
92 +)
93 +async def list_enabled_dashboards_multi(
94 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
95 + current_user: User = Depends(AuthHandler().get_current_user),
96 + db: AsyncSession = Depends(get_db),
97 +) -> EnabledDashboardsListResponse:
98 + # Resolve the requested subset against the user's access (never widens scope).
99 + effective_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
100 + rows = await get_enabled_dashboards_for_customers(effective_customers, db)
101 + return EnabledDashboardsListResponse(
102 + enabled_dashboards=[EnabledDashboardResponse.from_orm(r) for r in rows],
103 + success=True,
104 + message="Enabled dashboards retrieved successfully",
105 + )
106 +
107 +
108 @dashboards_router.get(
109 "/enabled/{customer_code}",
110 response_model=EnabledDashboardsListResponse,
backend/app/siem/services/dashboards.py
+17
@@ -73,6 +73,23 @@ async def get_enabled_dashboards(
73 return result.scalars().all()
74
75
76 +async def get_enabled_dashboards_for_customers(
77 + customer_codes: List[str],
78 + db: AsyncSession,
79 +) -> List[EnabledDashboards]:
80 + """Fetch enabled dashboards across a set of customers.
81 +
82 + ``customer_codes`` is the caller's already-resolved effective set: ``["*"]``
83 + means all customers (admin/analyst), an empty list means none.
84 + """
85 + logger.info(f"Fetching enabled dashboards for customers {customer_codes}")
86 + query = select(EnabledDashboards)
87 + if "*" not in customer_codes:
88 + query = query.where(EnabledDashboards.customer_code.in_(customer_codes))
89 + result = await db.execute(query)
90 + return result.scalars().all()
91 +
92 +
93 async def enable_dashboard(
94 request: EnableDashboardRequest,
95 db: AsyncSession,
customer-portal/src/api/endpoints/agents.ts
+3 -2
@@ -1,13 +1,14 @@
1 import type { Agent } from "@/types/agents"
2 import type { CommonResponse } from "@/types/common"
3 import { HttpClient } from "../httpClient"
4 +import { withCustomerCodes } from "../params"
5
6 export default {
7 /**
8 * Get all agents for the authenticated customer
9 */
9 - getAgents() {
10 - return HttpClient.get<CommonResponse<{ agents: Agent[] }>>("/agents")
10 + getAgents(customerCodes?: string[]) {
11 + return HttpClient.get<CommonResponse<{ agents: Agent[] }>>("/agents", withCustomerCodes(customerCodes))
12 },
13
14 /**
customer-portal/src/api/endpoints/alerts.ts
+33 -20
@@ -76,45 +76,58 @@ export default {
76 getAlertsByStatus(
77 status: AlertStatus,
78 { page = 1, pageSize = 25, order = "desc" }: Pagination,
79 - signal?: AbortSignal
79 + signal?: AbortSignal,
80 + customerCodes?: string[]
81 ) {
81 - return HttpClient.get<CommonResponse<AlertsListResponse>>(`/incidents/db_operations/alerts/status/${status}`, {
82 - params: { page, page_size: pageSize, order },
83 - signal
84 - })
82 + return HttpClient.get<CommonResponse<AlertsListResponse>>(
83 + `/incidents/db_operations/alerts/status/${status}`,
84 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
85 + )
86 },
87
88 /**
89 * Get alerts by asset name with customer filtering
90 */
90 - getAlertsByAsset(assetName: string, { page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal) {
91 + getAlertsByAsset(
92 + assetName: string,
93 + { page = 1, pageSize = 25, order = "desc" }: Pagination,
94 + signal?: AbortSignal,
95 + customerCodes?: string[]
96 + ) {
97 return HttpClient.get<CommonResponse<AlertsListResponse>>(
98 `/incidents/db_operations/alerts/asset/${assetName}`,
93 - {
94 - params: { page, page_size: pageSize, order },
95 - signal
96 - }
99 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
100 )
101 },
102
103 /**
104 * Get alerts by tag with customer filtering
105 */
103 - getAlertsByTag(tag: string, { page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal) {
104 - return HttpClient.get<CommonResponse<AlertsListResponse>>(`/incidents/db_operations/alert/tag/${tag}`, {
105 - params: { page, page_size: pageSize, order },
106 - signal
107 - })
106 + getAlertsByTag(
107 + tag: string,
108 + { page = 1, pageSize = 25, order = "desc" }: Pagination,
109 + signal?: AbortSignal,
110 + customerCodes?: string[]
111 + ) {
112 + return HttpClient.get<CommonResponse<AlertsListResponse>>(
113 + `/incidents/db_operations/alert/tag/${tag}`,
114 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
115 + )
116 },
117
118 /**
119 * Get alerts by source with customer filtering
120 */
113 - getAlertsBySource(source: string, { page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal) {
114 - return HttpClient.get<CommonResponse<AlertsListResponse>>(`/incidents/db_operations/alerts/source/${source}`, {
115 - params: { page, page_size: pageSize, order },
116 - signal
117 - })
121 + getAlertsBySource(
122 + source: string,
123 + { page = 1, pageSize = 25, order = "desc" }: Pagination,
124 + signal?: AbortSignal,
125 + customerCodes?: string[]
126 + ) {
127 + return HttpClient.get<CommonResponse<AlertsListResponse>>(
128 + `/incidents/db_operations/alerts/source/${source}`,
129 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
130 + )
131 },
132
133 /**
customer-portal/src/api/endpoints/cases.ts
+9 -10
@@ -80,12 +80,13 @@ export default {
80 getCasesByStatus(
81 status: CaseStatus,
82 { page = 1, pageSize = 25, order = "desc" }: Pagination,
83 - signal?: AbortSignal
83 + signal?: AbortSignal,
84 + customerCodes?: string[]
85 ) {
85 - return HttpClient.get<CommonResponse<CasesListResponse>>(`/incidents/db_operations/case/status/${status}`, {
86 - params: { page, page_size: pageSize, order },
87 - signal
88 - })
86 + return HttpClient.get<CommonResponse<CasesListResponse>>(
87 + `/incidents/db_operations/case/status/${status}`,
88 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
89 + )
90 },
91
92 /**
@@ -94,14 +95,12 @@ export default {
95 getCasesByAssignedTo(
96 assignedTo: string,
97 { page = 1, pageSize = 25, order = "desc" }: Pagination,
97 - signal?: AbortSignal
98 + signal?: AbortSignal,
99 + customerCodes?: string[]
100 ) {
101 return HttpClient.get<CommonResponse<CasesListResponse>>(
102 `/incidents/db_operations/case/assigned-to/${assignedTo}`,
101 - {
102 - params: { page, page_size: pageSize, order },
103 - signal
104 - }
103 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
104 )
105 },
106
customer-portal/src/api/endpoints/siem.ts
+8
@@ -7,6 +7,7 @@ import type {
7 PanelDataResponse
8 } from "@/types/siem"
9 import { HttpClient } from "../httpClient"
10 +import { withCustomerCodes } from "../params"
11
12 export default {
13 getEventSources(customerCode: string) {
@@ -44,6 +45,13 @@ export default {
45 )
46 },
47
48 + getEnabledDashboardsForCustomers(customerCodes?: string[]) {
49 + return HttpClient.get<CommonResponse<{ enabled_dashboards: EnabledDashboard[] }>>(
50 + "/siem/dashboards/enabled",
51 + withCustomerCodes(customerCodes)
52 + )
53 + },
54 +
55 disableDashboard(dashboardId: number) {
56 return HttpClient.delete<CommonResponse>(`/siem/dashboards/disable/${dashboardId}`)
57 },
customer-portal/src/components/agents/List.vue
+15 -1
@@ -67,6 +67,7 @@ import Api from "@/api"
67 import Filters from "@/components/agents/Filters.vue"
68 import Chip from "@/components/common/Chip.vue"
69 import Icon from "@/components/common/Icon.vue"
70 +import { useCustomerFilterStore } from "@/stores/customerFilter"
71 import { useSettingsStore } from "@/stores/settings"
72 import { getApiErrorMessage, getStatusColor } from "@/utils"
73 import { formatDate } from "@/utils/format"
@@ -218,6 +219,8 @@ const columns = computed<DataTableColumns<Agent>>(() => [
219
220 let abortController = new AbortController()
221
222 +const customerFilterStore = useCustomerFilterStore()
223 +
224 const loadAgents = useDebounceFn(async () => {
225 loading.value = true
226
@@ -225,7 +228,7 @@ const loadAgents = useDebounceFn(async () => {
228 abortController = new AbortController()
229
230 try {
228 - const response = await Api.agents.getAgents()
231 + const response = await Api.agents.getAgents(customerFilterStore.queryCustomerCodes)
232
233 data.value = response.data.agents || []
234 emit("loaded", data.value)
@@ -262,6 +265,17 @@ watch([() => pagination.value.pageSize, filters], resetPage, {
265 immediate: true
266 })
267
268 +// The agents list is fetched once and filtered client-side, so a change to the
269 +// global customer filter must re-fetch (the backend scopes by customer_codes).
270 +watch(
271 + () => customerFilterStore.selectedCustomerCodes,
272 + () => {
273 + pagination.value.page = 1
274 + loadAgents()
275 + },
276 + { deep: true }
277 +)
278 +
279 onBeforeMount(() => {
280 loadAgents()
281 })
customer-portal/src/components/alerts/List.vue
+8 -4
@@ -180,28 +180,32 @@ const loadAlerts = useDebounceFn(async () => {
180 response = await Api.alerts.getAlertsByStatus(
181 filters.value.value as AlertStatus,
182 paginationPayload,
183 - abortController.signal
183 + abortController.signal,
184 + customerFilterStore.queryCustomerCodes
185 )
186 break
187 case "sources":
188 response = await Api.alerts.getAlertsBySource(
189 filters.value.value,
190 paginationPayload,
190 - abortController.signal
191 + abortController.signal,
192 + customerFilterStore.queryCustomerCodes
193 )
194 break
195 case "assets":
196 response = await Api.alerts.getAlertsByAsset(
197 filters.value.value,
198 paginationPayload,
197 - abortController.signal
199 + abortController.signal,
200 + customerFilterStore.queryCustomerCodes
201 )
202 break
203 case "tags":
204 response = await Api.alerts.getAlertsByTag(
205 filters.value.value,
206 paginationPayload,
204 - abortController.signal
207 + abortController.signal,
208 + customerFilterStore.queryCustomerCodes
209 )
210 break
211 default:
customer-portal/src/components/cases/List.vue
+4 -2
@@ -195,14 +195,16 @@ const loadCases = useDebounceFn(async () => {
195 response = await Api.cases.getCasesByStatus(
196 filters.value.value as CaseStatus,
197 paginationPayload,
198 - abortController.signal
198 + abortController.signal,
199 + customerFilterStore.queryCustomerCodes
200 )
201 break
202 case "assigned_to":
203 response = await Api.cases.getCasesByAssignedTo(
204 filters.value.value,
205 paginationPayload,
205 - abortController.signal
206 + abortController.signal,
207 + customerFilterStore.queryCustomerCodes
208 )
209 break
210 default:
customer-portal/src/components/dashboards/List.vue
+19 -4
@@ -61,7 +61,7 @@ import Api from "@/api"
61 import Chip from "@/components/common/Chip.vue"
62 import Icon from "@/components/common/Icon.vue"
63 import { useNavigation } from "@/composables/common/useNavigation"
64 -import { useAuthStore } from "@/stores/auth"
64 +import { useCustomerFilterStore } from "@/stores/customerFilter"
65 import { useSettingsStore } from "@/stores/settings"
66 import { getApiErrorMessage } from "@/utils"
67 import { formatDate } from "@/utils/format"
@@ -71,12 +71,11 @@ const emit = defineEmits<{
71 (e: "loading", value: boolean): void
72 }>()
73
74 -const authStore = useAuthStore()
74 +const customerFilterStore = useCustomerFilterStore()
75 const { routeDashboardViewer } = useNavigation()
76 const message = useMessage()
77 const loading = ref(false)
78 const dFormats = useSettingsStore().dateFormat
79 -const customerCode = computed(() => authStore.userCustomerCode || "")
79
80 const { width: headerWidthRef } = useElementSize(useTemplateRef("headerRef"))
81 const pageSizes = [10, 25, 50, 100]
@@ -108,6 +107,12 @@ const columns = computed<DataTableColumns<EnabledDashboard>>(() => [
107 width: 280,
108 render: row => <div>{row.display_name}</div>
109 },
110 + {
111 + title: "Customer",
112 + key: "customer_code",
113 + width: 150,
114 + render: row => <div>{row.customer_code}</div>
115 + },
116 {
117 title: "Category",
118 key: "library_card",
@@ -153,7 +158,7 @@ const loadDashboards = useDebounceFn(async () => {
158 abortController = new AbortController()
159
160 try {
156 - const response = await Api.siem.getEnabledDashboards(customerCode.value)
161 + const response = await Api.siem.getEnabledDashboardsForCustomers(customerFilterStore.queryCustomerCodes)
162
163 data.value = response.data?.enabled_dashboards || []
164 emit("loaded", data.value)
@@ -183,6 +188,16 @@ watch([() => pagination.value.pageSize], resetPage, {
188 immediate: true
189 })
190
191 +// Re-fetch when the global customer filter changes (the list is scoped server-side).
192 +watch(
193 + () => customerFilterStore.selectedCustomerCodes,
194 + () => {
195 + pagination.value.page = 1
196 + loadDashboards()
197 + },
198 + { deep: true }
199 +)
200 +
201 onBeforeMount(() => {
202 loadDashboards()
203 })
customer-portal/src/components/eventSearch/SearchForm.vue
+37 -4
@@ -2,7 +2,13 @@
2 <div class="@container w-full">
3 <div class="grid grid-cols-1 gap-6 @md:grid-cols-2 @5xl:grid-cols-3">
4 <n-form-item label="Customer" :show-feedback="false">
5 - <n-input v-model:value="selectedCustomerCode" disabled />
5 + <n-select
6 + v-model:value="selectedCustomerCode"
7 + placeholder="Select Customer"
8 + filterable
9 + :options="customerOptions"
10 + @update:value="onCustomerChange"
11 + />
12 </n-form-item>
13
14 <n-form-item label="Event Source" :show-feedback="false">
@@ -105,7 +111,6 @@ import {
111 NButton,
112 NDatePicker,
113 NFormItem,
108 - NInput,
114 NInputGroup,
115 NInputNumber,
116 NMention,
@@ -118,6 +123,7 @@ import { useRoute } from "vue-router"
123 import Api from "@/api"
124 import Icon from "@/components/common/Icon.vue"
125 import { useAuthStore } from "@/stores/auth"
126 +import { useCustomerFilterStore } from "@/stores/customerFilter"
127 import { getApiErrorMessage } from "@/utils"
128 import dayjs from "@/utils/dayjs"
129
@@ -152,10 +158,18 @@ const TRAILING_WHITESPACE_RE = /\s$/
158
159 const route = useRoute()
160 const authStore = useAuthStore()
161 +const customerFilterStore = useCustomerFilterStore()
162 const message = useMessage()
163
164 const customerCode = computed(() => authStore.userCustomerCode)
158 -const selectedCustomerCode = ref(customerCode.value || "")
165 +const customerOptions = computed(() => authStore.accessibleCustomerCodes.map(code => ({ label: code, value: code })))
166 +// Seed the searched customer from the global filter when it resolves to a single
167 +// customer, otherwise the user's primary / first accessible customer.
168 +const selectedCustomerCode = ref(
169 + customerFilterStore.selectedCustomerCodes.length === 1
170 + ? customerFilterStore.selectedCustomerCodes[0]
171 + : customerCode.value || authStore.accessibleCustomerCodes[0] || ""
172 +)
173
174 const eventSources = ref<EventSourceItem[]>([])
175 const loadingEventSources = ref(false)
@@ -225,6 +239,13 @@ async function loadEventSources(customerCode: string) {
239 }
240 }
241
242 +function onCustomerChange(code: string) {
243 + // Reload event sources for the newly selected customer (resets source + fields).
244 + if (code) {
245 + loadEventSources(code)
246 + }
247 +}
248 +
249 async function loadFieldMappings() {
250 if (!selectedCustomerCode.value || !selectedSourceName.value) return
251
@@ -294,7 +315,7 @@ function onMentionSelect(option: MentionOption, prefix: string) {
315
316 // -- Lifecycle --
317 async function applyRouteParams() {
297 - const qCustomer = (route.query.customer_code || customerCode.value) as string
318 + const qCustomer = (route.query.customer_code || selectedCustomerCode.value) as string
319 const qSource = route.query.source_name as string | undefined
320 const qQuery = route.query.query as string | undefined
321
@@ -329,6 +350,18 @@ watch(
350 { immediate: true }
351 )
352
353 +// Follow the global customer filter when it narrows to a single customer.
354 +watch(
355 + () => customerFilterStore.selectedCustomerCodes,
356 + codes => {
357 + if (codes.length === 1 && codes[0] !== selectedCustomerCode.value) {
358 + selectedCustomerCode.value = codes[0]
359 + loadEventSources(codes[0])
360 + }
361 + },
362 + { deep: true }
363 +)
364 +
365 onBeforeMount(() => {
366 applyRouteParams()
367 })