Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3228fd6
DATAGO-119348 project sharing poc code
rudraneel-chakraborty Jan 2, 2026
333ad03
Merge branch 'main' into DATAGO-119348-poc
rudraneel-chakraborty Jan 11, 2026
e60f65a
Merge remote-tracking branch 'origin/main' into DATAGO-119348-poc
JKaram Jan 12, 2026
f77e9ad
DATAGO-119348 project sharing poc code
rudraneel-chakraborty Jan 12, 2026
8bcb469
DATAGO-119348 updated framework
rudraneel-chakraborty Jan 14, 2026
163b1c6
DATAGO-119348 poc code
rudraneel-chakraborty Jan 15, 2026
e8955e3
DATAGO-119348 poc code
rudraneel-chakraborty Jan 20, 2026
3032e76
DATAGO-119348 poc code
rudraneel-chakraborty Jan 20, 2026
0c6be3a
DATAGO-119348 poc code
rudraneel-chakraborty Jan 22, 2026
276f49d
fix: DefaultResourceSharingService.can_access_resource returns False
JKaram Jan 26, 2026
651d519
feat: Implement is_resource_sharing_available property in ResourceSha…
JKaram Jan 27, 2026
3ccd069
feat: Add get_owned_or_shared method to ProjectRepository and update …
JKaram Jan 27, 2026
d4bf607
refactor: Simplify resource sharing service and project repository me…
JKaram Jan 28, 2026
ad73c7b
refactor: Remove outdated comments from unit tests for DefaultResourc…
JKaram Jan 28, 2026
600fcea
Merge branch 'main' into JKaram/DATAGO-119348/project-sharing
JKaram Jan 28, 2026
355bfa5
Merge branch 'main' into JKaram/DATAGO-119348/project-sharing
JKaram Jan 30, 2026
ff87a36
Merge branch 'main' into JKaram/DATAGO-119348/project-sharing
JKaram Feb 2, 2026
bacdb72
feat(middleware): Add post-migration hook registration and execution …
JKaram Feb 2, 2026
0a7f496
refactor(ProjectService): Simplify access control methods for project…
JKaram Feb 2, 2026
6dcd0a4
feat(repository): Add method to retrieve all projects owned by a spec…
JKaram Feb 2, 2026
b00f81b
refactor(ResourceSharingService): Replace SharingRole with string acc…
JKaram Feb 2, 2026
f64436f
feat(repository): Add method to retrieve accessible projects for a user
JKaram Feb 2, 2026
c049351
refactor(tests): Remove sharing-related tests for community edition l…
JKaram Feb 2, 2026
9eec6df
feat(session): Implement shared user session deletion on project dele…
JKaram Feb 2, 2026
10d648f
Merge branch 'main' into JKaram/DATAGO-115145/add_session_deletion
JKaram Feb 3, 2026
7b2f9c8
feat(DATAGO-115145): Implement deletion of resource shares for soft d…
JKaram Feb 4, 2026
f7b989c
refactor: Remove unused session management methods and related tests
JKaram Feb 4, 2026
9c93cd3
DATAGO-115145 fix structure
rudraneel-chakraborty Feb 5, 2026
322a085
DATAGO-115145 fix structure
rudraneel-chakraborty Feb 5, 2026
773f7cd
DATAGO-115145 fix structure
rudraneel-chakraborty Feb 5, 2026
82596da
Merge branch 'main' into JKaram/DATAGO-115145/add_session_deletion
JKaram Feb 5, 2026
966ef51
DATAGO-115145 bug fix chat session
rudraneel-chakraborty Feb 5, 2026
143b33e
DATAGO-115145 bug fix chat session
rudraneel-chakraborty Feb 5, 2026
c3fc77e
DATAGO-115145 bug fix chat session
rudraneel-chakraborty Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,23 @@ def delete_resource_shares(
resource_type: ResourceType
) -> bool:
"""Community has no shares to delete - return True (no-op success)."""
return True
return True

def unshare_users_from_resource(
self,
session,
resource_id: str,
resource_type: ResourceType,
user_emails: List[str]
) -> bool:
"""Community has no shares to unshare - return True (no-op success)."""
return True

def get_shared_users(
self,
session,
resource_id: str,
resource_type: ResourceType
) -> List[str]:
"""Community has no sharing - return empty list."""
return []
35 changes: 35 additions & 0 deletions src/solace_agent_mesh/gateway/http_sse/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@

log = logging.getLogger(__name__)

SESSION_NOT_FOUND_MSG = "Session not found."


