From 6faa77d5c7c784a7c0bafca73382d7c44ed8a9fd Mon Sep 17 00:00:00 2001 From: Edwin Jose Date: Fri, 26 Dec 2025 17:07:20 -0500 Subject: [PATCH] add user groups --- .../api/mutations/useCreateGroupMutation.ts | 61 +++++ .../api/mutations/useDeleteGroupMutation.ts | 52 +++++ frontend/app/api/queries/useGetGroupsQuery.ts | 32 +++ frontend/app/settings/page.tsx | 61 +++-- frontend/components/ManageGroupsModal.tsx | 175 ++++++++++++++ src/api/groups.py | 114 +++++++++ src/config/settings.py | 17 ++ src/main.py | 39 ++++ src/services/group_service.py | 219 ++++++++++++++++++ 9 files changed, 750 insertions(+), 20 deletions(-) create mode 100644 frontend/app/api/mutations/useCreateGroupMutation.ts create mode 100644 frontend/app/api/mutations/useDeleteGroupMutation.ts create mode 100644 frontend/app/api/queries/useGetGroupsQuery.ts create mode 100644 frontend/components/ManageGroupsModal.tsx create mode 100644 src/api/groups.py create mode 100644 src/services/group_service.py diff --git a/frontend/app/api/mutations/useCreateGroupMutation.ts b/frontend/app/api/mutations/useCreateGroupMutation.ts new file mode 100644 index 00000000..0ccc71db --- /dev/null +++ b/frontend/app/api/mutations/useCreateGroupMutation.ts @@ -0,0 +1,61 @@ +import { + type UseMutationOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; + +export interface CreateGroupRequest { + name: string; + description?: string; +} + +export interface CreateGroupResponse { + success: boolean; + group_id: string; + name: string; + description: string; + created_at: string; + error?: string; +} + +export const useCreateGroupMutation = ( + options?: Omit< + UseMutationOptions, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + async function createGroup( + variables: CreateGroupRequest, + ): Promise { + const response = await fetch("/api/groups", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(variables), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to create group"); + } + + return data; + } + + return useMutation({ + mutationFn: createGroup, + onSuccess: (...args) => { + queryClient.invalidateQueries({ + queryKey: ["groups"], + }); + options?.onSuccess?.(...args); + }, + onError: options?.onError, + onSettled: options?.onSettled, + }); +}; + diff --git a/frontend/app/api/mutations/useDeleteGroupMutation.ts b/frontend/app/api/mutations/useDeleteGroupMutation.ts new file mode 100644 index 00000000..8b4676fe --- /dev/null +++ b/frontend/app/api/mutations/useDeleteGroupMutation.ts @@ -0,0 +1,52 @@ +import { + type UseMutationOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; + +export interface DeleteGroupRequest { + group_id: string; +} + +export interface DeleteGroupResponse { + success: boolean; + error?: string; +} + +export const useDeleteGroupMutation = ( + options?: Omit< + UseMutationOptions, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + + async function deleteGroup( + variables: DeleteGroupRequest, + ): Promise { + const response = await fetch(`/api/groups/${variables.group_id}`, { + method: "DELETE", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to delete group"); + } + + return data; + } + + return useMutation({ + mutationFn: deleteGroup, + onSuccess: (...args) => { + queryClient.invalidateQueries({ + queryKey: ["groups"], + }); + options?.onSuccess?.(...args); + }, + onError: options?.onError, + onSettled: options?.onSettled, + }); +}; + diff --git a/frontend/app/api/queries/useGetGroupsQuery.ts b/frontend/app/api/queries/useGetGroupsQuery.ts new file mode 100644 index 00000000..76abb7b7 --- /dev/null +++ b/frontend/app/api/queries/useGetGroupsQuery.ts @@ -0,0 +1,32 @@ +import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; + +export interface Group { + group_id: string; + name: string; + description: string; + created_at: string; +} + +export interface GetGroupsResponse { + success: boolean; + groups: Group[]; +} + +export const useGetGroupsQuery = ( + options?: Omit, "queryKey" | "queryFn">, +) => { + async function getGroups(): Promise { + const response = await fetch("/api/groups"); + if (response.ok) { + return await response.json(); + } + throw new Error("Failed to fetch groups"); + } + + return useQuery({ + queryKey: ["groups"], + queryFn: getGroups, + ...options, + }); +}; + diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index fc8b0322..2f0fbf06 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -12,8 +12,11 @@ import { useGetOpenAIModelsQuery, } from "@/app/api/queries/useGetModelsQuery"; import { useGetApiKeysQuery } from "@/app/api/queries/useGetApiKeysQuery"; +import { useGetGroupsQuery } from "@/app/api/queries/useGetGroupsQuery"; import { useCreateApiKeyMutation } from "@/app/api/mutations/useCreateApiKeyMutation"; import { useRevokeApiKeyMutation } from "@/app/api/mutations/useRevokeApiKeyMutation"; +import { ManageGroupsModal } from "@/components/ManageGroupsModal"; +import { MultiSelect } from "@/components/ui/multi-select"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { ConfirmationDialog } from "@/components/confirmation-dialog"; import { @@ -136,9 +139,10 @@ function KnowledgeSourcesPage() { // API Keys state const [createKeyDialogOpen, setCreateKeyDialogOpen] = useState(false); const [newKeyName, setNewKeyName] = useState(""); - const [newKeyGroups, setNewKeyGroups] = useState(""); + const [newKeyGroups, setNewKeyGroups] = useState([]); const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); const [showKeyDialogOpen, setShowKeyDialogOpen] = useState(false); + const [manageGroupsOpen, setManageGroupsOpen] = useState(false); // Fetch settings using React Query const { data: settings = {} } = useGetSettingsQuery({ @@ -150,6 +154,11 @@ function KnowledgeSourcesPage() { enabled: isAuthenticated || isNoAuthMode, }); + // Fetch user groups + const { data: groupsData } = useGetGroupsQuery({ + enabled: isAuthenticated || isNoAuthMode, + }); + // API key mutations const createApiKeyMutation = useCreateApiKeyMutation({ onSuccess: (data) => { @@ -157,7 +166,7 @@ function KnowledgeSourcesPage() { setCreateKeyDialogOpen(false); setShowKeyDialogOpen(true); setNewKeyName(""); - setNewKeyGroups(""); + setNewKeyGroups([]); toast.success("API key created"); }, onError: (error) => { @@ -440,15 +449,10 @@ function KnowledgeSourcesPage() { toast.error("Please enter a name for the API key"); return; } - // Parse groups from comma-separated string - const groups = newKeyGroups - .split(",") - .map((g) => g.trim()) - .filter((g) => g.length > 0); - + // Use selected groups directly (already an array) createApiKeyMutation.mutate({ name: newKeyName.trim(), - groups: groups.length > 0 ? groups : undefined, + groups: newKeyGroups.length > 0 ? newKeyGroups : undefined, }); }; @@ -1563,23 +1567,34 @@ function KnowledgeSourcesPage() { }} /> +
- ({ + value: g.name, + label: g.name, + }))} value={newKeyGroups} - onChange={(e) => setNewKeyGroups(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCreateApiKey(); - } - }} + onValueChange={setNewKeyGroups} + placeholder="Select groups..." + showAllOption={false} + searchPlaceholder="Search groups..." /> + +
+ + setNewGroupDescription(e.target.value)} + className="text-sm" + /> + + + {/* Existing groups list */} +
+ + {groupsLoading ? ( +
+ +
+ ) : groups.length > 0 ? ( +
+ {groups.map((group) => ( +
+
+

+ {group.name} +

+ {group.description && ( +

+ {group.description} +

+ )} +
+ +
+ ))} +
+ ) : ( +
+ +

+ No groups yet. Create one above. +

+
+ )} +
+ + + + ); +} + diff --git a/src/api/groups.py b/src/api/groups.py new file mode 100644 index 00000000..7f4521ce --- /dev/null +++ b/src/api/groups.py @@ -0,0 +1,114 @@ +""" +User Groups management endpoints. + +These endpoints allow managing user groups for RBAC. +""" +from starlette.requests import Request +from starlette.responses import JSONResponse +from utils.logging_config import get_logger + +logger = get_logger(__name__) + + +async def list_groups_endpoint(request: Request, group_service): + """ + List all user groups. + + GET /groups + + Response: + { + "success": true, + "groups": [ + { + "group_id": "...", + "name": "finance", + "description": "Finance team", + "created_at": "2024-01-01T00:00:00" + } + ] + } + """ + result = await group_service.list_groups() + return JSONResponse(result) + + +async def create_group_endpoint(request: Request, group_service): + """ + Create a new user group. + + POST /groups + Body: {"name": "finance", "description": "Finance team"} + + Response: + { + "success": true, + "group_id": "...", + "name": "finance", + "description": "Finance team", + "created_at": "2024-01-01T00:00:00" + } + """ + try: + data = await request.json() + name = data.get("name", "").strip() + description = data.get("description", "").strip() + + if not name: + return JSONResponse( + {"success": False, "error": "Name is required"}, + status_code=400, + ) + + if len(name) > 100: + return JSONResponse( + {"success": False, "error": "Name must be 100 characters or less"}, + status_code=400, + ) + + result = await group_service.create_group( + name=name, + description=description, + ) + + if result.get("success"): + return JSONResponse(result) + elif "already exists" in result.get("error", ""): + return JSONResponse(result, status_code=409) + else: + return JSONResponse(result, status_code=500) + + except Exception as e: + logger.error(f"Failed to create group: {e}") + return JSONResponse( + {"success": False, "error": str(e)}, + status_code=500, + ) + + +async def delete_group_endpoint(request: Request, group_service): + """ + Delete a user group. + + DELETE /groups/{group_id} + + Response: + {"success": true} + """ + group_id = request.path_params.get("group_id") + + if not group_id: + return JSONResponse( + {"success": False, "error": "Group ID is required"}, + status_code=400, + ) + + result = await group_service.delete_group(group_id=group_id) + + if result.get("success"): + return JSONResponse(result) + elif result.get("error") == "Group not found": + return JSONResponse(result, status_code=404) + else: + return JSONResponse(result, status_code=500) + diff --git a/src/config/settings.py b/src/config/settings.py index 78cf03a6..e51cf6cf 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -165,6 +165,23 @@ API_KEYS_INDEX_BODY = { }, } +# User Groups index for RBAC management +GROUPS_INDEX_NAME = "openrag_groups" +GROUPS_INDEX_BODY = { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + }, + "mappings": { + "properties": { + "group_id": {"type": "keyword"}, + "name": {"type": "keyword"}, + "description": {"type": "text"}, + "created_at": {"type": "date"}, + } + }, +} + # Convenience base URL for Langflow REST API LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1" diff --git a/src/main.py b/src/main.py index 7830dce8..01928c3c 100644 --- a/src/main.py +++ b/src/main.py @@ -58,6 +58,10 @@ from auth_middleware import optional_auth, require_auth from api_key_middleware import require_api_key from services.api_key_service import APIKeyService from api import keys as api_keys + +# User Groups management +from services.group_service import GroupService +from api import groups as api_groups from api.v1 import chat as v1_chat, search as v1_search, documents as v1_documents, settings as v1_settings, knowledge_filters as v1_knowledge_filters # Configuration and setup @@ -665,6 +669,9 @@ async def initialize_services(): # API Key service for public API authentication api_key_service = APIKeyService(session_manager) + # Group service for RBAC management + group_service = GroupService(session_manager) + return { "document_service": document_service, "search_service": search_service, @@ -679,6 +686,7 @@ async def initialize_services(): "monitor_service": monitor_service, "session_manager": session_manager, "api_key_service": api_key_service, + "group_service": group_service, } @@ -1310,6 +1318,37 @@ async def create_app(): ), methods=["DELETE"], ), + # ===== User Groups Management Endpoints (JWT auth for UI) ===== + Route( + "/groups", + require_auth(services["session_manager"])( + partial( + api_groups.list_groups_endpoint, + group_service=services["group_service"], + ) + ), + methods=["GET"], + ), + Route( + "/groups", + require_auth(services["session_manager"])( + partial( + api_groups.create_group_endpoint, + group_service=services["group_service"], + ) + ), + methods=["POST"], + ), + Route( + "/groups/{group_id}", + require_auth(services["session_manager"])( + partial( + api_groups.delete_group_endpoint, + group_service=services["group_service"], + ) + ), + methods=["DELETE"], + ), # ===== Public API v1 Endpoints (API Key auth) ===== # Chat endpoints Route( diff --git a/src/services/group_service.py b/src/services/group_service.py new file mode 100644 index 00000000..a446367b --- /dev/null +++ b/src/services/group_service.py @@ -0,0 +1,219 @@ +""" +Group Service for managing user groups for RBAC. +""" +import secrets +from datetime import datetime +from typing import Any, Dict, List, Optional + +from config.settings import GROUPS_INDEX_NAME +from utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class GroupService: + """Service for managing user groups for RBAC.""" + + def __init__(self, session_manager=None): + self.session_manager = session_manager + + async def _ensure_index_exists(self, opensearch_client) -> None: + """Ensure the groups index exists.""" + from config.settings import GROUPS_INDEX_BODY + + try: + exists = await opensearch_client.indices.exists(index=GROUPS_INDEX_NAME) + if not exists: + await opensearch_client.indices.create( + index=GROUPS_INDEX_NAME, + body=GROUPS_INDEX_BODY, + ) + logger.info(f"Created groups index: {GROUPS_INDEX_NAME}") + except Exception as e: + # Index might already exist from concurrent creation + if "resource_already_exists_exception" not in str(e): + logger.error(f"Failed to create groups index: {e}") + raise + + async def create_group( + self, + name: str, + description: str = "", + ) -> Dict[str, Any]: + """ + Create a new user group. + + Args: + name: The group name (must be unique) + description: Optional description of the group + + Returns: + Dict with success status and group info + """ + try: + # Get OpenSearch client + from config.settings import clients + + opensearch_client = clients.opensearch + + # Ensure index exists + await self._ensure_index_exists(opensearch_client) + + # Check if group with this name already exists + search_body = { + "query": {"term": {"name": name}}, + "size": 1, + } + + result = await opensearch_client.search( + index=GROUPS_INDEX_NAME, + body=search_body, + ) + + if result.get("hits", {}).get("hits", []): + return {"success": False, "error": f"Group '{name}' already exists"} + + # Create a unique group_id + group_id = secrets.token_urlsafe(16) + now = datetime.utcnow().isoformat() + + # Create the document to store + group_doc = { + "group_id": group_id, + "name": name, + "description": description, + "created_at": now, + } + + # Index the group document + result = await opensearch_client.index( + index=GROUPS_INDEX_NAME, + id=group_id, + body=group_doc, + refresh="wait_for", + ) + + if result.get("result") in ("created", "updated"): + logger.info(f"Created group: {name} (id: {group_id})") + return { + "success": True, + "group_id": group_id, + "name": name, + "description": description, + "created_at": now, + } + else: + return {"success": False, "error": "Failed to create group"} + + except Exception as e: + logger.error(f"Failed to create group: {e}") + return {"success": False, "error": str(e)} + + async def list_groups(self) -> Dict[str, Any]: + """ + List all user groups. + + Returns: + Dict with list of groups + """ + try: + # Get OpenSearch client + from config.settings import clients + + opensearch_client = clients.opensearch + + # Ensure index exists + await self._ensure_index_exists(opensearch_client) + + # Search for all groups + search_body = { + "query": {"match_all": {}}, + "sort": [{"name": {"order": "asc"}}], + "_source": ["group_id", "name", "description", "created_at"], + "size": 1000, + } + + result = await opensearch_client.search( + index=GROUPS_INDEX_NAME, + body=search_body, + ) + + groups = [] + for hit in result.get("hits", {}).get("hits", []): + groups.append(hit["_source"]) + + return {"success": True, "groups": groups} + + except Exception as e: + logger.error(f"Failed to list groups: {e}") + return {"success": False, "error": str(e), "groups": []} + + async def get_group(self, group_id: str) -> Optional[Dict[str, Any]]: + """ + Get a group by ID. + + Args: + group_id: The group ID + + Returns: + Group info if found, None otherwise + """ + try: + # Get OpenSearch client + from config.settings import clients + + opensearch_client = clients.opensearch + + doc = await opensearch_client.get( + index=GROUPS_INDEX_NAME, + id=group_id, + ) + + return doc["_source"] + + except Exception: + return None + + async def delete_group(self, group_id: str) -> Dict[str, Any]: + """ + Delete a user group. + + Args: + group_id: The group ID to delete + + Returns: + Dict with success status + """ + try: + # Get OpenSearch client + from config.settings import clients + + opensearch_client = clients.opensearch + + # Verify the group exists + try: + doc = await opensearch_client.get( + index=GROUPS_INDEX_NAME, + id=group_id, + ) + group_name = doc["_source"].get("name", "unknown") + except Exception: + return {"success": False, "error": "Group not found"} + + # Delete the group + result = await opensearch_client.delete( + index=GROUPS_INDEX_NAME, + id=group_id, + refresh="wait_for", + ) + + if result.get("result") == "deleted": + logger.info(f"Deleted group: {group_name} (id: {group_id})") + return {"success": True} + else: + return {"success": False, "error": "Failed to delete group"} + + except Exception as e: + logger.error(f"Failed to delete group: {e}") + return {"success": False, "error": str(e)} +