add user groups
This commit is contained in:
parent
ce51628db2
commit
6faa77d5c7
9 changed files with 750 additions and 20 deletions
61
frontend/app/api/mutations/useCreateGroupMutation.ts
Normal file
61
frontend/app/api/mutations/useCreateGroupMutation.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
||||
52
frontend/app/api/mutations/useDeleteGroupMutation.ts
Normal file
52
frontend/app/api/mutations/useDeleteGroupMutation.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
||||
32
frontend/app/api/queries/useGetGroupsQuery.ts
Normal file
32
frontend/app/api/queries/useGetGroupsQuery.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
175
frontend/components/ManageGroupsModal.tsx
Normal file
175
frontend/components/ManageGroupsModal.tsx
Normal 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
114
src/api/groups.py
Normal 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)
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
39
src/main.py
39
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(
|
||||
|
|
|
|||
219
src/services/group_service.py
Normal file
219
src/services/group_service.py
Normal 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)}
|
||||
|
||||
Loading…
Add table
Reference in a new issue