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,
|
useGetOpenAIModelsQuery,
|
||||||
} from "@/app/api/queries/useGetModelsQuery";
|
} from "@/app/api/queries/useGetModelsQuery";
|
||||||
import { useGetApiKeysQuery } from "@/app/api/queries/useGetApiKeysQuery";
|
import { useGetApiKeysQuery } from "@/app/api/queries/useGetApiKeysQuery";
|
||||||
|
import { useGetGroupsQuery } from "@/app/api/queries/useGetGroupsQuery";
|
||||||
import { useCreateApiKeyMutation } from "@/app/api/mutations/useCreateApiKeyMutation";
|
import { useCreateApiKeyMutation } from "@/app/api/mutations/useCreateApiKeyMutation";
|
||||||
import { useRevokeApiKeyMutation } from "@/app/api/mutations/useRevokeApiKeyMutation";
|
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 { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||||
import { ConfirmationDialog } from "@/components/confirmation-dialog";
|
import { ConfirmationDialog } from "@/components/confirmation-dialog";
|
||||||
import {
|
import {
|
||||||
|
|
@ -136,9 +139,10 @@ function KnowledgeSourcesPage() {
|
||||||
// API Keys state
|
// API Keys state
|
||||||
const [createKeyDialogOpen, setCreateKeyDialogOpen] = useState(false);
|
const [createKeyDialogOpen, setCreateKeyDialogOpen] = useState(false);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [newKeyGroups, setNewKeyGroups] = useState("");
|
const [newKeyGroups, setNewKeyGroups] = useState<string[]>([]);
|
||||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||||
const [showKeyDialogOpen, setShowKeyDialogOpen] = useState(false);
|
const [showKeyDialogOpen, setShowKeyDialogOpen] = useState(false);
|
||||||
|
const [manageGroupsOpen, setManageGroupsOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch settings using React Query
|
// Fetch settings using React Query
|
||||||
const { data: settings = {} } = useGetSettingsQuery({
|
const { data: settings = {} } = useGetSettingsQuery({
|
||||||
|
|
@ -150,6 +154,11 @@ function KnowledgeSourcesPage() {
|
||||||
enabled: isAuthenticated || isNoAuthMode,
|
enabled: isAuthenticated || isNoAuthMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch user groups
|
||||||
|
const { data: groupsData } = useGetGroupsQuery({
|
||||||
|
enabled: isAuthenticated || isNoAuthMode,
|
||||||
|
});
|
||||||
|
|
||||||
// API key mutations
|
// API key mutations
|
||||||
const createApiKeyMutation = useCreateApiKeyMutation({
|
const createApiKeyMutation = useCreateApiKeyMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
|
@ -157,7 +166,7 @@ function KnowledgeSourcesPage() {
|
||||||
setCreateKeyDialogOpen(false);
|
setCreateKeyDialogOpen(false);
|
||||||
setShowKeyDialogOpen(true);
|
setShowKeyDialogOpen(true);
|
||||||
setNewKeyName("");
|
setNewKeyName("");
|
||||||
setNewKeyGroups("");
|
setNewKeyGroups([]);
|
||||||
toast.success("API key created");
|
toast.success("API key created");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -440,15 +449,10 @@ function KnowledgeSourcesPage() {
|
||||||
toast.error("Please enter a name for the API key");
|
toast.error("Please enter a name for the API key");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Parse groups from comma-separated string
|
// Use selected groups directly (already an array)
|
||||||
const groups = newKeyGroups
|
|
||||||
.split(",")
|
|
||||||
.map((g) => g.trim())
|
|
||||||
.filter((g) => g.length > 0);
|
|
||||||
|
|
||||||
createApiKeyMutation.mutate({
|
createApiKeyMutation.mutate({
|
||||||
name: newKeyName.trim(),
|
name: newKeyName.trim(),
|
||||||
groups: groups.length > 0 ? groups : undefined,
|
groups: newKeyGroups.length > 0 ? newKeyGroups : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1563,23 +1567,34 @@ function KnowledgeSourcesPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
|
<div className="space-y-2">
|
||||||
<LabelWrapper
|
<LabelWrapper
|
||||||
label="Groups (optional)"
|
label="Groups (optional)"
|
||||||
id="api-key-groups"
|
id="api-key-groups"
|
||||||
helperText="Comma-separated list of groups this key can access"
|
helperText="Restrict this key to specific groups"
|
||||||
>
|
>
|
||||||
<Input
|
<MultiSelect
|
||||||
id="api-key-groups"
|
options={(groupsData?.groups || []).map((g) => ({
|
||||||
placeholder="e.g., finance, hr, engineering"
|
value: g.name,
|
||||||
|
label: g.name,
|
||||||
|
}))}
|
||||||
value={newKeyGroups}
|
value={newKeyGroups}
|
||||||
onChange={(e) => setNewKeyGroups(e.target.value)}
|
onValueChange={setNewKeyGroups}
|
||||||
onKeyDown={(e) => {
|
placeholder="Select groups..."
|
||||||
if (e.key === "Enter") {
|
showAllOption={false}
|
||||||
handleCreateApiKey();
|
searchPlaceholder="Search groups..."
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</LabelWrapper>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1587,7 +1602,7 @@ function KnowledgeSourcesPage() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateKeyDialogOpen(false);
|
setCreateKeyDialogOpen(false);
|
||||||
setNewKeyName("");
|
setNewKeyName("");
|
||||||
setNewKeyGroups("");
|
setNewKeyGroups([]);
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|
@ -1644,6 +1659,12 @@ function KnowledgeSourcesPage() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Manage Groups Modal */}
|
||||||
|
<ManageGroupsModal
|
||||||
|
open={manageGroupsOpen}
|
||||||
|
onOpenChange={setManageGroupsOpen}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
# Convenience base URL for Langflow REST API
|
||||||
LANGFLOW_BASE_URL = f"{LANGFLOW_URL}/api/v1"
|
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 api_key_middleware import require_api_key
|
||||||
from services.api_key_service import APIKeyService
|
from services.api_key_service import APIKeyService
|
||||||
from api import keys as api_keys
|
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
|
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
|
# Configuration and setup
|
||||||
|
|
@ -665,6 +669,9 @@ async def initialize_services():
|
||||||
# API Key service for public API authentication
|
# API Key service for public API authentication
|
||||||
api_key_service = APIKeyService(session_manager)
|
api_key_service = APIKeyService(session_manager)
|
||||||
|
|
||||||
|
# Group service for RBAC management
|
||||||
|
group_service = GroupService(session_manager)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"document_service": document_service,
|
"document_service": document_service,
|
||||||
"search_service": search_service,
|
"search_service": search_service,
|
||||||
|
|
@ -679,6 +686,7 @@ async def initialize_services():
|
||||||
"monitor_service": monitor_service,
|
"monitor_service": monitor_service,
|
||||||
"session_manager": session_manager,
|
"session_manager": session_manager,
|
||||||
"api_key_service": api_key_service,
|
"api_key_service": api_key_service,
|
||||||
|
"group_service": group_service,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1310,6 +1318,37 @@ async def create_app():
|
||||||
),
|
),
|
||||||
methods=["DELETE"],
|
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) =====
|
# ===== Public API v1 Endpoints (API Key auth) =====
|
||||||
# Chat endpoints
|
# Chat endpoints
|
||||||
Route(
|
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