added Quality Audit and fixed various bugs

This commit is contained in:
michael 2025-09-05 13:41:33 -05:00
parent db62e2d92d
commit f811854198
25 changed files with 1519 additions and 601 deletions

2
.env
View file

@ -2,7 +2,7 @@ MONGODB_URI=mongodb://localhost:27017
MONGODB_DBNAME=agenthub_db
SECRET_KEY=your-super-secret-jwt-key-change-in-production-agenthub-2024
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
ACCESS_TOKEN_EXPIRE_MINUTES=240
# Agent Collector API Configuration
AGENT_COLLECTOR_API_KEY=agent-collector-static-key-2024-secure

98
.gitignore vendored
View file

@ -1,3 +1,99 @@
# Python
venv/
env/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Environment variables
.env
.env.local
.env.*.local
# IDEs and editors
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite
*.sqlite3
# Node.js (if using for frontend build tools)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Linux
*~
# Temporary files
*.tmp
*.temp
.cache/
# FastAPI/Uvicorn specific
.uvicorn*
# MongoDB dumps
*.dump
*.bson

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,310 +0,0 @@
# Agent Collector API Documentation
## Overview
Agent Collector is a Flask-based REST API for collecting and storing agent metadata across an organization. The application validates agent data against a predefined JSON schema and stores it in MongoDB for persistence.
## Base Configuration
- **Default Host**: `0.0.0.0`
- **Default Port**: `8475`
- **Server**: Hypercorn ASGI server
- **Database**: MongoDB
- **Content Type**: `application/json`
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MONGO_HOST` | `localhost` | MongoDB host address |
| `MONGO_PORT` | `27017` | MongoDB port |
| `MONGO_USERNAME` | `admin` | MongoDB username |
| `MONGO_PASSWORD` | `admin` | MongoDB password |
| `MONGO_DB_NAME` | `agent_collector` | MongoDB database name |
| `MONGO_COLLECTION` | `agents` | MongoDB collection name |
| `DEBUG` | `False` | Flask debug mode |
| `HOST` | `0.0.0.0` | Host to bind to |
| `PORT` | `8475` | Port to bind to |
| `HYPERCORN_BIND` | `0.0.0.0:8475` | Hypercorn bind address |
| `HYPERCORN_WORKERS` | `1` | Number of worker processes |
| `HYPERCORN_LOG_LEVEL` | `info` | Logging level |
## API Endpoints
### Health Check
**Endpoint**: `GET /`
**Description**: Health check endpoint that verifies the application and MongoDB connection status.
**Request**: No parameters required
**Response**:
```json
{
"status": "healthy",
"message": "Agent collector API is running",
"timestamp": "2024-01-01T12:00:00.000000",
"database": {
"status": "connected",
"healthy": true
}
}
```
**Status Codes**:
- `200 OK`: Service is healthy
### Submit Agent Data
**Endpoint**: `POST /agents`
**Description**: Collects and validates agent metadata, then stores it in MongoDB.
**Request Headers**:
- `Content-Type: application/json`
**Request Body Schema**:
**Required Fields**:
- `name` (string): Name of the agent (minimum length: 1)
- `description` (string): Detailed description of the agent (minimum length: 1)
- `purpose` (string): Primary purpose or function of the agent (minimum length: 1)
**Optional Fields**:
- `location` (string): Physical or virtual location where the agent is deployed
- `userbase` (array of strings): List of users or user groups who use this agent
- `version` (string): Current version of the agent
- `creation_date` (string, ISO 8601 datetime): Date and time when the agent was created
- `last_updated` (string, ISO 8601 datetime): Date and time when the agent was last updated
- `capabilities` (array of strings): List of agent capabilities
- `status` (string): Current operational status - must be one of: `active`, `inactive`, `deprecated`, `development`
- `department` (string): Department or team responsible for the agent
- `contact_person` (string): Person to contact for issues or inquiries about the agent
- `tags` (array of strings): Tags for categorizing the agent
- `metadata` (object): Additional arbitrary metadata about the agent
**Example Request**:
```json
{
"name": "TestAgent",
"description": "Test description",
"purpose": "Testing purposes",
"location": "Development Environment",
"userbase": ["dev-team", "qa-team"],
"version": "1.0.0",
"capabilities": ["data-processing", "automated-testing"],
"status": "development",
"department": "Engineering",
"contact_person": "john.doe@company.com",
"tags": ["automation", "testing"],
"metadata": {
"framework": "custom",
"language": "python"
}
}
```
**Success Response** (`201 Created`):
```json
{
"status": "success",
"message": "Agent data collected successfully",
"agent_id": "507f1f77bcf86cd799439011"
}
```
**Error Responses**:
**Unsupported Media Type** (`415`):
```json
{
"error": "Unsupported Media Type",
"message": "Request must be JSON"
}
```
**Validation Error** (`400 Bad Request`):
```json
{
"error": "Invalid Data",
"message": "'name' is a required property"
}
```
**Database Unavailable** (`503 Service Unavailable`):
```json
{
"error": "Database Unavailable",
"message": "MongoDB connection is not available. Please check the database setup.",
"agent_data": {
// Original submitted data returned for client retry
}
}
```
**Database Error** (`500 Internal Server Error`):
```json
{
"error": "Database Error",
"message": "Failed to store agent data. MongoDB may be unavailable or there was an error processing the request.",
"agent_data": {
// Original submitted data returned for client retry
}
}
```
## Data Processing
### Automatic Timestamp Addition
The API automatically adds timestamps to submitted data:
- If `creation_date` is not provided, it's set to the current UTC time
- If `last_updated` is not provided, it's set to the current UTC time
- Timestamps are in ISO 8601 format
### Schema Validation
All submitted data is validated against the JSON schema before storage. The validation:
- Ensures required fields are present
- Validates data types for all fields
- Enforces string length constraints
- Validates enum values for status field
- Rejects additional properties not defined in the schema
## Database Operations
### Connection Management
- Automatic connection retry with exponential backoff
- Connection health checks before operations
- Graceful error handling for connection failures
- Connection pooling via PyMongo MongoClient
### Data Storage
- Each agent record is stored as a MongoDB document
- Unique ObjectId is generated for each record
- Full document is stored with all provided metadata
## Error Handling
The API implements comprehensive error handling:
1. **Input Validation**: JSON schema validation with detailed error messages
2. **Database Connectivity**: Connection retries and graceful degradation
3. **Data Preservation**: Failed requests return original data for client retry
4. **Logging**: Detailed error logging for troubleshooting
## Security Considerations
- No authentication mechanism implemented (authentication should be handled at proxy/gateway level)
- Input validation prevents injection attacks
- Database credentials configurable via environment variables
- Connection timeouts prevent resource exhaustion
## Deployment Notes
### Production Deployment
Run with Hypercorn for production:
```bash
python main.py
```
### Development Mode
For development with Flask's built-in server:
```bash
python wsgi.py
```
### Docker Deployment
The application is designed to work in containerized environments with:
- Environment variable configuration
- MongoDB connection timeout handling
- Graceful shutdown handling
## Client Integration
### Example Client Code (Python)
```python
import requests
import json
# Health check
response = requests.get('http://localhost:8475/')
print(f"Health: {response.json()}")
# Submit agent data
agent_data = {
"name": "MyAgent",
"description": "Agent for processing customer data",
"purpose": "Customer service automation",
"status": "active",
"department": "Customer Success"
}
response = requests.post(
'http://localhost:8475/agents',
headers={'Content-Type': 'application/json'},
data=json.dumps(agent_data)
)
if response.status_code == 201:
result = response.json()
print(f"Agent stored with ID: {result['agent_id']}")
else:
print(f"Error: {response.json()}")
```
### Example Client Code (curl)
```bash
# Health check
curl http://localhost:8475/
# Submit agent data
curl -X POST http://localhost:8475/agents \
-H "Content-Type: application/json" \
-d '{
"name": "TestAgent",
"description": "Test description",
"purpose": "Testing purposes"
}'
```
## MongoDB Schema
The MongoDB collection stores documents with the following structure:
```javascript
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"name": "AgentName",
"description": "Agent description",
"purpose": "Agent purpose",
"location": "Optional location",
"userbase": ["user1", "user2"],
"version": "1.0.0",
"creation_date": "2024-01-01T12:00:00.000000",
"last_updated": "2024-01-01T12:00:00.000000",
"capabilities": ["capability1", "capability2"],
"status": "active",
"department": "Engineering",
"contact_person": "contact@company.com",
"tags": ["tag1", "tag2"],
"metadata": {
"custom_field": "custom_value"
}
}
```
## Rate Limiting and Scaling
- No built-in rate limiting (should be implemented at proxy/gateway level)
- Hypercorn workers can be scaled via `HYPERCORN_WORKERS` environment variable
- MongoDB connection pooling handles concurrent requests
- Application is stateless and horizontally scalable

View file

@ -5,6 +5,7 @@ load_dotenv()
# Environment Configuration
DISABLE_MSAL = os.getenv("DISABLE_MSAL", "false").lower() == "true"
HIDE_LOCAL_LOGIN = os.getenv("HIDE_LOCAL_LOGIN", "false").lower() == "true"
BASE_PATH = os.getenv("BASE_PATH", "").rstrip("/") # Remove trailing slash
def get_base_path() -> str:
@ -22,6 +23,10 @@ def is_msal_enabled() -> bool:
"""Check if MSAL authentication is enabled"""
return not DISABLE_MSAL
def show_local_login() -> bool:
"""Check if local login should be shown (hidden in production)"""
return not HIDE_LOCAL_LOGIN
def get_msal_config():
"""Get MSAL configuration if enabled"""
if not is_msal_enabled():
@ -29,6 +34,7 @@ def get_msal_config():
return {
"client_id": os.getenv("AZURE_CLIENT_ID"),
"client_secret": os.getenv("AZURE_CLIENT_SECRET"),
"authority": os.getenv("AZURE_AUTHORITY"),
"redirect_uri": os.getenv("AZURE_REDIRECT_URI"),
}

53
crud.py
View file

@ -137,6 +137,16 @@ async def create_agent(agent_data: dict, user_id: str):
"created_at": now,
"updated_at": now,
}
# Initialize quality audit fields if not provided
if "quality_audit_status" not in agent_doc:
agent_doc["quality_audit_status"] = False
if "quality_audit_updated_by" not in agent_doc:
agent_doc["quality_audit_updated_by"] = None
if "quality_audit_updated_at" not in agent_doc:
agent_doc["quality_audit_updated_at"] = None
if "quality_audit_updated_by_name" not in agent_doc:
agent_doc["quality_audit_updated_by_name"] = None
result = await agents_collection.insert_one(agent_doc)
agent_doc["_id"] = result.inserted_id
return agent_doc
@ -197,12 +207,23 @@ async def get_agent_stats():
stats = await agents_collection.aggregate(pipeline).to_list(length=None)
return {stat["_id"]: stat["count"] for stat in stats}
async def update_agent(agent_id: str, update_data: dict, user_id: str = None):
async def update_agent(agent_id: str, update_data: dict, user_id: str = None, admin_user_info: dict = None):
try:
filter_query = {"_id": ObjectId(agent_id)}
if user_id:
filter_query["created_by"] = user_id
# Handle Quality Audit updates specially
if "quality_audit_status" in update_data and admin_user_info:
# Get current agent to check if quality audit status is changing
current_agent = await get_agent_by_id(agent_id)
if current_agent and current_agent.get("quality_audit_status") != update_data["quality_audit_status"]:
# Quality audit status is changing, update audit fields with separate timestamp
update_data["quality_audit_updated_by"] = admin_user_info.get("user_id")
update_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
update_data["quality_audit_updated_by_name"] = admin_user_info.get("user_name", admin_user_info.get("email"))
update_data["updated_at"] = datetime.utcnow()
result = await agents_collection.update_one(
filter_query,
@ -214,6 +235,36 @@ async def update_agent(agent_id: str, update_data: dict, user_id: str = None):
except:
return None
async def update_agent_quality_audit(agent_id: str, quality_audit_status: bool, admin_user_info: dict):
"""Update only the quality audit status of an agent (admin only)"""
try:
# Get current agent to ensure it exists
current_agent = await get_agent_by_id(agent_id)
if not current_agent:
return None
# Only update if status is actually changing
if current_agent.get("quality_audit_status") == quality_audit_status:
return current_agent
update_data = {
"quality_audit_status": quality_audit_status,
"quality_audit_updated_by": admin_user_info.get("user_id"),
"quality_audit_updated_at": datetime.utcnow().isoformat(),
"quality_audit_updated_by_name": admin_user_info.get("user_name", admin_user_info.get("email"))
}
result = await agents_collection.update_one(
{"_id": ObjectId(agent_id)},
{"$set": update_data}
)
if result.modified_count:
return await get_agent_by_id(agent_id)
return None
except:
return None
async def delete_agent(agent_id: str, user_id: str = None):
try:
print(f"🗑️ CRUD: Deleting agent {agent_id} with user_id filter: {user_id}")

Binary file not shown.

View file

@ -0,0 +1,306 @@
# Agent Registration API Documentation
## Overview
The Agent Registration API allows you to register AI agents and track their usage in the AgentHub system. The API automatically handles duplicate registrations by tracking usage when an agent with the same name already exists.
**Base URL:** `https://ai-sandbox.oliver.solutions/agent_collector/agents`
## Authentication
All requests require authentication using a static API key.
**API Key:** `agent-collector-static-key-2024-secure`
**Header:** `X-API-Key`
## Agent Registration Endpoint
### POST /agents
Register a new agent or track usage of an existing agent.
**URL:** `https://ai-sandbox.oliver.solutions/agent_collector/agents`
**Method:** `POST`
**Required Headers:**
- `Content-Type: application/json`
- `X-API-Key: agent-collector-static-key-2024-secure`
## Request Schema
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | The unique name of the agent (minimum 1 character) |
| `description` | string | A detailed description of the agent's functionality (minimum 1 character) |
| `purpose` | string | The primary purpose or goal of the agent (minimum 1 character) |
| `tool` | string | The tool or platform where the agent operates (minimum 1 character) |
### Optional Fields
| Field | Type | Description | Default |
|-------|------|-------------|---------|
| `location` | string | Where the agent is deployed or operates | null |
| `userbase` | array[string] | List of user groups or departments that use this agent | null |
| `version` | string | Version number or identifier of the agent | null |
| `creation_date` | string | ISO 8601 datetime when the agent was first created | null |
| `last_updated` | string | ISO 8601 datetime when the agent was last modified | null |
| `capabilities` | array[string] | List of specific capabilities or features the agent provides | null |
| `status` | string | Current operational status of the agent | "development" |
| `department` | string | Department or team that owns/manages the agent | null |
| `contact_person` | string | Primary contact for questions about this agent | null |
| `tags` | array[string] | Keywords or labels for categorizing the agent | null |
| `metadata` | object | Additional key-value pairs of agent information | null |
### Status Values
The `status` field accepts the following values (case-insensitive):
- `"active"` - Agent is live and operational
- `"inactive"` - Agent is temporarily disabled
- `"deprecated"` - Agent is being phased out
- `"development"` - Agent is in development/testing phase (default)
## Usage Examples
### Basic Agent Registration
**cURL Example:**
```bash
curl -X POST https://ai-sandbox.oliver.solutions/agent_collector/agents \
-H "Content-Type: application/json" \
-H "X-API-Key: agent-collector-static-key-2024-secure" \
-d '{
"name": "CustomerSupportBot",
"description": "AI assistant that handles customer inquiries and support tickets",
"purpose": "Provide 24/7 automated customer support and reduce response times",
"tool": "chat-sandbox"
}'
```
**Python Example:**
```python
import requests
import json
url = "https://ai-sandbox.oliver.solutions/agent_collector/agents"
headers = {
"Content-Type": "application/json",
"X-API-Key": "agent-collector-static-key-2024-secure"
}
data = {
"name": "CustomerSupportBot",
"description": "AI assistant that handles customer inquiries and support tickets",
"purpose": "Provide 24/7 automated customer support and reduce response times",
"tool": "chat-sandbox"
}
response = requests.post(url, headers=headers, data=json.dumps(data))
print(response.json())
```
### Complex Agent Registration
**cURL Example:**
```bash
curl -X POST https://ai-sandbox.oliver.solutions/agent_collector/agents \
-H "Content-Type: application/json" \
-H "X-API-Key: agent-collector-static-key-2024-secure" \
-d '{
"name": "DataAnalysisAgent",
"description": "Advanced AI agent that performs automated data analysis and generates business insights",
"purpose": "Transform raw business data into actionable insights and recommendations",
"tool": "Custom Python Framework",
"location": "AWS us-east-1",
"userbase": ["Data Science Team", "Business Intelligence", "Executive Team"],
"version": "2.1.3",
"creation_date": "2024-01-15T10:30:00Z",
"last_updated": "2024-08-22T14:45:30Z",
"capabilities": ["Data Visualization", "Trend Analysis", "Predictive Modeling", "Report Generation"],
"status": "active",
"department": "Data Science",
"contact_person": "Dr. Sarah Johnson",
"tags": ["analytics", "business-intelligence", "automation", "reporting"],
"metadata": {
"framework": "TensorFlow",
"language": "Python",
"memory_usage": "2GB",
"processing_time": "5-10 minutes"
}
}'
```
**Python Example:**
```python
import requests
import json
from datetime import datetime
url = "https://ai-sandbox.oliver.solutions/agent_collector/agents"
headers = {
"Content-Type": "application/json",
"X-API-Key": "agent-collector-static-key-2024-secure"
}
data = {
"name": "DataAnalysisAgent",
"description": "Advanced AI agent that performs automated data analysis and generates business insights",
"purpose": "Transform raw business data into actionable insights and recommendations",
"tool": "Custom Python Framework",
"location": "AWS us-east-1",
"userbase": ["Data Science Team", "Business Intelligence", "Executive Team"],
"version": "2.1.3",
"creation_date": "2024-01-15T10:30:00Z",
"last_updated": datetime.utcnow().isoformat() + "Z",
"capabilities": ["Data Visualization", "Trend Analysis", "Predictive Modeling", "Report Generation"],
"status": "active",
"department": "Data Science",
"contact_person": "Dr. Sarah Johnson",
"tags": ["analytics", "business-intelligence", "automation", "reporting"],
"metadata": {
"framework": "TensorFlow",
"language": "Python",
"memory_usage": "2GB",
"processing_time": "5-10 minutes"
}
}
response = requests.post(url, headers=headers, data=json.dumps(data))
print(response.json())
```
## Response Formats
### Successful New Agent Registration
When a new agent is successfully registered:
```json
{
"status": "success",
"message": "Agent data collected successfully",
"agent_id": "64f7b8a9e1234567890abcde"
}
```
### Usage Tracking Response
When an agent with the same name already exists, the system logs usage instead of creating a duplicate:
```json
{
"status": "usage_logged",
"message": "Agent already exists, usage tracked",
"agent_name": "CustomerSupportBot"
}
```
### Error Responses
#### Invalid API Key (401)
```json
{
"detail": "Invalid API key"
}
```
#### Unsupported Media Type (415)
```json
{
"error": "Unsupported Media Type",
"message": "Request must be JSON"
}
```
#### Database Error (500)
```json
{
"error": "Database Error",
"message": "Failed to store agent data. MongoDB may be unavailable or there was an error processing the request.",
"agent_data": {
"name": "YourAgentName",
"description": "Your agent description",
"purpose": "Your agent purpose",
"tool": "YourAgentTool"
}
}
```
#### Database Unavailable (503)
```json
{
"error": "Database Unavailable",
"message": "MongoDB connection is not available. Please check the database setup.",
"agent_data": {
"name": "YourAgentName",
"description": "Your agent description",
"purpose": "Your agent purpose",
"tool": "YourAgentTool"
}
}
```
## Usage Tracking Behavior
The API implements intelligent duplicate detection and usage tracking:
### New Agent Registration
- When you register an agent with a **new name**, the system creates a new agent record
- Returns a success response with the new agent's unique ID
### Usage Tracking
- When you register an agent with an **existing name**, the system:
- Logs the usage attempt with a timestamp
- Compares the submitted data with the existing agent data
- Updates the existing agent record if any fields have changed
- Returns a usage tracking response
### Data Updates
If usage tracking detects that the submitted agent data differs from the existing record (excluding the name field), the system will automatically update the existing agent with the new information. This ensures agent records stay current while maintaining usage statistics.
## Date Format
All date fields (`creation_date`, `last_updated`) should be provided in **ISO 8601 format**:
- `"2024-08-22T14:45:30Z"` (with timezone)
- `"2024-08-22T14:45:30"` (without timezone)
## Health Check
You can check if the API is operational by sending a GET request with JSON Accept header:
```bash
curl -H "Accept: application/json" https://ai-sandbox.oliver.solutions/agent_collector/agents
```
Response:
```json
{
"status": "healthy",
"message": "Agent collector API is running",
"timestamp": "2024-08-22T14:45:30.123456",
"database": {
"healthy": true,
"connection_status": "connected"
}
}
```
## Best Practices
1. **Use descriptive names**: Agent names should be unique and descriptive to avoid confusion
2. **Include version information**: Use the `version` field to track agent iterations
3. **Set appropriate status**: Keep the `status` field updated to reflect the agent's current state
4. **Provide contact information**: Include a `contact_person` for operational support
5. **Use tags effectively**: Tags help with categorization and searching
6. **Handle errors gracefully**: Always check response status codes and handle errors appropriately
## Rate Limiting
Currently, there are no explicit rate limits on the API, but please be considerate of the shared infrastructure and avoid excessive concurrent requests.
## Support
For technical support or questions about the Agent Registration API, please contact the AgentHub development team.