# Background Task Status Models and Endpoints
class TaskStatusResponse(BaseModel):
Expand Down Expand Up @@ -385,6 +387,36 @@ async def _submit_task(
finally:
db.close()

# Security: Validate user still has project access
if project_id and project_service:
if SessionLocal is not None:
db = SessionLocal()
try:
project = project_service.get_project(db, project_id, user_id)
if not project:
log.warning(
"%sUser %s denied - project %s not found or access denied",
log_prefix,
user_id,
project_id
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=SESSION_NOT_FOUND_MSG
)
except HTTPException:
raise
except Exception as e:
log.error(
"%sFailed to validate project access: %s", log_prefix, e
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=SESSION_NOT_FOUND_MSG
)
finally:
db.close()

if frontend_session_id:
session_id = frontend_session_id
log.info(
Expand Down Expand Up @@ -555,6 +587,9 @@ async def _submit_task(
result=task_object, request_id=payload.id
)

except HTTPException:
# Re-raise HTTPExceptions (including our security check) without wrapping
raise
except PermissionError as pe:
log.warning("%sPermission denied: %s", log_prefix, str(pe))
raise HTTPException(
Expand Down
16 changes: 13 additions & 3 deletions src/solace_agent_mesh/gateway/http_sse/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,11 +693,21 @@ def soft_delete_project(self, db, project_id: str, user_id: str) -> bool:
if not soft_deleted:
return False

# Cascade to sessions
from ..repository.session_repository import SessionRepository
session_repo = SessionRepository()
deleted_count = session_repo.soft_delete_by_project(db, project_id, user_id)
self.logger.info(f"Successfully soft deleted project {project_id} and {deleted_count} associated sessions")

owner_deleted_count = session_repo.soft_delete_by_project(db, project_id, user_id)

self._resource_sharing_service.delete_resource_shares(
session=db,
resource_id=project_id,
resource_type=ResourceType.PROJECT
)

self.logger.info(
f"Successfully soft deleted project {project_id} and {owner_deleted_count} owner sessions "
f"(shared users handled by sharing service)"
)

return True

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,12 +313,9 @@ async def move_session_to_project(

# Validate project exists and user has access if project_id is provided
if new_project_id:
from ..repository.models import ProjectModel
project = db.query(ProjectModel).filter(
ProjectModel.id == new_project_id,
ProjectModel.user_id == user_id,
ProjectModel.deleted_at.is_(None)
).first()
from .project_service import ProjectService
project_service = ProjectService(component=self.component)
project = project_service.get_project(db, new_project_id, user_id)

if not project:
raise ValueError(f"Project {new_project_id} not found or access denied")
Expand Down
46 changes: 46 additions & 0 deletions src/solace_agent_mesh/services/resource_sharing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,53 @@ def delete_resource_shares(
"""
Delete all sharing records for a resource (e.g., when resource is deleted).

IMPORTANT: Implementations MUST also cleanup any dependent data
(e.g., sessions created by shared users) to maintain data consistency.

Returns:
True if successful, False otherwise.
"""
pass

@abstractmethod
def unshare_users_from_resource(
self,
session,
resource_id: str,
resource_type: ResourceType,
user_emails: List[str]
) -> bool:
"""
Remove specific users' access to a resource.

IMPORTANT: Implementations MUST cleanup dependent data
(e.g., sessions) when removing access.

Args:
session: Database session
resource_id: The resource ID
resource_type: Type of resource (e.g., PROJECT)
user_emails: List of user emails to unshare

Returns:
True if successful, False otherwise.
"""
pass

@abstractmethod
def get_shared_users(
self,
session,
resource_id: str,
resource_type: ResourceType
) -> List[str]:
"""
Get list of user emails with shared access to a resource.

This is used when deleting resources to ensure sessions created by
shared users are also cleaned up.

Returns:
List of user emails. Empty list for community, actual emails for enterprise.
"""
pass
152 changes: 152 additions & 0 deletions tests/integration/apis/persistence/test_tasks_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,3 +780,155 @@ def test_task_and_session_integration(api_client: TestClient):
assert user_message["message"] == "Integration test message"

print(f"✓ Task-session integration verified for session {session_id}")


def test_send_message_to_nonexistent_project_returns_404(api_client: TestClient):
"""Test that sending a message with non-existent project_id returns 404"""
import uuid

nonexistent_project_id = str(uuid.uuid4())

task_payload = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"kind": "message",
"parts": [{"kind": "text", "text": "Test message"}],
"metadata": {
"agent_name": "TestAgent",
"project_id": nonexistent_project_id,
},
}
},
}

response = api_client.post("/api/v1/message:stream", json=task_payload)

# Should return 404 when project doesn't exist
assert response.status_code == 404
response_data = response.json()
# HTTPException returns 'detail' field directly, or wrapped in 'message' by error handler
error_message = response_data.get("detail") or response_data.get("message", "")
assert "Session not found" in error_message

print("Non-existent project returns 404 with standard message")


def test_send_message_to_valid_project(api_client: TestClient, gateway_adapter):
"""Test that sending a message to a valid project succeeds"""
import uuid

# First create a project using gateway adapter
project_id = str(uuid.uuid4())
gateway_adapter.seed_project(
project_id=project_id,
name="Test Project for Message",
user_id="sam_dev_user",
description="Project for testing message submission",
)

# Now send a message with that project_id
task_payload = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"kind": "message",
"parts": [{"kind": "text", "text": "Message to valid project"}],
"metadata": {
"agent_name": "TestAgent",
"project_id": project_id,
},
}
},
}

