mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 20:40:13 +03:00
feat(docs): add default config endpoints and clarify config inheritance
- Add `/default` endpoints for users and groups to retrieve default LLM model configurations - Update documentation to clarify that users receive both own and inherited configurations - Improve response examples to show combined configs with source field - Add 404 response example for missing default configurations - Fix optimistic locking documentation formatting
This commit is contained in:
parent
8fb4bf1117
commit
b06593cf2a
@ -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 <token>"
|
||||
```
|
||||
|
||||
**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 <token>"
|
||||
```
|
||||
|
||||
### 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 <token>"
|
||||
```
|
||||
|
||||
**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 <token>"
|
||||
```
|
||||
|
||||
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)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user