382
main.py
View file

@ -71,6 +71,7 @@ def get_template_context(request: Request, current_user=None, **kwargs):
"current_user": current_user,
"base_path": config.get_base_path(),
"msal_enabled": msal_auth.is_msal_available(),
"show_local_login": config.show_local_login(),
**kwargs
}
@ -117,6 +118,32 @@ async def require_admin(current_user: dict = Depends(get_current_user_from_cooki
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
def create_agent_response(agent: dict) -> models.AiAgentResponse:
"""Helper function to create AiAgentResponse with all fields including Quality Audit"""
return models.AiAgentResponse(
agent_id=str(agent["_id"]),
agent_name=agent["agent_name"],
agent_tool=agent.get("agent_tool"),
agent_description=agent.get("agent_description"),
agent_purpose=agent.get("agent_purpose"),
agent_version=agent.get("agent_version"),
agent_status=agent.get("agent_status"),
agent_location=agent.get("agent_location"),
agent_department=agent.get("agent_department"),
agent_contact_person=agent.get("agent_contact_person"),
agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None,
agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None,
agent_tags=agent.get("agent_tags"),
agent_metadata=agent.get("agent_metadata"),
agent_userbase=agent.get("agent_userbase"),
agent_capabilities=agent.get("agent_capabilities"),
quality_audit_status=agent.get("quality_audit_status", False),
quality_audit_updated_by=agent.get("quality_audit_updated_by"),
quality_audit_updated_at=agent.get("quality_audit_updated_at"),
quality_audit_updated_by_name=agent.get("quality_audit_updated_by_name"),
created_by=agent["created_by"]
)
async def verify_agent_collector_api_key(x_api_key: str = Header(alias="X-API-Key")):
"""Verify static API key for agent collector endpoints"""
expected_key = os.getenv("AGENT_COLLECTOR_API_KEY")
@ -141,6 +168,7 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
return {
"agent_name": collector_data.name,
"agent_tool": collector_data.tool,
"agent_description": collector_data.description,
"agent_purpose": collector_data.purpose,
"agent_location": collector_data.location,
@ -230,7 +258,18 @@ async def register_form(
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
current_user = await get_current_user_optional(request)
return templates.TemplateResponse("login.html", get_template_context(request, current_user))
# Add MSAL configuration if enabled
context = get_template_context(request, current_user)
if msal_auth.is_msal_available():
msal_config = config.get_msal_config()
context.update({
"client_id": msal_config["client_id"],
"authority": msal_config["authority"],
"redirect_uri": msal_config["redirect_uri"]
})
return templates.TemplateResponse("login.html", context)
@app.post("/login", response_class=HTMLResponse)
async def login_form(
@ -279,8 +318,14 @@ async def login_form(
# Regular user goes to all agents page
response = RedirectResponse(url=get_app_url("agent-management"), status_code=303)
# Set token in cookie (for demo purposes)
response.set_cookie(key="access_token", value=token)
# Set token in cookie with proper attributes
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="lax",
secure=False # Set to True in production with HTTPS
)
return response
except Exception as e:
@ -289,79 +334,84 @@ async def login_form(
{"request": request, "error": f"Login failed: {str(e)}"}
)
# Azure AD/MSAL Authentication Routes
async def azure_login(request: Request):
"""Initiate Azure AD login with PKCE"""
try:
msal_instance = msal_auth.get_msal_instance()
auth_data = msal_instance.get_auth_url()
# Store PKCE parameters in session
request.session["msal_state"] = auth_data["state"]
request.session["msal_code_verifier"] = auth_data["code_verifier"]
# Redirect to Azure AD
return RedirectResponse(url=auth_data["auth_url"], status_code=302)
except Exception as e:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": f"Azure login failed: {str(e)}"}
)
# Azure AD/MSAL Authentication - Using popup-based authentication per specification
async def azure_callback(request: Request):
"""Handle Azure AD callback and complete authentication"""
@app.post("/api/auth/azure/token")
async def azure_token_exchange(request: Request):
"""Exchange Azure AD token for local JWT token with proper validation"""
try:
# Get authorization code and state from callback
auth_code = request.query_params.get("code")
state = request.query_params.get("state")
error = request.query_params.get("error")
data = await request.json()
access_token = data.get("access_token")
id_token = data.get("id_token")
if error:
error_description = request.query_params.get("error_description", "Unknown error")
return templates.TemplateResponse(
"login.html",
{"request": request, "error": f"Azure AD error: {error_description}"}
if not id_token:
raise HTTPException(status_code=400, detail="Missing ID token")
# Validate JWT token against Azure AD public keys (as per specification)
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import base64
msal_config = config.get_msal_config()
# Get Azure AD public keys for token verification
jwks_uri = f"{msal_config['authority']}/discovery/v2.0/keys"
jwks_response = requests.get(jwks_uri)
jwks = jwks_response.json()
# Decode token header to get key ID
unverified_header = jwt.get_unverified_header(id_token)
kid = unverified_header.get("kid")
# Find the matching public key
public_key = None
for key in jwks["keys"]:
if key["kid"] == kid:
# Convert JWK to PEM format for validation
n = base64.urlsafe_b64decode(key["n"] + "==")
e = base64.urlsafe_b64decode(key["e"] + "==")
# Convert to RSA public key
numbers = rsa.RSAPublicNumbers(
int.from_bytes(e, byteorder="big"),
int.from_bytes(n, byteorder="big")
)
public_key = numbers.public_key()
break
if not public_key:
raise HTTPException(status_code=400, detail="Unable to verify token signature")
# Convert to PEM format for JWT library
pem_key = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Validate JWT token signature and claims
try:
id_token_claims = jwt.decode(
id_token,
pem_key,
algorithms=["RS256"],
audience=msal_config["client_id"],
issuer=f"{msal_config['authority']}/v2.0"
)
except jwt.InvalidTokenError as e:
raise HTTPException(status_code=400, detail=f"Token validation failed: {str(e)}")
if not auth_code:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "No authorization code received from Azure AD"}
)
# Validate state parameter (CSRF protection)
session_state = request.session.get("msal_state")
code_verifier = request.session.get("msal_code_verifier")
if not session_state or not code_verifier:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Session expired. Please try logging in again."}
)
msal_instance = msal_auth.get_msal_instance()
if not msal_instance.validate_state(state, session_state):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Invalid state parameter. Possible CSRF attack."}
)
# Exchange authorization code for tokens
token_result = msal_instance.acquire_token_by_auth_code(auth_code, code_verifier)
if not token_result:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Failed to acquire token from Azure AD"}
)
# Extract user profile
user_profile = msal_instance.get_user_profile(token_result)
if not user_profile:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Failed to get user profile from Azure AD"}
)
# Create user profile from validated ID token claims
user_profile = {
"azure_ad_id": id_token_claims.get("oid"),
"email": id_token_claims.get("email") or id_token_claims.get("preferred_username"),
"full_name": id_token_claims.get("name"),
"first_name": id_token_claims.get("given_name"),
"last_name": id_token_claims.get("family_name"),
"tenant_id": id_token_claims.get("tid")
}
# Create or update user in local database
user = await crud.create_or_update_azure_user(user_profile)
@ -369,29 +419,30 @@ async def azure_callback(request: Request):
# Create JWT token for local session management
jwt_token = auth.create_access_token({"sub": str(user["_id"])})
# Clear MSAL session data
request.session.pop("msal_state", None)
request.session.pop("msal_code_verifier", None)
# Return user info and set secure cookie (following specification)
response = JSONResponse({
"success": True,
"is_admin": user.get("is_admin", False),
"email": user.get("email"),
"full_name": user.get("full_name")
})
# Redirect based on user role
if user.get("is_admin"):
response = RedirectResponse(url=get_app_url("admin"), status_code=303)
else:
response = RedirectResponse(url=get_app_url("agent-management"), status_code=303)
# Set JWT token in httpOnly cookie with security flags (as per specification)
response.set_cookie(
key="access_token",
value=jwt_token,
httponly=True,
samesite="lax",
secure=False, # Set to True in production with HTTPS
max_age=24 * 60 * 60 # 24 hours as per specification
)
# Set JWT token in cookie
response.set_cookie(key="access_token", value=jwt_token)
return response
except HTTPException:
raise
except Exception as e:
# Clear session on error
request.session.pop("msal_state", None)
request.session.pop("msal_code_verifier", None)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": f"Authentication failed: {str(e)}"}
)
raise HTTPException(status_code=400, detail=f"Token exchange failed: {str(e)}")
@app.get("/agent-register", response_class=HTMLResponse)
async def agent_register_page(request: Request):
@ -404,6 +455,7 @@ async def agent_register_page(request: Request):
async def agent_register_form(
request: Request,
agent_name: str = Form(...),
agent_tool: str = Form(...),
agent_description: str = Form(None),
agent_purpose: str = Form(None),
agent_version: str = Form(None),
@ -413,7 +465,8 @@ async def agent_register_form(
agent_contact_person: str = Form(None),
agent_tags: str = Form(None),
agent_userbase: str = Form(None),
agent_capabilities: str = Form(None)
agent_capabilities: str = Form(None),
quality_audit_status: bool = Form(False)
):
try:
# Get user from cookie - require authentication
@ -426,6 +479,7 @@ async def agent_register_form(
# Prepare agent data
agent_data = {
"agent_name": agent_name,
"agent_tool": agent_tool,
"agent_description": agent_description,
"agent_purpose": agent_purpose,
"agent_version": agent_version,
@ -443,6 +497,18 @@ async def agent_register_form(
if agent_capabilities:
agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()]
# Handle Quality Audit - only admins can set it to True
if quality_audit_status and current_user.get("is_admin"):
# Admin is setting quality audit to true
from datetime import datetime
agent_data["quality_audit_status"] = True
agent_data["quality_audit_updated_by"] = user_id
agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email"))
else:
# Non-admin or quality audit not checked
agent_data["quality_audit_status"] = False
# Remove None values
agent_data = {k: v for k, v in agent_data.items() if v is not None}
@ -635,77 +701,23 @@ async def get_all_agents_for_users(current_user: dict = Depends(get_current_user
"""Get all agents for regular users (read-only access)"""
agents = await crud.get_all_agents()
return [
models.AiAgentResponse(
agent_id=str(agent["_id"]),
agent_name=agent["agent_name"],
agent_description=agent.get("agent_description"),
agent_purpose=agent.get("agent_purpose"),
agent_version=agent.get("agent_version"),
agent_status=agent.get("agent_status"),
agent_location=agent.get("agent_location"),
agent_department=agent.get("agent_department"),
agent_contact_person=agent.get("agent_contact_person"),
agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None,
agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None,
agent_tags=agent.get("agent_tags"),
agent_metadata=agent.get("agent_metadata"),
agent_userbase=agent.get("agent_userbase"),
agent_capabilities=agent.get("agent_capabilities"),
created_by=agent["created_by"]
) for agent in agents
create_agent_response(agent) for agent in agents
]
@app.post("/api/agents", response_model=models.AiAgentResponse)
async def create_agent(agent: models.AiAgentCreate, current_user: dict = Depends(get_current_user_from_cookie)):
agent_data = agent.model_dump()
created_agent = await crud.create_agent(agent_data, str(current_user["_id"]))
return models.AiAgentResponse(
agent_id=str(created_agent["_id"]),
agent_name=created_agent["agent_name"],
agent_description=created_agent.get("agent_description"),
agent_purpose=created_agent.get("agent_purpose"),
agent_version=created_agent.get("agent_version"),
agent_status=created_agent.get("agent_status"),
agent_location=created_agent.get("agent_location"),
agent_department=created_agent.get("agent_department"),
agent_contact_person=created_agent.get("agent_contact_person"),
agent_created_at=created_agent["created_at"].isoformat(),
agent_updated_at=created_agent["updated_at"].isoformat(),
agent_tags=created_agent.get("agent_tags"),
agent_metadata=created_agent.get("agent_metadata"),
agent_userbase=created_agent.get("agent_userbase"),
agent_capabilities=created_agent.get("agent_capabilities"),
created_by=created_agent["created_by"]
)
return create_agent_response(created_agent)
@app.get("/api/agents", response_model=List[models.AiAgentResponse])
async def get_user_agents(current_user: dict = Depends(get_current_user_from_cookie)):
agents = await crud.get_agents_by_user(str(current_user["_id"]))
return [
models.AiAgentResponse(
agent_id=str(agent["_id"]),
agent_name=agent["agent_name"],
agent_description=agent.get("agent_description"),
agent_purpose=agent.get("agent_purpose"),
agent_version=agent.get("agent_version"),
agent_status=agent.get("agent_status"),
agent_location=agent.get("agent_location"),
agent_department=agent.get("agent_department"),
agent_contact_person=agent.get("agent_contact_person"),
agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None,
agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None,
agent_tags=agent.get("agent_tags"),
agent_metadata=agent.get("agent_metadata"),
agent_userbase=agent.get("agent_userbase"),
agent_capabilities=agent.get("agent_capabilities"),
created_by=agent["created_by"]
) for agent in agents
create_agent_response(agent) for agent in agents
]
# Conditionally register MSAL routes if available
if msal_auth.is_msal_available():
app.get("/auth/azure/login")(azure_login)
app.get("/auth/azure/callback")(azure_callback)
# MSAL routes no longer needed - using popup authentication
@app.get("/api/agents/{agent_id}", response_model=models.AiAgentResponse)
async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)):
@ -716,23 +728,7 @@ async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user
if agent["created_by"] != str(current_user["_id"]) and not current_user.get("is_admin"):
raise HTTPException(status_code=403, detail="Not authorized to view this agent")
return models.AiAgentResponse(
agent_id=str(agent["_id"]),
agent_name=agent["agent_name"],
agent_description=agent.get("agent_description"),
agent_purpose=agent.get("agent_purpose"),
agent_version=agent.get("agent_version"),
agent_status=agent.get("agent_status"),
agent_location=agent.get("agent_location"),
agent_department=agent.get("agent_department"),
agent_contact_person=agent.get("agent_contact_person"),
agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None,
agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None,
agent_tags=agent.get("agent_tags"),
agent_metadata=agent.get("agent_metadata"),
agent_userbase=agent.get("agent_userbase"),
created_by=agent["created_by"]
)
return create_agent_response(agent)
@app.put("/api/agents/{agent_id}", response_model=models.AiAgentResponse)
async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user: dict = Depends(get_current_user_from_cookie)):
@ -748,28 +744,24 @@ async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user:
# For regular users, pass user_id to enforce ownership at DB level
# For admins, pass None to allow editing any agent
user_id_filter = str(current_user["_id"]) if not current_user.get("is_admin") else None
updated_agent = await crud.update_agent(agent_id, agent.model_dump(), user_id_filter)
# Prepare admin info for Quality Audit updates
admin_user_info = None
agent_data = agent.model_dump()
if current_user.get("is_admin"):
admin_user_info = {
"user_id": str(current_user["_id"]),
"user_name": current_user.get("full_name", current_user.get("email")),
"email": current_user.get("email")
}
updated_agent = await crud.update_agent(agent_id, agent_data, user_id_filter, admin_user_info)
if not updated_agent:
raise HTTPException(status_code=404, detail="Agent not found or not authorized")
return models.AiAgentResponse(
agent_id=str(updated_agent["_id"]),
agent_name=updated_agent["agent_name"],
agent_description=updated_agent.get("agent_description"),
agent_purpose=updated_agent.get("agent_purpose"),
agent_version=updated_agent.get("agent_version"),
agent_status=updated_agent.get("agent_status"),
agent_location=updated_agent.get("agent_location"),
agent_department=updated_agent.get("agent_department"),
agent_contact_person=updated_agent.get("agent_contact_person"),
agent_created_at=updated_agent["created_at"].isoformat() if updated_agent.get("created_at") else None,
agent_updated_at=updated_agent["updated_at"].isoformat() if updated_agent.get("updated_at") else None,
agent_tags=updated_agent.get("agent_tags"),
agent_metadata=updated_agent.get("agent_metadata"),
agent_userbase=updated_agent.get("agent_userbase"),
created_by=updated_agent["created_by"]
)
return create_agent_response(updated_agent)
@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)):
@ -837,24 +829,7 @@ async def get_all_agents_admin(current_user: dict = Depends(require_admin)):
agents = await crud.get_all_agents()
return [
models.AiAgentResponse(
agent_id=str(agent["_id"]),
agent_name=agent["agent_name"],
agent_description=agent.get("agent_description"),
agent_purpose=agent.get("agent_purpose"),
agent_version=agent.get("agent_version"),
agent_status=agent.get("agent_status"),
agent_location=agent.get("agent_location"),
agent_department=agent.get("agent_department"),
agent_contact_person=agent.get("agent_contact_person"),
agent_created_at=agent["created_at"].isoformat() if agent.get("created_at") else None,
agent_updated_at=agent["updated_at"].isoformat() if agent.get("updated_at") else None,
agent_tags=agent.get("agent_tags"),
agent_metadata=agent.get("agent_metadata"),
agent_userbase=agent.get("agent_userbase"),
agent_capabilities=agent.get("agent_capabilities"),
created_by=agent["created_by"]
) for agent in agents
create_agent_response(agent) for agent in agents
]
# Agent Collector API Endpoints (for compatibility with agent_collector app)
@ -1007,7 +982,4 @@ async def get_agent_usage_chart(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get chart data: {str(e)}")
# Conditionally register MSAL routes if available
if msal_auth.is_msal_available():
app.get("/auth/azure/login")(azure_login)
app.get("/auth/azure/callback")(azure_callback)
# MSAL routes no longer needed - using popup authentication

View file

@ -5,6 +5,7 @@ from typing import Optional
class AiAgent(BaseModel):
agent_id: int
agent_name: str
agent_tool: str | None = Field(default=None, title="The tool or platform where the agent operates", max_length=100)
agent_description: str | None = Field(default=None, title="The description of the agent", max_length=300)
agent_purpose: str | None = Field(default=None, title="The purpose of the agent", max_length=200)
agent_version: str | None = Field(default=None, title="The version of the agent", max_length=100)
@ -18,6 +19,10 @@ class AiAgent(BaseModel):
agent_metadata: dict[str, str] | None = Field(default=None, title="Metadata associated with the agent")
agent_userbase: list[str] | None = Field(default=None, title="Userbase associated with the agent")
agent_capabilities: list[str] | None = Field(default=None, title="Capabilities of the agent")
quality_audit_status: bool | None = Field(default=False, title="Quality audit status")
quality_audit_updated_by: str | None = Field(default=None, title="Admin user ID who updated quality audit")
quality_audit_updated_at: str | None = Field(default=None, title="Quality audit last update timestamp")
quality_audit_updated_by_name: str | None = Field(default=None, title="Admin user name who updated quality audit")
@ -50,6 +55,7 @@ class Token(BaseModel):
# Agent models for creation and response
class AiAgentCreate(BaseModel):
agent_name: str
agent_tool: str
agent_description: Optional[str] = None
agent_purpose: Optional[str] = None
agent_version: Optional[str] = None
@ -61,10 +67,15 @@ class AiAgentCreate(BaseModel):
agent_metadata: Optional[dict[str, str]] = None
agent_userbase: Optional[list[str]] = None
agent_capabilities: Optional[list[str]] = None
quality_audit_status: Optional[bool] = False
quality_audit_updated_by: Optional[str] = None
quality_audit_updated_at: Optional[str] = None
quality_audit_updated_by_name: Optional[str] = None
class AiAgentResponse(BaseModel):
agent_id: str
agent_name: str
agent_tool: Optional[str] = None
agent_description: Optional[str] = None
agent_purpose: Optional[str] = None
agent_version: Optional[str] = None
@ -78,6 +89,10 @@ class AiAgentResponse(BaseModel):
agent_metadata: Optional[dict[str, str]] = None
agent_userbase: Optional[list[str]] = None
agent_capabilities: Optional[list[str]] = None
quality_audit_status: Optional[bool] = None
quality_audit_updated_by: Optional[str] = None
quality_audit_updated_at: Optional[str] = None
quality_audit_updated_by_name: Optional[str] = None
created_by: str
# Agent Collector API Models (for compatibility with agent_collector app)
@ -85,6 +100,7 @@ class AgentCollectorCreate(BaseModel):
name: str = Field(min_length=1)
description: str = Field(min_length=1)
purpose: str = Field(min_length=1)
tool: str = Field(min_length=1)
location: Optional[str] = None
userbase: Optional[list[str]] = None
version: Optional[str] = None

View file

@ -11,8 +11,8 @@ if config.is_msal_enabled():
else:
msal = None
# MSAL Scopes
SCOPES = ["User.Read"]
# MSAL Scopes - Following specification
SCOPES = ["openid", "profile", "email"]
class MSALAuth:
def __init__(self):
@ -36,7 +36,7 @@ class MSALAuth:
raise ValueError("Missing Azure AD configuration. Check environment variables.")
def _build_msal_app(self, cache=None):
"""Create MSAL confidential client application"""
"""Create MSAL public client application for SPA"""
return msal.PublicClientApplication(
self.client_id,
authority=self.authority,
@ -52,7 +52,7 @@ class MSALAuth:
def get_auth_url(self, session_state: Optional[str] = None) -> Dict[str, Any]:
"""
Generate authorization URL with PKCE challenge
Generate authorization URL with PKCE challenge for SPA
Returns: dict with auth_url, state, and code_verifier for session storage
"""
if not self.enabled:
@ -60,19 +60,30 @@ class MSALAuth:
app = self._build_msal_app()
# Generate PKCE parameters
# Generate PKCE parameters (required for SPA)
code_verifier = secrets.token_urlsafe(96)
# Generate state parameter for CSRF protection
state = session_state or secrets.token_urlsafe(32)
auth_url = app.get_authorization_request_url(
scopes=SCOPES,
redirect_uri=self.redirect_uri,
state=state,
code_challenge=self._generate_pkce_challenge(code_verifier),
code_challenge_method="S256"
)
code_challenge = self._generate_pkce_challenge(code_verifier)
# Manual URL construction to ensure PKCE parameters are included
# MSAL library sometimes doesn't include PKCE params correctly for SPA
import urllib.parse
auth_params = {
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": " ".join(SCOPES),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"response_mode": "query"
}
auth_url = f"{self.authority}/oauth2/v2.0/authorize?" + urllib.parse.urlencode(auth_params)
return {
"auth_url": auth_url,
@ -85,22 +96,43 @@ class MSALAuth:
code_verifier: str,
scopes: Optional[list] = None) -> Optional[Dict[str, Any]]:
"""
Exchange authorization code for tokens using PKCE
Exchange authorization code for tokens using PKCE for SPA
"""
if not self.enabled:
raise RuntimeError("MSAL is disabled")
if not code_verifier:
print("MSAL Error: code_verifier is required for SPA")
return None
app = self._build_msal_app()
if scopes is None:
scopes = SCOPES
result = app.acquire_token_by_authorization_code(
auth_code,
scopes=scopes,
redirect_uri=self.redirect_uri,
code_verifier=code_verifier
)
# Manual token request for PKCE flow - MSAL library may not support code_verifier properly
import requests
token_url = f"{self.authority}/oauth2/v2.0/token"
token_data = {
"client_id": self.client_id,
"scope": " ".join(scopes),
"code": auth_code,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
"code_verifier": code_verifier
}
try:
response = requests.post(token_url, data=token_data)
result = response.json()
if response.status_code != 200:
result["error"] = result.get("error", "token_request_failed")
except Exception as e:
print(f"MSAL Error: Manual token request failed: {e}")
return None
if "error" in result:
print(f"MSAL Error: {result.get('error_description', result.get('error'))}")

224
msal_pkce_flow.md Normal file
View file

@ -0,0 +1,224 @@
# MSAL PKCE Authentication Flow Specification
## Overview
This document specifies the Microsoft Authentication Library (MSAL) implementation with Proof Key for Code Exchange (PKCE) flow used in this web application. The implementation uses popup-based authentication with Azure AD/Entra ID and includes server-side JWT validation with httpOnly cookies for secure session management.
## Architecture Components
### Frontend Components
- **MSAL Browser Library**: `msal-browser.min.js` v2.15.0
- **Authentication Method**: Popup-based login with PKCE
- **Token Storage**: Session storage for MSAL tokens, httpOnly cookies for server sessions
- **Cache Configuration**: Session storage with fallback cookie for auth state
### Backend Components
- **Server-Side Validation**: Custom JWT validation using Firebase JWT library
- **Session Management**: httpOnly cookies with security headers
- **Token Validation**: Real-time validation against Azure AD public keys
- **Authentication Middleware**: Server-side protection for all routes
## Azure AD Configuration
### Application Registration
- **Client ID**: `9079054c-9620-4757-a256-23413042f1ef`
- **Tenant ID**: `e519c2e6-bc6d-4fdf-8d9c-923c2f002385`
- **Authority URL**: `https://login.microsoftonline.com/{tenantId}`
- **Application Type**: Single Page Application (SPA)
- **Authentication Flow**: Authorization Code with PKCE
- **Redirect URI**: Dynamic based on application origin
### Required Scopes
- `openid` - OpenID Connect authentication
- `profile` - User profile information
- `email` - User email address
## MSAL Configuration
### Client Configuration
```javascript
const msalConfig = {
auth: {
clientId: "9079054c-9620-4757-a256-23413042f1ef",
authority: "https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385",
redirectUri: window.location.origin + window.location.pathname.replace(/\/$/, '')
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: true,
}
};
```
### Login Request Configuration
```javascript
const loginRequest = {
scopes: ["openid", "profile", "email"],
prompt: "select_account"
};
```
## Authentication Flow Steps
### 1. User Access Control
- **Server-Side Gate**: All pages require authentication via `AuthMiddleware->requireAuth()`
- **Unauthenticated Users**: Redirected to login interface automatically
- **Token Validation**: Every request validates JWT against Azure AD public keys
### 2. Login Initiation
1. User clicks "Sign In with Microsoft" button
2. `myMSALObj.loginPopup(loginRequest)` triggers PKCE flow
3. MSAL automatically generates PKCE code verifier and challenge
4. User redirected to Azure AD login in popup window
### 3. Azure AD Authentication
1. User authenticates with Microsoft credentials
2. Azure AD validates user and application permissions
3. Authorization code returned with PKCE validation
4. MSAL exchanges code for tokens using PKCE code verifier
### 4. Token Exchange and Validation
1. **Client receives tokens**: ID token and access token from MSAL
2. **Server submission**: Tokens sent to `auth.php` endpoint
3. **JWT validation**: Server validates token signature against Azure AD JWKS
4. **Claims validation**: Audience, issuer, expiration, and timing claims checked
5. **Cookie creation**: Valid tokens stored in httpOnly cookie with security flags
### 5. Session Management
1. **httpOnly cookie**: Prevents JavaScript access to authentication token
2. **Security flags**: Secure, SameSite=Lax, path=/, 24-hour expiration
3. **Server-side validation**: Every request validates cookie token
4. **Automatic renewal**: No automatic token refresh implemented
### 6. Logout Process
1. **Client logout**: `auth.php` called to clear server-side cookie
2. **MSAL logout**: `myMSALObj.logoutPopup()` clears client-side tokens
3. **Page reload**: Fresh page load to reflect authentication state
## Security Implementation
### Token Security
- **httpOnly cookies**: Prevent XSS access to authentication tokens
- **Secure flag**: Cookies only sent over HTTPS in production
- **SameSite protection**: CSRF protection with Lax policy
- **Server-side validation**: No client-side security dependencies
### JWT Validation Process
1. **JWKS retrieval**: Public keys fetched from Azure AD OpenID configuration
2. **Signature verification**: JWT signature validated using RS256 algorithm
3. **Claims validation**: Comprehensive validation of all standard claims
4. **Real-time validation**: Every request validates token freshness
### Supported Token Types
- **Primary**: ID tokens (preferred for user authentication)
- **Fallback**: Access tokens (Microsoft Graph audience)
- **Validation**: Both token types supported with appropriate audience validation
## Server-Side Components
### AuthMiddleware.php
- **Purpose**: Authentication gate for all protected resources
- **Methods**:
- `requireAuth()`: Enforce authentication for page access
- `isAuthenticated()`: Check current authentication status
- `setAuthToken()`: Store validated token in httpOnly cookie
- `clearAuthToken()`: Remove authentication cookie
### JWTValidator.php
- **Purpose**: Validate Azure AD JWT tokens server-side
- **Features**:
- Real-time JWKS key retrieval from Azure AD
- Comprehensive claims validation (exp, nbf, aud, iss)
- Support for multiple token audiences and issuers
- Firebase JWT library integration
### auth.php
- **Purpose**: Authentication endpoint for login/logout operations
- **Endpoints**:
- `POST /auth.php?action=login`: Process authentication tokens
- `POST /auth.php?action=logout`: Clear authentication session
- `GET /auth.php?action=status`: Check authentication status
## Error Handling
### Authentication Errors
- **Invalid tokens**: Clear error messages with retry options
- **Expired sessions**: Automatic redirect to login interface
- **Network errors**: Graceful fallback with user notifications
- **Popup blocking**: Error handling for blocked popup windows
### Server-Side Error Handling
- **Token validation failures**: Specific error messages logged and returned
- **JWKS retrieval errors**: Fallback mechanisms and retry logic
- **Cookie security**: Proper error responses for cookie failures
## Implementation Requirements
### Frontend Dependencies
- MSAL Browser library v2.15.0 or later
- Modern browser with popup support
- JavaScript ES6+ support
- Fetch API for token submission
### Backend Dependencies
- PHP 7.4+ with OpenSSL support
- Firebase JWT library via Composer
- cURL or file_get_contents for HTTPS requests
- Cookie support in server environment
### Azure AD Requirements
- Application registered as Single Page Application
- Redirect URIs configured for all deployment environments
- Appropriate API permissions granted
- PKCE support enabled (default for SPAs)
## Configuration Variables
### Required Environment Configuration
```php
// AuthMiddleware.php
private $tenantId = 'e519c2e6-bc6d-4fdf-8d9c-923c2f002385';
private $clientId = '9079054c-9620-4757-a256-23413042f1ef';
```
### Cookie Security Configuration
```php
$cookieOptions = [
'expires' => time() + (24 * 60 * 60), // 24 hours
'path' => '/',
'domain' => '',
'secure' => isset($_SERVER['HTTPS']), // HTTPS in production
'httponly' => true,
'samesite' => 'Lax'
];
```
## Testing and Validation
### Authentication Testing
1. **Successful login**: Verify popup authentication and cookie creation
2. **Token validation**: Confirm server-side JWT validation
3. **Session persistence**: Test page navigation with authentication
4. **Logout functionality**: Verify complete session termination
### Security Testing
1. **Cookie security**: Verify httpOnly and secure flags
2. **XSS protection**: Confirm no client-side token exposure
3. **Token expiration**: Test automatic session invalidation
4. **CSRF protection**: Verify SameSite cookie protection
## Migration Considerations
### From Basic MSAL Implementation
1. **Add server-side validation**: Implement JWT validation middleware
2. **Replace client-side token storage**: Move to httpOnly cookies
3. **Add authentication gates**: Protect all server endpoints
4. **Update error handling**: Implement proper fallback mechanisms
### Security Upgrades
1. **Remove client-side security**: Eliminate CSS-based protection
2. **Implement token validation**: Add real-time JWT verification
3. **Secure cookie implementation**: Use httpOnly with security flags
4. **Add comprehensive error handling**: User-friendly error messages
This specification provides a complete reference for implementing secure MSAL PKCE authentication with server-side validation and httpOnly cookie session management.

View file

@ -37,3 +37,4 @@ urllib3==2.5.0
uvicorn==0.35.0
msal==1.26.0
itsdangerous==2.2.0
cryptography==41.0.8

View file

@ -1,7 +1,7 @@
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--accent-color: #f093fb;
--primary-color: #f3ae3e;
--secondary-color: #f3ae3e;
--accent-color: #f3ae3e;
--text-dark: #2d3748;
--text-light: #718096;
--bg-light: #f7fafc;
@ -11,8 +11,8 @@
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Microsoft Brand Colors */
--ms-blue: #0078d4;
--ms-blue-dark: #106ebe;
--ms-blue: #f3ae3e;
--ms-blue-dark: #f3ae3e;
--ms-gray: #5e5e5e;
}
@ -170,7 +170,7 @@ body {
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
box-shadow: 0 0 0 3px rgba(243, 174, 62, 0.1);
}
.form-label {
@ -259,14 +259,14 @@ h2 {
border-color: var(--ms-blue-dark);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 120, 212, 0.3);
box-shadow: 0 4px 8px rgba(243, 174, 62, 0.3);
}
.btn-microsoft:focus {
background-color: var(--ms-blue-dark);
border-color: var(--ms-blue-dark);
color: white;
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.2);
box-shadow: 0 0 0 3px rgba(243, 174, 62, 0.2);
}
.btn-microsoft:active {

View file

@ -224,9 +224,111 @@
</div>
</div>
<!-- Edit Agent Modal -->
<div class="modal fade" id="editAgentModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Agent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editAgentForm">
<input type="hidden" id="editAgentId">
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentName" class="form-label">Agent Name *</label>
<input type="text" class="form-control" id="editAgentName" required>
</div>
<div class="col-md-6 mb-3">
<label for="editAgentTool" class="form-label">Tool *</label>
<input type="text" class="form-control" id="editAgentTool" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentStatus" class="form-label">Status</label>
<select class="form-select" id="editAgentStatus">
<option value="Development">Development</option>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Deprecated">Deprecated</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="editAgentVersion" class="form-label">Version</label>
<input type="text" class="form-control" id="editAgentVersion">
</div>
</div>
<div class="mb-3">
<label for="editAgentDescription" class="form-label">Description</label>
<textarea class="form-control" id="editAgentDescription" rows="3" maxlength="300"></textarea>
</div>
<div class="mb-3">
<label for="editAgentPurpose" class="form-label">Purpose</label>
<input type="text" class="form-control" id="editAgentPurpose" maxlength="200">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentLocation" class="form-label">Location</label>
<input type="text" class="form-control" id="editAgentLocation">
</div>
<div class="col-md-6 mb-3">
<label for="editAgentDepartment" class="form-label">Department</label>
<input type="text" class="form-control" id="editAgentDepartment">
</div>
</div>
<div class="mb-3">
<label for="editAgentContact" class="form-label">Contact Person</label>
<input type="text" class="form-control" id="editAgentContact">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentTags" class="form-label">Tags</label>
<input type="text" class="form-control" id="editAgentTags" placeholder="Separate with commas">
</div>
<div class="col-md-6 mb-3">
<label for="editAgentUserbase" class="form-label">Target Userbase</label>
<input type="text" class="form-control" id="editAgentUserbase" placeholder="Separate with commas">
</div>
</div>
<div class="mb-3">
<label for="editAgentCapabilities" class="form-label">Capabilities</label>
<input type="text" class="form-control" id="editAgentCapabilities" placeholder="Separate with commas">
</div>
<div class="mb-3" id="editQualityAuditSection">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editQualityAuditStatus">
<label class="form-check-label" for="editQualityAuditStatus">
<i class="fas fa-certificate me-2"></i>Quality Audit
<span class="badge bg-warning text-dark ms-2">Admin Only</span>
</label>
</div>
<div class="form-text" id="editQualityAuditNote">
Check this box if the agent has passed quality audit review.
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="editAgentForm" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</div>
<style>
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
border-radius: 16px;
padding: 1.5rem;
color: white;
@ -237,15 +339,15 @@
}
.admin-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
}
.agent-card {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
}
.active-card {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
}
.stat-icon {
@ -270,7 +372,7 @@
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
color: white;
display: flex;
align-items: center;
@ -287,7 +389,7 @@
}
.table-hover tbody tr:hover {
background-color: rgba(102, 126, 234, 0.05);
background-color: rgba(243, 174, 62, 0.05);
}
.card {
@ -297,7 +399,7 @@
}
.card-header {
background: rgba(102, 126, 234, 0.05);
background: rgba(243, 174, 62, 0.05);
border-bottom: 1px solid #e2e8f0;
border-radius: 16px 16px 0 0 !important;
}
@ -310,8 +412,8 @@
.nav-tabs .nav-link.active {
background-color: transparent;
border-bottom: 2px solid #667eea;
color: #667eea;
border-bottom: 2px solid #f3ae3e;
color: #f3ae3e;
}
.status-Active { background-color: #d4edda; color: #155724; }
@ -351,6 +453,7 @@ function setupEventListeners() {
document.getElementById('agentSearch').addEventListener('input', filterAgents);
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
document.getElementById('editUserForm').addEventListener('submit', handleEditUserSubmit);
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
}
async function loadAdminData() {
@ -467,6 +570,10 @@ function displayAgents(agents) {
const agentsHtml = agents.map(agent => {
const owner = allUsers.find(u => u.email === agent.created_by);
const ownerName = owner?.full_name || agent.agent_contact_person || 'Unknown';
const ownerEmail = owner?.email || agent.created_by;
const avatarText = (owner?.full_name || agent.agent_contact_person || 'U')[0].toUpperCase();
return `
<tr>
<td>
@ -476,11 +583,11 @@ function displayAgents(agents) {
<td>
<div class="d-flex align-items-center">
<div class="user-avatar-sm me-2">
${(owner?.full_name || owner?.email || 'U')[0].toUpperCase()}
${avatarText}
</div>
<div>
<div class="fw-medium">${owner?.full_name || 'Unknown'}</div>
<small class="text-muted">${owner?.email || agent.created_by}</small>
<div class="fw-medium">${ownerName}</div>
<small class="text-muted">${ownerEmail}</small>
</div>
</div>
</td>
@ -498,6 +605,9 @@ function displayAgents(agents) {
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewAgentDetails('${agent.agent_id}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-warning btn-sm me-1" onclick="editAgentAdmin('${agent.agent_id}')">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteAgentAdmin('${agent.agent_id}')">
<i class="fas fa-trash"></i>
</button>
@ -539,11 +649,42 @@ function viewUserDetails(email) {
alert(`User Details:\nName: ${user.full_name || 'No Name'}\nEmail: ${user.email}\nType: ${user.is_admin ? 'Admin' : 'User'}\nStatus: ${user.is_active ? 'Active' : 'Inactive'}\nAgents Created: ${userAgents.length}`);
}
function viewAgentDetails(agentId) {
const agent = allAgents.find(a => a.agent_id === agentId);
if (!agent) return;
alert(`Agent Details:\nName: ${agent.agent_name}\nStatus: ${agent.agent_status || 'Development'}\nVersion: ${agent.agent_version || 'N/A'}\nDescription: ${agent.agent_description || 'No description'}\nOwner: ${agent.created_by}`);
async function viewAgentDetails(agentId) {
try {
console.log('DEBUG: Admin fetching fresh agent details for ID:', agentId);
// Fetch fresh agent data from server
const response = await fetch(`{{ base_path }}/api/agents/${agentId}`, {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_path }}/login';
return;
} else if (response.status === 403) {
showError('Not authorized to view this agent');
return;
} else {
showError('Failed to load agent details');
return;
}
}
const agent = await response.json();
console.log('DEBUG: Admin fresh agent data loaded:', agent);
const qualityAuditText = agent.quality_audit_status ? 'Audited' : 'Not Audited';
const auditTrail = agent.quality_audit_updated_by_name && agent.quality_audit_updated_at ?
`\nQuality Audit: ${qualityAuditText} by ${agent.quality_audit_updated_by_name} on ${formatDate(agent.quality_audit_updated_at)}` :
`\nQuality Audit: ${qualityAuditText}`;
alert(`Agent Details:\nName: ${agent.agent_name}\nStatus: ${agent.agent_status || 'Development'}\nVersion: ${agent.agent_version || 'N/A'}\nDescription: ${agent.agent_description || 'No description'}\nOwner: ${agent.created_by}${auditTrail}`);
} catch (error) {
console.error('Error loading agent details:', error);
showError('Failed to load agent details. Please try again.');
}
}
function editUser(email) {
@ -675,6 +816,117 @@ async function handleEditUserSubmit(e) {
}
}
async function editAgentAdmin(agentId) {
try {
console.log('DEBUG: Admin fetching fresh agent data for editing:', agentId);
// Fetch fresh agent data from server
const response = await fetch(`{{ base_path }}/api/agents/${agentId}`, {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_path }}/login';
return;
} else if (response.status === 403) {
showError('Not authorized to edit this agent');
return;
} else {
showError('Failed to load agent for editing');
return;
}
}
const agent = await response.json();
console.log('DEBUG: Admin fresh agent data for editing:', agent);
// Populate edit form
document.getElementById('editAgentId').value = agent.agent_id;
document.getElementById('editAgentName').value = agent.agent_name;
document.getElementById('editAgentTool').value = agent.agent_tool || '';
document.getElementById('editAgentStatus').value = agent.agent_status || 'Development';
document.getElementById('editAgentDescription').value = agent.agent_description || '';
document.getElementById('editAgentPurpose').value = agent.agent_purpose || '';
document.getElementById('editAgentVersion').value = agent.agent_version || '';
document.getElementById('editAgentLocation').value = agent.agent_location || '';
document.getElementById('editAgentDepartment').value = agent.agent_department || '';
document.getElementById('editAgentContact').value = agent.agent_contact_person || '';
document.getElementById('editAgentTags').value = agent.agent_tags ? agent.agent_tags.join(', ') : '';
document.getElementById('editAgentUserbase').value = agent.agent_userbase ? agent.agent_userbase.join(', ') : '';
document.getElementById('editAgentCapabilities').value = agent.agent_capabilities ? agent.agent_capabilities.join(', ') : '';
// Handle Quality Audit field (admin always has access)
document.getElementById('editQualityAuditStatus').checked = agent.quality_audit_status || false;
// Update the note with audit trail information
let noteHtml = 'Check this box if the agent has passed quality audit review.';
if (agent.quality_audit_updated_by_name && agent.quality_audit_updated_at) {
const action = agent.quality_audit_status ? 'checked' : 'unchecked';
const date = formatDate(agent.quality_audit_updated_at);
noteHtml += `<br><br><small class="text-info"><i class="fas fa-history me-1"></i><strong>${agent.quality_audit_updated_by_name}</strong> ${action} Quality Audit on ${date}</small>`;
} else {
noteHtml += '<br><br><small class="text-muted"><i class="fas fa-info-circle me-1"></i>No quality audit changes recorded yet.</small>';
}
document.getElementById('editQualityAuditNote').innerHTML = noteHtml;
// Show edit modal
const modal = new bootstrap.Modal(document.getElementById('editAgentModal'));
modal.show();
} catch (error) {
console.error('Error loading agent for editing:', error);
showError('Failed to load agent for editing. Please try again.');
}
}
async function handleEditAgentSubmit(e) {
e.preventDefault();
const agentId = document.getElementById('editAgentId').value;
const agentData = {
agent_name: document.getElementById('editAgentName').value,
agent_tool: document.getElementById('editAgentTool').value,
agent_status: document.getElementById('editAgentStatus').value,
agent_description: document.getElementById('editAgentDescription').value,
agent_purpose: document.getElementById('editAgentPurpose').value,
agent_version: document.getElementById('editAgentVersion').value,
agent_location: document.getElementById('editAgentLocation').value,
agent_department: document.getElementById('editAgentDepartment').value,
agent_contact_person: document.getElementById('editAgentContact').value,
agent_tags: document.getElementById('editAgentTags').value.split(',').map(s => s.trim()).filter(s => s),
agent_userbase: document.getElementById('editAgentUserbase').value.split(',').map(s => s.trim()).filter(s => s),
agent_capabilities: document.getElementById('editAgentCapabilities').value.split(',').map(s => s.trim()).filter(s => s),
quality_audit_status: document.getElementById('editQualityAuditStatus').checked
};
try {
const response = await fetch(`{{ base_path }}/api/agents/${agentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(agentData)
});
if (response.ok) {
// Hide the modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editAgentModal'));
modal.hide();
// Reload data and show success
await loadAdminData();
showSuccess('Agent updated successfully');
} else {
const error = await response.json();
showError(error.detail || 'Failed to update agent');
}
} catch (error) {
showError('Failed to update agent');
}
}
function logout() {
localStorage.removeItem('access_token');
window.location.href = '{{ base_path }}/';

View file

@ -239,6 +239,13 @@
<label for="editAgentName" class="form-label">Agent Name *</label>
<input type="text" class="form-control" id="editAgentName" required>
</div>
<div class="col-md-6 mb-3">
<label for="editAgentTool" class="form-label">Tool *</label>
<input type="text" class="form-control" id="editAgentTool" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentStatus" class="form-label">Status</label>
<select class="form-select" id="editAgentStatus">
@ -248,6 +255,10 @@
<option value="Deprecated">Deprecated</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="editAgentVersion" class="form-label">Version</label>
<input type="text" class="form-control" id="editAgentVersion">
</div>
</div>
<div class="mb-3">
@ -255,15 +266,9 @@
<textarea class="form-control" id="editAgentDescription" rows="3" maxlength="300"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="editAgentPurpose" class="form-label">Purpose</label>
<input type="text" class="form-control" id="editAgentPurpose" maxlength="200">
</div>
<div class="col-md-6 mb-3">
<label for="editAgentVersion" class="form-label">Version</label>
<input type="text" class="form-control" id="editAgentVersion">
</div>
<div class="mb-3">
<label for="editAgentPurpose" class="form-label">Purpose</label>
<input type="text" class="form-control" id="editAgentPurpose" maxlength="200">
</div>
<div class="row">
@ -297,6 +302,19 @@
<label for="editAgentCapabilities" class="form-label">Capabilities</label>
<input type="text" class="form-control" id="editAgentCapabilities" placeholder="Separate with commas">
</div>
<div class="mb-3" id="editQualityAuditSection">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="editQualityAuditStatus">
<label class="form-check-label" for="editQualityAuditStatus">
<i class="fas fa-certificate me-2"></i>Quality Audit
<span class="badge bg-warning text-dark ms-2">Admin Only</span>
</label>
</div>
<div class="form-text" id="editQualityAuditNote">
Only administrators can modify quality audit status.
</div>
</div>
</form>
</div>
<div class="modal-footer">
@ -337,7 +355,7 @@
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
display: flex;
align-items: center;
justify-content: center;
@ -349,7 +367,7 @@
width: 35px;
height: 35px;
border-radius: 8px;
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
display: flex;
align-items: center;
justify-content: center;
@ -375,6 +393,7 @@ let myAgents = [];
let currentAgentId = null;
let currentView = '{{ current_view }}';
let currentUserId = '{{ current_user._id|string }}';
let currentUserIsAdmin = {{ 'true' if current_user.is_admin else 'false' }};
document.addEventListener('DOMContentLoaded', function() {
// Load both datasets initially for accurate counts
@ -420,12 +439,19 @@ async function loadAgentsForCurrentView() {
async function loadAllAgents() {
try {
console.log('DEBUG: Loading all agents from server...');
const response = await fetch('{{ base_path }}/api/agents/all', {
credentials: 'include'
});
if (response.ok) {
allAgents = await response.json();
console.log('DEBUG: Loaded all agents:', allAgents.length, 'agents');
console.log('DEBUG: First agent Quality Audit data:', allAgents.length > 0 ? {
name: allAgents[0].agent_name,
quality_audit_status: allAgents[0].quality_audit_status,
quality_audit_updated_by_name: allAgents[0].quality_audit_updated_by_name
} : 'No agents');
agents = allAgents;
displayAgents(agents);
updateAgentCounts();
@ -535,6 +561,7 @@ function displayAgents(agentsToShow) {
${agent.agent_status || 'Development'}
</span>
${agent.agent_version ? `<span class="badge bg-light text-dark">v${agent.agent_version}</span>` : ''}
${agent.quality_audit_status ? '<span class="badge bg-success" title="Quality Audited"><i class="fas fa-certificate"></i></span>' : ''}
</div>
</div>
<div class="text-end">
@ -566,12 +593,38 @@ function displayAgents(agentsToShow) {
container.innerHTML = agentsHtml;
}
function showAgentDetails(agentId) {
const agent = agents.find(a => a.agent_id === agentId);
if (!agent) return;
async function showAgentDetails(agentId) {
console.log('DEBUG: Fetching fresh agent details for ID:', agentId);
currentAgentId = agentId;
try {
// Fetch fresh agent data from server
const response = await fetch(`{{ base_path }}/api/agents/${agentId}`, {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_path }}/login';
return;
} else if (response.status === 403) {
showError('Not authorized to view this agent');
return;
} else {
showError('Failed to load agent details');
return;
}
}
const agent = await response.json();
console.log('DEBUG: Fresh agent data loaded:', agent);
console.log('DEBUG: Quality Audit Details:', {
status: agent.quality_audit_status,
updated_by: agent.quality_audit_updated_by,
updated_by_name: agent.quality_audit_updated_by_name,
updated_at: agent.quality_audit_updated_at
});
const modalContent = `
<div class="row">
<div class="col-md-6">
@ -581,6 +634,12 @@ function showAgentDetails(agentId) {
<tr><td><strong>Status:</strong></td><td><span class="agent-status status-${agent.agent_status || 'Development'}">${agent.agent_status || 'Development'}</span></td></tr>
<tr><td><strong>Version:</strong></td><td>${agent.agent_version || 'N/A'}</td></tr>
<tr><td><strong>Purpose:</strong></td><td>${agent.agent_purpose || 'N/A'}</td></tr>
<tr><td><strong>Quality Audit:</strong></td><td>
${agent.quality_audit_status ?
'<span class="badge bg-success"><i class="fas fa-certificate me-1"></i>Audited</span>' :
'<span class="badge bg-secondary">Not Audited</span>'
}
</td></tr>
</table>
</div>
<div class="col-md-6">
@ -636,6 +695,23 @@ function showAgentDetails(agentId) {
</div>
` : ''}
<div class="row mt-3">
<div class="col-12">
<h6>Quality Audit History</h6>
${agent.quality_audit_updated_by_name && agent.quality_audit_updated_at ? `
<p class="text-muted small mb-0">
<i class="fas fa-user me-1"></i><strong>${agent.quality_audit_updated_by_name}</strong>
${agent.quality_audit_status ? 'checked' : 'unchecked'} Quality Audit on
<strong>${formatDate(agent.quality_audit_updated_at)}</strong>
</p>
` : `
<p class="text-muted small mb-0">
<i class="fas fa-info-circle me-1"></i>No quality audit changes recorded yet.
</p>
`}
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h6>Usage Analytics</h6>
@ -693,20 +769,51 @@ function showAgentDetails(agentId) {
document.getElementById('deleteAgentBtn').addEventListener('click', deleteAgent);
}
const modal = new bootstrap.Modal(document.getElementById('agentModal'));
modal.show();
// Load usage data after modal is shown
loadUsageChart(agent.agent_name);
const modal = new bootstrap.Modal(document.getElementById('agentModal'));
modal.show();
// Load usage data after modal is shown
loadUsageChart(agent.agent_name);
} catch (error) {
console.error('Error loading agent details:', error);
showError('Failed to load agent details. Please try again.');
}
}
function showEditModal() {
const agent = agents.find(a => a.agent_id === currentAgentId);
if (!agent) return;
async function showEditModal() {
console.log('showEditModal called with currentAgentId:', currentAgentId);
try {
// Fetch fresh agent data from server
const response = await fetch(`{{ base_path }}/api/agents/${currentAgentId}`, {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_path }}/login';
return;
} else if (response.status === 403) {
showError('Not authorized to edit this agent');
return;
} else {
showError('Failed to load agent for editing');
return;
}
}
const agent = await response.json();
console.log('DEBUG: Fresh agent data for editing:', agent);
console.log('Agent Quality Audit Status:', agent.quality_audit_status);
console.log('Agent Quality Audit Updated By:', agent.quality_audit_updated_by_name);
console.log('Agent Quality Audit Updated At:', agent.quality_audit_updated_at);
console.log('Agent Quality Audit Updated By ID:', agent.quality_audit_updated_by);
// Populate edit form
document.getElementById('editAgentId').value = agent.agent_id;
document.getElementById('editAgentName').value = agent.agent_name;
document.getElementById('editAgentTool').value = agent.agent_tool || '';
document.getElementById('editAgentStatus').value = agent.agent_status || 'Development';
document.getElementById('editAgentDescription').value = agent.agent_description || '';
document.getElementById('editAgentPurpose').value = agent.agent_purpose || '';
@ -718,10 +825,44 @@ function showEditModal() {
document.getElementById('editAgentUserbase').value = agent.agent_userbase ? agent.agent_userbase.join(', ') : '';
document.getElementById('editAgentCapabilities').value = agent.agent_capabilities ? agent.agent_capabilities.join(', ') : '';
// Hide details modal and show edit modal
bootstrap.Modal.getInstance(document.getElementById('agentModal')).hide();
const editModal = new bootstrap.Modal(document.getElementById('editModal'));
editModal.show();
// Handle Quality Audit field
const qualityAuditCheckbox = document.getElementById('editQualityAuditStatus');
const qualityAuditSection = document.getElementById('editQualityAuditSection');
if (currentUserIsAdmin) {
qualityAuditSection.style.display = 'block';
qualityAuditCheckbox.checked = agent.quality_audit_status || false;
qualityAuditCheckbox.disabled = false;
// Update the note with audit trail information
let noteHtml = 'Check this box if the agent has passed quality audit review.';
if (agent.quality_audit_updated_by_name && agent.quality_audit_updated_at) {
const action = agent.quality_audit_status ? 'checked' : 'unchecked';
const date = formatDate(agent.quality_audit_updated_at);
noteHtml += `<br><br><small class="text-info"><i class="fas fa-history me-1"></i><strong>${agent.quality_audit_updated_by_name}</strong> ${action} Quality Audit on ${date}</small>`;
} else {
noteHtml += '<br><br><small class="text-muted"><i class="fas fa-info-circle me-1"></i>No quality audit changes recorded yet.</small>';
}
document.getElementById('editQualityAuditNote').innerHTML = noteHtml;
} else {
qualityAuditSection.style.display = 'none';
}
// Hide details modal if it's open, then show edit modal
const agentModal = bootstrap.Modal.getInstance(document.getElementById('agentModal'));
if (agentModal) {
agentModal.hide();
}
console.log('About to show edit modal');
const editModal = new bootstrap.Modal(document.getElementById('editModal'));
editModal.show();
console.log('Edit modal show() called');
} catch (error) {
console.error('Error loading agent for editing:', error);
showError('Failed to load agent for editing. Please try again.');
}
}
async function updateAgent(e) {
@ -730,6 +871,7 @@ async function updateAgent(e) {
const agentId = document.getElementById('editAgentId').value;
const agentData = {
agent_name: document.getElementById('editAgentName').value,
agent_tool: document.getElementById('editAgentTool').value,
agent_status: document.getElementById('editAgentStatus').value,
agent_description: document.getElementById('editAgentDescription').value,
agent_purpose: document.getElementById('editAgentPurpose').value,
@ -742,6 +884,13 @@ async function updateAgent(e) {
agent_capabilities: document.getElementById('editAgentCapabilities').value.split(',').map(s => s.trim()).filter(s => s)
};
// Add Quality Audit status if user is admin
if (currentUserIsAdmin) {
agentData.quality_audit_status = document.getElementById('editQualityAuditStatus').checked;
}
console.log('Sending agent update data:', agentData);
try {
const response = await fetch(`{{ base_path }}/api/agents/${agentId}`, {
method: 'PUT',
@ -753,12 +902,19 @@ async function updateAgent(e) {
});
if (response.ok) {
const updatedAgent = await response.json();
console.log('Update successful, received agent data:', updatedAgent);
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
await loadAgentsForCurrentView();
// Properly reload data from server instead of using cached data
await Promise.all([loadAllAgents(), loadMyAgents()]);
loadAgentsForCurrentView();
showSuccess('Agent updated successfully');
} else {
const error = await response.json();
showError(error.detail || 'Failed to update agent');
console.error('Update failed with response:', response.status, error);
showError(error.detail || `Failed to update agent (${response.status})`);
}
} catch (error) {
showError('Failed to update agent');
@ -855,6 +1011,8 @@ function showError(message) {
}
function editAgent(agentId) {
console.log('Edit button clicked for agent:', agentId);
console.log('Current agents array:', agents);
currentAgentId = agentId;
showEditModal();
}

View file

@ -35,6 +35,19 @@
required>
</div>
<div class="mb-4">
<label for="agentTool" class="form-label">
<i class="fas fa-tools me-2"></i>Tool *
</label>
<input type="text"
name="agent_tool"
class="form-control form-control-lg"
id="agentTool"
placeholder="e.g., chat-sandbox (LibreChat), Copilot, Custom"
required>
<div class="form-text">The platform or environment where this agent operates</div>
</div>
<div class="mb-4">
<label for="agentDescription" class="form-label">
<i class="fas fa-align-left me-2"></i>Description
@ -155,6 +168,32 @@
<div class="form-text">Separate multiple capabilities with commas</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
name="quality_audit_status"
value="true"
id="qualityAuditStatus"
{% if not current_user.is_admin %}disabled{% endif %}>
<label class="form-check-label" for="qualityAuditStatus">
<i class="fas fa-certificate me-2"></i>Quality Audit
{% if current_user.is_admin %}
<span class="badge bg-warning text-dark ms-2">Admin Only</span>
{% else %}
<span class="badge bg-secondary ms-2">Admin Required</span>
{% endif %}
</label>
</div>
<div class="form-text" id="qualityAuditNote">
{% if current_user.is_admin %}
Check this box if the agent has passed quality audit review.
{% else %}
Only administrators can mark agents as quality audited.
{% endif %}
</div>
</div>
<div class="d-grid mb-4">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-robot me-2"></i>Register Agent
@ -180,7 +219,7 @@
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
display: flex;
align-items: center;
justify-content: center;
@ -223,6 +262,7 @@ let formSubmitted = false;
document.getElementById('agentForm').addEventListener('submit', function(e) {
const agentName = document.getElementById('agentName').value.trim();
const agentTool = document.getElementById('agentTool').value.trim();
if (!agentName) {
e.preventDefault();
@ -230,6 +270,12 @@ document.getElementById('agentForm').addEventListener('submit', function(e) {
return false;
}
if (!agentTool) {
e.preventDefault();
alert('Agent tool is required!');
return false;
}
// Prevent duplicate submissions
if (formSubmitted) {
e.preventDefault();

View file

@ -19,20 +19,23 @@
{% if msal_enabled %}
<!-- Microsoft Sign-in Button -->
<div class="d-grid mb-4">
<a href="{{ base_path }}/auth/azure/login" class="btn btn-microsoft btn-lg">
<button type="button" id="microsoftLoginBtn" class="btn btn-microsoft btn-lg">
<svg width="20" height="20" viewBox="0 0 24 24" class="me-2">
<path fill="currentColor" d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zM24 11.4H12.6V0H24v11.4z"/>
</svg>
Sign in with Microsoft
</a>
</button>
</div>
{% if show_local_login %}
<!-- Divider -->
<div class="auth-divider mb-4">
<span class="auth-divider-text">or continue with email</span>
</div>
{% endif %}
{% endif %}
{% if show_local_login %}
<!-- Local Authentication Section -->
<div class="local-auth-section">
<p class="text-muted small mb-3 text-center">
@ -86,18 +89,13 @@
</div>
<div class="text-center">
<p class="text-muted mb-2">
Don't have an account?
<a href="{{ base_path }}/register" class="text-decoration-none fw-bold">
Create one here
</a>
</p>
<p class="text-muted small">
<em>Local login is for administrators and development use only</em>
</p>
</div>
</form>
</div> <!-- End local-auth-section -->
{% endif %}
</div>
</div>
</div>
@ -173,6 +171,11 @@
}
</style>
{% if msal_enabled %}
<!-- MSAL Browser Library -->
<script src="https://alcdn.msauth.net/browser/2.38.1/js/msal-browser.min.js"></script>
{% endif %}
<script>
function togglePassword(inputId) {
const passwordInput = document.getElementById(inputId);
@ -186,5 +189,70 @@ function togglePassword(inputId) {
icon.className = 'fas fa-eye';
}
}
{% if msal_enabled %}
// MSAL configuration following specification
const msalConfig = {
auth: {
clientId: "{{ client_id }}",
authority: "{{ authority }}",
redirectUri: "{{ redirect_uri }}"
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: true,
}
};
const loginRequest = {
scopes: ["openid", "profile", "email"],
prompt: "select_account"
};
// Initialize MSAL instance
const myMSALObj = new msal.PublicClientApplication(msalConfig);
// Microsoft login handler
document.addEventListener('DOMContentLoaded', function() {
const microsoftBtn = document.getElementById('microsoftLoginBtn');
if (microsoftBtn) {
microsoftBtn.addEventListener('click', async function() {
try {
// Use popup-based login as per specification
const response = await myMSALObj.loginPopup(loginRequest);
// Send tokens to server for validation and session creation
const serverResponse = await fetch('{{ base_path }}/api/auth/azure/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
access_token: response.accessToken,
id_token: response.idToken
})
});
if (!serverResponse.ok) {
throw new Error('Failed to authenticate with server');
}
const result = await serverResponse.json();
// Redirect based on user role
if (result.is_admin) {
window.location.href = '{{ base_path }}/admin';
} else {
window.location.href = '{{ base_path }}/agent-management';
}
} catch (error) {
console.error('Authentication error:', error);
alert('Authentication failed: ' + error.message);
}
});
}
});
{% endif %}
</script>
{% endblock %}

View file

@ -130,7 +130,7 @@
<style>
.border-left-primary {
border-left: 4px solid #007bff !important;
border-left: 4px solid #f3ae3e !important;
}
.badge-status-active {