@cryptotaxi247 / CoPilot / commits / 66e35072

632 add alert tagging tag scoped rbac (#641)

* feat: add tag-based RBAC support with user and role tag access models * feat: implement tag-based RBAC handler for alert access control * feat: add tag access management functionality with user and role support * feat: add tag access management endpoints with role-based access control * feat: add tag-based RBAC support for alert filtering and access control * feat: implement tag-based access validation for alert retrieval and updates * feat: add user tag assignment and access settings interfaces for tag-based RBAC * feat: add tag-based RBAC settings and user tag assignment components * feat: enhance tag-based RBAC with improved settings and user tag management * feat: remove commented-out alert-related endpoints for cleaner codebase * precommit fixes * lint fixes * feat: update current version to 0.1.33

taylor_socfortress committed Jan 29, 2026 at 17:25 UTC 66e35072b67d060b55977627bfdcb992955852ed
18 files changed +2439 -234
backend/alembic/versions/72635705c067_add_tagaccesssettings_tables.py new
+78
@@ -0,0 +1,78 @@
1 +"""Add tagaccesssettings tables
2 +
3 +Revision ID: 72635705c067
4 +Revises: 8c419faae3e5
5 +Create Date: 2026-01-29 11:43:48.319764
6 +
7 +"""
8 +from typing import Sequence
9 +from typing import Union
10 +
11 +import sqlalchemy as sa
12 +
13 +from alembic import op
14 +
15 +# revision identifiers, used by Alembic.
16 +revision: str = "72635705c067"
17 +down_revision: Union[str, None] = "8c419faae3e5"
18 +branch_labels: Union[str, Sequence[str], None] = None
19 +depends_on: Union[str, Sequence[str], None] = None
20 +
21 +
22 +def upgrade() -> None:
23 + # ### commands auto generated by Alembic - please adjust! ###
24 + op.create_table(
25 + "incident_management_tag_access_settings",
26 + sa.Column("id", sa.Integer(), nullable=False),
27 + sa.Column("enabled", sa.Boolean(), nullable=False),
28 + sa.Column("untagged_alert_behavior", sa.String(length=50), nullable=False),
29 + sa.Column("default_tag_id", sa.Integer(), nullable=True),
30 + sa.Column("updated_at", sa.DateTime(), nullable=False),
31 + sa.Column("updated_by", sa.String(length=100), nullable=True),
32 + sa.ForeignKeyConstraint(
33 + ["default_tag_id"],
34 + ["incident_management_alerttag.id"],
35 + ),
36 + sa.PrimaryKeyConstraint("id"),
37 + )
38 + op.create_table(
39 + "role_tag_access",
40 + sa.Column("id", sa.Integer(), nullable=False),
41 + sa.Column("role_id", sa.Integer(), nullable=False),
42 + sa.Column("tag_id", sa.Integer(), nullable=False),
43 + sa.Column("created_at", sa.DateTime(), nullable=False),
44 + sa.ForeignKeyConstraint(
45 + ["role_id"],
46 + ["role.id"],
47 + ),
48 + sa.ForeignKeyConstraint(
49 + ["tag_id"],
50 + ["incident_management_alerttag.id"],
51 + ),
52 + sa.PrimaryKeyConstraint("id"),
53 + )
54 + op.create_table(
55 + "user_tag_access",
56 + sa.Column("id", sa.Integer(), nullable=False),
57 + sa.Column("user_id", sa.Integer(), nullable=False),
58 + sa.Column("tag_id", sa.Integer(), nullable=False),
59 + sa.Column("created_at", sa.DateTime(), nullable=False),
60 + sa.ForeignKeyConstraint(
61 + ["tag_id"],
62 + ["incident_management_alerttag.id"],
63 + ),
64 + sa.ForeignKeyConstraint(
65 + ["user_id"],
66 + ["user.id"],
67 + ),
68 + sa.PrimaryKeyConstraint("id"),
69 + )
70 + # ### end Alembic commands ###
71 +
72 +
73 +def downgrade() -> None:
74 + # ### commands auto generated by Alembic - please adjust! ###
75 + op.drop_table("user_tag_access")
76 + op.drop_table("role_tag_access")
77 + op.drop_table("incident_management_tag_access_settings")
78 + # ### end Alembic commands ###
backend/app/auth/models/users.py
+28
@@ -21,6 +21,7 @@ class Role(SQLModel, table=True):
21 description: str = Field(max_length=256)
22
23 user: Optional["User"] = Relationship(back_populates="role")
24 + tag_access: List["RoleTagAccess"] = Relationship(back_populates="role")
25
26
27 class UserCustomerAccess(SQLModel, table=True):
@@ -34,6 +35,32 @@ class UserCustomerAccess(SQLModel, table=True):
35 user: "User" = Relationship(back_populates="customer_access")
36
37
38 +class UserTagAccess(SQLModel, table=True):
39 + """Defines which tags a user can access (allow-list)."""
40 +
41 + __tablename__ = "user_tag_access"
42 + id: Optional[int] = Field(primary_key=True)
43 + user_id: int = Field(foreign_key="user.id")
44 + tag_id: int = Field(foreign_key="incident_management_alerttag.id")
45 + created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
46 +
47 + # Relationships
48 + user: "User" = Relationship(back_populates="tag_access")
49 +
50 +
51 +class RoleTagAccess(SQLModel, table=True):
52 + """Defines which tags a role can access (allow-list)."""
53 +
54 + __tablename__ = "role_tag_access"
55 + id: Optional[int] = Field(primary_key=True)
56 + role_id: int = Field(foreign_key="role.id")
57 + tag_id: int = Field(foreign_key="incident_management_alerttag.id")
58 + created_at: datetime.datetime = Field(default_factory=datetime.datetime.now)
59 +
60 + # Relationships
61 + role: "Role" = Relationship(back_populates="tag_access")
62 +
63 +
64 class User(SQLModel, table=True):
65 id: Optional[int] = Field(primary_key=True)
66 username: str = Field(index=True, max_length=256)
@@ -45,6 +72,7 @@ class User(SQLModel, table=True):
72 smtp: "SMTP" = Relationship(back_populates="user")
73 role: Optional["Role"] = Relationship(back_populates="user")
74 customer_access: List["UserCustomerAccess"] = Relationship(back_populates="user")
75 + tag_access: List["UserTagAccess"] = Relationship(back_populates="user")
76
77
78 # Enum class for role_id 1,2
backend/app/incidents/middleware/tag_access.py new
+263
@@ -0,0 +1,263 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import Set
4 +from typing import Union
5 +
6 +from loguru import logger
7 +from sqlalchemy import select
8 +from sqlalchemy.ext.asyncio import AsyncSession
9 +
10 +from app.auth.models.users import RoleEnum
11 +from app.auth.models.users import RoleTagAccess
12 +from app.auth.models.users import User
13 +from app.auth.models.users import UserTagAccess
14 +from app.incidents.models import Alert
15 +from app.incidents.models import AlertToTag
16 +from app.incidents.models import TagAccessSettings
17 +
18 +
19 +class TagAccessHandler:
20 + """
21 + Handles tag-based access control for alerts.
22 +
23 + This works alongside customer_access_handler to provide multi-dimensional
24 + access control:
25 + - Customer access: Which customers' data can the user see?
26 + - Tag access: Which tagged alerts can the user see?
27 +
28 + Both filters are applied (AND logic) when tag RBAC is enabled.
29 + """
30 +
31 + # Roles that bypass tag restrictions (full access)
32 + UNRESTRICTED_ROLES = {RoleEnum.admin.value, RoleEnum.scheduler.value}
33 +
34 + async def is_tag_rbac_enabled(self, db: AsyncSession) -> bool:
35 + """Check if tag-based RBAC is enabled globally."""
36 + result = await db.execute(select(TagAccessSettings).limit(1))
37 + settings = result.scalars().first()
38 +
39 + if settings is None:
40 + # No settings configured = disabled (backward compatible)
41 + return False
42 +
43 + return settings.enabled
44 +
45 + async def get_tag_access_settings(self, db: AsyncSession) -> TagAccessSettings:
46 + """Get the global tag access settings, or None if not configured."""
47 + result = await db.execute(select(TagAccessSettings).limit(1))
48 + return result.scalars().first()
49 +
50 + async def get_user_accessible_tags(self, user: User, db: AsyncSession) -> Set[Union[int, str]]:
51 + """
52 + Get the set of tag IDs a user can access.
53 +
54 + Returns:
55 + Set of tag IDs, or {"*"} for unrestricted access
56 + """
57 + # Check if tag RBAC is even enabled
58 + if not await self.is_tag_rbac_enabled(db):
59 + return {"*"} # Tag RBAC disabled = no filtering
60 +
61 + # Admin and scheduler roles bypass tag restrictions
62 + if user.role_id in self.UNRESTRICTED_ROLES:
63 + logger.debug(f"User {user.username} has unrestricted role, bypassing tag RBAC")
64 + return {"*"}
65 +
66 + accessible_tags: Set[int] = set()
67 +
68 + # Get user-specific tag access
69 + user_tags_result = await db.execute(select(UserTagAccess.tag_id).where(UserTagAccess.user_id == user.id))
70 + user_tags = {row[0] for row in user_tags_result}
71 + accessible_tags.update(user_tags)
72 +
73 + # Get role-based tag access
74 + if user.role_id:
75 + role_tags_result = await db.execute(select(RoleTagAccess.tag_id).where(RoleTagAccess.role_id == user.role_id))
76 + role_tags = {row[0] for row in role_tags_result}
77 + accessible_tags.update(role_tags)
78 +
79 + logger.debug(f"User {user.username} has access to tags: {accessible_tags}")
80 + return accessible_tags
81 +
82 + async def can_user_access_alert(self, user: User, alert_id: int, db: AsyncSession) -> bool:
83 + """
84 + Check if a user can access a specific alert based on tags.
85 +
86 + This should be called AFTER customer access is verified.
87 + """
88 + # Check if tag RBAC is enabled
89 + if not await self.is_tag_rbac_enabled(db):
90 + return True # Tag RBAC disabled
91 +
92 + # Admin/scheduler bypass
93 + if user.role_id in self.UNRESTRICTED_ROLES:
94 + return True
95 +
96 + # Get the alert's tags
97 + alert_tag_ids = await self._get_alert_tag_ids(alert_id, db)
98 +
99 + # Handle untagged alerts
100 + if not alert_tag_ids:
101 + return await self._can_access_untagged_alert(user, db)
102 +
103 + # Get user's accessible tags
104 + accessible_tags = await self.get_user_accessible_tags(user, db)
105 +
106 + if "*" in accessible_tags:
107 + return True
108 +
109 + # User can access if they have access to ANY of the alert's tags
110 + return bool(alert_tag_ids & accessible_tags)
111 +
112 + async def _get_alert_tag_ids(self, alert_id: int, db: AsyncSession) -> Set[int]:
113 + """Get all tag IDs for an alert."""
114 + result = await db.execute(select(AlertToTag.tag_id).where(AlertToTag.alert_id == alert_id))
115 + return {row[0] for row in result}
116 +
117 + async def _can_access_untagged_alert(self, user: User, db: AsyncSession) -> bool:
118 + """
119 + Determine if user can access an untagged alert based on settings.
120 + """
121 + settings = await self.get_tag_access_settings(db)
122 +
123 + if settings is None:
124 + return True # No settings = visible to all
125 +
126 + behavior = settings.untagged_alert_behavior
127 +
128 + if behavior == "visible_to_all":
129 + return True
130 + elif behavior == "admin_only":
131 + return user.role_id in self.UNRESTRICTED_ROLES
132 + elif behavior == "default_tag":
133 + # Check if user has access to the default tag
134 + if settings.default_tag_id is None:
135 + return True # No default tag configured
136 + accessible_tags = await self.get_user_accessible_tags(user, db)
137 + if "*" in accessible_tags:
138 + return True
139 + return settings.default_tag_id in accessible_tags
140 +
141 + return True # Unknown behavior = allow
142 +
143 + def build_tag_filter_subquery(self, accessible_tags: Set[Union[int, str]]):
144 + """
145 + Build a SQLAlchemy subquery for tag-based filtering.
146 +
147 + Returns a subquery that can be used with .where(Alert.id.in_(subquery))
148 + """
149 + if "*" in accessible_tags:
150 + return None # No filter needed
151 +
152 + if not accessible_tags:
153 + # User has no tag access - return subquery that matches nothing
154 + return select(Alert.id).where(Alert.id == -1)
155 +
156 + # Subquery: alert IDs that have at least one accessible tag
157 + return select(AlertToTag.alert_id).where(AlertToTag.tag_id.in_(accessible_tags)).distinct()
158 +
159 + async def check_alert_tag_access(
160 + self,
161 + user: User,
162 + alert: Any, # Alert or AlertOut object
163 + session: AsyncSession,
164 + ) -> bool:
165 + """
166 + Check if a user has tag-based access to a specific alert.
167 +
168 + Args:
169 + user: The user to check access for
170 + alert: The alert object (must have 'tags' attribute)
171 + session: Database session
172 +
173 + Returns:
174 + True if user has access, False otherwise
175 + """
176 + # Get user's tag filters
177 + tag_filters = await self.build_alert_query_filters(user, session)
178 + accessible_tags = tag_filters["accessible_tags"]
179 +
180 + # If user has wildcard access, allow everything
181 + if "*" in accessible_tags:
182 + return True
183 +
184 + # Get alert's tag IDs
185 + alert_tag_ids = set()
186 + if hasattr(alert, "tags") and alert.tags:
187 + for tag_item in alert.tags:
188 + # Handle both AlertToTag objects and AlertTagBase objects
189 + if hasattr(tag_item, "tag_id"):
190 + alert_tag_ids.add(tag_item.tag_id)
191 + elif hasattr(tag_item, "id"):
192 + alert_tag_ids.add(tag_item.id)
193 +
194 + # Check if alert is untagged
195 + is_untagged = len(alert_tag_ids) == 0
196 +
197 + # If alert is untagged, check if untagged alerts are allowed
198 + if is_untagged:
199 + return tag_filters["include_untagged"]
200 +
201 + # Check if user has access to any of the alert's tags
202 + if accessible_tags:
203 + accessible_tag_ids = set(accessible_tags)
204 + if alert_tag_ids & accessible_tag_ids: # Intersection
205 + return True
206 +
207 + return False
208 +
209 + async def build_alert_query_filters(
210 + self,
211 + user: User,
212 + db: AsyncSession,
213 + ) -> Dict[str, Any]:
214 + """
215 + Build query filters for alert access based on user's tag permissions.
216 +
217 + Returns a dict with:
218 + - accessible_tags: Set of tag IDs the user can access (or {"*"} for all)
219 + - include_untagged: Whether to include alerts without tags
220 + - default_tag_id: If set, untagged alerts are treated as having this tag
221 + """
222 + # Check if tag RBAC is enabled
223 + if not await self.is_tag_rbac_enabled(db):
224 + return {"accessible_tags": {"*"}, "include_untagged": True, "default_tag_id": None}
225 +
226 + # Admins and schedulers have full access
227 + if user.role_id in [RoleEnum.admin.value, RoleEnum.scheduler.value]:
228 + return {"accessible_tags": {"*"}, "include_untagged": True, "default_tag_id": None}
229 +
230 + # Get user's accessible tags
231 + accessible_tags = await self.get_user_accessible_tags(user, db)
232 +
233 + # If user has wildcard access, they can see everything
234 + if "*" in accessible_tags:
235 + return {"accessible_tags": {"*"}, "include_untagged": True, "default_tag_id": None}
236 +
237 + # Get settings for untagged alert behavior
238 + settings = await self.get_tag_access_settings(db) # Changed from _get_settings
239 + include_untagged = False
240 + default_tag_id = None
241 +
242 + if settings:
243 + if settings.untagged_alert_behavior == "visible_to_all":
244 + include_untagged = True
245 + elif settings.untagged_alert_behavior == "admin_only":
246 + include_untagged = False
247 + elif settings.untagged_alert_behavior == "default_tag":
248 + # If user has access to the default tag, they can see untagged alerts
249 + default_tag_id = settings.default_tag_id
250 + if default_tag_id and default_tag_id in accessible_tags:
251 + include_untagged = True
252 + else:
253 + include_untagged = False
254 +
255 + return {
256 + "accessible_tags": accessible_tags,
257 + "include_untagged": include_untagged,
258 + "default_tag_id": default_tag_id,
259 + }
260 +
261 +
262 +# Singleton instance
263 +tag_access_handler = TagAccessHandler()
backend/app/incidents/models.py
+23
@@ -255,3 +255,26 @@ class VeloSigmaExclusion(SQLModel, table=True):
255 last_matched_at: Optional[datetime] = Field(nullable=True, description="When this exclusion last matched an alert")
256 match_count: int = Field(default=0, description="How many times this exclusion has matched")
257 enabled: bool = Field(default=True, description="Whether this exclusion is active")
258 +
259 +
260 +class TagAccessSettings(SQLModel, table=True):
261 + """Global settings for tag-based access control."""
262 +
263 + __tablename__ = "incident_management_tag_access_settings"
264 + id: Optional[int] = Field(default=None, primary_key=True)
265 +
266 + # Whether tag-based RBAC is enabled (False = current behavior, no filtering)
267 + enabled: bool = Field(default=False)
268 +
269 + # How to handle untagged alerts: "admin_only", "visible_to_all", "default_tag"
270 + untagged_alert_behavior: str = Field(default="visible_to_all", max_length=50)
271 +
272 + # If untagged_alert_behavior is "default_tag", which tag to use
273 + default_tag_id: Optional[int] = Field(
274 + foreign_key="incident_management_alerttag.id",
275 + nullable=True,
276 + )
277 +
278 + # Last modified
279 + updated_at: datetime = Field(default_factory=datetime.utcnow)
280 + updated_by: Optional[str] = Field(max_length=100, nullable=True)
backend/app/incidents/routes/db_operations.py
+50 -133
@@ -109,6 +109,7 @@ from app.incidents.services.db_operations import alert_total
109 from app.incidents.services.db_operations import alert_total_by_alert_title
110 from app.incidents.services.db_operations import alert_total_by_assest_name
111 from app.incidents.services.db_operations import alert_total_by_customer_codes
112 +from app.incidents.services.db_operations import alert_total_for_user
113 from app.incidents.services.db_operations import alerts_closed
114 from app.incidents.services.db_operations import alerts_closed_by_alert_title
115 from app.incidents.services.db_operations import alerts_closed_by_asset_name
@@ -118,6 +119,7 @@ from app.incidents.services.db_operations import alerts_closed_by_customer_codes
119 from app.incidents.services.db_operations import alerts_closed_by_ioc
120 from app.incidents.services.db_operations import alerts_closed_by_source
121 from app.incidents.services.db_operations import alerts_closed_by_tag
122 +from app.incidents.services.db_operations import alerts_closed_for_user
123 from app.incidents.services.db_operations import alerts_in_progress
124 from app.incidents.services.db_operations import alerts_in_progress_by_alert_title
125 from app.incidents.services.db_operations import alerts_in_progress_by_assest_name
@@ -127,6 +129,7 @@ from app.incidents.services.db_operations import alerts_in_progress_by_customer_
129 from app.incidents.services.db_operations import alerts_in_progress_by_ioc
130 from app.incidents.services.db_operations import alerts_in_progress_by_source
131 from app.incidents.services.db_operations import alerts_in_progress_by_tag
132 +from app.incidents.services.db_operations import alerts_in_progress_for_user
133 from app.incidents.services.db_operations import alerts_open
134 from app.incidents.services.db_operations import alerts_open_by_alert_title
135 from app.incidents.services.db_operations import alerts_open_by_assest_name
@@ -136,6 +139,7 @@ from app.incidents.services.db_operations import alerts_open_by_customer_codes
139 from app.incidents.services.db_operations import alerts_open_by_ioc
140 from app.incidents.services.db_operations import alerts_open_by_source
141 from app.incidents.services.db_operations import alerts_open_by_tag
142 +from app.incidents.services.db_operations import alerts_open_for_user
143 from app.incidents.services.db_operations import alerts_total_by_assigned_to
144 from app.incidents.services.db_operations import alerts_total_by_customer_code
145 from app.incidents.services.db_operations import alerts_total_by_ioc
@@ -434,13 +438,13 @@ async def update_alert_escalated_endpoint(
438 current_user: User = Depends(AuthHandler().get_current_user),
439 db: AsyncSession = Depends(get_db),
440 ):
437 - """Update alert escalated status with customer access validation"""
441 + """Update alert escalated status with customer and tag access validation"""
442 logger.info(
443 f"Updating alert {escalate_alert.alert_id} escalated status for user: {current_user.username} with role_id: {current_user.role_id}",
444 )
445
442 - # Get the alert first to check customer access
443 - alert = await get_alert_by_id(escalate_alert.alert_id, db)
446 + # Get the alert first to check customer and tag access
447 + alert = await get_alert_by_id(escalate_alert.alert_id, db, user=current_user)
448
449 # Check if user has access to this alert's customer
450 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -456,8 +460,8 @@ async def create_comment_endpoint(
460 current_user: User = Depends(AuthHandler().get_current_user),
461 db: AsyncSession = Depends(get_db),
462 ):
459 - # Get the alert to check customer access
460 - alert = await get_alert_by_id(comment.alert_id, db)
463 + # Get the alert to check customer and tag access
464 + alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
465
466 # Check if user has access to this alert's customer
467 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -472,8 +476,8 @@ async def edit_comment_endpoint(
476 current_user: User = Depends(AuthHandler().get_current_user),
477 db: AsyncSession = Depends(get_db),
478 ):
475 - # Get the alert to check customer access
476 - alert = await get_alert_by_id(comment.alert_id, db)
479 + # Get the alert to check customer and tag access
480 + alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
481
482 # Check if user has access to this alert's customer
483 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -494,8 +498,8 @@ async def delete_comment_endpoint(
498 if not comment:
499 raise HTTPException(status_code=404, detail="Comment not found")
500
497 - # Get the alert to check customer access
498 - alert = await get_alert_by_id(comment.alert_id, db)
501 + # Get the alert to check customer and tag access
502 + alert = await get_alert_by_id(comment.alert_id, db, user=current_user)
503
504 # Check if user has access to this alert's customer
505 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -774,52 +778,24 @@ async def create_case_from_alert_endpoint(alert_id: CaseCreateFromAlert, db: Asy
778 )
779
780
777 -# @incidents_db_operations_router.get("/alerts", response_model=AlertOutResponse)
778 -# async def list_alerts_endpoint(
779 -# page: int = Query(1, ge=1),
780 -# page_size: int = Query(25, ge=1),
781 -# order: str = Query("desc", regex="^(asc|desc)$"),
782 -# db: AsyncSession = Depends(get_db),
783 -# ):
784 -# return AlertOutResponse(
785 -# alerts=await list_alerts(db, page=page, page_size=page_size, order=order),
786 -# total=await alert_total(db),
787 -# open=await alerts_open(db),
788 -# in_progress=await alerts_in_progress(db),
789 -# closed=await alerts_closed(db),
790 -# success=True,
791 -# message="Alerts retrieved successfully",
792 -# )
793 -
794 -
781 @incidents_db_operations_router.get("/alerts", response_model=AlertOutResponse)
782 async def list_alerts_endpoint(
783 page: int = Query(1, ge=1),
784 page_size: int = Query(25, ge=1),
785 order: str = Query("desc", regex="^(asc|desc)$"),
800 - current_user: User = Depends(AuthHandler().get_current_user), # Get the full user object
786 + current_user: User = Depends(AuthHandler().get_current_user),
787 db: AsyncSession = Depends(get_db),
788 ):
789 + """List alerts with automatic customer and tag filtering"""
790 logger.info(f"Listing alerts for user: {current_user.username} with role_id: {current_user.role_id}")
804 - """List alerts with automatic customer filtering"""
791 +
792 alerts = await list_alerts_for_user(current_user, db, page, page_size, order)
793
807 - # Get totals with customer filtering
808 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
809 -
810 - logger.info(f"User {current_user.username} has access to customers: {accessible_customers}")
811 - if "*" in accessible_customers:
812 - # Admin/analyst - use existing total functions
813 - total = await alert_total(db)
814 - open_alerts = await alerts_open(db)
815 - in_progress = await alerts_in_progress(db)
816 - closed = await alerts_closed(db)
817 - else:
818 - # Customer user - filter totals by their customers
819 - total = await alert_total_by_customer_codes(db, accessible_customers)
820 - open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
821 - in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
822 - closed = await alerts_closed_by_customer_codes(db, accessible_customers)
794 + # Get totals with both customer and tag filtering
795 + total = await alert_total_for_user(current_user, db)
796 + open_alerts = await alerts_open_for_user(current_user, db)
797 + in_progress = await alerts_in_progress_for_user(current_user, db)
798 + closed = await alerts_closed_for_user(current_user, db)
799
800 return AlertOutResponse(
801 alerts=alerts,
@@ -838,11 +814,11 @@ async def get_alert_by_id_endpoint(
814 current_user: User = Depends(AuthHandler().get_current_user),
815 db: AsyncSession = Depends(get_db),
816 ):
841 - """Get alert by ID with customer access validation"""
817 + """Get alert by ID with customer and tag access validation"""
818 logger.info(f"Getting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
819
844 - # Get the alert first
845 - alert = await get_alert_by_id(alert_id, db)
820 + # Get the alert with tag access check
821 + alert = await get_alert_by_id(alert_id, db, user=current_user)
822
823 # Check if user has access to this alert's customer
824 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -857,11 +833,11 @@ async def delete_alert_endpoint(
833 current_user: User = Depends(AuthHandler().get_current_user),
834 db: AsyncSession = Depends(get_db),
835 ):
860 - """Delete alert with customer access validation"""
836 + """Delete alert with customer and tag access validation"""
837 logger.info(f"Deleting alert {alert_id} for user: {current_user.username} with role_id: {current_user.role_id}")
838
863 - # Get the alert first to check customer access
864 - alert = await get_alert_by_id(alert_id, db)
839 + # Get the alert first to check customer and tag access
840 + alert = await get_alert_by_id(alert_id, db, user=current_user)
841
842 # Check if user has access to this alert's customer
843 if not await customer_access_handler.check_customer_access(current_user, alert.customer_code, db):
@@ -1175,25 +1151,6 @@ async def list_alerts_by_title_endpoint(
1151 )
1152
1153
1178 -# @incidents_db_operations_router.get("/alerts/customer/{customer_code}", response_model=AlertOutResponse)
1179 -# async def list_alerts_by_customer_code_endpoint(
1180 -# customer_code: str,
1181 -# page: int = Query(1, ge=1),
1182 -# page_size: int = Query(25, ge=1),
1183 -# order: str = Query("desc", regex="^(asc|desc)$"),
1184 -# db: AsyncSession = Depends(get_db),
1185 -# ):
1186 -# return AlertOutResponse(
1187 -# alerts=await list_alerts_by_customer_code(customer_code, db, page=page, page_size=page_size, order=order),
1188 -# total=await alerts_total_by_customer_code(db, customer_code),
1189 -# open=await alerts_open_by_customer_code(db, customer_code),
1190 -# in_progress=await alerts_in_progress_by_customer_code(db, customer_code),
1191 -# closed=await alerts_closed_by_customer_code(db, customer_code),
1192 -# success=True,
1193 -# message="Alerts retrieved successfully",
1194 -# )
1195 -
1196 -
1154 @incidents_db_operations_router.get("/alerts/customer/{customer_code}", response_model=AlertOutResponse)
1155 async def list_alerts_by_customer_code_endpoint(
1156 customer_code: str,
@@ -1284,31 +1241,7 @@ async def list_alerts_multiple_filters_endpoint(
1241 db: AsyncSession = Depends(get_db),
1242 ):
1243 """
1287 - Endpoint to list alerts with multiple filters and customer access control.
1288 -
1289 - Parameters:
1290 - - assigned_to (str, optional): Filter by assigned user.
1291 - - alert_title (str, optional): Filter by alert title.
1292 - - customer_code (str, optional): Filter by customer code.
1293 - - source (str, optional): Filter by source.
1294 - - asset_name (str, optional): Filter by asset name.
1295 - - status (str, optional): Filter by status.
1296 - - tags (List[str], optional): Filter by tags.
1297 - - ioc_value (str, optional): Filter by IoC value.
1298 - - page (int, default=1): Page number.
1299 - - page_size (int, default=25): Number of alerts per page.
1300 - - order (str, default='desc'): Sorting order ('asc' or 'desc').
1301 - - current_user (User): Current authenticated user.
1302 - - db (AsyncSession): Database session.
1303 -
1304 - Returns:
1305 - - alerts (List[AlertOut]): List of alerts matching the filters.
1306 - - total (int): Total number of alerts matching the filters.
1307 - - open (int): Number of open alerts matching the filters.
1308 - - in_progress (int): Number of alerts in progress matching the filters.
1309 - - closed (int): Number of closed alerts matching the filters.
1310 - - success (bool): Indicates if the operation was successful.
1311 - - message (str): Success message.
1244 + Endpoint to list alerts with multiple filters and customer/tag access control.
1245 """
1246 logger.info(f"Listing alerts with filters for user: {current_user.username} with role_id: {current_user.role_id}")
1247
@@ -1322,10 +1255,10 @@ async def list_alerts_multiple_filters_endpoint(
1255 raise HTTPException(status_code=403, detail=f"Access denied to customer {customer_code}")
1256
1257 # If no customer_code specified, use the first accessible customer for single customer users
1325 - # For multi-customer users, we'll need to modify the query to handle multiple customers
1258 if not customer_code and len(accessible_customers) == 1:
1259 customer_code = accessible_customers[0]
1260
1261 + # Pass user for tag filtering
1262 alerts = await list_alerts_multiple_filters(
1263 assigned_to=assigned_to,
1264 alert_title=alert_title,
@@ -1339,51 +1272,35 @@ async def list_alerts_multiple_filters_endpoint(
1272 page=page,
1273 page_size=page_size,
1274 order=order,
1275 + user=current_user, # Pass user for tag filtering
1276 )
1277
1344 - # Get totals with customer filtering
1345 - if "*" in accessible_customers:
1346 - # Admin/analyst - use existing total functions
1347 - total = await alerts_total_multiple_filters(
1348 - assigned_to=assigned_to,
1349 - alert_title=alert_title,
1350 - customer_code=customer_code,
1351 - source=source,
1352 - asset_name=asset_name,
1353 - status=status,
1354 - tags=tags,
1355 - ioc_value=ioc_value,
1356 - db=db,
1357 - )
1358 - open_alerts = await alerts_open(db)
1359 - in_progress = await alerts_in_progress(db)
1360 - closed = await alerts_closed(db)
1361 - total_unfiltered = await alert_total(db)
1362 - else:
1363 - # Customer user - filter totals by their customers
1364 - total = await alerts_total_multiple_filters(
1365 - assigned_to=assigned_to,
1366 - alert_title=alert_title,
1367 - customer_code=customer_code,
1368 - source=source,
1369 - asset_name=asset_name,
1370 - status=status,
1371 - tags=tags,
1372 - ioc_value=ioc_value,
1373 - db=db,
1374 - )
1375 - open_alerts = await alerts_open_by_customer_codes(db, accessible_customers)
1376 - in_progress = await alerts_in_progress_by_customer_codes(db, accessible_customers)
1377 - closed = await alerts_closed_by_customer_codes(db, accessible_customers)
1378 - total_unfiltered = await alert_total_by_customer_codes(db, accessible_customers)
1278 + # Get totals with both customer and tag filtering
1279 + total = await alert_total_for_user(current_user, db)
1280 + open_alerts = await alerts_open_for_user(current_user, db)
1281 + in_progress = await alerts_in_progress_for_user(current_user, db)
1282 + closed = await alerts_closed_for_user(current_user, db)
1283 +
1284 + # Get filtered total
1285 + total_filtered = await alerts_total_multiple_filters(
1286 + assigned_to=assigned_to,
1287 + alert_title=alert_title,
1288 + customer_code=customer_code,
1289 + source=source,
1290 + asset_name=asset_name,
1291 + status=status,
1292 + tags=tags,
1293 + ioc_value=ioc_value,
1294 + db=db,
1295 + )
1296
1297 return AlertOutResponse(
1298 alerts=alerts,
1382 - total_filtered=total,
1299 + total_filtered=total_filtered,
1300 open=open_alerts,
1301 in_progress=in_progress,
1302 closed=closed,
1386 - total=total_unfiltered,
1303 + total=total,
1304 success=True,
1305 message="Alerts retrieved successfully",
1306 )
backend/app/incidents/routes/tag_access.py new
+527
@@ -0,0 +1,527 @@
1 +from typing import List
2 +
3 +from fastapi import APIRouter
4 +from fastapi import Depends
5 +from fastapi import HTTPException
6 +from fastapi import Security
7 +from loguru import logger
8 +from sqlalchemy.ext.asyncio import AsyncSession
9 +
10 +from app.auth.models.users import User
11 +from app.auth.utils import AuthHandler
12 +from app.db.db_session import get_db
13 +from app.incidents.middleware.tag_access import tag_access_handler
14 +from app.incidents.schema.db_operations import AlertTagItem
15 +from app.incidents.schema.db_operations import AllTagsResponse
16 +from app.incidents.schema.db_operations import RoleTagAccessResponse
17 +from app.incidents.schema.db_operations import TagAccessCreate
18 +from app.incidents.schema.db_operations import TagAccessSettingsItem
19 +from app.incidents.schema.db_operations import TagAccessSettingsResponse
20 +from app.incidents.schema.db_operations import TagAccessSettingsUpdate
21 +from app.incidents.schema.db_operations import UserEffectiveAccessResponse
22 +from app.incidents.schema.db_operations import UserTagAccessResponse
23 +from app.incidents.services import tag_access as tag_access_service
24 +
25 +tag_access_router = APIRouter()
26 +
27 +
28 +def _require_admin(current_user: User) -> User:
29 + """Verify user has admin role."""
30 + from app.auth.models.users import RoleEnum
31 +
32 + if current_user.role_id != RoleEnum.admin.value:
33 + raise HTTPException(status_code=403, detail="Admin access required")
34 + return current_user
35 +
36 +
37 +def _tags_to_response(tags) -> List[AlertTagItem]:
38 + """Convert AlertTag objects to response items."""
39 + return [AlertTagItem(id=t.id, tag=t.tag) for t in tags]
40 +
41 +
42 +# ============================================
43 +# Settings Endpoints
44 +# ============================================
45 +
46 +
47 +@tag_access_router.get(
48 + "/settings",
49 + response_model=TagAccessSettingsResponse,
50 + description="Get current tag access settings (admin only)",
51 +)
52 +async def get_tag_access_settings(
53 + current_user: User = Security(AuthHandler().get_current_user),
54 + db: AsyncSession = Depends(get_db),
55 +):
56 + """Get current tag access settings."""
57 + _require_admin(current_user)
58 +
59 + settings = await tag_access_service.get_or_create_tag_settings(db)
60 +
61 + # Get default tag name if set
62 + default_tag_name = None
63 + if settings.default_tag_id:
64 + tag = await tag_access_service.get_tag_by_id(settings.default_tag_id, db)
65 + if tag:
66 + default_tag_name = tag.tag
67 +
68 + return TagAccessSettingsResponse(
69 + settings=TagAccessSettingsItem(
70 + enabled=settings.enabled,
71 + untagged_alert_behavior=settings.untagged_alert_behavior,
72 + default_tag_id=settings.default_tag_id,
73 + default_tag_name=default_tag_name,
74 + ),
75 + success=True,
76 + message="Settings retrieved successfully",
77 + )
78 +
79 +
80 +@tag_access_router.put(
81 + "/settings",
82 + response_model=TagAccessSettingsResponse,
83 + description="Update tag access settings (admin only)",
84 +)
85 +async def update_tag_access_settings(
86 + request: TagAccessSettingsUpdate,
87 + current_user: User = Security(AuthHandler().get_current_user),
88 + db: AsyncSession = Depends(get_db),
89 +):
90 + """Update tag access settings."""
91 + _require_admin(current_user)
92 +
93 + # Validate default_tag_id exists if specified
94 + if request.default_tag_id:
95 + tag = await tag_access_service.get_tag_by_id(request.default_tag_id, db)
96 + if not tag:
97 + raise HTTPException(
98 + status_code=404,
99 + detail=f"Tag with id {request.default_tag_id} not found",
100 + )
101 +
102 + settings = await tag_access_service.update_tag_access_settings(
103 + enabled=request.enabled,
104 + untagged_alert_behavior=request.untagged_alert_behavior.value,
105 + default_tag_id=request.default_tag_id,
106 + updated_by=current_user.username,
107 + db=db,
108 + )
109 +
110 + # Get default tag name if set
111 + default_tag_name = None
112 + if settings.default_tag_id:
113 + tag = await tag_access_service.get_tag_by_id(settings.default_tag_id, db)
114 + if tag:
115 + default_tag_name = tag.tag
116 +
117 + logger.info(f"Admin {current_user.username} updated tag access settings")
118 +
119 + # Return with settings nested under 'settings' key to match schema
120 + return TagAccessSettingsResponse(
121 + settings=TagAccessSettingsItem(
122 + enabled=settings.enabled,
123 + untagged_alert_behavior=settings.untagged_alert_behavior,
124 + default_tag_id=settings.default_tag_id,
125 + default_tag_name=default_tag_name,
126 + ),
127 + success=True,
128 + message="Settings updated successfully",
129 + )
130 +
131 +
132 +# ============================================
133 +# Tag List Endpoints
134 +# ============================================
135 +
136 +
137 +@tag_access_router.get(
138 + "/tags",
139 + response_model=AllTagsResponse,
140 + description="List all available tags",
141 +)
142 +async def list_all_tags(
143 + current_user: User = Security(AuthHandler().get_current_user),
144 + db: AsyncSession = Depends(get_db),
145 +):
146 + """List all available tags."""
147 + tags = await tag_access_service.get_all_tags(db)
148 +
149 + return AllTagsResponse(
150 + tags=_tags_to_response(tags),
151 + success=True,
152 + message=f"Retrieved {len(tags)} tags",
153 + )
154 +
155 +
156 +# ============================================
157 +# User Tag Access Endpoints
158 +# ============================================
159 +
160 +
161 +@tag_access_router.get(
162 + "/user/{user_id}",
163 + response_model=UserTagAccessResponse,
164 + description="Get tags assigned to a specific user (admin only)",
165 +)
166 +async def get_user_tag_access(
167 + user_id: int,
168 + current_user: User = Security(AuthHandler().get_current_user),
169 + db: AsyncSession = Depends(get_db),
170 +):
171 + """Get tags assigned to a specific user."""
172 + _require_admin(current_user)
173 +
174 + user = await tag_access_service.get_user_by_id(user_id, db)
175 + if not user:
176 + raise HTTPException(status_code=404, detail="User not found")
177 +
178 + tags = await tag_access_service.get_user_accessible_tags(user_id, db)
179 +
180 + return UserTagAccessResponse(
181 + user_id=user_id,
182 + username=user.username,
183 + accessible_tags=_tags_to_response(tags),
184 + success=True,
185 + message="User tag access retrieved successfully",
186 + )
187 +
188 +
189 +@tag_access_router.put(
190 + "/user/{user_id}",
191 + response_model=UserTagAccessResponse,
192 + description="Set tags for a user - replaces existing (admin only)",
193 +)
194 +async def set_user_tag_access(
195 + user_id: int,
196 + request: TagAccessCreate,
197 + current_user: User = Security(AuthHandler().get_current_user),
198 + db: AsyncSession = Depends(get_db),
199 +):
200 + """Set tags for a user (replaces existing)."""
201 + _require_admin(current_user)
202 +
203 + user = await tag_access_service.get_user_by_id(user_id, db)
204 + if not user:
205 + raise HTTPException(status_code=404, detail="User not found")
206 +
207 + # Validate all tag IDs exist
208 + for tag_id in request.tag_ids:
209 + tag = await tag_access_service.get_tag_by_id(tag_id, db)
210 + if not tag:
211 + raise HTTPException(
212 + status_code=404,
213 + detail=f"Tag with id {tag_id} not found",
214 + )
215 +
216 + tags = await tag_access_service.set_user_tag_access(user_id, request.tag_ids, db)
217 +
218 + logger.info(
219 + f"Admin {current_user.username} set tag access for user {user.username}: {request.tag_ids}",
220 + )
221 +
222 + return UserTagAccessResponse(
223 + user_id=user_id,
224 + username=user.username,
225 + accessible_tags=_tags_to_response(tags),
226 + success=True,
227 + message="User tag access updated successfully",
228 + )
229 +
230 +
231 +@tag_access_router.post(
232 + "/user/{user_id}/add",
233 + response_model=UserTagAccessResponse,
234 + description="Add tags to a user's access (admin only)",
235 +)
236 +async def add_user_tag_access(
237 + user_id: int,
238 + request: TagAccessCreate,
239 + current_user: User = Security(AuthHandler().get_current_user),
240 + db: AsyncSession = Depends(get_db),
241 +):
242 + """Add tags to a user's access without removing existing."""
243 + _require_admin(current_user)
244 +
245 + user = await tag_access_service.get_user_by_id(user_id, db)
246 + if not user:
247 + raise HTTPException(status_code=404, detail="User not found")
248 +
249 + # Validate all tag IDs exist
250 + for tag_id in request.tag_ids:
251 + tag = await tag_access_service.get_tag_by_id(tag_id, db)
252 + if not tag:
253 + raise HTTPException(
254 + status_code=404,
255 + detail=f"Tag with id {tag_id} not found",
256 + )
257 +
258 + tags = await tag_access_service.add_user_tag_access(user_id, request.tag_ids, db)
259 +
260 + logger.info(
261 + f"Admin {current_user.username} added tag access for user {user.username}: {request.tag_ids}",
262 + )
263 +
264 + return UserTagAccessResponse(
265 + user_id=user_id,
266 + username=user.username,
267 + accessible_tags=_tags_to_response(tags),
268 + success=True,
269 + message="Tags added to user access successfully",
270 + )
271 +
272 +
273 +@tag_access_router.post(
274 + "/user/{user_id}/remove",
275 + response_model=UserTagAccessResponse,
276 + description="Remove tags from a user's access (admin only)",
277 +)
278 +async def remove_user_tag_access(
279 + user_id: int,
280 + request: TagAccessCreate,
281 + current_user: User = Security(AuthHandler().get_current_user),
282 + db: AsyncSession = Depends(get_db),
283 +):
284 + """Remove specific tags from a user's access."""
285 + _require_admin(current_user)
286 +
287 + user = await tag_access_service.get_user_by_id(user_id, db)
288 + if not user:
289 + raise HTTPException(status_code=404, detail="User not found")
290 +
291 + tags = await tag_access_service.remove_user_tag_access(user_id, request.tag_ids, db)
292 +
293 + logger.info(
294 + f"Admin {current_user.username} removed tag access for user {user.username}: {request.tag_ids}",
295 + )
296 +
297 + return UserTagAccessResponse(
298 + user_id=user_id,
299 + username=user.username,
300 + accessible_tags=_tags_to_response(tags),
301 + success=True,
302 + message="Tags removed from user access successfully",
303 + )
304 +
305 +
306 +# ============================================
307 +# Role Tag Access Endpoints
308 +# ============================================
309 +
310 +
311 +@tag_access_router.get(
312 + "/role/{role_id}",
313 + response_model=RoleTagAccessResponse,
314 + description="Get tags assigned to a specific role (admin only)",
315 +)
316 +async def get_role_tag_access(
317 + role_id: int,
318 + current_user: User = Security(AuthHandler().get_current_user),
319 + db: AsyncSession = Depends(get_db),
320 +):
321 + """Get tags assigned to a specific role."""
322 + _require_admin(current_user)
323 +
324 + role = await tag_access_service.get_role_by_id(role_id, db)
325 + if not role:
326 + raise HTTPException(status_code=404, detail="Role not found")
327 +
328 + tags = await tag_access_service.get_role_accessible_tags(role_id, db)
329 +
330 + return RoleTagAccessResponse(
331 + role_id=role_id,
332 + role_name=role.name,
333 + accessible_tags=_tags_to_response(tags),
334 + success=True,
335 + message="Role tag access retrieved successfully",
336 + )
337 +
338 +
339 +@tag_access_router.put(
340 + "/role/{role_id}",
341 + response_model=RoleTagAccessResponse,
342 + description="Set tags for a role - replaces existing (admin only)",
343 +)
344 +async def set_role_tag_access(
345 + role_id: int,
346 + request: TagAccessCreate,
347 + current_user: User = Security(AuthHandler().get_current_user),
348 + db: AsyncSession = Depends(get_db),
349 +):
350 + """Set tags for a role (replaces existing)."""
351 + _require_admin(current_user)
352 +
353 + role = await tag_access_service.get_role_by_id(role_id, db)
354 + if not role:
355 + raise HTTPException(status_code=404, detail="Role not found")
356 +
357 + # Validate all tag IDs exist
358 + for tag_id in request.tag_ids:
359 + tag = await tag_access_service.get_tag_by_id(tag_id, db)
360 + if not tag:
361 + raise HTTPException(
362 + status_code=404,
363 + detail=f"Tag with id {tag_id} not found",
364 + )
365 +
366 + tags = await tag_access_service.set_role_tag_access(role_id, request.tag_ids, db)
367 +
368 + logger.info(
369 + f"Admin {current_user.username} set tag access for role {role.name}: {request.tag_ids}",
370 + )
371 +
372 + return RoleTagAccessResponse(
373 + role_id=role_id,
374 + role_name=role.name,
375 + accessible_tags=_tags_to_response(tags),
376 + success=True,
377 + message="Role tag access updated successfully",
378 + )
379 +
380 +
381 +@tag_access_router.post(
382 + "/role/{role_id}/add",
383 + response_model=RoleTagAccessResponse,
384 + description="Add tags to a role's access (admin only)",
385 +)
386 +async def add_role_tag_access(
387 + role_id: int,
388 + request: TagAccessCreate,
389 + current_user: User = Security(AuthHandler().get_current_user),
390 + db: AsyncSession = Depends(get_db),
391 +):
392 + """Add tags to a role's access without removing existing."""
393 + _require_admin(current_user)
394 +
395 + role = await tag_access_service.get_role_by_id(role_id, db)
396 + if not role:
397 + raise HTTPException(status_code=404, detail="Role not found")
398 +
399 + # Validate all tag IDs exist
400 + for tag_id in request.tag_ids:
401 + tag = await tag_access_service.get_tag_by_id(tag_id, db)
402 + if not tag:
403 + raise HTTPException(
404 + status_code=404,
405 + detail=f"Tag with id {tag_id} not found",
406 + )
407 +
408 + tags = await tag_access_service.add_role_tag_access(role_id, request.tag_ids, db)
409 +
410 + logger.info(
411 + f"Admin {current_user.username} added tag access for role {role.name}: {request.tag_ids}",
412 + )
413 +
414 + return RoleTagAccessResponse(
415 + role_id=role_id,
416 + role_name=role.name,
417 + accessible_tags=_tags_to_response(tags),
418 + success=True,
419 + message="Tags added to role access successfully",
420 + )
421 +
422 +
423 +@tag_access_router.post(
424 + "/role/{role_id}/remove",
425 + response_model=RoleTagAccessResponse,
426 + description="Remove tags from a role's access (admin only)",
427 +)
428 +async def remove_role_tag_access(
429 + role_id: int,
430 + request: TagAccessCreate,
431 + current_user: User = Security(AuthHandler().get_current_user),
432 + db: AsyncSession = Depends(get_db),
433 +):
434 + """Remove specific tags from a role's access."""
435 + _require_admin(current_user)
436 +
437 + role = await tag_access_service.get_role_by_id(role_id, db)
438 + if not role:
439 + raise HTTPException(status_code=404, detail="Role not found")
440 +
441 + tags = await tag_access_service.remove_role_tag_access(role_id, request.tag_ids, db)
442 +
443 + logger.info(
444 + f"Admin {current_user.username} removed tag access for role {role.name}: {request.tag_ids}",
445 + )
446 +
447 + return RoleTagAccessResponse(
448 + role_id=role_id,
449 + role_name=role.name,
450 + accessible_tags=_tags_to_response(tags),
451 + success=True,
452 + message="Tags removed from role access successfully",
453 + )
454 +
455 +
456 +# ============================================
457 +# Current User Effective Access
458 +# ============================================
459 +
460 +
461 +@tag_access_router.get(
462 + "/me",
463 + response_model=UserEffectiveAccessResponse,
464 + description="Get current user's effective access (customer + tags)",
465 +)
466 +async def get_my_effective_access(
467 + current_user: User = Security(AuthHandler().get_current_user),
468 + db: AsyncSession = Depends(get_db),
469 +):
470 + """Get the current user's effective access (combines role + user-specific)."""
471 + # Get tag RBAC status
472 + tag_rbac_enabled = await tag_access_handler.is_tag_rbac_enabled(db)
473 +
474 + # Get accessible tags
475 + accessible_tag_ids = await tag_access_handler.get_user_accessible_tags(current_user, db)
476 + is_unrestricted = "*" in accessible_tag_ids
477 +
478 + if is_unrestricted:
479 + # User has unrestricted access - show all tags
480 + all_tags = await tag_access_service.get_all_tags(db)
481 + tag_list = _tags_to_response(all_tags)
482 + else:
483 + # Get specific tags user can access
484 + from sqlalchemy import select
485 +
486 + from app.incidents.models import AlertTag
487 +
488 + if accessible_tag_ids:
489 + result = await db.execute(
490 + select(AlertTag).where(AlertTag.id.in_(accessible_tag_ids)),
491 + )
492 + tags = result.scalars().all()
493 + tag_list = _tags_to_response(tags)
494 + else:
495 + tag_list = []
496 +
497 + # Get customer access (using existing middleware if available)
498 + try:
499 + from app.middleware.customer_access import customer_access_handler
500 +
501 + accessible_customers = await customer_access_handler.get_user_accessible_customers(
502 + current_user,
503 + db,
504 + )
505 + customer_list = list(accessible_customers) if "*" not in accessible_customers else ["*"]
506 + except ImportError:
507 + customer_list = ["*"] # Fallback if customer access handler doesn't exist
508 +
509 + # Get role name
510 + role_name = None
511 + if current_user.role_id:
512 + role = await tag_access_service.get_role_by_id(current_user.role_id, db)
513 + if role:
514 + role_name = role.name
515 +
516 + return UserEffectiveAccessResponse(
517 + user_id=current_user.id,
518 + username=current_user.username,
519 + role_id=current_user.role_id,
520 + role_name=role_name,
521 + accessible_customers=customer_list,
522 + accessible_tags=tag_list,
523 + is_tag_unrestricted=is_unrestricted,
524 + tag_rbac_enabled=tag_rbac_enabled,
525 + success=True,
526 + message="Effective access retrieved successfully",
527 + )
backend/app/incidents/schema/db_operations.py
+115
@@ -553,3 +553,118 @@ class CaseReportTemplateDataStoreListResponse(BaseModel):
553
554 class DefaultReportTemplateFileNames(Enum):
555 CASE_REPORT_JINJA_TEMPLATE = "case_report_jinja_template.docx"
556 +
557 +
558 +# ============================================
559 +# Tag Access RBAC Schemas
560 +# ============================================
561 +
562 +
563 +class AlertTagItem(BaseModel):
564 + """Single tag item for responses."""
565 +
566 + id: int
567 + tag: str
568 +
569 +
570 +class TagAccessCreate(BaseModel):
571 + """Base schema for creating tag access."""
572 +
573 + tag_ids: List[int]
574 +
575 +
576 +class UserTagAccessCreate(TagAccessCreate):
577 + """Assign tags to a user."""
578 +
579 + user_id: int
580 +
581 +
582 +class RoleTagAccessCreate(TagAccessCreate):
583 + """Assign tags to a role."""
584 +
585 + role_id: int
586 +
587 +
588 +class UserTagAccessResponse(BaseModel):
589 + """Response for user tag access operations."""
590 +
591 + user_id: int
592 + username: str
593 + accessible_tags: List[AlertTagItem]
594 + success: bool
595 + message: str
596 +
597 +
598 +class RoleTagAccessResponse(BaseModel):
599 + """Response for role tag access operations."""
600 +
601 + role_id: int
602 + role_name: str
603 + accessible_tags: List[AlertTagItem]
604 + success: bool
605 + message: str
606 +
607 +
608 +class UntaggedAlertBehavior(str, Enum):
609 + """Options for handling untagged alerts when tag RBAC is enabled."""
610 +
611 + VISIBLE_TO_ALL = "visible_to_all"
612 + ADMIN_ONLY = "admin_only"
613 + DEFAULT_TAG = "default_tag"
614 +
615 +
616 +class TagAccessSettingsUpdate(BaseModel):
617 + """Update tag access settings."""
618 +
619 + enabled: bool
620 + untagged_alert_behavior: UntaggedAlertBehavior = UntaggedAlertBehavior.VISIBLE_TO_ALL
621 + default_tag_id: Optional[int] = None
622 +
623 + @validator("default_tag_id")
624 + def validate_default_tag(cls, v, values):
625 + if values.get("untagged_alert_behavior") == UntaggedAlertBehavior.DEFAULT_TAG and v is None:
626 + raise HTTPException(
627 + status_code=400,
628 + detail="default_tag_id is required when untagged_alert_behavior is 'default_tag'",
629 + )
630 + return v
631 +
632 +
633 +class TagAccessSettingsItem(BaseModel):
634 + """Single tag access settings item."""
635 +
636 + enabled: bool
637 + untagged_alert_behavior: str
638 + default_tag_id: Optional[int]
639 + default_tag_name: Optional[str]
640 +
641 +
642 +class TagAccessSettingsResponse(BaseModel):
643 + """Response for tag access settings."""
644 +
645 + settings: TagAccessSettingsItem
646 + success: bool
647 + message: str
648 +
649 +
650 +class UserEffectiveAccessResponse(BaseModel):
651 + """Shows effective access for a user (combines role + user-specific access)."""
652 +
653 + user_id: int
654 + username: str
655 + role_id: Optional[int]
656 + role_name: Optional[str]
657 + accessible_customers: List[str]
658 + accessible_tags: List[AlertTagItem]
659 + is_tag_unrestricted: bool
660 + tag_rbac_enabled: bool
661 + success: bool
662 + message: str
663 +
664 +
665 +class AllTagsResponse(BaseModel):
666 + """Response for listing all available tags."""
667 +
668 + tags: List[AlertTagItem]
669 + success: bool
670 + message: str
backend/app/incidents/services/db_operations.py
+304 -14
@@ -28,6 +28,7 @@ from app.data_store.data_store_operations import upload_case_data_store
28 from app.data_store.data_store_operations import upload_case_report_template_data_store
29 from app.data_store.data_store_schema import CaseDataStoreCreation
30 from app.data_store.data_store_schema import CaseReportTemplateDataStoreCreation
31 +from app.incidents.middleware.tag_access import tag_access_handler
32 from app.incidents.models import Alert
33 from app.incidents.models import AlertContext
34 from app.incidents.models import AlertTag
@@ -238,6 +239,190 @@ async def alerts_open_by_customer_codes(db: AsyncSession, customer_codes: List[s
239 return len(result.scalars().all())
240
241
242 +async def alert_total_for_user(user: User, db: AsyncSession) -> int:
243 + """Get total alerts count with customer and tag filtering"""
244 + from sqlalchemy import and_
245 + from sqlalchemy import exists
246 + from sqlalchemy import or_
247 +
248 + filters = []
249 +
250 + # Customer filtering
251 + accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
252 + if "*" not in accessible_customers:
253 + filters.append(Alert.customer_code.in_(accessible_customers))
254 +
255 + # Tag filtering
256 + tag_filters = await tag_access_handler.build_alert_query_filters(user, db)
257 + accessible_tags = tag_filters["accessible_tags"]
258 +
259 + if "*" not in accessible_tags:
260 + tag_conditions = []
261 + if accessible_tags:
262 + has_accessible_tag = exists(
263 + select(AlertToTag.alert_id).where(
264 + and_(
265 + AlertToTag.alert_id == Alert.id,
266 + AlertToTag.tag_id.in_(accessible_tags),
267 + ),
268 + ),
269 + )
270 + tag_conditions.append(has_accessible_tag)
271 +
272 + if tag_filters["include_untagged"]:
273 + is_untagged = ~exists(
274 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
275 + )
276 + tag_conditions.append(is_untagged)
277 +
278 + if tag_conditions:
279 + filters.append(or_(*tag_conditions))
280 + else:
281 + return 0
282 +
283 + query = select(func.count(Alert.id)).where(*filters) if filters else select(func.count(Alert.id))
284 + result = await db.execute(query)
285 + return result.scalar_one()
286 +
287 +
288 +async def alerts_open_for_user(user: User, db: AsyncSession) -> int:
289 + """Get open alerts count with customer and tag filtering"""
290 + from sqlalchemy import and_
291 + from sqlalchemy import exists
292 + from sqlalchemy import or_
293 +
294 + filters = [Alert.status == "OPEN"]
295 +
296 + # Customer filtering
297 + accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
298 + if "*" not in accessible_customers:
299 + filters.append(Alert.customer_code.in_(accessible_customers))
300 +
301 + # Tag filtering
302 + tag_filters = await tag_access_handler.build_alert_query_filters(user, db)
303 + accessible_tags = tag_filters["accessible_tags"]
304 +
305 + if "*" not in accessible_tags:
306 + tag_conditions = []
307 + if accessible_tags:
308 + has_accessible_tag = exists(
309 + select(AlertToTag.alert_id).where(
310 + and_(
311 + AlertToTag.alert_id == Alert.id,
312 + AlertToTag.tag_id.in_(accessible_tags),
313 + ),
314 + ),
315 + )
316 + tag_conditions.append(has_accessible_tag)
317 +
318 + if tag_filters["include_untagged"]:
319 + is_untagged = ~exists(
320 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
321 + )
322 + tag_conditions.append(is_untagged)
323 +
324 + if tag_conditions:
325 + filters.append(or_(*tag_conditions))
326 + else:
327 + return 0
328 +
329 + query = select(func.count(Alert.id)).where(*filters)
330 + result = await db.execute(query)
331 + return result.scalar_one()
332 +
333 +
334 +async def alerts_in_progress_for_user(user: User, db: AsyncSession) -> int:
335 + """Get in-progress alerts count with customer and tag filtering"""
336 + from sqlalchemy import and_
337 + from sqlalchemy import exists
338 + from sqlalchemy import or_
339 +
340 + filters = [Alert.status == "IN_PROGRESS"]
341 +
342 + # Customer filtering
343 + accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
344 + if "*" not in accessible_customers:
345 + filters.append(Alert.customer_code.in_(accessible_customers))
346 +
347 + # Tag filtering
348 + tag_filters = await tag_access_handler.build_alert_query_filters(user, db)
349 + accessible_tags = tag_filters["accessible_tags"]
350 +
351 + if "*" not in accessible_tags:
352 + tag_conditions = []
353 + if accessible_tags:
354 + has_accessible_tag = exists(
355 + select(AlertToTag.alert_id).where(
356 + and_(
357 + AlertToTag.alert_id == Alert.id,
358 + AlertToTag.tag_id.in_(accessible_tags),
359 + ),
360 + ),
361 + )
362 + tag_conditions.append(has_accessible_tag)
363 +
364 + if tag_filters["include_untagged"]:
365 + is_untagged = ~exists(
366 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
367 + )
368 + tag_conditions.append(is_untagged)
369 +
370 + if tag_conditions:
371 + filters.append(or_(*tag_conditions))
372 + else:
373 + return 0
374 +
375 + query = select(func.count(Alert.id)).where(*filters)
376 + result = await db.execute(query)
377 + return result.scalar_one()
378 +
379 +
380 +async def alerts_closed_for_user(user: User, db: AsyncSession) -> int:
381 + """Get closed alerts count with customer and tag filtering"""
382 + from sqlalchemy import and_
383 + from sqlalchemy import exists
384 + from sqlalchemy import or_
385 +
386 + filters = [Alert.status == "CLOSED"]
387 +
388 + # Customer filtering
389 + accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
390 + if "*" not in accessible_customers:
391 + filters.append(Alert.customer_code.in_(accessible_customers))
392 +
393 + # Tag filtering
394 + tag_filters = await tag_access_handler.build_alert_query_filters(user, db)
395 + accessible_tags = tag_filters["accessible_tags"]
396 +
397 + if "*" not in accessible_tags:
398 + tag_conditions = []
399 + if accessible_tags:
400 + has_accessible_tag = exists(
401 + select(AlertToTag.alert_id).where(
402 + and_(
403 + AlertToTag.alert_id == Alert.id,
404 + AlertToTag.tag_id.in_(accessible_tags),
405 + ),
406 + ),
407 + )
408 + tag_conditions.append(has_accessible_tag)
409 +
410 + if tag_filters["include_untagged"]:
411 + is_untagged = ~exists(
412 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
413 + )
414 + tag_conditions.append(is_untagged)
415 +
416 + if tag_conditions:
417 + filters.append(or_(*tag_conditions))
418 + else:
419 + return 0
420 +
421 + query = select(func.count(Alert.id)).where(*filters)
422 + result = await db.execute(query)
423 + return result.scalar_one()
424 +
425 +
426 async def alerts_total_multiple_filters(
427 db: AsyncSession,
428 assigned_to: Optional[str] = None,
@@ -1105,7 +1290,15 @@ async def create_alert_context(alert_context: AlertContextCreate, db: AsyncSessi
1290 return db_alert_context
1291
1292
1108 -async def get_alert_by_id(alert_id: int, db: AsyncSession) -> AlertOut:
1293 +async def get_alert_by_id(alert_id: int, db: AsyncSession, user: Optional[User] = None) -> AlertOut:
1294 + """
1295 + Get alert by ID with optional tag-based access validation.
1296 +
1297 + Args:
1298 + alert_id: The alert ID to retrieve
1299 + db: Database session
1300 + user: Optional user for tag access validation
1301 + """
1302 result = await db.execute(
1303 select(Alert)
1304 .where(Alert.id == alert_id)
@@ -1114,15 +1307,23 @@ async def get_alert_by_id(alert_id: int, db: AsyncSession) -> AlertOut:
1307 selectinload(Alert.assets),
1308 selectinload(Alert.cases).selectinload(CaseAlertLink.case),
1309 selectinload(Alert.tags).selectinload(AlertToTag.tag),
1310 + selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
1311 ),
1312 )
1313 alert = result.scalars().first()
1314 if not alert:
1315 raise HTTPException(status_code=404, detail="Alert not found")
1316
1317 + # Check tag access if user is provided
1318 + if user:
1319 + has_access = await tag_access_handler.check_alert_tag_access(user, alert, db)
1320 + if not has_access:
1321 + raise HTTPException(status_code=403, detail=f"Access denied to alert {alert_id} - insufficient tag permissions")
1322 +
1323 comments = [CommentBase(**comment.__dict__) for comment in alert.comments]
1324 assets = [AssetBase(**asset.__dict__) for asset in alert.assets]
1325 tags = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
1326 + iocs = [IoCBase(**alert_to_ioc.ioc.__dict__) for alert_to_ioc in alert.iocs]
1327 linked_cases = [LinkedCaseCreate(**case_alert_link.case.__dict__) for case_alert_link in alert.cases]
1328
1329 alert_out = AlertOut(
@@ -1139,6 +1340,7 @@ async def get_alert_by_id(alert_id: int, db: AsyncSession) -> AlertOut:
1340 comments=comments,
1341 assets=assets,
1342 tags=tags,
1343 + iocs=iocs,
1344 linked_cases=linked_cases,
1345 )
1346
@@ -2061,7 +2263,13 @@ async def list_alerts_multiple_filters(
2263 page: int = 1,
2264 page_size: int = 25,
2265 order: str = "desc",
2266 + user: Optional[User] = None, # New parameter for tag filtering
2267 ) -> List[AlertOut]:
2268 + """List alerts with multiple filters including tag-based RBAC"""
2269 + from sqlalchemy import and_
2270 + from sqlalchemy import exists
2271 + from sqlalchemy import or_
2272 +
2273 offset = (page - 1) * page_size
2274 order_by = asc(Alert.id) if order == "asc" else desc(Alert.id)
2275
@@ -2084,15 +2292,48 @@ async def list_alerts_multiple_filters(
2292 if ioc_value:
2293 filters.append(IoC.value == ioc_value)
2294
2295 + # Apply tag-based RBAC filtering if user is provided
2296 + if user:
2297 + tag_filters = await tag_access_handler.build_alert_query_filters(user, db)
2298 + accessible_tags = tag_filters["accessible_tags"]
2299 +
2300 + if "*" not in accessible_tags:
2301 + tag_conditions = []
2302 +
2303 + if accessible_tags:
2304 + # Alerts that have at least one accessible tag
2305 + has_accessible_tag = exists(
2306 + select(AlertToTag.alert_id).where(
2307 + and_(
2308 + AlertToTag.alert_id == Alert.id,
2309 + AlertToTag.tag_id.in_(accessible_tags),
2310 + ),
2311 + ),
2312 + )
2313 + tag_conditions.append(has_accessible_tag)
2314 +
2315 + if tag_filters["include_untagged"]:
2316 + # Include untagged alerts
2317 + is_untagged = ~exists(
2318 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
2319 + )
2320 + tag_conditions.append(is_untagged)
2321 +
2322 + if tag_conditions:
2323 + filters.append(or_(*tag_conditions))
2324 + else:
2325 + # No accessible tags and untagged not allowed - return empty
2326 + return []
2327 +
2328 # Build the query with dynamic filters
2329 query = (
2330 select(Alert)
2331 .distinct(Alert.id)
2091 - .join(Asset, Asset.alert_linked == Alert.id, isouter=True) # Join with Asset table
2092 - .join(AlertToTag, AlertToTag.alert_id == Alert.id, isouter=True) # Join with AlertToTag table
2093 - .join(AlertTag, AlertToTag.tag_id == AlertTag.id, isouter=True) # Join with AlertTag table
2094 - .join(AlertToIoC, AlertToIoC.alert_id == Alert.id, isouter=True) # Join with AlertToIoC table
2095 - .join(IoC, AlertToIoC.ioc_id == IoC.id, isouter=True) # Join with IoC table
2332 + .join(Asset, Asset.alert_linked == Alert.id, isouter=True)
2333 + .join(AlertToTag, AlertToTag.alert_id == Alert.id, isouter=True)
2334 + .join(AlertTag, AlertToTag.tag_id == AlertTag.id, isouter=True)
2335 + .join(AlertToIoC, AlertToIoC.alert_id == Alert.id, isouter=True)
2336 + .join(IoC, AlertToIoC.ioc_id == IoC.id, isouter=True)
2337 .where(*filters)
2338 .options(
2339 selectinload(Alert.comments),
@@ -2113,7 +2354,7 @@ async def list_alerts_multiple_filters(
2354 for alert in alerts:
2355 comments = [CommentBase(**comment.__dict__) for comment in alert.comments]
2356 assets = [AssetBase(**asset.__dict__) for asset in alert.assets]
2116 - tags = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
2357 + tags_out = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
2358 iocs = [IoCBase(**alert_to_ioc.ioc.__dict__) for alert_to_ioc in alert.iocs]
2359 linked_cases = [LinkedCaseCreate(**case_alert_link.case.__dict__) for case_alert_link in alert.cases]
2360 alert_out = AlertOut(
@@ -2129,7 +2370,7 @@ async def list_alerts_multiple_filters(
2370 escalated=alert.escalated,
2371 comments=comments,
2372 assets=assets,
2132 - tags=tags,
2373 + tags=tags_out,
2374 iocs=iocs,
2375 linked_cases=linked_cases,
2376 )
@@ -2145,22 +2386,69 @@ async def list_alerts_for_user(
2386 page_size: int = 25,
2387 order: str = "desc",
2388 ) -> List[AlertOut]:
2148 - """List alerts filtered by user's customer access"""
2389 + """List alerts filtered by user's customer access and tag access"""
2390 + from sqlalchemy import and_
2391 + from sqlalchemy import exists
2392 + from sqlalchemy import or_
2393
2394 + offset = (page - 1) * page_size
2395 + order_by = asc(Alert.id) if order == "asc" else desc(Alert.id)
2396 +
2397 + # Start building the query
2398 base_query = select(Alert).options(
2399 selectinload(Alert.comments),
2400 selectinload(Alert.assets),
2401 selectinload(Alert.cases).selectinload(CaseAlertLink.case),
2402 selectinload(Alert.tags).selectinload(AlertToTag.tag),
2403 + selectinload(Alert.iocs).selectinload(AlertToIoC.ioc),
2404 )
2405
2157 - # Apply customer filtering
2158 - filtered_query = await customer_access_handler.filter_query_by_customer_access(user, session, base_query, Alert.customer_code)
2406 + filters = []
2407
2160 - offset = (page - 1) * page_size
2161 - order_by = asc(Alert.id) if order == "asc" else desc(Alert.id)
2408 + # 1. Apply customer filtering
2409 + accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2410 + if "*" not in accessible_customers:
2411 + filters.append(Alert.customer_code.in_(accessible_customers))
2412 +
2413 + # 2. Apply tag filtering (new)
2414 + tag_filters = await tag_access_handler.build_alert_query_filters(user, session)
2415 + accessible_tags = tag_filters["accessible_tags"]
2416 +
2417 + if "*" not in accessible_tags:
2418 + # User has tag restrictions
2419 + tag_conditions = []
2420 +
2421 + if accessible_tags:
2422 + # Alerts that have at least one accessible tag
2423 + has_accessible_tag = exists(
2424 + select(AlertToTag.alert_id).where(
2425 + and_(
2426 + AlertToTag.alert_id == Alert.id,
2427 + AlertToTag.tag_id.in_(accessible_tags),
2428 + ),
2429 + ),
2430 + )
2431 + tag_conditions.append(has_accessible_tag)
2432 +
2433 + if tag_filters["include_untagged"]:
2434 + # Include untagged alerts
2435 + is_untagged = ~exists(
2436 + select(AlertToTag.alert_id).where(AlertToTag.alert_id == Alert.id),
2437 + )
2438 + tag_conditions.append(is_untagged)
2439 +
2440 + if tag_conditions:
2441 + filters.append(or_(*tag_conditions))
2442 + else:
2443 + # No accessible tags and untagged not allowed - return empty
2444 + return []
2445 +
2446 + # Apply all filters
2447 + if filters:
2448 + base_query = base_query.where(and_(*filters))
2449
2163 - final_query = filtered_query.order_by(order_by).offset(offset).limit(page_size)
2450 + # Apply ordering and pagination
2451 + final_query = base_query.order_by(order_by).offset(offset).limit(page_size)
2452 result = await session.execute(final_query)
2453 alerts = result.scalars().all()
2454
@@ -2170,6 +2458,7 @@ async def list_alerts_for_user(
2458 comments = [CommentBase(**comment.__dict__) for comment in alert.comments]
2459 assets = [AssetBase(**asset.__dict__) for asset in alert.assets]
2460 tags = [AlertTagBase(**alert_to_tag.tag.__dict__) for alert_to_tag in alert.tags]
2461 + iocs = [IoCBase(**alert_to_ioc.ioc.__dict__) for alert_to_ioc in alert.iocs]
2462 linked_cases = [LinkedCaseCreate(**case_alert_link.case.__dict__) for case_alert_link in alert.cases]
2463
2464 alert_out = AlertOut(
@@ -2186,6 +2475,7 @@ async def list_alerts_for_user(
2475 comments=comments,
2476 assets=assets,
2477 tags=tags,
2478 + iocs=iocs,
2479 linked_cases=linked_cases,
2480 )
2481 alerts_out.append(alert_out)
backend/app/incidents/services/tag_access.py new
+293
@@ -0,0 +1,293 @@
1 +from datetime import datetime
2 +from typing import List
3 +from typing import Optional
4 +
5 +from loguru import logger
6 +from sqlalchemy import delete
7 +from sqlalchemy import select
8 +from sqlalchemy.ext.asyncio import AsyncSession
9 +
10 +from app.auth.models.users import Role
11 +from app.auth.models.users import RoleTagAccess
12 +from app.auth.models.users import User
13 +from app.auth.models.users import UserTagAccess
14 +from app.incidents.models import AlertTag
15 +from app.incidents.models import TagAccessSettings
16 +
17 +# ============================================
18 +# Tag Access Settings
19 +# ============================================
20 +
21 +
22 +async def get_or_create_tag_settings(db: AsyncSession) -> TagAccessSettings:
23 + """Get existing settings or create default ones."""
24 + result = await db.execute(select(TagAccessSettings).limit(1))
25 + settings = result.scalars().first()
26 +
27 + if settings is None:
28 + settings = TagAccessSettings(
29 + enabled=False,
30 + untagged_alert_behavior="visible_to_all",
31 + )
32 + db.add(settings)
33 + await db.commit()
34 + await db.refresh(settings)
35 + logger.info("Created default tag access settings")
36 +
37 + return settings
38 +
39 +
40 +async def get_tag_access_settings(db: AsyncSession) -> Optional[TagAccessSettings]:
41 + """Get the global tag access settings, or None if not configured."""
42 + result = await db.execute(select(TagAccessSettings).limit(1))
43 + return result.scalars().first()
44 +
45 +
46 +async def update_tag_access_settings(
47 + enabled: bool,
48 + untagged_alert_behavior: str,
49 + default_tag_id: Optional[int],
50 + updated_by: str,
51 + db: AsyncSession,
52 +) -> TagAccessSettings:
53 + """Update global tag access settings."""
54 + settings = await get_or_create_tag_settings(db)
55 +
56 + settings.enabled = enabled
57 + settings.untagged_alert_behavior = untagged_alert_behavior
58 + settings.default_tag_id = default_tag_id
59 + settings.updated_by = updated_by
60 + settings.updated_at = datetime.utcnow()
61 +
62 + await db.commit()
63 + await db.refresh(settings)
64 +
65 + logger.info(
66 + f"Tag access settings updated by {updated_by}: "
67 + f"enabled={enabled}, behavior={untagged_alert_behavior}, default_tag_id={default_tag_id}",
68 + )
69 + return settings
70 +
71 +
72 +# ============================================
73 +# User Tag Access
74 +# ============================================
75 +
76 +
77 +async def get_user_accessible_tags(user_id: int, db: AsyncSession) -> List[AlertTag]:
78 + """Get all tags a user has direct access to (not including role-based)."""
79 + result = await db.execute(
80 + select(AlertTag).join(UserTagAccess, UserTagAccess.tag_id == AlertTag.id).where(UserTagAccess.user_id == user_id),
81 + )
82 + return list(result.scalars().all())
83 +
84 +
85 +async def set_user_tag_access(
86 + user_id: int,
87 + tag_ids: List[int],
88 + db: AsyncSession,
89 +) -> List[AlertTag]:
90 + """
91 + Set the tags a user can access (replaces existing).
92 +
93 + Args:
94 + user_id: The user ID
95 + tag_ids: List of tag IDs to grant access to
96 + db: Database session
97 +
98 + Returns:
99 + List of AlertTag objects the user now has access to
100 + """
101 + # Remove existing access
102 + await db.execute(
103 + delete(UserTagAccess).where(UserTagAccess.user_id == user_id),
104 + )
105 +
106 + # Add new access
107 + for tag_id in tag_ids:
108 + access = UserTagAccess(user_id=user_id, tag_id=tag_id)
109 + db.add(access)
110 +
111 + await db.commit()
112 +
113 + logger.info(f"Set tag access for user {user_id}: {tag_ids}")
114 +
115 + # Return the tags
116 + if tag_ids:
117 + result = await db.execute(
118 + select(AlertTag).where(AlertTag.id.in_(tag_ids)),
119 + )
120 + return list(result.scalars().all())
121 + return []
122 +
123 +
124 +async def add_user_tag_access(
125 + user_id: int,
126 + tag_ids: List[int],
127 + db: AsyncSession,
128 +) -> List[AlertTag]:
129 + """Add tags to a user's access (doesn't remove existing)."""
130 + for tag_id in tag_ids:
131 + # Check if already exists
132 + result = await db.execute(
133 + select(UserTagAccess).where(
134 + UserTagAccess.user_id == user_id,
135 + UserTagAccess.tag_id == tag_id,
136 + ),
137 + )
138 + if result.scalars().first() is None:
139 + access = UserTagAccess(user_id=user_id, tag_id=tag_id)
140 + db.add(access)
141 +
142 + await db.commit()
143 +
144 + logger.info(f"Added tag access for user {user_id}: {tag_ids}")
145 + return await get_user_accessible_tags(user_id, db)
146 +
147 +
148 +async def remove_user_tag_access(
149 + user_id: int,
150 + tag_ids: List[int],
151 + db: AsyncSession,
152 +) -> List[AlertTag]:
153 + """Remove specific tags from a user's access."""
154 + await db.execute(
155 + delete(UserTagAccess).where(
156 + UserTagAccess.user_id == user_id,
157 + UserTagAccess.tag_id.in_(tag_ids),
158 + ),
159 + )
160 + await db.commit()
161 +
162 + logger.info(f"Removed tag access for user {user_id}: {tag_ids}")
163 + return await get_user_accessible_tags(user_id, db)
164 +
165 +
166 +# ============================================
167 +# Role Tag Access
168 +# ============================================
169 +
170 +
171 +async def get_role_accessible_tags(role_id: int, db: AsyncSession) -> List[AlertTag]:
172 + """Get all tags a role has access to."""
173 + result = await db.execute(
174 + select(AlertTag).join(RoleTagAccess, RoleTagAccess.tag_id == AlertTag.id).where(RoleTagAccess.role_id == role_id),
175 + )
176 + return list(result.scalars().all())
177 +
178 +
179 +async def set_role_tag_access(
180 + role_id: int,
181 + tag_ids: List[int],
182 + db: AsyncSession,
183 +) -> List[AlertTag]:
184 + """Set the tags a role can access (replaces existing)."""
185 + # Remove existing access
186 + await db.execute(
187 + delete(RoleTagAccess).where(RoleTagAccess.role_id == role_id),
188 + )
189 +
190 + # Add new access
191 + for tag_id in tag_ids:
192 + access = RoleTagAccess(role_id=role_id, tag_id=tag_id)
193 + db.add(access)
194 +
195 + await db.commit()
196 +
197 + logger.info(f"Set tag access for role {role_id}: {tag_ids}")
198 +
199 + # Return the tags
200 + if tag_ids:
201 + result = await db.execute(
202 + select(AlertTag).where(AlertTag.id.in_(tag_ids)),
203 + )
204 + return list(result.scalars().all())
205 + return []
206 +
207 +
208 +async def add_role_tag_access(
209 + role_id: int,
210 + tag_ids: List[int],
211 + db: AsyncSession,
212 +) -> List[AlertTag]:
213 + """Add tags to a role's access (doesn't remove existing)."""
214 + for tag_id in tag_ids:
215 + # Check if already exists
216 + result = await db.execute(
217 + select(RoleTagAccess).where(
218 + RoleTagAccess.role_id == role_id,
219 + RoleTagAccess.tag_id == tag_id,
220 + ),
221 + )
222 + if result.scalars().first() is None:
223 + access = RoleTagAccess(role_id=role_id, tag_id=tag_id)
224 + db.add(access)
225 +
226 + await db.commit()
227 +
228 + logger.info(f"Added tag access for role {role_id}: {tag_ids}")
229 + return await get_role_accessible_tags(role_id, db)
230 +
231 +
232 +async def remove_role_tag_access(
233 + role_id: int,
234 + tag_ids: List[int],
235 + db: AsyncSession,
236 +) -> List[AlertTag]:
237 + """Remove specific tags from a role's access."""
238 + await db.execute(
239 + delete(RoleTagAccess).where(
240 + RoleTagAccess.role_id == role_id,
241 + RoleTagAccess.tag_id.in_(tag_ids),
242 + ),
243 + )
244 + await db.commit()
245 +
246 + logger.info(f"Removed tag access for role {role_id}: {tag_ids}")
247 + return await get_role_accessible_tags(role_id, db)
248 +
249 +
250 +# ============================================
251 +# Tag Queries
252 +# ============================================
253 +
254 +
255 +async def get_all_tags(db: AsyncSession) -> List[AlertTag]:
256 + """Get all available tags."""
257 + result = await db.execute(select(AlertTag))
258 + return list(result.scalars().all())
259 +
260 +
261 +async def get_tag_by_id(tag_id: int, db: AsyncSession) -> Optional[AlertTag]:
262 + """Get a tag by ID."""
263 + result = await db.execute(
264 + select(AlertTag).where(AlertTag.id == tag_id),
265 + )
266 + return result.scalars().first()
267 +
268 +
269 +# ============================================
270 +# User Queries (helpers)
271 +# ============================================
272 +
273 +
274 +async def get_user_by_id(user_id: int, db: AsyncSession) -> Optional[User]:
275 + """Get a user by ID."""
276 + result = await db.execute(
277 + select(User).where(User.id == user_id),
278 + )
279 + return result.scalars().first()
280 +
281 +
282 +async def get_role_by_id(role_id: int, db: AsyncSession) -> Optional[Role]:
283 + """Get a role by ID."""
284 + result = await db.execute(
285 + select(Role).where(Role.id == role_id),
286 + )
287 + return result.scalars().first()
288 +
289 +
290 +async def get_all_roles(db: AsyncSession) -> List[Role]:
291 + """Get all roles."""
292 + result = await db.execute(select(Role))
293 + return list(result.scalars().all())
backend/app/routers/incidents.py
+2
@@ -3,6 +3,7 @@ from fastapi import APIRouter
3 from app.incidents.routes.db_operations import incidents_db_operations_router
4 from app.incidents.routes.incident_alert import incidents_alerts_router
5 from app.incidents.routes.incident_report import incidents_report_router
6 +from app.incidents.routes.tag_access import tag_access_router
7
8 # Instantiate the APIRouter
9 router = APIRouter()
@@ -10,3 +11,4 @@ router = APIRouter()
11 router.include_router(incidents_db_operations_router, prefix="/incidents/db_operations", tags=["incidents"])
12 router.include_router(incidents_alerts_router, prefix="/incidents/alerts", tags=["incidents-alerts"])
13 router.include_router(incidents_report_router, prefix="/incidents/report", tags=["incidents-report"])
14 +router.include_router(tag_access_router, prefix="/incidents/tag_access", tags=["incidents-tag-access"])
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.32"
10 +CURRENT_VERSION = "0.1.33"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/tagRbac.ts new
+56
@@ -0,0 +1,56 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import type {
3 + AvailableTagsResponse,
4 + TagAccessSettings,
5 + TagAccessSettingsResponse,
6 + UserTagsResponse
7 +} from "@/types/incidentManagement/tags.d"
8 +import { HttpClient } from "../httpClient"
9 +
10 +export default {
11 + // Get tag RBAC settings
12 + getSettings() {
13 + return HttpClient.get<TagAccessSettingsResponse>("/incidents/tag_access/settings")
14 + },
15 +
16 + // Update tag RBAC settings
17 + updateSettings(settings: TagAccessSettings) {
18 + return HttpClient.put<TagAccessSettingsResponse>("/incidents/tag_access/settings", settings)
19 + },
20 +
21 + // Get all available alert tags
22 + getAvailableTags() {
23 + return HttpClient.get<AvailableTagsResponse>("/incidents/tag_access/tags")
24 + },
25 +
26 + // Get tags assigned to a user (matches: GET /user/{user_id})
27 + getUserTags(userId: number) {
28 + return HttpClient.get<UserTagsResponse>(`/incidents/tag_access/user/${userId}`)
29 + },
30 +
31 + // Assign tags to a user (matches: PUT /user/{user_id})
32 + assignUserTags(userId: number, tagIds: number[]) {
33 + return HttpClient.put<UserTagsResponse>(`/incidents/tag_access/user/${userId}`, {
34 + tag_ids: tagIds
35 + })
36 + },
37 +
38 + // Add tags to a user (matches: POST /user/{user_id}/add)
39 + addUserTags(userId: number, tagIds: number[]) {
40 + return HttpClient.post<UserTagsResponse>(`/incidents/tag_access/user/${userId}/add`, {
41 + tag_ids: tagIds
42 + })
43 + },
44 +
45 + // Remove tags from a user (matches: POST /user/{user_id}/remove)
46 + removeUserTags(userId: number, tagIds: number[]) {
47 + return HttpClient.post<UserTagsResponse>(`/incidents/tag_access/user/${userId}/remove`, {
48 + tag_ids: tagIds
49 + })
50 + },
51 +
52 + // Get current user's effective access
53 + getMyEffectiveAccess() {
54 + return HttpClient.get<FlaskBaseResponse>("/incidents/tag_access/me")
55 + }
56 +}
frontend/src/api/index.ts
+3 -1
@@ -29,6 +29,7 @@ import snapshots from "./endpoints/snapshots"
29 import soc from "./endpoints/soc"
30 import stackProvisioning from "./endpoints/stackProvisioning"
31 import sysmonConfig from "./endpoints/sysmonConfig"
32 +import tagRbac from "./endpoints/tagRbac"
33 import threatIntel from "./endpoints/threatIntel"
34 import users from "./endpoints/users"
35 import version from "./endpoints/version"
@@ -75,5 +76,6 @@ export default {
76 copilotMCP,
77 customerPortal,
78 version,
78 - snapshots
79 + snapshots,
80 + tagRbac
81 }
frontend/src/components/users/AssignTags.vue new
+208
@@ -0,0 +1,208 @@
1 +<template>
2 + <div class="assign-tags-box flex flex-col gap-2 px-3 py-2">
3 + <div class="title flex items-center gap-2">
4 + <Icon :name="TagIcon" :size="16" />
5 + <span>Assign Tags</span>
6 + </div>
7 +
8 + <n-spin :show="loading" size="small">
9 + <div v-if="!tagRbacEnabled" class="text-xs opacity-70">
10 + Tag RBAC is disabled
11 + </div>
12 +
13 + <div v-else class="flex flex-col gap-2">
14 + <n-select
15 + v-model:value="selectedTagIds"
16 + multiple
17 + filterable
18 + clearable
19 + size="small"
20 + placeholder="No restrictions (full access)"
21 + :options="tagOptions"
22 + :loading="loadingTags"
23 + :disabled="saving"
24 + />
25 +
26 + <div class="flex gap-2">
27 + <n-button
28 + size="small"
29 + type="primary"
30 + :loading="saving"
31 + :disabled="!hasChanges"
32 + @click="saveTags"
33 + >
34 + Save
35 + </n-button>
36 + <n-button
37 + v-if="selectedTagIds.length > 0"
38 + size="small"
39 + quaternary
40 + :disabled="saving"
41 + @click="clearAllTags"
42 + >
43 + Clear All
44 + </n-button>
45 + </div>
46 + </div>
47 + </n-spin>
48 + </div>
49 +</template>
50 +
51 +<script setup lang="ts">
52 +import type { AlertTag } from "@/types/incidentManagement/tags.d"
53 +import type { User } from "@/types/user.d"
54 +import { NButton, NSelect, NSpin, useMessage } from "naive-ui"
55 +import { computed, onMounted, ref, watch } from "vue"
56 +import Api from "@/api"
57 +import Icon from "@/components/common/Icon.vue"
58 +
59 +const props = defineProps<{
60 + user: User | undefined
61 +}>()
62 +
63 +const emit = defineEmits<{
64 + (e: "success"): void
65 +}>()
66 +
67 +const TagIcon = "carbon:tag"
68 +
69 +const message = useMessage()
70 +
71 +const loading = ref(false)
72 +const loadingTags = ref(false)
73 +const saving = ref(false)
74 +const tagRbacEnabled = ref(false)
75 +const availableTags = ref<AlertTag[]>([])
76 +const selectedTagIds = ref<number[]>([])
77 +const originalTagIds = ref<number[]>([])
78 +
79 +const tagOptions = computed(() =>
80 + availableTags.value.map(tag => ({
81 + label: tag.tag,
82 + value: tag.id
83 + }))
84 +)
85 +
86 +const hasChanges = computed(() => {
87 + if (selectedTagIds.value.length !== originalTagIds.value.length) return true
88 + const sorted1 = [...selectedTagIds.value].sort()
89 + const sorted2 = [...originalTagIds.value].sort()
90 + return sorted1.some((val, idx) => val !== sorted2[idx])
91 +})
92 +
93 +async function loadSettings() {
94 + try {
95 + const res = await Api.tagRbac.getSettings()
96 + if (res.data.success && res.data.settings) {
97 + tagRbacEnabled.value = res.data.settings.enabled
98 + }
99 + } catch (error) {
100 + console.error("Failed to load tag RBAC settings:", error)
101 + }
102 +}
103 +
104 +async function loadAvailableTags() {
105 + loadingTags.value = true
106 + try {
107 + const res = await Api.tagRbac.getAvailableTags()
108 + if (res.data.success) {
109 + availableTags.value = res.data.tags
110 + }
111 + } catch (error) {
112 + console.error("Failed to load available tags:", error)
113 + } finally {
114 + loadingTags.value = false
115 + }
116 +}
117 +
118 +async function loadUserTags() {
119 + if (!props.user?.id) return
120 +
121 + loading.value = true
122 + try {
123 + const res = await Api.tagRbac.getUserTags(props.user.id)
124 + if (res.data.success) {
125 + // Backend returns accessible_tags, not tag_ids
126 + const tagIds = res.data.accessible_tags.map(tag => tag.id)
127 + selectedTagIds.value = tagIds
128 + originalTagIds.value = [...tagIds]
129 + }
130 + } catch (error) {
131 + console.error("Failed to load user tags:", error)
132 + } finally {
133 + loading.value = false
134 + }
135 +}
136 +
137 +async function saveTags() {
138 + if (!props.user?.id) return
139 +
140 + saving.value = true
141 + try {
142 + const res = await Api.tagRbac.assignUserTags(props.user.id, selectedTagIds.value)
143 + if (res.data.success) {
144 + message.success("Tag access updated")
145 + // Update original from response
146 + const tagIds = res.data.accessible_tags.map(tag => tag.id)
147 + originalTagIds.value = [...tagIds]
148 + emit("success")
149 + } else {
150 + message.error(res.data.message || "Failed to update tag access")
151 + }
152 + } catch (error: any) {
153 + message.error(error.response?.data?.message || "Failed to update tag access")
154 + } finally {
155 + saving.value = false
156 + }
157 +}
158 +
159 +async function clearAllTags() {
160 + if (!props.user?.id) return
161 +
162 + saving.value = true
163 + try {
164 + // Use assignUserTags with empty array to clear all
165 + const res = await Api.tagRbac.assignUserTags(props.user.id, [])
166 + if (res.data.success) {
167 + message.success("Tag restrictions cleared")
168 + selectedTagIds.value = []
169 + originalTagIds.value = []
170 + emit("success")
171 + }
172 + } catch (error: any) {
173 + message.error(error.response?.data?.message || "Failed to clear tags")
174 + } finally {
175 + saving.value = false
176 + }
177 +}
178 +
179 +watch(
180 + () => props.user?.id,
181 + () => {
182 + if (props.user?.id) {
183 + loadUserTags()
184 + }
185 + }
186 +)
187 +
188 +onMounted(async () => {
189 + await loadSettings()
190 + if (tagRbacEnabled.value) {
191 + await loadAvailableTags()
192 + if (props.user?.id) {
193 + await loadUserTags()
194 + }
195 + }
196 +})
197 +</script>
198 +
199 +<style scoped lang="scss">
200 +.assign-tags-box {
201 + min-width: 250px;
202 +
203 + .title {
204 + font-size: 14px;
205 + font-weight: 500;
206 + }
207 +}
208 +</style>
frontend/src/components/users/TagRbacSettings.vue new
+331
@@ -0,0 +1,331 @@
1 +<template>
2 + <n-spin :show="loading">
3 + <div class="settings-form flex flex-col gap-4">
4 + <n-form-item label="Enable Tag RBAC" :show-feedback="false">
5 + <n-switch v-model:value="settings.enabled">
6 + <template #checked>On</template>
7 + <template #unchecked>Off</template>
8 + </n-switch>
9 + </n-form-item>
10 +
11 + <n-form-item v-if="settings.enabled" label="Untagged Alert Visibility" :show-feedback="false">
12 + <n-radio-group v-model:value="settings.untagged_alert_behavior">
13 + <n-space vertical>
14 + <n-radio value="visible_to_all">
15 + <div class="radio-label">
16 + <span class="label">Visible to All</span>
17 + <span class="description">Users can see alerts without tags</span>
18 + </div>
19 + </n-radio>
20 + <n-radio value="admin_only">
21 + <div class="radio-label">
22 + <span class="label">Admin Only</span>
23 + <span class="description">Only admins can see untagged alerts</span>
24 + </div>
25 + </n-radio>
26 + <n-radio value="default_tag">
27 + <div class="radio-label">
28 + <span class="label">Default Tag</span>
29 + <span class="description">Assign untagged alerts to users with a specific tag</span>
30 + </div>
31 + </n-radio>
32 + </n-space>
33 + </n-radio-group>
34 + </n-form-item>
35 +
36 + <n-form-item
37 + v-if="settings.enabled && settings.untagged_alert_behavior === 'default_tag'"
38 + label="Default Tag"
39 + :show-feedback="false"
40 + >
41 + <n-select
42 + v-model:value="settings.default_tag_id"
43 + filterable
44 + clearable
45 + placeholder="Select a default tag"
46 + :options="tagOptions"
47 + :loading="loadingTags"
48 + />
49 + <template #help>
50 + <span class="text-xs opacity-70">
51 + Users with access to this tag will also see untagged alerts
52 + </span>
53 + </template>
54 + </n-form-item>
55 +
56 + <n-divider v-if="settings.enabled" class="!my-2" />
57 +
58 + <n-alert v-if="settings.enabled" type="info" title="How Tag RBAC Works">
59 + <div class="info-content">
60 + <p class="intro">
61 + Tag RBAC controls which alerts users can see based on assigned tags.
62 + </p>
63 +
64 + <div class="section">
65 + <strong>User Access Rules:</strong>
66 + <ul class="info-list">
67 + <li>
68 + <strong>No tags assigned</strong> → User can see
69 + <em>all alerts</em> (no restrictions)
70 + </li>
71 + <li>
72 + <strong>Tags assigned</strong> → User can
73 + <em>only</em> see alerts with matching tags
74 + </li>
75 + <li>
76 + <strong>Admins &amp; Schedulers</strong> → Always have full access
77 + </li>
78 + </ul>
79 + </div>
80 +
81 + <div class="section">
82 + <strong>Untagged Alert Behavior:</strong>
83 + <ul class="info-list">
84 + <li>
85 + <strong>Visible to All:</strong> Everyone sees untagged alerts
86 + </li>
87 + <li>
88 + <strong>Admin Only:</strong> Only admins see untagged alerts
89 + </li>
90 + <li>
91 + <strong>Default Tag:</strong> Users with the selected tag can see untagged alerts
92 + </li>
93 + </ul>
94 + </div>
95 +
96 + <n-divider class="!my-3" />
97 +
98 + <div class="example">
99 + <strong>Example:</strong>
100 + <p>
101 + If analyst "John" is assigned the tag "Network", John will only see alerts tagged
102 + "Network". If untagged behavior is set to "Admin Only", John won't see any untagged
103 + alerts. If set to "Default Tag: Network", John will also see untagged alerts.
104 + </p>
105 + </div>
106 + </div>
107 + </n-alert>
108 +
109 + <div class="actions flex gap-3">
110 + <n-button type="primary" :loading="saving" :disabled="!hasChanges" @click="saveSettings">
111 + Save Settings
112 + </n-button>
113 + <n-button :disabled="!hasChanges" @click="resetSettings">Cancel</n-button>
114 + </div>
115 + </div>
116 + </n-spin>
117 +</template>
118 +
119 +<script setup lang="ts">
120 +import type { AlertTag } from "@/types/incidentManagement/tags.d"
121 +import {
122 + NAlert,
123 + NButton,
124 + NDivider,
125 + NFormItem,
126 + NRadio,
127 + NRadioGroup,
128 + NSelect,
129 + NSpace,
130 + NSpin,
131 + NSwitch,
132 + useMessage
133 +} from "naive-ui"
134 +import { computed, onMounted, reactive, ref, watch } from "vue"
135 +import Api from "@/api"
136 +
137 +interface TagAccessSettings {
138 + enabled: boolean
139 + untagged_alert_behavior: "visible_to_all" | "admin_only" | "default_tag"
140 + default_tag_id: number | null
141 +}
142 +
143 +const message = useMessage()
144 +
145 +const loading = ref(false)
146 +const loadingTags = ref(false)
147 +const saving = ref(false)
148 +const availableTags = ref<AlertTag[]>([])
149 +
150 +const settings = reactive<TagAccessSettings>({
151 + enabled: false,
152 + untagged_alert_behavior: "visible_to_all",
153 + default_tag_id: null
154 +})
155 +
156 +const originalSettings = ref<TagAccessSettings>({
157 + enabled: false,
158 + untagged_alert_behavior: "visible_to_all",
159 + default_tag_id: null
160 +})
161 +
162 +const tagOptions = computed(() =>
163 + availableTags.value.map(tag => ({
164 + label: tag.tag,
165 + value: tag.id
166 + }))
167 +)
168 +
169 +const hasChanges = computed(() => {
170 + return (
171 + settings.enabled !== originalSettings.value.enabled ||
172 + settings.untagged_alert_behavior !== originalSettings.value.untagged_alert_behavior ||
173 + settings.default_tag_id !== originalSettings.value.default_tag_id
174 + )
175 +})
176 +
177 +async function loadAvailableTags() {
178 + loadingTags.value = true
179 + try {
180 + const res = await Api.tagRbac.getAvailableTags()
181 + if (res.data.success) {
182 + availableTags.value = res.data.tags
183 + }
184 + } catch (error) {
185 + console.error("Failed to load available tags:", error)
186 + } finally {
187 + loadingTags.value = false
188 + }
189 +}
190 +
191 +async function loadSettings() {
192 + loading.value = true
193 + try {
194 + const res = await Api.tagRbac.getSettings()
195 +
196 + if (res.data.success && res.data.settings) {
197 + settings.enabled = res.data.settings.enabled
198 + settings.untagged_alert_behavior = res.data.settings.untagged_alert_behavior
199 + settings.default_tag_id = res.data.settings.default_tag_id
200 + originalSettings.value = {
201 + enabled: res.data.settings.enabled,
202 + untagged_alert_behavior: res.data.settings.untagged_alert_behavior,
203 + default_tag_id: res.data.settings.default_tag_id
204 + }
205 + }
206 + } catch (error) {
207 + console.error("Failed to load tag RBAC settings:", error)
208 + message.error("Failed to load Tag RBAC settings")
209 + } finally {
210 + loading.value = false
211 + }
212 +}
213 +
214 +async function saveSettings() {
215 + // Validate default_tag_id is set when using default_tag behavior
216 + if (settings.untagged_alert_behavior === "default_tag" && !settings.default_tag_id) {
217 + message.warning("Please select a default tag")
218 + return
219 + }
220 +
221 + saving.value = true
222 + try {
223 + const res = await Api.tagRbac.updateSettings({
224 + enabled: settings.enabled,
225 + untagged_alert_behavior: settings.untagged_alert_behavior,
226 + default_tag_id: settings.default_tag_id
227 + })
228 +
229 + if (res.data.success) {
230 + message.success("Tag RBAC settings saved")
231 + originalSettings.value = { ...settings }
232 + } else {
233 + message.error(res.data.message || "Failed to save settings")
234 + }
235 + } catch (error: any) {
236 + console.error("Failed to save settings:", error)
237 + message.error(error.response?.data?.message || "Failed to save settings")
238 + } finally {
239 + saving.value = false
240 + }
241 +}
242 +
243 +function resetSettings() {
244 + settings.enabled = originalSettings.value.enabled
245 + settings.untagged_alert_behavior = originalSettings.value.untagged_alert_behavior
246 + settings.default_tag_id = originalSettings.value.default_tag_id
247 +}
248 +
249 +// Load tags when default_tag behavior is selected
250 +watch(
251 + () => settings.untagged_alert_behavior,
252 + newValue => {
253 + if (newValue === "default_tag" && availableTags.value.length === 0) {
254 + loadAvailableTags()
255 + }
256 + }
257 +)
258 +
259 +onMounted(async () => {
260 + await loadSettings()
261 + // Pre-load tags if default_tag is already selected
262 + if (settings.untagged_alert_behavior === "default_tag") {
263 + await loadAvailableTags()
264 + }
265 +})
266 +</script>
267 +
268 +<style scoped lang="scss">
269 +.settings-form {
270 + .radio-label {
271 + display: flex;
272 + flex-direction: column;
273 +
274 + .label {
275 + font-weight: 500;
276 + }
277 +
278 + .description {
279 + font-size: 12px;
280 + opacity: 0.7;
281 + }
282 + }
283 +
284 + .info-content {
285 + .intro {
286 + margin: 0 0 12px 0;
287 + font-weight: 500;
288 + }
289 +
290 + .section {
291 + margin-bottom: 12px;
292 +
293 + > strong {
294 + display: block;
295 + margin-bottom: 4px;
296 + }
297 + }
298 +
299 + .info-list {
300 + margin: 0;
301 + padding-left: 20px;
302 +
303 + li {
304 + margin-bottom: 4px;
305 +
306 + em {
307 + font-style: normal;
308 + text-decoration: underline;
309 + }
310 + }
311 + }
312 +
313 + .example {
314 + background: rgba(0, 0, 0, 0.05);
315 + border-radius: 4px;
316 + padding: 10px;
317 +
318 + > strong {
319 + display: block;
320 + margin-bottom: 4px;
321 + }
322 +
323 + p {
324 + margin: 0;
325 + font-size: 13px;
326 + line-height: 1.5;
327 + }
328 + }
329 + }
330 +}
331 +</style>
frontend/src/components/users/UsersList.vue
+116 -85
@@ -5,7 +5,13 @@
5 Total:
6 <strong class="font-mono">{{ usersList.length }}</strong>
7 </div>
8 - <div>
8 + <div class="flex gap-2">
9 + <n-button size="small" @click="showTagRbacSettings = true">
10 + <template #icon>
11 + <Icon :name="SettingsIcon" />
12 + </template>
13 + Tag RBAC Settings
14 + </n-button>
15 <n-button size="small" type="primary" @click="showForm = true">
16 <template #icon>
17 <Icon :name="UserAddIcon" />
@@ -68,6 +74,18 @@
74 </n-scrollbar>
75 </n-spin>
76
77 + <n-modal
78 + v-model:show="showTagRbacSettings"
79 + display-directive="show"
80 + preset="card"
81 + :style="{ maxWidth: 'min(500px, 90vw)', overflow: 'hidden' }"
82 + title="Tag RBAC Settings"
83 + :bordered="false"
84 + segmented
85 + >
86 + <TagRbacSettings />
87 + </n-modal>
88 +
89 <n-modal
90 v-model:show="showForm"
91 display-directive="show"
@@ -100,14 +118,18 @@ const ChangePassword = defineAsyncComponent(() => import("./ChangePassword.vue")
118 const DeleteUser = defineAsyncComponent(() => import("./DeleteUser.vue"))
119 const AssignRole = defineAsyncComponent(() => import("./AssignRole.vue"))
120 const AssignCustomer = defineAsyncComponent(() => import("./AssignCustomer.vue"))
121 +const AssignTags = defineAsyncComponent(() => import("./AssignTags.vue"))
122 +const TagRbacSettings = defineAsyncComponent(() => import("./TagRbacSettings.vue"))
123 const SignUp = defineAsyncComponent(() => import("@/components/auth/SignUp.vue"))
124
125 const UserAddIcon = "carbon:user-follow"
126 +const SettingsIcon = "carbon:settings"
127 const DropdownIcon = "carbon:overflow-menu-horizontal"
128 const message = useMessage()
129 const loadingUsers = ref(false)
130 const loadingDelete = ref(false)
131 const showForm = ref(false)
132 +const showTagRbacSettings = ref(false)
133 const usersList = ref<User[]>([])
134 const isAdmin = useAuthStore().isAdmin
135 const selectedUser = ref<User | null>(null)
@@ -116,109 +138,118 @@ const usernameList = computed(() => usersList.value.map(user => user.username))
138 const emailList = computed(() => usersList.value.map(user => user.email))
139
140 function getRoleTagType(roleName: string | null | undefined) {
119 - switch (roleName?.toLowerCase()) {
120 - case "admin":
121 - return "error"
122 - case "analyst":
123 - return "warning"
124 - case "scheduler":
125 - return "info"
126 - case "customer_user":
127 - return "success"
128 - default:
129 - return "default"
130 - }
141 + switch (roleName?.toLowerCase()) {
142 + case "admin":
143 + return "error"
144 + case "analyst":
145 + return "warning"
146 + case "scheduler":
147 + return "info"
148 + case "customer_user":
149 + return "success"
150 + default:
151 + return "default"
152 + }
153 }
154
155 const options = [
134 - {
135 - key: "AssignRole",
136 - type: "render",
137 - render: () =>
138 - h(AssignRole, {
139 - user: selectedUser.value || undefined,
140 - onSuccess: getUsers
141 - })
142 - },
143 - {
144 - key: "AssignCustomer",
145 - type: "render",
146 - render: () =>
147 - h(AssignCustomer, {
148 - user: selectedUser.value || undefined,
149 - onSuccess: getUsers
150 - })
151 - },
152 - {
153 - key: "ChangePassword",
154 - type: "render",
155 - render: () => h(ChangePassword, { user: selectedUser.value || undefined })
156 - },
157 - {
158 - key: "DeleteUser",
159 - type: "render",
160 - render: () =>
161 - h(DeleteUser, {
162 - user: selectedUser.value || undefined,
163 - onSuccess: getUsers,
164 - onLoading: updateLoadingDelete
165 - })
166 - }
156 + {
157 + key: "AssignRole",
158 + type: "render",
159 + render: () =>
160 + h(AssignRole, {
161 + user: selectedUser.value || undefined,
162 + onSuccess: getUsers
163 + })
164 + },
165 + {
166 + key: "AssignCustomer",
167 + type: "render",
168 + render: () =>
169 + h(AssignCustomer, {
170 + user: selectedUser.value || undefined,
171 + onSuccess: getUsers
172 + })
173 + },
174 + {
175 + key: "AssignTags",
176 + type: "render",
177 + render: () =>
178 + h(AssignTags, {
179 + user: selectedUser.value || undefined,
180 + onSuccess: getUsers
181 + })
182 + },
183 + {
184 + key: "ChangePassword",
185 + type: "render",
186 + render: () => h(ChangePassword, { user: selectedUser.value || undefined })
187 + },
188 + {
189 + key: "DeleteUser",
190 + type: "render",
191 + render: () =>
192 + h(DeleteUser, {
193 + user: selectedUser.value || undefined,
194 + onSuccess: getUsers,
195 + onLoading: updateLoadingDelete
196 + })
197 + }
198 ]
199
200 function updateLoadingDelete(value: boolean) {
170 - loadingDelete.value = value
201 + loadingDelete.value = value
202 }
203
204 function addUserSuccess() {
174 - getUsers()
175 - showForm.value = false
205 + getUsers()
206 + showForm.value = false
207 }
208
209 function getUsers() {
179 - loadingUsers.value = true
180 -
181 - Api.users
182 - .getUsers()
183 - .then(res => {
184 - if (res.data.success) {
185 - usersList.value = res.data?.users || []
186 - } else {
187 - message.warning(res.data?.message || "An error occurred. Please try again later.")
188 - }
189 - })
190 - .catch(err => {
191 - usersList.value = []
192 -
193 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
194 - })
195 - .finally(() => {
196 - loadingUsers.value = false
197 - })
210 + loadingUsers.value = true
211 +
212 + Api.users
213 + .getUsers()
214 + .then(res => {
215 + if (res.data.success) {
216 + usersList.value = res.data?.users || []
217 + } else {
218 + message.warning(res.data?.message || "An error occurred. Please try again later.")
219 + }
220 + })
221 + .catch(err => {
222 + usersList.value = []
223 +
224 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
225 + })
226 + .finally(() => {
227 + loadingUsers.value = false
228 + })
229 }
230
231 onBeforeMount(() => {
201 - getUsers()
232 + getUsers()
233 })
234 </script>
235
236 <style lang="scss" scoped>
237 .users-list {
207 - border-radius: var(--border-radius);
208 - overflow: hidden;
209 -
210 - tr:hover {
211 - td {
212 - background-color: rgba(var(--primary-color-rgb) / 0.05);
213 - }
214 - }
215 -
216 - .highlight {
217 - td {
218 - border-top: 1px solid rgba(var(--primary-color-rgb) / 0.3);
219 - border-bottom: 1px solid rgba(var(--primary-color-rgb) / 0.3);
220 - background-color: rgba(var(--primary-color-rgb) / 0.05);
221 - }
222 - }
238 + border-radius: var(--border-radius);
239 + overflow: hidden;
240 +
241 + tr:hover {
242 + td {
243 + background-color: rgba(var(--primary-color-rgb) / 0.05);
244 + }
245 + }
246 +
247 + .highlight {
248 + td {
249 + border-top: 1px solid rgba(var(--primary-color-rgb) / 0.3);
250 + border-bottom: 1px solid rgba(var(--primary-color-rgb) / 0.3);
251 + background-color: rgba(var(--primary-color-rgb) / 0.05);
252 + }
253 + }
254 }
255 </style>
frontend/src/types/tags.d.ts new
+40
@@ -0,0 +1,40 @@
1 +export interface AlertTag {
2 + id: number
3 + tag: string
4 +}
5 +
6 +export interface TagAccessSettings {
7 + enabled: boolean
8 + untagged_alert_behavior: "visible_to_all" | "admin_only" | "default_tag"
9 + default_tag_id?: number | null
10 +}
11 +
12 +export interface TagAccessSettingsItem {
13 + enabled: boolean
14 + untagged_alert_behavior: "visible_to_all" | "admin_only" | "default_tag"
15 + default_tag_id: number | null
16 + default_tag_name: string | null
17 +}
18 +
19 +// Response from GET /settings - settings nested under 'settings' key
20 +export interface TagAccessSettingsResponse {
21 + settings: TagAccessSettingsItem
22 + success: boolean
23 + message: string
24 +}
25 +
26 +// Response from GET /user/{user_id}
27 +export interface UserTagsResponse {
28 + user_id: number
29 + username: string
30 + accessible_tags: AlertTag[]
31 + success: boolean
32 + message: string
33 +}
34 +
35 +// Response from GET /tags
36 +export interface AvailableTagsResponse {
37 + tags: AlertTag[]
38 + success: boolean
39 + message: string
40 +}
frontend/src/types/user.d.ts
+1
@@ -4,4 +4,5 @@ export interface User {
4 email: string
5 role_id?: number
6 role_name?: string
7 + assigned_tags?: number[]
8 }