response = api_client.post("/api/v1/message:stream", json=task_payload)

# Should succeed
assert response.status_code == 200
response_data = response.json()
assert "result" in response_data
assert "id" in response_data["result"]

print(f"Message to valid project {project_id} succeeded")


def test_send_message_without_project_id_succeeds(api_client: TestClient):
"""Test that messages without project_id work normally (non-project sessions)"""
import uuid

task_payload = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"kind": "message",
"parts": [{"kind": "text", "text": "Non-project message"}],
"metadata": {
"agent_name": "TestAgent",
# No project_id - regular session
},
}
},
}

response = api_client.post("/api/v1/message:stream", json=task_payload)

# Should succeed without project access check
assert response.status_code == 200
response_data = response.json()
assert "result" in response_data

print("Message without project_id succeeded (regular session)")


def test_send_message_project_access_check_handles_exceptions(api_client: TestClient):
"""Test that project access validation handles database errors gracefully"""
import uuid
from unittest.mock import patch

# Create a valid project
project_id = str(uuid.uuid4())

task_payload = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/stream",
"params": {
"message": {
"role": "user",
"messageId": str(uuid.uuid4()),
"kind": "message",
"parts": [{"kind": "text", "text": "Test message"}],
"metadata": {
"agent_name": "TestAgent",
"project_id": project_id,
},
}
},
}

# Mock project_service.get_project to raise an exception
with patch('solace_agent_mesh.gateway.http_sse.services.project_service.ProjectService.get_project') as mock_get:
mock_get.side_effect = Exception("Database error")

response = api_client.post("/api/v1/message:stream", json=task_payload)

# Should return 404 due to exception in access validation
assert response.status_code == 404
response_data = response.json()
error_message = response_data.get("detail") or response_data.get("message", "")
assert "Session not found" in error_message

print("Exception in project access check handled correctly")
49 changes: 49 additions & 0 deletions tests/unit/services/test_default_resource_sharing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,52 @@ def test_delete_resource_shares_returns_true(self):
)

assert result is True

def test_unshare_users_from_resource_returns_true(self):
"""Test that unshare_users_from_resource returns True indicating successful operation.

When users are unshared from a resource, this method is called to remove
their access. In community edition, there are no shares to remove, but
the operation succeeds (no-op). Returning True indicates the operation
completed successfully without errors.
"""
user_emails = ["[email protected]", "[email protected]"]

result = self.service.unshare_users_from_resource(
session=self.mock_session,
resource_id=self.resource_id,
resource_type=self.resource_type,
user_emails=user_emails,
)

assert result is True

def test_unshare_users_from_resource_handles_empty_list(self):
"""Test that unshare_users_from_resource handles empty email list correctly.

Edge case: when called with an empty list of user emails, the method
should still succeed (no-op on empty input).
"""
result = self.service.unshare_users_from_resource(
session=self.mock_session,
resource_id=self.resource_id,
resource_type=self.resource_type,
user_emails=[],
)

assert result is True

def test_get_shared_users_returns_empty_list(self):
"""Test that get_shared_users returns an empty list.

This method is used to retrieve users who have shared access to a resource.
In community edition, no users have shared access (only ownership exists),
so this method always returns an empty list.
"""
result = self.service.get_shared_users(
session=self.mock_session,
resource_id=self.resource_id,
resource_type=self.resource_type,
)

assert result == []
Loading
Loading