diff --git a/docs/llm-model-configs-api.md b/docs/llm-model-configs-api.md index 8fa1144f8..a19086251 100644 --- a/docs/llm-model-configs-api.md +++ b/docs/llm-model-configs-api.md @@ -17,10 +17,12 @@ This API provides LLM model configuration management for users and user groups w ``` User requests configs: - ├─ If user has own configs → return user's configs - └─ If user has NO configs → return inherited group configs + ├─ Always return user's own configs (if any) + └─ Always return inherited group configs (if any) ``` +**Note:** Users can see both their own configurations AND configurations inherited from their groups. The `source` field in the response indicates the origin of each configuration. + ### Configuration Priority ``` @@ -78,6 +80,7 @@ The `model_type` field accepts the following values: |--------|------|-------------|-----------| | GET | `/v3/access/users/{user_id}/llm-model-configs` | Get user's effective configs (own + inherited) | User.Audit | | GET | `/v3/access/users/{user_id}/llm-model-configs/own` | Get user's own configs only | User.Audit | +| GET | `/v3/access/users/{user_id}/llm-model-configs/default` | Get user's default configuration | User.Audit | | POST | `/v3/access/users/{user_id}/llm-model-configs` | Create a new configuration | User.Modify | | PUT | `/v3/access/users/{user_id}/llm-model-configs/{config_id}` | Update a configuration | User.Modify | | DELETE | `/v3/access/users/{user_id}/llm-model-configs/{config_id}` | Delete a configuration | User.Modify | @@ -88,6 +91,7 @@ The `model_type` field accepts the following values: | Method | Path | Description | Privilege | |--------|------|-------------|-----------| | GET | `/v3/access/groups/{group_id}/llm-model-configs` | Get all group configurations | Group.Audit | +| GET | `/v3/access/groups/{group_id}/llm-model-configs/default` | Get group's default configuration | Group.Audit | | POST | `/v3/access/groups/{group_id}/llm-model-configs` | Create a new configuration | Group.Modify | | PUT | `/v3/access/groups/{group_id}/llm-model-configs/{config_id}` | Update a configuration | Group.Modify | | DELETE | `/v3/access/groups/{group_id}/llm-model-configs/{config_id}` | Delete a configuration | Group.Modify | @@ -246,7 +250,7 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \ -H "Authorization: Bearer " ``` -**Response (user has own configs):** +**Response (user has both own configs and inherited group configs):** ```json { "configs": [ @@ -262,6 +266,19 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \ "base_url": "https://api.openai.com/v1", "temperature": 0.7, "api_key": "sk-xxx" + }, + { + "config_id": "uuid-2", + "name": "Claude-3", + "model_type": "text", + "source": "group", + "group_name": "Developers", + "is_default": true, + "provider": "anthropic", + "model": "claude-3-opus-20240229", + "base_url": "https://api.anthropic.com", + "temperature": 0.7, + "api_key": null } ], "default_config": { @@ -274,40 +291,15 @@ curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs \ "provider": "openai", ... }, - "total": 1 + "total": 2 } ``` -**Response (user inherits from group):** -```json -{ - "configs": [ - { - "config_id": "uuid-2", - "name": "Claude-3", - "model_type": "text", - "source": "group", - "group_name": "Developers", - "is_default": true, - "provider": "anthropic", - "model": "claude-3-opus-20240229", - "base_url": "https://api.anthropic.com", - "temperature": 0.7, - "api_key": "sk-ant-xxx" - } - ], - "default_config": { - "config_id": "uuid-2", - "name": "Claude-3", - "model_type": "text", - "source": "group", - "group_name": "Developers", - "is_default": true, - ... - }, - "total": 1 -} -``` +**Note:** +- User's own config shows `api_key: "sk-xxx"` (visible to owner) +- Inherited group config shows `api_key: null` (hidden from users) +- `source: "user"` indicates the config belongs to the user +- `source: "group"` indicates the config is inherited from a group ### 4. Update a configuration (without optimistic locking) @@ -367,7 +359,56 @@ curl -X PUT http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/de -H "Authorization: Bearer " ``` -### 7. Delete a configuration +### 7. Get default configuration + +Get the user's default configuration: + +```bash +curl -X GET http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/default \ + -H "Authorization: Bearer " +``` + +**Response:** +```json +{ + "config_id": "uuid-1", + "name": "GPT-4", + "model_type": "text", + "config": { + "provider": "openai", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4", + "temperature": 0.7, + "api_key": "sk-xxx" + }, + "user_id": "uuid-user", + "group_id": null, + "is_default": true, + "version": 0, + "created_at": "2026-03-03T18:15:00Z", + "updated_at": "2026-03-03T18:15:00Z" +} +``` + +**If no default configuration is set:** + +```json +HTTP 404 Not Found +{ + "detail": "No default LLM model configuration found for user '{user_id}'" +} +``` + +Get the group's default configuration: + +```bash +curl -X GET http://localhost:3080/v3/access/groups/{group_id}/llm-model-configs/default \ + -H "Authorization: Bearer " +``` + +The response format is the same as for users. + +### 8. Delete a configuration ```bash curl -X DELETE http://localhost:3080/v3/access/users/{user_id}/llm-model-configs/{config_id} \ @@ -420,55 +461,68 @@ This API uses **optimistic locking** to prevent concurrent modification conflict - You're sure no one else is modifying the config - Performance is more important than data integrity (not recommended) -### Example Workflow -```python -# Client-side example (Python) -import requests - -def update_config_safely(config_id, updates): - max_retries = 3 - for attempt in range(max_retries): - # 1. Fetch current config - response = requests.get( - f"/users/{user_id}/llm-model-configs/own", - headers={"Authorization": f"Bearer {token}"} - ) - configs = response.json() - config = next(c for c in configs if c["config_id"] == config_id) - current_version = config["version"] - - # 2. Try update with expected_version - try: - response = requests.put( - f"/users/{user_id}/llm-model-configs/{config_id}", - headers={"Authorization": f"Bearer {token}"}, - json={ - **updates, - "expected_version": current_version - } - ) - response.raise_for_status() - return response.json() # Success - - except requests.HTTPError as e: - if e.response.status_code == 409: - # Conflict: someone else modified it - if attempt < max_retries - 1: - continue # Retry - raise Exception("Max retries exceeded for concurrent update") - raise -``` --- ## Security Notes -1. **API Key Encryption**: All API keys are encrypted using Fernet symmetric encryption (AES-128-CBC) -2. **Access Control**: All endpoints require appropriate privileges (User.Audit, User.Modify, Group.Audit, Group.Modify) -3. **User Isolation**: Users can only access their own configurations -4. **Group Access**: Group configurations can only be modified by users with Group.Modify privilege -5. **Encryption Key Storage**: Encryption keys are stored in `{secrets_dir}/gns3_encryption_key` with 0600 permissions +### API Key Encryption + +All API keys are encrypted using Fernet symmetric encryption (AES-128-CBC). Encryption keys are stored in `{secrets_dir}/gns3_encryption_key` with 0600 permissions. + +### Access Control + +All endpoints require appropriate privileges: +- **User.Audit**: View user configurations +- **User.Modify**: Create, update, delete user configurations +- **Group.Audit**: View group configurations +- **Group.Modify**: Create, update, delete group configurations + +### API Key Visibility + +The API implements strict API key visibility controls to protect sensitive credentials: + +| Scenario | User Configs | Group Configs | +|----------|-------------|---------------| +| User viewing own configs | **Visible** | **Hidden** | +| Admin viewing other users' configs | **Hidden** | **Hidden** | +| Viewing group configs directly | N/A | **Visible** | + +**Rules:** +1. **Users viewing their own configs**: Can see API keys in their own configurations, but NOT in inherited group configurations +2. **Admins viewing other users' configs**: Cannot see API keys in any user configurations (user privacy) +3. **Viewing group configs**: Users with `Group.Audit` privilege can see API keys in group configurations + +**Example:** +```json +// User viewing their own configs +{ + "configs": [ + { + "config_id": "uuid-1", + "source": "user", + "api_key": "sk-xxx" // Visible (own config) + }, + { + "config_id": "uuid-2", + "source": "group", + "api_key": null // Hidden (inherited from group) + } + ] +} + +// Admin viewing another user's configs +{ + "configs": [ + { + "config_id": "uuid-1", + "source": "user", + "api_key": null // Hidden (another user's config) + } + ] +} +``` --- diff --git a/gns3server/api/routes/controller/llm_model_configs.py b/gns3server/api/routes/controller/llm_model_configs.py index 01bd95ba2..c7acde0d6 100644 --- a/gns3server/api/routes/controller/llm_model_configs.py +++ b/gns3server/api/routes/controller/llm_model_configs.py @@ -49,11 +49,11 @@ router = APIRouter() @router.get( "/users/{user_id}/llm-model-configs", - response_model=schemas.LLMModelConfigInheritedResponse, - dependencies=[Depends(has_privilege("User.Audit"))] + response_model=schemas.LLMModelConfigInheritedResponse ) async def get_user_llm_model_configs( user_id: UUID, + current_user: schemas.User = Depends(has_privilege("User.Audit")), llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) ) -> schemas.LLMModelConfigInheritedResponse: """ @@ -63,7 +63,11 @@ async def get_user_llm_model_configs( """ try: - result = await llm_repo.get_user_effective_configs(user_id) + result = await llm_repo.get_user_effective_configs( + user_id, + current_user_id=current_user.user_id, + current_user_is_superadmin=current_user.is_superadmin + ) return schemas.LLMModelConfigInheritedResponse( configs=result["configs"], default_config=result.get("default_config"), @@ -117,6 +121,51 @@ async def get_user_own_llm_model_configs( ) +@router.get( + "/users/{user_id}/llm-model-configs/default", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("User.Audit"))] +) +async def get_user_default_llm_model_config( + user_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Get user's default LLM model configuration. + + Required privilege: User.Audit + """ + + try: + config = await llm_repo.get_user_default_config(user_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No default LLM model configuration found for user '{user_id}'" + ) + + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=config.config, + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to retrieve user's default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configuration" + ) + + @router.post( "/users/{user_id}/llm-model-configs", response_model=schemas.LLMModelConfigResponse, @@ -366,6 +415,51 @@ async def get_group_llm_model_configs( ) +@router.get( + "/groups/{group_id}/llm-model-configs/default", + response_model=schemas.LLMModelConfigResponse, + dependencies=[Depends(has_privilege("Group.Audit"))] +) +async def get_group_default_llm_model_config( + group_id: UUID, + llm_repo: LLMModelConfigsRepository = Depends(get_repository(LLMModelConfigsRepository)) +) -> schemas.LLMModelConfigResponse: + """ + Get group's default LLM model configuration. + + Required privilege: Group.Audit + """ + + try: + config = await llm_repo.get_group_default_config(group_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No default LLM model configuration found for group '{group_id}'" + ) + + return schemas.LLMModelConfigResponse( + config_id=config.config_id, + name=config.name, + model_type=config.model_type, + config=config.config, + user_id=config.user_id, + group_id=config.group_id, + is_default=config.is_default, + version=config.version, + created_at=config.created_at, + updated_at=config.updated_at + ) + except HTTPException: + raise + except Exception as e: + log.error(f"Failed to retrieve group's default LLM model config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve LLM model configuration" + ) + + @router.post( "/groups/{group_id}/llm-model-configs", response_model=schemas.LLMModelConfigResponse, diff --git a/gns3server/db/repositories/llm_model_configs.py b/gns3server/db/repositories/llm_model_configs.py index 884a54cef..a1753a0b0 100644 --- a/gns3server/db/repositories/llm_model_configs.py +++ b/gns3server/db/repositories/llm_model_configs.py @@ -402,13 +402,25 @@ class LLMModelConfigsRepository(BaseRepository): # Inheritance methods - async def get_user_effective_configs(self, user_id: UUID) -> Dict[str, Any]: + async def get_user_effective_configs( + self, + user_id: UUID, + current_user_id: Optional[UUID] = None, + current_user_is_superadmin: bool = False + ) -> Dict[str, Any]: """ Get user's effective configurations (own + inherited from groups). Returns a dict with 'configs' list and 'default_config'. + + API key visibility rules: + - Users viewing own configs: CAN see API keys in user configs, CANNOT see API keys in group configs + - Admins viewing other users' configs: CANNOT see ANY API keys """ from gns3server.utils.encryption import decrypt, is_encrypted + # Determine if current user is viewing their own configs + is_viewing_own = current_user_id == user_id if current_user_id else False + # Get user's own configs user_configs = await self.get_user_configs(user_id) @@ -435,12 +447,18 @@ class LLMModelConfigsRepository(BaseRepository): # Add user's configs for config in user_configs: config_dict = config.config.copy() + + # API key visibility: only show if viewing own configs if "api_key" in config_dict and config_dict["api_key"]: - try: - if is_encrypted(config_dict["api_key"]): - config_dict["api_key"] = decrypt(config_dict["api_key"]) - except Exception as e: - log.warning(f"Failed to decrypt API key for config {config.config_id}: {e}") + if is_viewing_own: + try: + if is_encrypted(config_dict["api_key"]): + config_dict["api_key"] = decrypt(config_dict["api_key"]) + except Exception as e: + log.warning(f"Failed to decrypt API key for config {config.config_id}: {e}") + config_dict["api_key"] = None + else: + # Hide API key for admins viewing other users' configs config_dict["api_key"] = None configs_with_source.append({ @@ -456,31 +474,27 @@ class LLMModelConfigsRepository(BaseRepository): if config.is_default and default_config is None: default_config = configs_with_source[-1] - # Add inherited group configs (only if user has no configs) - if not user_configs: - for group_id, configs in group_configs_map.items(): - for config in configs: - config_dict = config.config.copy() - if "api_key" in config_dict and config_dict["api_key"]: - try: - if is_encrypted(config_dict["api_key"]): - config_dict["api_key"] = decrypt(config_dict["api_key"]) - except Exception as e: - log.warning(f"Failed to decrypt API key for config {config.config_id}: {e}") - config_dict["api_key"] = None + # Add inherited group configs (always shown, regardless of user configs) + for group_id, configs in group_configs_map.items(): + for config in configs: + config_dict = config.config.copy() - configs_with_source.append({ - "config_id": config.config_id, - "name": config.name, - "model_type": config.model_type, - "source": "group", - "group_name": group_names_map[group_id], - "is_default": config.is_default, - **config_dict - }) + # API key visibility: NEVER show API keys from inherited group configs + if "api_key" in config_dict and config_dict["api_key"]: + config_dict["api_key"] = None - if config.is_default and default_config is None: - default_config = configs_with_source[-1] + configs_with_source.append({ + "config_id": config.config_id, + "name": config.name, + "model_type": config.model_type, + "source": "group", + "group_name": group_names_map[group_id], + "is_default": config.is_default, + **config_dict + }) + + if config.is_default and default_config is None: + default_config = configs_with_source[-1] return { "configs": configs_with_source,