add user groups

This commit is contained in:
Edwin Jose 2025-12-26 17:07:20 -05:00
parent ce51628db2
commit 6faa77d5c7
9 changed files with 750 additions and 20 deletions

View file

@ -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<CreateGroupResponse, Error, CreateGroupRequest>,
"mutationFn"
>,
) => {
const queryClient = useQueryClient();
async function createGroup(
variables: CreateGroupRequest,
): Promise<CreateGroupResponse> {
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,
});
};

View file

@ -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<DeleteGroupResponse, Error, DeleteGroupRequest>,
"mutationFn"
>,
) => {
const queryClient = useQueryClient();
async function deleteGroup(
variables: DeleteGroupRequest,
): Promise<DeleteGroupResponse> {
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,
});
};

View file

@ -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<UseQueryOptions<GetGroupsResponse>, "queryKey" | "queryFn">,
) => {
async function getGroups(): Promise<GetGroupsResponse> {
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,
});
};

View file

@ -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<string[]>([]);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(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() {
}}
/>
</LabelWrapper>
<div className="space-y-2">
<LabelWrapper
label="Groups (optional)"
id="api-key-groups"
helperText="Comma-separated list of groups this key can access"
helperText="Restrict this key to specific groups"
>
<Input
id="api-key-groups"
placeholder="e.g., finance, hr, engineering"
<MultiSelect
options={(groupsData?.groups || []).map((g) => ({
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..."
/>
</LabelWrapper>
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => setManageGroupsOpen(true)}
>
<Plus className="h-3 w-3 mr-1" />
Manage Groups
</Button>
</div>
</div>
<DialogFooter>
<Button
@ -1587,7 +1602,7 @@ function KnowledgeSourcesPage() {
onClick={() => {
setCreateKeyDialogOpen(false);
setNewKeyName("");
setNewKeyGroups("");
setNewKeyGroups([]);
}}
size="sm"
>
@ -1644,6 +1659,12 @@ function KnowledgeSourcesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Groups Modal */}
<ManageGroupsModal
open={manageGroupsOpen}
onOpenChange={setManageGroupsOpen}
/>
</div>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useGetGroupsQuery } from "@/app/api/queries/useGetGroupsQuery";
import { useCreateGroupMutation } from "@/app/api/mutations/useCreateGroupMutation";
import { useDeleteGroupMutation } from "@/app/api/mutations/useDeleteGroupMutation";
import { Plus, Trash2, Loader2, Users } from "lucide-react";
import { toast } from "sonner";
interface ManageGroupsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ManageGroupsModal({
open,
onOpenChange,
}: ManageGroupsModalProps) {
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDescription, setNewGroupDescription] = useState("");
const { data: groupsData, isLoading: groupsLoading } = useGetGroupsQuery({
enabled: open,
});
const createGroupMutation = useCreateGroupMutation({
onSuccess: () => {
setNewGroupName("");
setNewGroupDescription("");
toast.success("Group created successfully");
},
onError: (error) => {
toast.error("Failed to create group", { description: error.message });
},
});
const deleteGroupMutation = useDeleteGroupMutation({
onSuccess: () => {
toast.success("Group deleted successfully");
},
onError: (error) => {
toast.error("Failed to delete group", { description: error.message });
},
});
const handleCreateGroup = () => {
if (!newGroupName.trim()) {
toast.error("Please enter a group name");
return;
}
createGroupMutation.mutate({
name: newGroupName.trim(),
description: newGroupDescription.trim(),
});
};
const handleDeleteGroup = (groupId: string, groupName: string) => {
if (confirm(`Are you sure you want to delete the group "${groupName}"?`)) {
deleteGroupMutation.mutate({ group_id: groupId });
}
};
const groups = groupsData?.groups || [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Manage User Groups</DialogTitle>
<DialogDescription>
Create and manage user groups for access control. Groups can be
assigned to API keys to restrict document access.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Add new group section */}
<div className="space-y-2">
<label className="text-sm font-medium">Create New Group</label>
<div className="flex gap-2">
<Input
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateGroup();
}
}}
className="flex-1"
/>
<Button
onClick={handleCreateGroup}
disabled={
createGroupMutation.isPending || !newGroupName.trim()
}
size="sm"
>
{createGroupMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
<Input
placeholder="Description (optional)"
value={newGroupDescription}
onChange={(e) => setNewGroupDescription(e.target.value)}
className="text-sm"
/>
</div>
{/* Existing groups list */}
<div className="space-y-2">
<label className="text-sm font-medium">Existing Groups</label>
{groupsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : groups.length > 0 ? (
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{groups.map((group) => (
<div
key={group.group_id}
className="flex items-center justify-between px-3 py-2 hover:bg-muted/50"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{group.name}
</p>
{group.description && (
<p className="text-xs text-muted-foreground truncate">
{group.description}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
handleDeleteGroup(group.group_id, group.name)
}
disabled={deleteGroupMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-6 border rounded-lg">
<Users className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">
No groups yet. Create one above.
</p>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

114
src/api/groups.py Normal file
View file

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

View file

@ -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"

View file

@ -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(

View file

@ -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)}