Initialisation depot
This commit is contained in:
31
samba-api/.env.example
Normal file
31
samba-api/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Environment variables for Samba API
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# API Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-change-in-production-minimum-32-characters
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
ALGORITHM=HS256
|
||||
|
||||
# CORS
|
||||
ALLOWED_HOSTS=["*"]
|
||||
|
||||
# Samba Configuration
|
||||
SAMBA_DOMAIN=example.com
|
||||
SAMBA_DC=dc01.example.com
|
||||
SAMBA_ADMIN_USER=Administrator
|
||||
SAMBA_ADMIN_PASSWORD=admin-password
|
||||
SAMBA_BASE_DN=DC=example,DC=com
|
||||
|
||||
# LDAP Configuration
|
||||
LDAP_SERVER=ldap://localhost:389
|
||||
LDAP_USE_SSL=false
|
||||
LDAP_BIND_DN=Administrator@example.com
|
||||
LDAP_BIND_PASSWORD=admin-password
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
150
samba-api/.gitignore
vendored
Normal file
150
samba-api/.gitignore
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.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
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# Kubernetes
|
||||
*.yaml.backup
|
||||
292
samba-api/Architecture.md
Normal file
292
samba-api/Architecture.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Samba API Architecture Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
The Samba API serves as an interface between web applications and samba-tool functionalities for Active Directory management. This document analyzes different architectural approaches to handle the security and communication challenges between the API layer and the privileged Samba operations.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Samba operations require elevated privileges to manage Active Directory objects (users, groups, computers, OUs). The challenge is to provide a secure, scalable, and maintainable interface while minimizing security risks and maintaining proper separation of concerns.
|
||||
|
||||
## Architectural Solutions
|
||||
|
||||
### 1. Pass-Through System (Direct Execution)
|
||||
|
||||
#### Description
|
||||
The Samba API container runs with elevated privileges and directly executes samba-tool commands and LDAP operations.
|
||||
|
||||
#### Implementation
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Web App │◄──►│ Samba API │◄──►│ Samba DC │
|
||||
│ │ │ (Privileged) │ │ (LDAP/Kerberos)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ samba-tool │
|
||||
│ execution │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- **Simplicity**: Direct implementation with minimal complexity
|
||||
- **Performance**: No additional communication overhead
|
||||
- **Real-time Operations**: Immediate execution and response
|
||||
- **Complete Feature Set**: Access to all samba-tool capabilities
|
||||
- **Atomic Operations**: Operations complete in single request cycle
|
||||
|
||||
#### Drawbacks
|
||||
- **Security Risk**: API container requires root/domain admin privileges
|
||||
- **Attack Surface**: Web-facing service with elevated privileges
|
||||
- **Blast Radius**: Compromise of API means full domain compromise
|
||||
- **Audit Complexity**: Difficult to track individual operations
|
||||
- **Resource Intensive**: Each API instance needs full Samba stack
|
||||
- **Scaling Issues**: Privileged containers are harder to scale securely
|
||||
|
||||
#### Security Considerations
|
||||
- Container must run with `privileged: true` or specific capabilities
|
||||
- Network access to domain controller required
|
||||
- Domain admin credentials stored in container
|
||||
- No privilege separation between web interface and domain operations
|
||||
|
||||
---
|
||||
|
||||
### 2. File-Based Communication with inotify
|
||||
|
||||
#### Description
|
||||
Separation of concerns using file system communication. The API writes operation requests to files, and a privileged worker container monitors file changes using inotify to execute operations.
|
||||
|
||||
#### Implementation
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Web App │◄──►│ Samba API │ │ Samba Worker │
|
||||
│ │ │ (Unprivileged) │ │ (Privileged) │
|
||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Shared Volume │
|
||||
│ /requests/ │ /responses/ │
|
||||
│ *.json │ *.json │
|
||||
└─────────────────────────────────────┘
|
||||
▲
|
||||
│
|
||||
┌─────────────┐
|
||||
│ inotify │
|
||||
│ watcher │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- **Security Isolation**: API container runs unprivileged
|
||||
- **Clear Separation**: Web interface isolated from domain operations
|
||||
- **Audit Trail**: All operations logged as files
|
||||
- **Scalability**: Multiple API instances can share worker
|
||||
- **Resilience**: Operations survive container restarts
|
||||
- **Debugging**: Easy to inspect pending/completed operations
|
||||
|
||||
#### Drawbacks
|
||||
- **Complexity**: Additional components and file management
|
||||
- **Latency**: File I/O overhead and polling delays
|
||||
- **Race Conditions**: File locking and concurrent access issues
|
||||
- **Storage Requirements**: Persistent storage for operation queues
|
||||
- **Error Handling**: Complex error propagation through files
|
||||
- **Limited Real-time**: Delays in operation execution
|
||||
- **File System Dependencies**: Shared volume requirements
|
||||
|
||||
#### Implementation Details
|
||||
```python
|
||||
# API writes request
|
||||
request = {
|
||||
"id": "uuid-123",
|
||||
"operation": "user_create",
|
||||
"params": {"username": "john", "password": "***"},
|
||||
"timestamp": "2025-10-22T10:30:00Z"
|
||||
}
|
||||
# Write to /requests/uuid-123.json
|
||||
|
||||
# Worker processes with inotify
|
||||
import inotify.adapters
|
||||
i = inotify.adapters.Inotify()
|
||||
i.add_watch('/requests')
|
||||
for event in i.event_gen():
|
||||
if event and 'IN_CLOSE_WRITE' in event[1]:
|
||||
process_request(event[3]) # filename
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. MQTT-Based Communication
|
||||
|
||||
#### Description
|
||||
Message queue architecture using MQTT broker for communication between API and privileged worker containers.
|
||||
|
||||
#### Implementation
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Web App │◄──►│ Samba API │ │ Samba Worker │
|
||||
│ │ │ (Unprivileged) │ │ (Privileged) │
|
||||
└─────────────────┘ └──────────┬───────┘ └─────────┬────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ MQTT Broker │
|
||||
│ │
|
||||
│ Topics: │
|
||||
│ • samba/requests │
|
||||
│ • samba/responses │
|
||||
│ • samba/status │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- **Security Isolation**: API container completely unprivileged
|
||||
- **Scalability**: Multiple workers, load balancing
|
||||
- **Reliability**: Message persistence and delivery guarantees
|
||||
- **Real-time**: Near real-time operation execution
|
||||
- **Monitoring**: Built-in message tracking and metrics
|
||||
- **Flexibility**: Easy to add new operation types
|
||||
- **Resilience**: Message queuing survives container failures
|
||||
- **Load Distribution**: Work distributed across multiple workers
|
||||
|
||||
#### Drawbacks
|
||||
- **Infrastructure Complexity**: Additional MQTT broker service
|
||||
- **Network Dependencies**: Broker availability critical
|
||||
- **Message Overhead**: JSON serialization/deserialization
|
||||
- **Debugging Complexity**: Distributed system debugging
|
||||
- **Additional Security**: MQTT broker security configuration
|
||||
- **Resource Usage**: Additional memory and CPU for broker
|
||||
- **Message Size Limits**: Large payloads may need special handling
|
||||
|
||||
#### Implementation Details
|
||||
```python
|
||||
# API publishes request
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
request = {
|
||||
"id": "uuid-123",
|
||||
"operation": "user_create",
|
||||
"params": {"username": "john", "password": "***"},
|
||||
"reply_to": "samba/responses/uuid-123"
|
||||
}
|
||||
|
||||
client.publish("samba/requests", json.dumps(request))
|
||||
|
||||
# Worker subscribes and processes
|
||||
def on_message(client, userdata, message):
|
||||
request = json.loads(message.payload)
|
||||
result = execute_samba_operation(request)
|
||||
client.publish(request["reply_to"], json.dumps(result))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. gRPC-Based Microservice (Alternative Solution)
|
||||
|
||||
#### Description
|
||||
Dedicated privileged microservice exposing gRPC interface for Samba operations.
|
||||
|
||||
#### Implementation
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Web App │◄──►│ Samba API │◄──►│ Samba Service │
|
||||
│ │ │ (Unprivileged) │ │ (Privileged) │
|
||||
└─────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
│ │
|
||||
│ gRPC/HTTP │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- **Type Safety**: Strongly typed interfaces with Protocol Buffers
|
||||
- **Performance**: Binary protocol, efficient serialization
|
||||
- **Security**: mTLS authentication and encryption
|
||||
- **Standardized**: Well-established patterns and tooling
|
||||
- **Streaming**: Support for real-time operation streams
|
||||
- **Multi-language**: Easy client generation for different languages
|
||||
- **Service Discovery**: Built-in load balancing and discovery
|
||||
|
||||
#### Drawbacks
|
||||
- **Complexity**: gRPC setup and maintenance
|
||||
- **Debugging**: Binary protocol harder to debug
|
||||
- **Firewall Issues**: HTTP/2 may have network restrictions
|
||||
- **Learning Curve**: Team familiarity with gRPC required
|
||||
|
||||
---
|
||||
|
||||
### 5. Unix Domain Sockets (Alternative Solution)
|
||||
|
||||
#### Description
|
||||
Communication through Unix domain sockets with a privileged daemon.
|
||||
|
||||
#### Advantages
|
||||
- **Performance**: Fastest IPC mechanism
|
||||
- **Security**: File system permissions control access
|
||||
- **Simplicity**: No network configuration required
|
||||
|
||||
#### Drawbacks
|
||||
- **Single Host**: Cannot scale across multiple machines
|
||||
- **Socket Management**: File cleanup and permissions complexity
|
||||
- **Container Limitations**: Requires shared volume mounts
|
||||
|
||||
---
|
||||
|
||||
## Recommendation Matrix
|
||||
|
||||
| Criteria | Pass-Through | File-based | MQTT | gRPC | Unix Sockets |
|
||||
|----------|--------------|------------|------|------|--------------|
|
||||
| Security | ❌ Poor | ✅ Good | ✅ Good | ✅ Good | ⚠️ Medium |
|
||||
| Performance | ✅ Excellent | ⚠️ Medium | ✅ Good | ✅ Excellent | ✅ Excellent |
|
||||
| Scalability | ❌ Poor | ⚠️ Medium | ✅ Excellent | ✅ Good | ❌ Poor |
|
||||
| Complexity | ✅ Simple | ⚠️ Medium | ❌ Complex | ❌ Complex | ✅ Simple |
|
||||
| Reliability | ⚠️ Medium | ✅ Good | ✅ Excellent | ✅ Good | ⚠️ Medium |
|
||||
| Audit Trail | ❌ Poor | ✅ Excellent | ✅ Good | ⚠️ Medium | ❌ Poor |
|
||||
| Real-time | ✅ Excellent | ❌ Poor | ✅ Good | ✅ Excellent | ✅ Excellent |
|
||||
| Maintenance | ✅ Simple | ⚠️ Medium | ❌ Complex | ❌ Complex | ✅ Simple |
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
### **Recommended Solution: MQTT-Based Architecture**
|
||||
|
||||
For production environments, the **MQTT-based approach** is recommended because:
|
||||
|
||||
1. **Security First**: Complete isolation of privileged operations
|
||||
2. **Enterprise Scale**: Supports multiple workers and high availability
|
||||
3. **Operational Excellence**: Built-in monitoring, logging, and error handling
|
||||
4. **Future-Proof**: Easy to extend with new features and integrations
|
||||
|
||||
### **Development/Testing Alternative: File-Based**
|
||||
|
||||
For development or smaller deployments, the **file-based approach** offers:
|
||||
- Good security with simpler implementation
|
||||
- Excellent debugging capabilities
|
||||
- Lower resource requirements
|
||||
- Easier troubleshooting
|
||||
|
||||
### **Implementation Phases**
|
||||
|
||||
1. **Phase 1**: Start with file-based for MVP and testing
|
||||
2. **Phase 2**: Migrate to MQTT for production deployment
|
||||
3. **Phase 3**: Add advanced features like operation batching and workflow management
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
Regardless of chosen architecture:
|
||||
|
||||
1. **Principle of Least Privilege**: Minimize permissions at every level
|
||||
2. **Input Validation**: Sanitize all inputs before processing
|
||||
3. **Audit Logging**: Log all operations with user attribution
|
||||
4. **Encryption**: Encrypt communication channels and stored credentials
|
||||
5. **Network Segmentation**: Isolate Samba operations network
|
||||
6. **Regular Updates**: Keep all components updated with security patches
|
||||
7. **Monitoring**: Implement comprehensive monitoring and alerting
|
||||
|
||||
## Conclusion
|
||||
|
||||
The choice of architecture depends on specific requirements:
|
||||
- **Security-critical environments**: MQTT or gRPC
|
||||
- **Simple deployments**: File-based or Unix sockets
|
||||
- **Performance-critical**: Pass-through (with additional security measures)
|
||||
|
||||
The recommended MQTT approach provides the best balance of security, scalability, and maintainability for enterprise deployments.
|
||||
60
samba-api/Dockerfile
Normal file
60
samba-api/Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Use an official Python runtime as base image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies including Samba tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
samba \
|
||||
samba-common-bin \
|
||||
samba-dsdb-modules \
|
||||
winbind \
|
||||
libldap2-dev \
|
||||
libsasl2-dev \
|
||||
libssl-dev \
|
||||
krb5-user \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash app && \
|
||||
chown -R app:app /app
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
COPY main.py .
|
||||
COPY start.sh .
|
||||
|
||||
# Copy SSL certificates
|
||||
COPY ssl/ ./ssl/
|
||||
|
||||
# Set ownership and make start script executable
|
||||
RUN chown -R app:app /app && chmod +x /app/start.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8000
|
||||
EXPOSE 8443
|
||||
|
||||
# Health check (will try HTTPS first, then HTTP)
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -k -f https://localhost:8443/health || curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the startup script
|
||||
CMD ["/app/start.sh"]
|
||||
219
samba-api/HTTPS-README.md
Normal file
219
samba-api/HTTPS-README.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Samba API - HTTPS Configuration
|
||||
|
||||
This guide explains how to use the Samba API with HTTPS support.
|
||||
|
||||
## 🔒 HTTPS Setup
|
||||
|
||||
The Samba API now supports HTTPS with SSL/TLS encryption for secure communication.
|
||||
|
||||
### Components
|
||||
|
||||
1. **Self-signed SSL certificates** (generated automatically)
|
||||
2. **Direct HTTPS support** via Uvicorn
|
||||
3. **Optional Nginx reverse proxy** for production use
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Standard HTTPS (Direct)
|
||||
|
||||
```bash
|
||||
# Start with HTTPS enabled
|
||||
docker-compose up -d
|
||||
|
||||
# The API will be available at:
|
||||
# - HTTPS: https://localhost:8443
|
||||
# - HTTP: http://localhost:8000 (fallback)
|
||||
```
|
||||
|
||||
### 2. Production with Nginx (Optional)
|
||||
|
||||
```bash
|
||||
# Start with Nginx reverse proxy
|
||||
docker-compose --profile production up -d
|
||||
|
||||
# The API will be available at:
|
||||
# - HTTPS: https://localhost:443 (via Nginx)
|
||||
# - HTTP: http://localhost:80 (redirects to HTTPS)
|
||||
```
|
||||
|
||||
## 📋 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `USE_HTTPS` | `true` | Enable HTTPS support |
|
||||
| `PORT` | `8000` | HTTP port |
|
||||
| `HTTPS_PORT` | `8443` | HTTPS port |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `DEBUG` | `true` | Enable debug mode |
|
||||
|
||||
### SSL Certificates
|
||||
|
||||
The API uses self-signed certificates located in `/app/ssl/`:
|
||||
- `server.crt` - SSL certificate
|
||||
- `server.key` - Private key
|
||||
|
||||
For production, replace these with certificates from a trusted CA.
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Base URLs
|
||||
- **HTTPS**: `https://localhost:8443`
|
||||
- **HTTP**: `http://localhost:8000`
|
||||
|
||||
### Key Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/` | GET | API status |
|
||||
| `/health` | GET | Health check |
|
||||
| `/docs` | GET | Interactive API documentation |
|
||||
| `/api/v1/auth/login` | POST | User authentication |
|
||||
| `/api/v1/users` | GET | List users |
|
||||
| `/api/v1/groups` | GET | List groups |
|
||||
|
||||
## 🔑 Authentication
|
||||
|
||||
### Login Request
|
||||
|
||||
```bash
|
||||
curl -k -X POST "https://localhost:8443/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "Administrator@example.com",
|
||||
"password": "Admin123!@#"
|
||||
}'
|
||||
```
|
||||
|
||||
### Using Bearer Token
|
||||
|
||||
```bash
|
||||
# Get token from login response
|
||||
TOKEN="your-jwt-token"
|
||||
|
||||
# Use token in subsequent requests
|
||||
curl -k -H "Authorization: Bearer $TOKEN" \
|
||||
"https://localhost:8443/api/v1/users"
|
||||
```
|
||||
|
||||
## 🌐 API Documentation
|
||||
|
||||
Access the interactive API documentation:
|
||||
- **Swagger UI**: https://localhost:8443/docs
|
||||
- **ReDoc**: https://localhost:8443/redoc
|
||||
- **OpenAPI JSON**: https://localhost:8443/openapi.json
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### SSL/TLS Configuration
|
||||
- **Protocols**: TLS 1.2, TLS 1.3
|
||||
- **Ciphers**: Strong encryption only
|
||||
- **HSTS**: Strict Transport Security enabled
|
||||
|
||||
### Security Headers
|
||||
- `X-Frame-Options: DENY`
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-XSS-Protection: 1; mode=block`
|
||||
- `Strict-Transport-Security: max-age=63072000`
|
||||
|
||||
## 🔧 Development vs Production
|
||||
|
||||
### Development (Current Setup)
|
||||
```yaml
|
||||
environment:
|
||||
- USE_HTTPS=true
|
||||
- DEBUG=true
|
||||
ports:
|
||||
- "8000:8000" # HTTP
|
||||
- "8443:8443" # HTTPS
|
||||
```
|
||||
|
||||
### Production with Nginx
|
||||
```yaml
|
||||
# Use docker-compose-https.yml
|
||||
services:
|
||||
nginx-ssl:
|
||||
ports:
|
||||
- "80:80" # HTTP (redirects)
|
||||
- "443:443" # HTTPS
|
||||
```
|
||||
|
||||
## 📝 Testing
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
# HTTPS
|
||||
curl -k https://localhost:8443/health
|
||||
|
||||
# HTTP
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### API Status
|
||||
```bash
|
||||
curl -k https://localhost:8443/
|
||||
```
|
||||
|
||||
### Authentication Test
|
||||
```bash
|
||||
curl -k -X POST "https://localhost:8443/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "Administrator@example.com", "password": "Admin123!@#"}'
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Certificate Warnings**
|
||||
- Use `-k` flag with curl for self-signed certificates
|
||||
- For browsers, accept the security warning
|
||||
|
||||
2. **Connection Refused**
|
||||
- Check if the container is running: `docker-compose ps`
|
||||
- Verify logs: `docker-compose logs samba-api`
|
||||
|
||||
3. **Authentication Failures**
|
||||
- Verify Samba DC is running: `docker-compose logs samba-dc`
|
||||
- Check LDAP connectivity from API container
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# View API logs
|
||||
docker-compose logs -f samba-api
|
||||
|
||||
# View Samba DC logs
|
||||
docker-compose logs -f samba-dc
|
||||
|
||||
# View all logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 🔄 Certificate Renewal
|
||||
|
||||
For production use, set up automatic certificate renewal:
|
||||
|
||||
```bash
|
||||
# Example with Let's Encrypt (certbot)
|
||||
certbot certonly --webroot -w /data/apps/samba-api/ssl \
|
||||
-d your-domain.com
|
||||
|
||||
# Copy certificates
|
||||
cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./ssl/server.crt
|
||||
cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./ssl/server.key
|
||||
|
||||
# Restart services
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [FastAPI HTTPS Documentation](https://fastapi.tiangolo.com/deployment/https/)
|
||||
- [Uvicorn SSL Configuration](https://www.uvicorn.org/settings/#https)
|
||||
- [Docker Compose Profiles](https://docs.docker.com/compose/profiles/)
|
||||
|
||||
---
|
||||
|
||||
**Note**: The current setup uses self-signed certificates for development. For production environments, use certificates from a trusted Certificate Authority (CA) or Let's Encrypt.
|
||||
242
samba-api/README.md
Normal file
242
samba-api/README.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Samba API
|
||||
|
||||
A REST API for managing Samba Active Directory users, groups, organizational units, and computers. Built with FastAPI and designed to run in Kubernetes.
|
||||
|
||||
## Features
|
||||
|
||||
- **User Management**: Create, read, update, delete users with full LDAP integration
|
||||
- **Group Management**: Manage security and distribution groups with membership control
|
||||
- **Organizational Units**: Create and manage OU hierarchies
|
||||
- **Computer Management**: Handle computer accounts and domain joins
|
||||
- **JWT Authentication**: Secure API with token-based authentication
|
||||
- **LDAP Integration**: Direct integration with Samba/Active Directory via LDAP
|
||||
- **Kubernetes Ready**: Full Kubernetes deployment manifests included
|
||||
- **Docker Support**: Containerized application with Samba tools
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Frontend/ │ │ Samba API │ │ Samba DC / │
|
||||
│ Client App │◄──►│ (FastAPI) │◄──►│ Active │
|
||||
│ │ │ │ │ Directory │
|
||||
└─────────────────┘ └──────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ samba-tool / │
|
||||
│ LDAP Queries │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd samba-api
|
||||
```
|
||||
|
||||
2. Copy environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Update the configuration in `.env` file with your domain settings.
|
||||
|
||||
4. Start the services:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
5. Access the API documentation at: http://localhost:8000/docs
|
||||
|
||||
### Using Kubernetes
|
||||
|
||||
1. Build and push the Docker image:
|
||||
```bash
|
||||
docker build -t your-registry/samba-api:latest .
|
||||
docker push your-registry/samba-api:latest
|
||||
```
|
||||
|
||||
2. Update the image in `k8s/deployment.yaml`
|
||||
|
||||
3. Update secrets and configuration in `k8s/configmap.yaml`
|
||||
|
||||
4. Deploy to Kubernetes:
|
||||
```bash
|
||||
chmod +x k8s/deploy.sh
|
||||
./k8s/deploy.sh
|
||||
```
|
||||
|
||||
5. Access the API:
|
||||
```bash
|
||||
kubectl port-forward svc/samba-api-service 8000:80 -n samba-api
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - Login and get JWT token
|
||||
- `GET /api/v1/auth/me` - Get current user info
|
||||
- `POST /api/v1/auth/logout` - Logout
|
||||
- `POST /api/v1/auth/refresh` - Refresh JWT token
|
||||
|
||||
### Users
|
||||
- `GET /api/v1/users` - List users (with pagination and search)
|
||||
- `POST /api/v1/users` - Create new user
|
||||
- `GET /api/v1/users/{username}` - Get user details
|
||||
- `PUT /api/v1/users/{username}` - Update user
|
||||
- `DELETE /api/v1/users/{username}` - Delete user
|
||||
- `POST /api/v1/users/{username}/password` - Change password
|
||||
- `POST /api/v1/users/{username}/enable` - Enable user account
|
||||
- `POST /api/v1/users/{username}/disable` - Disable user account
|
||||
|
||||
### Groups
|
||||
- `GET /api/v1/groups` - List groups
|
||||
- `POST /api/v1/groups` - Create new group
|
||||
- `GET /api/v1/groups/{group_name}` - Get group details
|
||||
- `PUT /api/v1/groups/{group_name}` - Update group
|
||||
- `DELETE /api/v1/groups/{group_name}` - Delete group
|
||||
- `POST /api/v1/groups/{group_name}/members` - Add members to group
|
||||
- `DELETE /api/v1/groups/{group_name}/members` - Remove members from group
|
||||
|
||||
### Organizational Units
|
||||
- `GET /api/v1/ous` - List OUs
|
||||
- `POST /api/v1/ous` - Create new OU
|
||||
- `GET /api/v1/ous/tree` - Get OU tree structure
|
||||
- `GET /api/v1/ous/{ou_dn}` - Get OU details
|
||||
- `PUT /api/v1/ous/{ou_dn}` - Update OU
|
||||
- `DELETE /api/v1/ous/{ou_dn}` - Delete OU
|
||||
|
||||
### Computers
|
||||
- `GET /api/v1/computers` - List computers
|
||||
- `POST /api/v1/computers` - Create computer account
|
||||
- `GET /api/v1/computers/{computer_name}` - Get computer details
|
||||
- `PUT /api/v1/computers/{computer_name}` - Update computer
|
||||
- `DELETE /api/v1/computers/{computer_name}` - Delete computer
|
||||
- `POST /api/v1/computers/join` - Join computer to domain
|
||||
- `POST /api/v1/computers/{computer_name}/reset-password` - Reset computer password
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HOST` | API host | `0.0.0.0` |
|
||||
| `PORT` | API port | `8000` |
|
||||
| `DEBUG` | Debug mode | `false` |
|
||||
| `SECRET_KEY` | JWT secret key | Required |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Token expiration | `30` |
|
||||
| `SAMBA_DOMAIN` | Samba domain | Required |
|
||||
| `SAMBA_DC` | Domain controller hostname | Required |
|
||||
| `SAMBA_ADMIN_USER` | Admin username | `Administrator` |
|
||||
| `SAMBA_ADMIN_PASSWORD` | Admin password | Required |
|
||||
| `SAMBA_BASE_DN` | Base DN | Required |
|
||||
| `LDAP_SERVER` | LDAP server URL | Required |
|
||||
| `LDAP_USE_SSL` | Use SSL for LDAP | `false` |
|
||||
| `LDAP_BIND_DN` | LDAP bind DN | Required |
|
||||
| `LDAP_BIND_PASSWORD` | LDAP bind password | Required |
|
||||
|
||||
### Kubernetes Configuration
|
||||
|
||||
The application is configured for Kubernetes deployment with:
|
||||
|
||||
- **High Availability**: 3 replicas with pod disruption budget
|
||||
- **Auto Scaling**: HPA based on CPU and memory usage
|
||||
- **Security**: Non-root containers, read-only filesystem, RBAC
|
||||
- **Monitoring**: Health checks and readiness probes
|
||||
- **Storage**: Persistent volumes for Samba DC data
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
```bash
|
||||
python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
### Code Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # Core configuration and exceptions
|
||||
├── models/ # Pydantic models for API
|
||||
├── routers/ # API route handlers
|
||||
├── services/ # Business logic and Samba integration
|
||||
└── main.py # FastAPI application setup
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Change default passwords in production
|
||||
- Use strong JWT secret keys (minimum 32 characters)
|
||||
- Enable SSL/TLS for LDAP connections in production
|
||||
- Implement proper network segmentation
|
||||
- Use Kubernetes secrets for sensitive configuration
|
||||
- Regularly update base images and dependencies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **LDAP Connection Failed**
|
||||
- Check LDAP server URL and credentials
|
||||
- Verify network connectivity to domain controller
|
||||
- Check if LDAP service is running
|
||||
|
||||
2. **Authentication Errors**
|
||||
- Verify JWT secret key configuration
|
||||
- Check user credentials and permissions
|
||||
- Ensure domain controller is accessible
|
||||
|
||||
3. **Samba Tool Errors**
|
||||
- Verify samba-tool is installed in container
|
||||
- Check domain configuration
|
||||
- Ensure proper DNS resolution
|
||||
|
||||
### Logs and Monitoring
|
||||
|
||||
- Application logs are available in container stdout
|
||||
- Configure log level with `LOG_LEVEL` environment variable
|
||||
- Use Kubernetes logging and monitoring solutions for production
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests for new functionality
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
1. Check the troubleshooting section
|
||||
2. Search existing issues in the repository
|
||||
3. Create a new issue with detailed information
|
||||
64
samba-api/build.py
Executable file
64
samba-api/build.py
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Build script for Samba API Docker image
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(command, check=True):
|
||||
"""Run shell command and return result"""
|
||||
print(f"Running: {command}")
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
||||
|
||||
if check and result.returncode != 0:
|
||||
print(f"Error running command: {command}")
|
||||
print(f"Error output: {result.stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
return result
|
||||
|
||||
def build_image(tag, push=False, registry=None):
|
||||
"""Build Docker image"""
|
||||
|
||||
# Build image
|
||||
build_cmd = f"docker build -t samba-api:{tag} ."
|
||||
if registry:
|
||||
build_cmd = f"docker build -t {registry}/samba-api:{tag} ."
|
||||
|
||||
run_command(build_cmd)
|
||||
|
||||
# Tag as latest
|
||||
if tag != "latest":
|
||||
if registry:
|
||||
run_command(f"docker tag {registry}/samba-api:{tag} {registry}/samba-api:latest")
|
||||
else:
|
||||
run_command(f"docker tag samba-api:{tag} samba-api:latest")
|
||||
|
||||
# Push if requested
|
||||
if push and registry:
|
||||
run_command(f"docker push {registry}/samba-api:{tag}")
|
||||
run_command(f"docker push {registry}/samba-api:latest")
|
||||
print(f"Image pushed to registry: {registry}/samba-api:{tag}")
|
||||
|
||||
print(f"Build completed: samba-api:{tag}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build Samba API Docker image")
|
||||
parser.add_argument("--tag", default="latest", help="Docker image tag")
|
||||
parser.add_argument("--push", action="store_true", help="Push image to registry")
|
||||
parser.add_argument("--registry", help="Docker registry (required if --push is used)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.push and not args.registry:
|
||||
print("Error: --registry is required when --push is used")
|
||||
sys.exit(1)
|
||||
|
||||
build_image(args.tag, args.push, args.registry)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
samba-api/docker-compose-https.yml
Normal file
98
samba-api/docker-compose-https.yml
Normal file
@@ -0,0 +1,98 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
samba-api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000" # HTTP port (for internal use)
|
||||
- "8443:8443" # HTTPS port (direct access)
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
- USE_HTTPS=true
|
||||
- SECRET_KEY=your-secret-key-change-in-production
|
||||
- SAMBA_DOMAIN=example.com
|
||||
- SAMBA_DC=samba-dc
|
||||
- SAMBA_ADMIN_USER=Administrator
|
||||
- SAMBA_ADMIN_PASSWORD=Admin123!@#
|
||||
- SAMBA_BASE_DN=DC=example,DC=com
|
||||
- LDAP_SERVER=ldap://samba-dc:389
|
||||
- LDAP_BIND_DN=Administrator@example.com
|
||||
- LDAP_BIND_PASSWORD=Admin123!@#
|
||||
depends_on:
|
||||
- samba-dc
|
||||
networks:
|
||||
- samba-network
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: Nginx reverse proxy for production HTTPS
|
||||
nginx-ssl:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "443:443" # HTTPS
|
||||
- "80:80" # HTTP (redirects to HTTPS)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- samba-api
|
||||
networks:
|
||||
- samba-network
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- production # Only start with: docker-compose --profile production up
|
||||
|
||||
samba-dc:
|
||||
image: hexah/samba-dc:4.22.3-05
|
||||
container_name: samba-dc
|
||||
hostname: dc01
|
||||
environment:
|
||||
- DOMAIN=example.com
|
||||
- DOMAINPASS=Admin123!@#
|
||||
- DNSFORWARDER=8.8.8.8
|
||||
- HOSTIP=172.20.0.2
|
||||
ports:
|
||||
- "5353:53"
|
||||
- "5353:53/udp"
|
||||
- "8088:88"
|
||||
- "8088:88/udp"
|
||||
- "8135:135"
|
||||
- "8137:137/udp"
|
||||
- "8138:138/udp"
|
||||
- "8139:139"
|
||||
- "8389:389"
|
||||
- "8389:389/udp"
|
||||
- "8445:445"
|
||||
- "8464:464"
|
||||
- "8464:464/udp"
|
||||
- "8636:636"
|
||||
- "9024:1024"
|
||||
- "9268:3268"
|
||||
- "9269:3269"
|
||||
networks:
|
||||
samba-network:
|
||||
ipv4_address: 172.20.0.2
|
||||
volumes:
|
||||
- samba-data:/var/lib/samba
|
||||
- samba-config:/etc/samba
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- "/dev/net/tun:/dev/net/tun"
|
||||
privileged: true
|
||||
|
||||
networks:
|
||||
samba-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
samba-data:
|
||||
samba-config:
|
||||
81
samba-api/docker-compose.yml
Normal file
81
samba-api/docker-compose.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
samba-api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000" # HTTP port
|
||||
- "8443:8443" # HTTPS port
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
- USE_HTTPS=true
|
||||
- SECRET_KEY=your-secret-key-change-in-production
|
||||
- SAMBA_DOMAIN=example.com
|
||||
- SAMBA_DC=samba-dc
|
||||
- SAMBA_ADMIN_USER=Administrator
|
||||
- SAMBA_ADMIN_PASSWORD=Admin123!@#
|
||||
- SAMBA_BASE_DN=DC=example,DC=com
|
||||
- LDAP_SERVER=ldap://samba-dc:389
|
||||
- LDAP_BIND_DN=Administrator@example.com
|
||||
- LDAP_BIND_PASSWORD=Admin123!@#
|
||||
depends_on:
|
||||
- samba-dc
|
||||
networks:
|
||||
- samba-network
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
samba-dc:
|
||||
image: hexah/samba-dc:4.22.3-05
|
||||
container_name: samba-dc
|
||||
hostname: dc01
|
||||
environment:
|
||||
- DOMAIN=example.com
|
||||
- DOMAINPASS=Admin123!@#
|
||||
- DNSFORWARDER=8.8.8.8
|
||||
- HOSTIP=172.20.0.2
|
||||
ports:
|
||||
- "5353:53"
|
||||
- "5353:53/udp"
|
||||
- "8088:88"
|
||||
- "8088:88/udp"
|
||||
- "8135:135"
|
||||
- "8137:137/udp"
|
||||
- "8138:138/udp"
|
||||
- "8139:139"
|
||||
- "8389:389"
|
||||
- "8389:389/udp"
|
||||
- "8445:445"
|
||||
- "8464:464"
|
||||
- "8464:464/udp"
|
||||
- "8636:636"
|
||||
- "9024:1024"
|
||||
- "9268:3268"
|
||||
- "9269:3269"
|
||||
networks:
|
||||
samba-network:
|
||||
ipv4_address: 172.20.0.2
|
||||
volumes:
|
||||
- samba-data:/var/lib/samba
|
||||
- samba-config:/etc/samba
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- "/dev/net/tun:/dev/net/tun"
|
||||
privileged: true
|
||||
|
||||
networks:
|
||||
samba-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
samba-data:
|
||||
samba-config:
|
||||
31
samba-api/k8s/configmap.yaml
Normal file
31
samba-api/k8s/configmap.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: samba-api-secrets
|
||||
namespace: samba-api
|
||||
type: Opaque
|
||||
stringData:
|
||||
SECRET_KEY: "your-secret-key-change-in-production-minimum-32-characters"
|
||||
SAMBA_ADMIN_PASSWORD: "admin-password"
|
||||
LDAP_BIND_PASSWORD: "admin-password"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: samba-api-config
|
||||
namespace: samba-api
|
||||
data:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
DEBUG: "false"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: "30"
|
||||
ALGORITHM: "HS256"
|
||||
ALLOWED_HOSTS: '["*"]'
|
||||
SAMBA_DOMAIN: "example.com"
|
||||
SAMBA_DC: "samba-dc.samba-api.svc.cluster.local"
|
||||
SAMBA_ADMIN_USER: "Administrator"
|
||||
SAMBA_BASE_DN: "DC=example,DC=com"
|
||||
LDAP_SERVER: "ldap://samba-dc.samba-api.svc.cluster.local:389"
|
||||
LDAP_USE_SSL: "false"
|
||||
LDAP_BIND_DN: "Administrator@example.com"
|
||||
LOG_LEVEL: "INFO"
|
||||
55
samba-api/k8s/deploy.sh
Executable file
55
samba-api/k8s/deploy.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Kubernetes deployment script
|
||||
|
||||
set -e
|
||||
|
||||
NAMESPACE="samba-api"
|
||||
IMAGE_TAG=${1:-latest}
|
||||
|
||||
echo "Deploying Samba API to Kubernetes..."
|
||||
|
||||
# Apply namespace first
|
||||
echo "Creating namespace..."
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# Apply RBAC
|
||||
echo "Applying RBAC configuration..."
|
||||
kubectl apply -f k8s/rbac.yaml
|
||||
|
||||
# Apply ConfigMap and Secrets
|
||||
echo "Applying configuration..."
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
|
||||
# Apply Samba DC StatefulSet
|
||||
echo "Deploying Samba DC..."
|
||||
kubectl apply -f k8s/samba-dc.yaml
|
||||
|
||||
# Wait for Samba DC to be ready
|
||||
echo "Waiting for Samba DC to be ready..."
|
||||
kubectl wait --for=condition=Ready pod -l app=samba-dc -n ${NAMESPACE} --timeout=300s
|
||||
|
||||
# Apply API deployment
|
||||
echo "Deploying Samba API..."
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
|
||||
# Apply services
|
||||
echo "Applying services..."
|
||||
kubectl apply -f k8s/service.yaml
|
||||
|
||||
# Apply HPA and PDB
|
||||
echo "Applying autoscaling configuration..."
|
||||
kubectl apply -f k8s/hpa.yaml
|
||||
|
||||
# Wait for deployment to be ready
|
||||
echo "Waiting for deployment to be ready..."
|
||||
kubectl wait --for=condition=Available deployment/samba-api -n ${NAMESPACE} --timeout=300s
|
||||
|
||||
echo "Deployment completed successfully!"
|
||||
|
||||
# Show deployment status
|
||||
kubectl get all -n ${NAMESPACE}
|
||||
|
||||
echo ""
|
||||
echo "To access the API:"
|
||||
echo "kubectl port-forward svc/samba-api-service 8000:80 -n ${NAMESPACE}"
|
||||
echo "Then visit: http://localhost:8000/docs"
|
||||
158
samba-api/k8s/deployment.yaml
Normal file
158
samba-api/k8s/deployment.yaml
Normal file
@@ -0,0 +1,158 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: samba-api
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-api
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: samba-api
|
||||
version: v1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: samba-api
|
||||
version: v1
|
||||
spec:
|
||||
containers:
|
||||
- name: samba-api
|
||||
image: samba-api:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: HOST
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: HOST
|
||||
- name: PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: PORT
|
||||
- name: DEBUG
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: DEBUG
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: samba-api-secrets
|
||||
key: SECRET_KEY
|
||||
- name: ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
- name: ALGORITHM
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: ALGORITHM
|
||||
- name: ALLOWED_HOSTS
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: ALLOWED_HOSTS
|
||||
- name: SAMBA_DOMAIN
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: SAMBA_DOMAIN
|
||||
- name: SAMBA_DC
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: SAMBA_DC
|
||||
- name: SAMBA_ADMIN_USER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: SAMBA_ADMIN_USER
|
||||
- name: SAMBA_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: samba-api-secrets
|
||||
key: SAMBA_ADMIN_PASSWORD
|
||||
- name: SAMBA_BASE_DN
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: SAMBA_BASE_DN
|
||||
- name: LDAP_SERVER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: LDAP_SERVER
|
||||
- name: LDAP_USE_SSL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: LDAP_USE_SSL
|
||||
- name: LDAP_BIND_DN
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: LDAP_BIND_DN
|
||||
- name: LDAP_BIND_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: samba-api-secrets
|
||||
key: LDAP_BIND_PASSWORD
|
||||
- name: LOG_LEVEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: samba-api-config
|
||||
key: LOG_LEVEL
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: logs
|
||||
mountPath: /app/logs
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: logs
|
||||
emptyDir: {}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
restartPolicy: Always
|
||||
49
samba-api/k8s/hpa.yaml
Normal file
49
samba-api/k8s/hpa.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: samba-api-pdb
|
||||
namespace: samba-api
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: samba-api
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: samba-api-hpa
|
||||
namespace: samba-api
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: samba-api
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 60
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 50
|
||||
periodSeconds: 60
|
||||
6
samba-api/k8s/namespace.yaml
Normal file
6
samba-api/k8s/namespace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: samba-api
|
||||
labels:
|
||||
app: samba-api
|
||||
34
samba-api/k8s/rbac.yaml
Normal file
34
samba-api/k8s/rbac.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: samba-api
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-api
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
namespace: samba-api
|
||||
name: samba-api-role
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "services", "endpoints"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps", "secrets"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: samba-api-rolebinding
|
||||
namespace: samba-api
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: samba-api
|
||||
namespace: samba-api
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: samba-api-role
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
140
samba-api/k8s/samba-dc.yaml
Normal file
140
samba-api/k8s/samba-dc.yaml
Normal file
@@ -0,0 +1,140 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: samba-dc
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-dc
|
||||
spec:
|
||||
serviceName: samba-dc
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: samba-dc
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: samba-dc
|
||||
spec:
|
||||
containers:
|
||||
- name: samba-dc
|
||||
image: nowsci/samba-domain:4.16.0
|
||||
env:
|
||||
- name: DOMAIN
|
||||
value: "example.com"
|
||||
- name: DOMAINPASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: samba-api-secrets
|
||||
key: SAMBA_ADMIN_PASSWORD
|
||||
- name: DNSFORWARDER
|
||||
value: "8.8.8.8"
|
||||
- name: HOSTIP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
ports:
|
||||
- containerPort: 53
|
||||
name: dns
|
||||
protocol: UDP
|
||||
- containerPort: 53
|
||||
name: dns-tcp
|
||||
protocol: TCP
|
||||
- containerPort: 88
|
||||
name: kerberos
|
||||
protocol: TCP
|
||||
- containerPort: 88
|
||||
name: kerberos-udp
|
||||
protocol: UDP
|
||||
- containerPort: 135
|
||||
name: rpc
|
||||
- containerPort: 139
|
||||
name: netbios
|
||||
- containerPort: 389
|
||||
name: ldap
|
||||
- containerPort: 445
|
||||
name: smb
|
||||
- containerPort: 464
|
||||
name: kpasswd
|
||||
- containerPort: 636
|
||||
name: ldaps
|
||||
- containerPort: 3268
|
||||
name: gc
|
||||
- containerPort: 3269
|
||||
name: gc-ssl
|
||||
volumeMounts:
|
||||
- name: samba-data
|
||||
mountPath: /var/lib/samba
|
||||
- name: samba-config
|
||||
mountPath: /etc/samba
|
||||
securityContext:
|
||||
privileged: true
|
||||
capabilities:
|
||||
add:
|
||||
- NET_ADMIN
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: samba-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: "standard"
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
- metadata:
|
||||
name: samba-config
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
storageClassName: "standard"
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: samba-dc
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-dc
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: 53
|
||||
name: dns
|
||||
protocol: UDP
|
||||
- port: 53
|
||||
name: dns-tcp
|
||||
protocol: TCP
|
||||
- port: 88
|
||||
name: kerberos
|
||||
protocol: TCP
|
||||
- port: 88
|
||||
name: kerberos-udp
|
||||
protocol: UDP
|
||||
- port: 135
|
||||
name: rpc
|
||||
- port: 139
|
||||
name: netbios
|
||||
- port: 389
|
||||
name: ldap
|
||||
- port: 445
|
||||
name: smb
|
||||
- port: 464
|
||||
name: kpasswd
|
||||
- port: 636
|
||||
name: ldaps
|
||||
- port: 3268
|
||||
name: gc
|
||||
- port: 3269
|
||||
name: gc-ssl
|
||||
selector:
|
||||
app: samba-dc
|
||||
46
samba-api/k8s/service.yaml
Normal file
46
samba-api/k8s/service.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: samba-api-service
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app: samba-api
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: samba-api-ingress
|
||||
namespace: samba-api
|
||||
labels:
|
||||
app: samba-api
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- samba-api.yourdomain.com
|
||||
secretName: samba-api-tls
|
||||
rules:
|
||||
- host: samba-api.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: samba-api-service
|
||||
port:
|
||||
number: 80
|
||||
85
samba-api/main.py
Normal file
85
samba-api/main.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
import uvicorn
|
||||
import logging
|
||||
|
||||
from src.routers import users, groups, ous, computers, auth
|
||||
from src.core.config import settings
|
||||
from src.core.exceptions import setup_exception_handlers
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Samba API",
|
||||
description="REST API for Samba Active Directory management",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_HOSTS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Setup exception handlers
|
||||
setup_exception_handlers(app)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
|
||||
app.include_router(groups.router, prefix="/api/v1/groups", tags=["Groups"])
|
||||
app.include_router(ous.router, prefix="/api/v1/ous", tags=["Organizational Units"])
|
||||
app.include_router(computers.router, prefix="/api/v1/computers", tags=["Computers"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Samba API is running", "version": "1.0.0"}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "samba-api"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ssl
|
||||
import os
|
||||
|
||||
# Check if SSL certificates exist
|
||||
ssl_keyfile = "/app/ssl/server.key"
|
||||
ssl_certfile = "/app/ssl/server.crt"
|
||||
|
||||
if os.path.exists(ssl_keyfile) and os.path.exists(ssl_certfile):
|
||||
# HTTPS configuration
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.HTTPS_PORT if hasattr(settings, 'HTTPS_PORT') else 8443,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info",
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile
|
||||
)
|
||||
else:
|
||||
# HTTP fallback
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info"
|
||||
)
|
||||
88
samba-api/nginx/nginx.conf
Normal file
88
samba-api/nginx/nginx.conf
Normal file
@@ -0,0 +1,88 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream samba-api {
|
||||
server samba-api:8000;
|
||||
}
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Health check endpoint (allow HTTP)
|
||||
location /health {
|
||||
proxy_pass http://samba-api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
|
||||
# SSL Security
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
|
||||
|
||||
# Proxy to Samba API
|
||||
location / {
|
||||
proxy_pass http://samba-api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
|
||||
# WebSocket support (if needed)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# API Documentation
|
||||
location /docs {
|
||||
proxy_pass http://samba-api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://samba-api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
samba-api/requirements.txt
Normal file
14
samba-api/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-ldap==3.4.3
|
||||
ldap3==2.9.1
|
||||
aiofiles==23.2.1
|
||||
email-validator==2.1.0
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
1
samba-api/src/__init__.py
Normal file
1
samba-api/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Samba API main package initialization
|
||||
1
samba-api/src/core/__init__.py
Normal file
1
samba-api/src/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core package initialization
|
||||
44
samba-api/src/core/config.py
Normal file
44
samba-api/src/core/config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
|
||||
# API Configuration
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
HTTPS_PORT: int = 8443
|
||||
DEBUG: bool = False
|
||||
USE_HTTPS: bool = False
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# CORS
|
||||
ALLOWED_HOSTS: List[str] = ["*"]
|
||||
|
||||
# Samba Configuration
|
||||
SAMBA_DOMAIN: str = "example.com"
|
||||
SAMBA_DC: str = "dc01.example.com"
|
||||
SAMBA_ADMIN_USER: str = "Administrator"
|
||||
SAMBA_ADMIN_PASSWORD: str = "admin-password"
|
||||
SAMBA_BASE_DN: str = "DC=example,DC=com"
|
||||
|
||||
# LDAP Configuration
|
||||
LDAP_SERVER: str = "ldap://localhost:389"
|
||||
LDAP_USE_SSL: bool = False
|
||||
LDAP_BIND_DN: str = "Administrator@example.com"
|
||||
LDAP_BIND_PASSWORD: str = "admin-password"
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
83
samba-api/src/core/exceptions.py
Normal file
83
samba-api/src/core/exceptions.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SambaAPIException(Exception):
|
||||
"""Base exception class for Samba API"""
|
||||
def __init__(self, message: str, status_code: int = 500):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class UserNotFoundException(SambaAPIException):
|
||||
"""Raised when a user is not found"""
|
||||
def __init__(self, username: str):
|
||||
super().__init__(f"User '{username}' not found", 404)
|
||||
|
||||
|
||||
class GroupNotFoundException(SambaAPIException):
|
||||
"""Raised when a group is not found"""
|
||||
def __init__(self, groupname: str):
|
||||
super().__init__(f"Group '{groupname}' not found", 404)
|
||||
|
||||
|
||||
class OUNotFoundException(SambaAPIException):
|
||||
"""Raised when an OU is not found"""
|
||||
def __init__(self, ou_dn: str):
|
||||
super().__init__(f"OU '{ou_dn}' not found", 404)
|
||||
|
||||
|
||||
class ComputerNotFoundException(SambaAPIException):
|
||||
"""Raised when a computer is not found"""
|
||||
def __init__(self, computer_name: str):
|
||||
super().__init__(f"Computer '{computer_name}' not found", 404)
|
||||
|
||||
|
||||
class AuthenticationException(SambaAPIException):
|
||||
"""Raised when authentication fails"""
|
||||
def __init__(self, message: str = "Authentication failed"):
|
||||
super().__init__(message, 401)
|
||||
|
||||
|
||||
class AuthorizationException(SambaAPIException):
|
||||
"""Raised when authorization fails"""
|
||||
def __init__(self, message: str = "Insufficient permissions"):
|
||||
super().__init__(message, 403)
|
||||
|
||||
|
||||
class SambaCommandException(SambaAPIException):
|
||||
"""Raised when samba-tool command fails"""
|
||||
def __init__(self, command: str, error: str):
|
||||
super().__init__(f"Samba command failed: {command} - {error}", 500)
|
||||
|
||||
|
||||
def setup_exception_handlers(app: FastAPI):
|
||||
"""Setup custom exception handlers for the FastAPI app"""
|
||||
|
||||
@app.exception_handler(SambaAPIException)
|
||||
async def samba_api_exception_handler(request: Request, exc: SambaAPIException):
|
||||
logger.error(f"Samba API Exception: {exc.message}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.message, "type": type(exc).__name__}
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTP Exception: {exc.detail}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"}
|
||||
)
|
||||
62
samba-api/src/main.py
Normal file
62
samba-api/src/main.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
import uvicorn
|
||||
import logging
|
||||
|
||||
from src.routers import users, groups, ous, computers, auth
|
||||
from src.core.config import settings
|
||||
from src.core.exceptions import setup_exception_handlers
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Samba API",
|
||||
description="REST API for Samba Active Directory management",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ALLOWED_HOSTS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Setup exception handlers
|
||||
setup_exception_handlers(app)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
|
||||
app.include_router(groups.router, prefix="/api/v1/groups", tags=["Groups"])
|
||||
app.include_router(ous.router, prefix="/api/v1/ous", tags=["Organizational Units"])
|
||||
app.include_router(computers.router, prefix="/api/v1/computers", tags=["Computers"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Samba API is running", "version": "1.0.0"}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "samba-api"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level="info"
|
||||
)
|
||||
1
samba-api/src/models/__init__.py
Normal file
1
samba-api/src/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package initialization
|
||||
59
samba-api/src/models/auth.py
Normal file
59
samba-api/src/models/auth.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Model for JWT token data"""
|
||||
username: Optional[str] = None
|
||||
scopes: List[str] = []
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Model for JWT token response"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
scope: List[str] = []
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Model for login request"""
|
||||
username: str = Field(..., min_length=1, description="Username")
|
||||
password: str = Field(..., min_length=1, description="Password")
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Model for login response"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
username: str
|
||||
permissions: List[str]
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
"""Model for current user information"""
|
||||
username: str
|
||||
full_name: str
|
||||
email: Optional[str]
|
||||
permissions: List[str]
|
||||
groups: List[str]
|
||||
last_login: Optional[datetime]
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
"""Model for password reset request"""
|
||||
username: str = Field(..., min_length=1, description="Username")
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
"""Model for password reset confirmation"""
|
||||
token: str = Field(..., description="Reset token")
|
||||
new_password: str = Field(..., min_length=8, description="New password")
|
||||
confirm_password: str = Field(..., min_length=8, description="Confirm password")
|
||||
|
||||
def validate_passwords_match(self):
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError("Passwords do not match")
|
||||
return self
|
||||
81
samba-api/src/models/computers.py
Normal file
81
samba-api/src/models/computers.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ComputerType(str, Enum):
|
||||
WORKSTATION = "workstation"
|
||||
SERVER = "server"
|
||||
DOMAIN_CONTROLLER = "domain_controller"
|
||||
|
||||
|
||||
class ComputerStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class ComputerBase(BaseModel):
|
||||
"""Base computer model with common fields"""
|
||||
name: str = Field(..., min_length=1, max_length=15, description="Computer name (NetBIOS)")
|
||||
description: Optional[str] = Field(None, max_length=255, description="Computer description")
|
||||
computer_type: ComputerType = Field(default=ComputerType.WORKSTATION, description="Computer type")
|
||||
location: Optional[str] = Field(None, max_length=64, description="Physical location")
|
||||
managed_by: Optional[str] = Field(None, description="Managed by (user DN)")
|
||||
|
||||
|
||||
class ComputerCreate(ComputerBase):
|
||||
"""Model for creating a new computer"""
|
||||
ou_dn: Optional[str] = Field(None, description="Organizational Unit DN")
|
||||
enable_account: bool = Field(default=True, description="Enable computer account")
|
||||
|
||||
|
||||
class ComputerUpdate(BaseModel):
|
||||
"""Model for updating an existing computer"""
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
computer_type: Optional[ComputerType] = None
|
||||
location: Optional[str] = Field(None, max_length=64)
|
||||
managed_by: Optional[str] = None
|
||||
status: Optional[ComputerStatus] = None
|
||||
|
||||
|
||||
class ComputerResponse(ComputerBase):
|
||||
"""Model for computer response data"""
|
||||
dn: str = Field(..., description="Distinguished Name")
|
||||
sam_account_name: str = Field(..., description="SAM Account Name")
|
||||
status: ComputerStatus = Field(..., description="Computer status")
|
||||
sid: Optional[str] = Field(None, description="Security Identifier")
|
||||
created_date: Optional[datetime] = Field(None, description="Creation date")
|
||||
last_logon: Optional[datetime] = Field(None, description="Last logon date")
|
||||
operating_system: Optional[str] = Field(None, description="Operating system")
|
||||
os_version: Optional[str] = Field(None, description="OS version")
|
||||
service_pack: Optional[str] = Field(None, description="Service pack")
|
||||
attributes: Dict[str, Any] = Field(default_factory=dict, description="Additional LDAP attributes")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ComputerList(BaseModel):
|
||||
"""Model for paginated computer list response"""
|
||||
computers: List[ComputerResponse]
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool
|
||||
has_previous: bool
|
||||
|
||||
|
||||
class ComputerJoinRequest(BaseModel):
|
||||
"""Model for domain join request"""
|
||||
computer_name: str = Field(..., min_length=1, max_length=15)
|
||||
ou_dn: Optional[str] = None
|
||||
reset_password: bool = Field(default=True, description="Reset computer password")
|
||||
|
||||
|
||||
class ComputerJoinResponse(BaseModel):
|
||||
"""Model for domain join response"""
|
||||
computer_name: str
|
||||
status: str
|
||||
password: Optional[str] = None # Only returned if reset_password is True
|
||||
message: str
|
||||
72
samba-api/src/models/groups.py
Normal file
72
samba-api/src/models/groups.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GroupType(str, Enum):
|
||||
SECURITY = "security"
|
||||
DISTRIBUTION = "distribution"
|
||||
|
||||
|
||||
class GroupScope(str, Enum):
|
||||
DOMAIN_LOCAL = "domain_local"
|
||||
GLOBAL = "global"
|
||||
UNIVERSAL = "universal"
|
||||
|
||||
|
||||
class GroupBase(BaseModel):
|
||||
"""Base group model with common fields"""
|
||||
name: str = Field(..., min_length=1, max_length=64, description="Group name")
|
||||
description: Optional[str] = Field(None, max_length=255, description="Group description")
|
||||
group_type: GroupType = Field(default=GroupType.SECURITY, description="Group type")
|
||||
scope: GroupScope = Field(default=GroupScope.GLOBAL, description="Group scope")
|
||||
|
||||
|
||||
class GroupCreate(GroupBase):
|
||||
"""Model for creating a new group"""
|
||||
ou_dn: Optional[str] = Field(None, description="Organizational Unit DN")
|
||||
members: Optional[List[str]] = Field(default_factory=list, description="Initial group members")
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
"""Model for updating an existing group"""
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
group_type: Optional[GroupType] = None
|
||||
scope: Optional[GroupScope] = None
|
||||
|
||||
|
||||
class GroupResponse(GroupBase):
|
||||
"""Model for group response data"""
|
||||
dn: str = Field(..., description="Distinguished Name")
|
||||
sid: Optional[str] = Field(None, description="Security Identifier")
|
||||
created_date: Optional[datetime] = Field(None, description="Creation date")
|
||||
members: List[str] = Field(default_factory=list, description="Group members")
|
||||
member_count: int = Field(default=0, description="Number of members")
|
||||
attributes: Dict[str, Any] = Field(default_factory=dict, description="Additional LDAP attributes")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GroupList(BaseModel):
|
||||
"""Model for paginated group list response"""
|
||||
groups: List[GroupResponse]
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool
|
||||
has_previous: bool
|
||||
|
||||
|
||||
class GroupMembershipRequest(BaseModel):
|
||||
"""Model for adding/removing group members"""
|
||||
members: List[str] = Field(..., description="List of usernames or DNs to add/remove")
|
||||
|
||||
|
||||
class GroupMembershipResponse(BaseModel):
|
||||
"""Model for group membership response"""
|
||||
group_name: str
|
||||
action: str # "added" or "removed"
|
||||
successful_members: List[str]
|
||||
failed_members: List[Dict[str, str]] # {"member": "username", "error": "reason"}
|
||||
58
samba-api/src/models/ous.py
Normal file
58
samba-api/src/models/ous.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class OUBase(BaseModel):
|
||||
"""Base OU model with common fields"""
|
||||
name: str = Field(..., min_length=1, max_length=64, description="OU name")
|
||||
description: Optional[str] = Field(None, max_length=255, description="OU description")
|
||||
|
||||
|
||||
class OUCreate(OUBase):
|
||||
"""Model for creating a new OU"""
|
||||
parent_dn: Optional[str] = Field(None, description="Parent OU Distinguished Name")
|
||||
|
||||
|
||||
class OUUpdate(BaseModel):
|
||||
"""Model for updating an existing OU"""
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class OUResponse(OUBase):
|
||||
"""Model for OU response data"""
|
||||
dn: str = Field(..., description="Distinguished Name")
|
||||
parent_dn: Optional[str] = Field(None, description="Parent OU DN")
|
||||
created_date: Optional[datetime] = Field(None, description="Creation date")
|
||||
child_ous: List[str] = Field(default_factory=list, description="Child OUs")
|
||||
users_count: int = Field(default=0, description="Number of users in OU")
|
||||
computers_count: int = Field(default=0, description="Number of computers in OU")
|
||||
groups_count: int = Field(default=0, description="Number of groups in OU")
|
||||
attributes: Dict[str, Any] = Field(default_factory=dict, description="Additional LDAP attributes")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OUList(BaseModel):
|
||||
"""Model for OU list response"""
|
||||
ous: List[OUResponse]
|
||||
total_count: int
|
||||
|
||||
|
||||
class OUTree(BaseModel):
|
||||
"""Model for hierarchical OU tree structure"""
|
||||
dn: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
children: List['OUTree'] = Field(default_factory=list)
|
||||
users_count: int = 0
|
||||
computers_count: int = 0
|
||||
groups_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Enable forward reference resolution
|
||||
OUTree.model_rebuild()
|
||||
78
samba-api/src/models/users.py
Normal file
78
samba-api/src/models/users.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class UserStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
LOCKED = "locked"
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user model with common fields"""
|
||||
username: str = Field(..., min_length=1, max_length=64, description="Username")
|
||||
first_name: str = Field(..., min_length=1, max_length=64, description="First name")
|
||||
last_name: str = Field(..., min_length=1, max_length=64, description="Last name")
|
||||
email: Optional[EmailStr] = Field(None, description="Email address")
|
||||
description: Optional[str] = Field(None, max_length=255, description="User description")
|
||||
department: Optional[str] = Field(None, max_length=64, description="Department")
|
||||
title: Optional[str] = Field(None, max_length=64, description="Job title")
|
||||
office: Optional[str] = Field(None, max_length=64, description="Office location")
|
||||
phone: Optional[str] = Field(None, max_length=32, description="Phone number")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Model for creating a new user"""
|
||||
password: str = Field(..., min_length=8, description="User password")
|
||||
ou_dn: Optional[str] = Field(None, description="Organizational Unit DN")
|
||||
groups: Optional[List[str]] = Field(default_factory=list, description="List of groups to add user to")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Model for updating an existing user"""
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
email: Optional[EmailStr] = None
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
department: Optional[str] = Field(None, max_length=64)
|
||||
title: Optional[str] = Field(None, max_length=64)
|
||||
office: Optional[str] = Field(None, max_length=64)
|
||||
phone: Optional[str] = Field(None, max_length=32)
|
||||
status: Optional[UserStatus] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Model for user response data"""
|
||||
dn: str = Field(..., description="Distinguished Name")
|
||||
status: UserStatus = Field(..., description="User status")
|
||||
created_date: Optional[datetime] = Field(None, description="Creation date")
|
||||
last_login: Optional[datetime] = Field(None, description="Last login date")
|
||||
groups: List[str] = Field(default_factory=list, description="List of groups user belongs to")
|
||||
attributes: Dict[str, Any] = Field(default_factory=dict, description="Additional LDAP attributes")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserList(BaseModel):
|
||||
"""Model for paginated user list response"""
|
||||
users: List[UserResponse]
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool
|
||||
has_previous: bool
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
"""Model for password change request"""
|
||||
current_password: Optional[str] = Field(None, description="Current password (required for self-service)")
|
||||
new_password: str = Field(..., min_length=8, description="New password")
|
||||
confirm_password: str = Field(..., min_length=8, description="Confirm new password")
|
||||
|
||||
def validate_passwords_match(self):
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError("Passwords do not match")
|
||||
return self
|
||||
1
samba-api/src/routers/__init__.py
Normal file
1
samba-api/src/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package initialization
|
||||
242
samba-api/src/routers/auth.py
Normal file
242
samba-api/src/routers/auth.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import logging
|
||||
|
||||
from src.models.auth import LoginRequest, LoginResponse, UserInfo, TokenData
|
||||
from src.core.config import settings
|
||||
from src.core.exceptions import AuthenticationException, AuthorizationException
|
||||
from src.services.user_service import user_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Security setup
|
||||
security = HTTPBearer()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT token functions
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Create JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str) -> TokenData:
|
||||
"""Verify and decode JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise AuthenticationException("Invalid token")
|
||||
|
||||
scopes = payload.get("scopes", [])
|
||||
return TokenData(username=username, scopes=scopes)
|
||||
|
||||
except JWTError:
|
||||
raise AuthenticationException("Invalid token")
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||
"""Get current authenticated user"""
|
||||
token_data = verify_token(credentials.credentials)
|
||||
|
||||
if token_data.username is None:
|
||||
raise AuthenticationException("Invalid token")
|
||||
|
||||
try:
|
||||
user = await user_service.get_user(token_data.username)
|
||||
return {
|
||||
"username": user.username,
|
||||
"full_name": f"{user.first_name} {user.last_name}",
|
||||
"email": user.email,
|
||||
"permissions": token_data.scopes,
|
||||
"groups": user.groups
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get current user: {str(e)}")
|
||||
raise AuthenticationException("User not found")
|
||||
|
||||
async def authenticate_user(username: str, password: str) -> Optional[dict]:
|
||||
"""Authenticate user against Samba/LDAP"""
|
||||
try:
|
||||
from ldap3 import Server, Connection, ALL, SIMPLE, SUBTREE
|
||||
|
||||
# Handle username format - if it already contains @domain, use as is
|
||||
if "@" in username:
|
||||
user_principal_name = username
|
||||
base_username = username.split("@")[0]
|
||||
else:
|
||||
user_principal_name = f"{username}@{settings.SAMBA_DOMAIN}"
|
||||
base_username = username
|
||||
|
||||
logger.info(f"Attempting authentication for user: {user_principal_name}")
|
||||
|
||||
# Create server connection
|
||||
server = Server(settings.LDAP_SERVER, get_info=ALL, use_ssl=False)
|
||||
|
||||
# First, try to authenticate by searching for the user using admin credentials,
|
||||
# then verify password with a new connection
|
||||
admin_conn = None
|
||||
|
||||
try:
|
||||
# Connect as admin to search for user
|
||||
admin_conn = Connection(
|
||||
server,
|
||||
user=settings.LDAP_BIND_DN,
|
||||
password=settings.LDAP_BIND_PASSWORD,
|
||||
authentication=SIMPLE
|
||||
)
|
||||
|
||||
if not admin_conn.bind():
|
||||
logger.error(f"Failed to bind as admin: {admin_conn.result}")
|
||||
return None
|
||||
|
||||
logger.info("Admin bind successful, searching for user")
|
||||
|
||||
# Search for the user
|
||||
search_filter = f"(|(sAMAccountName={base_username})(userPrincipalName={user_principal_name}))"
|
||||
|
||||
admin_conn.search(
|
||||
search_base=settings.SAMBA_BASE_DN,
|
||||
search_filter=search_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=['sAMAccountName', 'userPrincipalName', 'distinguishedName', 'mail', 'cn', 'displayName', 'memberOf']
|
||||
)
|
||||
|
||||
if not admin_conn.entries:
|
||||
logger.warning(f"User not found: {base_username}")
|
||||
admin_conn.unbind()
|
||||
return None
|
||||
|
||||
user_entry = admin_conn.entries[0]
|
||||
user_dn = str(user_entry.distinguishedName)
|
||||
|
||||
logger.info(f"Found user DN: {user_dn}")
|
||||
|
||||
admin_conn.unbind()
|
||||
|
||||
# Now try to authenticate as the found user
|
||||
user_conn = Connection(
|
||||
server,
|
||||
user=user_dn,
|
||||
password=password,
|
||||
authentication=SIMPLE
|
||||
)
|
||||
|
||||
if user_conn.bind():
|
||||
logger.info(f"Authentication successful for user: {user_dn}")
|
||||
user_conn.unbind()
|
||||
|
||||
# Extract user information from the LDAP entry
|
||||
display_name = str(user_entry.displayName) if user_entry.displayName else str(user_entry.cn) if user_entry.cn else base_username
|
||||
email = str(user_entry.mail) if user_entry.mail else user_principal_name
|
||||
groups = [str(group) for group in user_entry.memberOf] if user_entry.memberOf else []
|
||||
|
||||
return {
|
||||
"username": base_username,
|
||||
"full_name": display_name,
|
||||
"email": email,
|
||||
"groups": groups,
|
||||
"permissions": ["read", "write"] # TODO: Implement role-based permissions
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Password verification failed for user: {user_dn}")
|
||||
user_conn.unbind()
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LDAP search/bind failed: {str(e)}")
|
||||
if admin_conn:
|
||||
try:
|
||||
admin_conn.unbind()
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication failed for user {username}: {str(e)}")
|
||||
return None
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(login_data: LoginRequest):
|
||||
"""Authenticate user and return JWT token"""
|
||||
try:
|
||||
user = await authenticate_user(login_data.username, login_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user["username"], "scopes": user["permissions"]},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
username=user["username"],
|
||||
permissions=user["permissions"]
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Login failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication service error")
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
async def get_current_user_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Get current user information"""
|
||||
return UserInfo(
|
||||
username=current_user["username"],
|
||||
full_name=current_user["full_name"],
|
||||
email=current_user["email"],
|
||||
permissions=current_user["permissions"],
|
||||
groups=current_user["groups"],
|
||||
last_login=None # TODO: Implement last login tracking
|
||||
)
|
||||
|
||||
@router.post("/logout", status_code=200)
|
||||
async def logout(current_user: dict = Depends(get_current_user)):
|
||||
"""Logout user (client-side token invalidation)"""
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh_token(current_user: dict = Depends(get_current_user)):
|
||||
"""Refresh JWT token"""
|
||||
try:
|
||||
# Create new access token
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": current_user["username"], "scopes": current_user["permissions"]},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
username=current_user["username"],
|
||||
permissions=current_user["permissions"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Token refresh failed")
|
||||
83
samba-api/src/routers/computers.py
Normal file
83
samba-api/src/routers/computers.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from src.models.computers import (
|
||||
ComputerCreate, ComputerUpdate, ComputerResponse, ComputerList,
|
||||
ComputerJoinRequest, ComputerJoinResponse
|
||||
)
|
||||
from src.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=ComputerResponse, status_code=201)
|
||||
async def create_computer(
|
||||
computer_data: ComputerCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new computer account"""
|
||||
# TODO: Implement computer creation
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/", response_model=ComputerList)
|
||||
async def list_computers(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
search: Optional[str] = Query(None, description="Search computers by name"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List computers with pagination and optional search"""
|
||||
# TODO: Implement computer listing
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/{computer_name}", response_model=ComputerResponse)
|
||||
async def get_computer(
|
||||
computer_name: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get computer by name"""
|
||||
# TODO: Implement computer retrieval
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.put("/{computer_name}", response_model=ComputerResponse)
|
||||
async def update_computer(
|
||||
computer_name: str,
|
||||
computer_data: ComputerUpdate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update computer information"""
|
||||
# TODO: Implement computer update
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.delete("/{computer_name}", status_code=204)
|
||||
async def delete_computer(
|
||||
computer_name: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete computer account"""
|
||||
# TODO: Implement computer deletion
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.post("/join", response_model=ComputerJoinResponse)
|
||||
async def join_computer_to_domain(
|
||||
join_data: ComputerJoinRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Join computer to domain"""
|
||||
# TODO: Implement domain join
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.post("/{computer_name}/reset-password", status_code=200)
|
||||
async def reset_computer_password(
|
||||
computer_name: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Reset computer account password"""
|
||||
# TODO: Implement password reset
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
85
samba-api/src/routers/groups.py
Normal file
85
samba-api/src/routers/groups.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from src.models.groups import (
|
||||
GroupCreate, GroupUpdate, GroupResponse, GroupList,
|
||||
GroupMembershipRequest, GroupMembershipResponse
|
||||
)
|
||||
from src.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=GroupResponse, status_code=201)
|
||||
async def create_group(
|
||||
group_data: GroupCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new group"""
|
||||
# TODO: Implement group creation
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/", response_model=GroupList)
|
||||
async def list_groups(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
search: Optional[str] = Query(None, description="Search groups by name"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List groups with pagination and optional search"""
|
||||
# TODO: Implement group listing
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/{group_name}", response_model=GroupResponse)
|
||||
async def get_group(
|
||||
group_name: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get group by name"""
|
||||
# TODO: Implement group retrieval
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.put("/{group_name}", response_model=GroupResponse)
|
||||
async def update_group(
|
||||
group_name: str,
|
||||
group_data: GroupUpdate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update group information"""
|
||||
# TODO: Implement group update
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.delete("/{group_name}", status_code=204)
|
||||
async def delete_group(
|
||||
group_name: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete group"""
|
||||
# TODO: Implement group deletion
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.post("/{group_name}/members", response_model=GroupMembershipResponse)
|
||||
async def add_group_members(
|
||||
group_name: str,
|
||||
membership_data: GroupMembershipRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add members to group"""
|
||||
# TODO: Implement add members
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.delete("/{group_name}/members", response_model=GroupMembershipResponse)
|
||||
async def remove_group_members(
|
||||
group_name: str,
|
||||
membership_data: GroupMembershipRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Remove members from group"""
|
||||
# TODO: Implement remove members
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
66
samba-api/src/routers/ous.py
Normal file
66
samba-api/src/routers/ous.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from src.models.ous import OUCreate, OUUpdate, OUResponse, OUList, OUTree
|
||||
from src.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=OUResponse, status_code=201)
|
||||
async def create_ou(
|
||||
ou_data: OUCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new organizational unit"""
|
||||
# TODO: Implement OU creation
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/", response_model=OUList)
|
||||
async def list_ous(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List organizational units"""
|
||||
# TODO: Implement OU listing
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/tree", response_model=OUTree)
|
||||
async def get_ou_tree(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get OU tree structure"""
|
||||
# TODO: Implement OU tree
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.get("/{ou_dn:path}", response_model=OUResponse)
|
||||
async def get_ou(
|
||||
ou_dn: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get OU by DN"""
|
||||
# TODO: Implement OU retrieval
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.put("/{ou_dn:path}", response_model=OUResponse)
|
||||
async def update_ou(
|
||||
ou_dn: str,
|
||||
ou_data: OUUpdate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update OU information"""
|
||||
# TODO: Implement OU update
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
|
||||
|
||||
@router.delete("/{ou_dn:path}", status_code=204)
|
||||
async def delete_ou(
|
||||
ou_dn: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete OU"""
|
||||
# TODO: Implement OU deletion
|
||||
raise HTTPException(status_code=501, detail="Not implemented yet")
|
||||
160
samba-api/src/routers/users.py
Normal file
160
samba-api/src/routers/users.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from src.models.users import (
|
||||
UserCreate, UserUpdate, UserResponse, UserList, PasswordChange
|
||||
)
|
||||
from src.services.user_service import user_service
|
||||
from src.core.exceptions import UserNotFoundException, SambaAPIException
|
||||
from src.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse, status_code=201)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new user"""
|
||||
try:
|
||||
return await user_service.create_user(user_data)
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/", response_model=UserList)
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
search: Optional[str] = Query(None, description="Search users by username, name, or email"),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List users with pagination and optional search"""
|
||||
try:
|
||||
return await user_service.list_users(page=page, page_size=page_size, search=search)
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{username}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
username: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get user by username"""
|
||||
try:
|
||||
return await user_service.get_user(username)
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/{username}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
username: str,
|
||||
user_data: UserUpdate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update user information"""
|
||||
try:
|
||||
return await user_service.update_user(username, user_data)
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/{username}", status_code=204)
|
||||
async def delete_user(
|
||||
username: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete user"""
|
||||
try:
|
||||
await user_service.delete_user(username)
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{username}/password", status_code=200)
|
||||
async def change_user_password(
|
||||
username: str,
|
||||
password_data: PasswordChange,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Change user password"""
|
||||
try:
|
||||
# Validate passwords match
|
||||
password_data.validate_passwords_match()
|
||||
|
||||
# For admin users, current password is not required
|
||||
# For self-service, validate current password first
|
||||
if current_user.get('username') == username and password_data.current_password:
|
||||
# TODO: Implement current password validation
|
||||
pass
|
||||
|
||||
await user_service.change_password(username, password_data.new_password)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{username}/enable", status_code=200)
|
||||
async def enable_user(
|
||||
username: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Enable user account"""
|
||||
try:
|
||||
from src.models.users import UserStatus
|
||||
user_update = UserUpdate(status=UserStatus.ACTIVE)
|
||||
await user_service.update_user(username, user_update)
|
||||
return {"message": f"User '{username}' enabled successfully"}
|
||||
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{username}/disable", status_code=200)
|
||||
async def disable_user(
|
||||
username: str,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Disable user account"""
|
||||
try:
|
||||
from src.models.users import UserStatus
|
||||
user_update = UserUpdate(status=UserStatus.DISABLED)
|
||||
await user_service.update_user(username, user_update)
|
||||
return {"message": f"User '{username}' disabled successfully"}
|
||||
|
||||
except UserNotFoundException:
|
||||
raise HTTPException(status_code=404, detail=f"User '{username}' not found")
|
||||
except SambaAPIException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
1
samba-api/src/services/__init__.py
Normal file
1
samba-api/src/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package initialization
|
||||
112
samba-api/src/services/samba_service.py
Normal file
112
samba-api/src/services/samba_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import subprocess
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from ldap3 import Server, Connection, ALL, SUBTREE
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.exceptions import SambaCommandException, SambaAPIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SambaService:
|
||||
"""Service class for interacting with Samba Active Directory"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = Server(settings.LDAP_SERVER, get_info=ALL, use_ssl=settings.LDAP_USE_SSL)
|
||||
self.base_dn = settings.SAMBA_BASE_DN
|
||||
|
||||
def _get_connection(self) -> Connection:
|
||||
"""Get LDAP connection"""
|
||||
try:
|
||||
conn = Connection(
|
||||
self.server,
|
||||
user=settings.LDAP_BIND_DN,
|
||||
password=settings.LDAP_BIND_PASSWORD,
|
||||
auto_bind=True
|
||||
)
|
||||
return conn
|
||||
except LDAPException as e:
|
||||
logger.error(f"Failed to connect to LDAP server: {str(e)}")
|
||||
raise SambaAPIException(f"LDAP connection failed: {str(e)}")
|
||||
|
||||
async def _run_samba_tool(self, command: List[str]) -> Dict[str, Any]:
|
||||
"""Run samba-tool command asynchronously"""
|
||||
try:
|
||||
full_command = ['samba-tool'] + command
|
||||
logger.info(f"Running command: {' '.join(full_command)}")
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*full_command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'}
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
result = {
|
||||
'returncode': process.returncode,
|
||||
'stdout': stdout.decode('utf-8') if stdout else '',
|
||||
'stderr': stderr.decode('utf-8') if stderr else ''
|
||||
}
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = result['stderr'] or result['stdout'] or 'Unknown error'
|
||||
logger.error(f"Samba tool command failed: {error_msg}")
|
||||
raise SambaCommandException(' '.join(full_command), error_msg)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, SambaCommandException):
|
||||
raise
|
||||
logger.error(f"Failed to run samba-tool command: {str(e)}")
|
||||
raise SambaAPIException(f"Command execution failed: {str(e)}")
|
||||
|
||||
def _parse_dn_components(self, dn: str) -> Dict[str, str]:
|
||||
"""Parse DN components"""
|
||||
components = {}
|
||||
for part in dn.split(','):
|
||||
if '=' in part:
|
||||
key, value = part.split('=', 1)
|
||||
components[key.strip().lower()] = value.strip()
|
||||
return components
|
||||
|
||||
def _build_user_dn(self, username: str, ou_dn: Optional[str] = None) -> str:
|
||||
"""Build user DN"""
|
||||
if ou_dn:
|
||||
return f"CN={username},{ou_dn}"
|
||||
else:
|
||||
return f"CN={username},CN=Users,{self.base_dn}"
|
||||
|
||||
def _build_group_dn(self, groupname: str, ou_dn: Optional[str] = None) -> str:
|
||||
"""Build group DN"""
|
||||
if ou_dn:
|
||||
return f"CN={groupname},{ou_dn}"
|
||||
else:
|
||||
return f"CN={groupname},CN=Groups,{self.base_dn}"
|
||||
|
||||
def _build_computer_dn(self, computer_name: str, ou_dn: Optional[str] = None) -> str:
|
||||
"""Build computer DN"""
|
||||
if ou_dn:
|
||||
return f"CN={computer_name},{ou_dn}"
|
||||
else:
|
||||
return f"CN={computer_name},CN=Computers,{self.base_dn}"
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Test Samba/LDAP connection"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
conn.unbind()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
# Global service instance
|
||||
samba_service = SambaService()
|
||||
295
samba-api/src/services/user_service.py
Normal file
295
samba-api/src/services/user_service.py
Normal file
@@ -0,0 +1,295 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from ldap3 import SUBTREE
|
||||
import logging
|
||||
|
||||
from .samba_service import samba_service
|
||||
from src.models.users import UserCreate, UserUpdate, UserResponse, UserList, UserStatus
|
||||
from src.core.exceptions import UserNotFoundException, SambaAPIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Service class for user management operations"""
|
||||
|
||||
async def create_user(self, user_data: UserCreate) -> UserResponse:
|
||||
"""Create a new user"""
|
||||
try:
|
||||
# Build samba-tool command for user creation
|
||||
command = [
|
||||
'user', 'create',
|
||||
user_data.username,
|
||||
user_data.password,
|
||||
'--given-name', user_data.first_name,
|
||||
'--surname', user_data.last_name
|
||||
]
|
||||
|
||||
# Add optional parameters
|
||||
if user_data.email:
|
||||
command.extend(['--mail-address', user_data.email])
|
||||
|
||||
if user_data.description:
|
||||
command.extend(['--description', user_data.description])
|
||||
|
||||
if user_data.ou_dn:
|
||||
command.extend(['--userou', user_data.ou_dn])
|
||||
|
||||
# Execute command
|
||||
await samba_service._run_samba_tool(command)
|
||||
|
||||
# Add user to groups if specified
|
||||
if user_data.groups:
|
||||
for group in user_data.groups:
|
||||
try:
|
||||
await self._add_user_to_group(user_data.username, group)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to add user {user_data.username} to group {group}: {str(e)}")
|
||||
|
||||
# Return created user
|
||||
return await self.get_user(user_data.username)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create user {user_data.username}: {str(e)}")
|
||||
if isinstance(e, SambaAPIException):
|
||||
raise
|
||||
raise SambaAPIException(f"User creation failed: {str(e)}")
|
||||
|
||||
async def get_user(self, username: str) -> UserResponse:
|
||||
"""Get user by username"""
|
||||
try:
|
||||
conn = samba_service._get_connection()
|
||||
|
||||
# Search for user
|
||||
search_filter = f"(&(objectClass=user)(sAMAccountName={username}))"
|
||||
conn.search(
|
||||
search_base=samba_service.base_dn,
|
||||
search_filter=search_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=['*']
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
raise UserNotFoundException(username)
|
||||
|
||||
entry = conn.entries[0]
|
||||
|
||||
# Parse user data
|
||||
user_data = self._parse_user_entry(entry)
|
||||
conn.unbind()
|
||||
|
||||
return UserResponse(**user_data)
|
||||
|
||||
except UserNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user {username}: {str(e)}")
|
||||
raise SambaAPIException(f"Failed to retrieve user: {str(e)}")
|
||||
|
||||
async def update_user(self, username: str, user_data: UserUpdate) -> UserResponse:
|
||||
"""Update user information"""
|
||||
try:
|
||||
# Build samba-tool command for user modification
|
||||
command = ['user', 'setexpiry', username, '--noexpiry']
|
||||
|
||||
# For other attributes, we'll use LDAP modify operations
|
||||
conn = samba_service._get_connection()
|
||||
|
||||
user_dn = samba_service._build_user_dn(username)
|
||||
modifications = {}
|
||||
|
||||
if user_data.first_name is not None:
|
||||
modifications['givenName'] = [(2, [user_data.first_name])] # MODIFY_REPLACE
|
||||
|
||||
if user_data.last_name is not None:
|
||||
modifications['sn'] = [(2, [user_data.last_name])]
|
||||
|
||||
if user_data.email is not None:
|
||||
modifications['mail'] = [(2, [user_data.email])]
|
||||
|
||||
if user_data.description is not None:
|
||||
modifications['description'] = [(2, [user_data.description])]
|
||||
|
||||
if user_data.department is not None:
|
||||
modifications['department'] = [(2, [user_data.department])]
|
||||
|
||||
if user_data.title is not None:
|
||||
modifications['title'] = [(2, [user_data.title])]
|
||||
|
||||
if user_data.office is not None:
|
||||
modifications['physicalDeliveryOfficeName'] = [(2, [user_data.office])]
|
||||
|
||||
if user_data.phone is not None:
|
||||
modifications['telephoneNumber'] = [(2, [user_data.phone])]
|
||||
|
||||
if user_data.status is not None:
|
||||
if user_data.status == UserStatus.DISABLED:
|
||||
# Disable user account
|
||||
await samba_service._run_samba_tool(['user', 'disable', username])
|
||||
elif user_data.status == UserStatus.ACTIVE:
|
||||
# Enable user account
|
||||
await samba_service._run_samba_tool(['user', 'enable', username])
|
||||
|
||||
# Apply LDAP modifications
|
||||
if modifications:
|
||||
conn.modify(user_dn, modifications)
|
||||
|
||||
conn.unbind()
|
||||
|
||||
# Return updated user
|
||||
return await self.get_user(username)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update user {username}: {str(e)}")
|
||||
if isinstance(e, SambaAPIException):
|
||||
raise
|
||||
raise SambaAPIException(f"User update failed: {str(e)}")
|
||||
|
||||
async def delete_user(self, username: str) -> bool:
|
||||
"""Delete user"""
|
||||
try:
|
||||
command = ['user', 'delete', username]
|
||||
await samba_service._run_samba_tool(command)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete user {username}: {str(e)}")
|
||||
if isinstance(e, SambaAPIException):
|
||||
raise
|
||||
raise SambaAPIException(f"User deletion failed: {str(e)}")
|
||||
|
||||
async def list_users(self, page: int = 1, page_size: int = 50, search: Optional[str] = None) -> UserList:
|
||||
"""List users with pagination and optional search"""
|
||||
try:
|
||||
conn = samba_service._get_connection()
|
||||
|
||||
# Build search filter
|
||||
base_filter = "(objectClass=user)"
|
||||
if search:
|
||||
search_filter = f"(&{base_filter}(|(sAMAccountName=*{search}*)(cn=*{search}*)(mail=*{search}*)))"
|
||||
else:
|
||||
search_filter = base_filter
|
||||
|
||||
conn.search(
|
||||
search_base=samba_service.base_dn,
|
||||
search_filter=search_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=['*'],
|
||||
paged_size=page_size,
|
||||
generator=True
|
||||
)
|
||||
|
||||
users = []
|
||||
total_count = 0
|
||||
|
||||
for entry in conn.entries:
|
||||
user_data = self._parse_user_entry(entry)
|
||||
users.append(UserResponse(**user_data))
|
||||
total_count += 1
|
||||
|
||||
conn.unbind()
|
||||
|
||||
# Calculate pagination
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
paginated_users = users[start_index:end_index]
|
||||
|
||||
has_next = end_index < total_count
|
||||
has_previous = page > 1
|
||||
|
||||
return UserList(
|
||||
users=paginated_users,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
has_next=has_next,
|
||||
has_previous=has_previous
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list users: {str(e)}")
|
||||
raise SambaAPIException(f"Failed to retrieve users: {str(e)}")
|
||||
|
||||
async def change_password(self, username: str, new_password: str) -> bool:
|
||||
"""Change user password"""
|
||||
try:
|
||||
command = ['user', 'setpassword', username, '--newpassword', new_password]
|
||||
await samba_service._run_samba_tool(command)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to change password for user {username}: {str(e)}")
|
||||
if isinstance(e, SambaAPIException):
|
||||
raise
|
||||
raise SambaAPIException(f"Password change failed: {str(e)}")
|
||||
|
||||
async def _add_user_to_group(self, username: str, group_name: str) -> bool:
|
||||
"""Add user to group"""
|
||||
try:
|
||||
command = ['group', 'addmembers', group_name, username]
|
||||
await samba_service._run_samba_tool(command)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add user {username} to group {group_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def _parse_user_entry(self, entry) -> Dict[str, Any]:
|
||||
"""Parse LDAP user entry to user data"""
|
||||
try:
|
||||
attributes = entry.entry_attributes_as_dict
|
||||
|
||||
# Extract basic information
|
||||
username = attributes.get('sAMAccountName', [''])[0] if 'sAMAccountName' in attributes else ''
|
||||
first_name = attributes.get('givenName', [''])[0] if 'givenName' in attributes else ''
|
||||
last_name = attributes.get('sn', [''])[0] if 'sn' in attributes else ''
|
||||
email = attributes.get('mail', [None])[0] if 'mail' in attributes else None
|
||||
description = attributes.get('description', [None])[0] if 'description' in attributes else None
|
||||
|
||||
# Provide default values for required fields if they're empty
|
||||
if not first_name:
|
||||
first_name = username # Use username as fallback for first_name
|
||||
if not last_name:
|
||||
last_name = "User" # Default last name
|
||||
|
||||
# Determine status
|
||||
uac = attributes.get('userAccountControl', [0])[0]
|
||||
if isinstance(uac, list):
|
||||
uac = uac[0] if uac else 0
|
||||
|
||||
status = UserStatus.ACTIVE
|
||||
if uac and int(uac) & 0x2: # ACCOUNTDISABLE flag
|
||||
status = UserStatus.DISABLED
|
||||
|
||||
# Get group memberships
|
||||
groups = []
|
||||
if 'memberOf' in attributes:
|
||||
for group_dn in attributes['memberOf']:
|
||||
# Extract group name from DN
|
||||
cn_part = group_dn.split(',')[0]
|
||||
if cn_part.startswith('CN='):
|
||||
groups.append(cn_part[3:])
|
||||
|
||||
return {
|
||||
'username': username,
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'email': email,
|
||||
'description': description,
|
||||
'department': attributes.get('department', [None])[0],
|
||||
'title': attributes.get('title', [None])[0],
|
||||
'office': attributes.get('physicalDeliveryOfficeName', [None])[0],
|
||||
'phone': attributes.get('telephoneNumber', [None])[0],
|
||||
'dn': str(entry.entry_dn),
|
||||
'status': status,
|
||||
'created_date': None, # Would need to parse whenCreated
|
||||
'last_login': None, # Would need to parse lastLogon
|
||||
'groups': groups,
|
||||
'attributes': {}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse user entry: {str(e)}")
|
||||
raise SambaAPIException(f"Failed to parse user data: {str(e)}")
|
||||
|
||||
|
||||
# Global service instance
|
||||
user_service = UserService()
|
||||
32
samba-api/ssl/server.crt
Normal file
32
samba-api/ssl/server.crt
Normal file
@@ -0,0 +1,32 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFjzCCA3egAwIBAgIUBD3mFcCaIqWjHnupZGsmPIDKrQ0wDQYJKoZIhvcNAQEL
|
||||
BQAwVzELMAkGA1UEBhMCRlIxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
|
||||
MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y
|
||||
NTEwMTcxNDAzNDBaFw0yNjEwMTcxNDAzNDBaMFcxCzAJBgNVBAYTAkZSMQ4wDAYD
|
||||
VQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UECgwMT3JnYW5pemF0aW9u
|
||||
MRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
|
||||
AoICAQC/XIzYPBdY9vVxfCUDA470EaHCXw3PCyE9aJddBPPdpHjnp+zQmVO+BHPB
|
||||
0B+oD/UJBb6P2Rt2CCm4emRdHC6bofrKNrRVbOOvGEwjsAMtygi6528bvPJIDdTt
|
||||
B9mhMC9s8xI1wZ/EASH0U5R3jOjp88Ljuua5yShukSTpRXuQ/8APMErIVao6Z1mj
|
||||
JbPKr8/KA7dv8oaO7DxF7Q4PN5UhHPU22h7hPz67ucCE3MhwhNjgmHhXLyoJWkO/
|
||||
6UcwaREHYPQn/ENZfVxN10nDLXHZ7VMeiHgKHPWC4t2lkkK/M3h80huaqJValLvx
|
||||
sbD5dGV0H/V/zZ3cAwcHei3rIaEI9sSV6N8DCn0syw/JR4uqJjQHIuKYFXyhYYcW
|
||||
ATftkaQuFaUXMRwarf0Bu/J6vaIinuJbyG2dN4qpJtFXuMh7TKvS4xBgb7h1oXjT
|
||||
7Uhy7rzdsKruWMNtbzHtOTSliqNAQ9/ylG1w9ODNkwAk8yItxRRLicag8wp+CspT
|
||||
ndqzj97YdopXArK/xDXGHTqP5XItMZqBNXRO3KaDweWnO/88XyZQ7uhcX4eDDJwA
|
||||
2jS9twr2ijuFNJAz31TsZ8uudW2oGCzwnDqY/VDb+gN8zrtVIhGLLaIqUQqA5bNg
|
||||
UwA+dNJQhrY13V1kW/IfkWhPxMf6r8b2a7YToxin3hjuu5QWVQIDAQABo1MwUTAd
|
||||
BgNVHQ4EFgQUCPvlXUUMu/RXPCsDO01IxplRhJEwHwYDVR0jBBgwFoAUCPvlXUUM
|
||||
u/RXPCsDO01IxplRhJEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
|
||||
AgEAU95uvE7QO8Pmwzru9kORPDGgxxqSKjbiU9ti6JUW4XUnCQjYYFK4JiwEMpaA
|
||||
NVUmFHMQdWhPKJOXe/+5WG6SQHsAypVI7CLJPSRq9aemlLVIVRn3Y9XTy4X2PwiE
|
||||
e6dU0MCgjilvFtD09bomnMYQ8OIxW5kkquLVpRSPx9pAV/bJ3Q5E6tsIQJgeH2MR
|
||||
boi+33PVjXd2U+0oe54bgV27e2LH1YCu67Ai4ZqF6v79WjdwYHGekvaEThTCm+xd
|
||||
bN4JmNLWef2O0rXFZlz27xPAC0V5Eo+TGT4naq2C9AYjU5KV72ABO1l2K7yYhaJz
|
||||
IuePOu1Ehs+CUz8vABZiiBOJHlZLTt86THq07dTHhDx6KyqIOJdM6l2+X5vG4b0y
|
||||
kh63r5T3QrdRlj5GlOGVBHiEoHgHPEAPVsYdnqWOPaLIwZQlKmQAYCygl0y1dsoN
|
||||
AUaUZBldwhLBn5OXH6PL/tDwaP8/jG+OBctsty3ydi08gYtMMTbPn5O0m1HGKJfm
|
||||
j6qeY/AHv1bFvLrIvpMuNZrUQTxqf1pdv3Sc9MIBpPZNHyLnTZZrmSZi6W8ZIqqv
|
||||
rFeBIevuguEbqNce8t+mggsI3pC0s7an6ySzrOvZZSg7JTErlyEzJFcpGxokVWGv
|
||||
Kv4mhDyUcdcoNDkCBQJ8rI6Fipyr8cDXvi/bW7EekxLbGIo=
|
||||
-----END CERTIFICATE-----
|
||||
52
samba-api/ssl/server.key
Normal file
52
samba-api/ssl/server.key
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC/XIzYPBdY9vVx
|
||||
fCUDA470EaHCXw3PCyE9aJddBPPdpHjnp+zQmVO+BHPB0B+oD/UJBb6P2Rt2CCm4
|
||||
emRdHC6bofrKNrRVbOOvGEwjsAMtygi6528bvPJIDdTtB9mhMC9s8xI1wZ/EASH0
|
||||
U5R3jOjp88Ljuua5yShukSTpRXuQ/8APMErIVao6Z1mjJbPKr8/KA7dv8oaO7DxF
|
||||
7Q4PN5UhHPU22h7hPz67ucCE3MhwhNjgmHhXLyoJWkO/6UcwaREHYPQn/ENZfVxN
|
||||
10nDLXHZ7VMeiHgKHPWC4t2lkkK/M3h80huaqJValLvxsbD5dGV0H/V/zZ3cAwcH
|
||||
ei3rIaEI9sSV6N8DCn0syw/JR4uqJjQHIuKYFXyhYYcWATftkaQuFaUXMRwarf0B
|
||||
u/J6vaIinuJbyG2dN4qpJtFXuMh7TKvS4xBgb7h1oXjT7Uhy7rzdsKruWMNtbzHt
|
||||
OTSliqNAQ9/ylG1w9ODNkwAk8yItxRRLicag8wp+CspTndqzj97YdopXArK/xDXG
|
||||
HTqP5XItMZqBNXRO3KaDweWnO/88XyZQ7uhcX4eDDJwA2jS9twr2ijuFNJAz31Ts
|
||||
Z8uudW2oGCzwnDqY/VDb+gN8zrtVIhGLLaIqUQqA5bNgUwA+dNJQhrY13V1kW/If
|
||||
kWhPxMf6r8b2a7YToxin3hjuu5QWVQIDAQABAoICACpCHWZJCtzeGICZqjC6sfBr
|
||||
Dl42kH2W1x3RAZAMnm/lOL/rgOvl2CzfndKAi+UYtQNrjdQFXT+Y+OGgwZYgOZir
|
||||
0g6iuvscY0FQ68t7vI/5jCj+H7av6I8J4un/MEucsPRtzyko24e0ulNSu7gU2YCE
|
||||
kJaquPXxGqkkC1MqQWnZWIfiIbmQ1Vk1ZoGVO1l4rrnNTU5+78ETIRJOEatBmoCn
|
||||
/OzCiUwzo75f/Eg621aht6UNdpHGPBG5qblxIgPqR9Tpz7Eez56tBNu5vbPIztoR
|
||||
wye8ekm9cGgZglnkbTH9A1AJNAhYzzakHsb2dv73ecoFnri85u3li0FW9Vn14LIQ
|
||||
dG4p1QULJzxHbda/4rbpvn6A/qKvi6HMWG9iYck8gPwBHaCAlg8FvlJos1XKoauF
|
||||
BvJyps3GZbt3X+eGm2dEVErWX62sIRCE+4r8PKMdOlMaXjcCE4754xcuf6ngFnT5
|
||||
H3qYLUU4I89KGS/jRKHA03hvZUNUUgYGF38GdAUyZenB7Wug8V5X9U3ccq0SxnwD
|
||||
/UYT5qbfgXj+N0uDIPPwWkO8ZudanUr/ZzUxTcqUGQ6XhYRgjIAXNSibqmMpfbmZ
|
||||
N59jaVe8V/4nQ2J5dSWibcI/LJLKIxsXjNKs5pDbJVV5M3VDz2wVc9UQT3FsMSIp
|
||||
4AC+bAnYJUL38ytKgGDRAoIBAQDw/wyLK7h9HuZVwBPUXrTA/XRlDr7sQ78ede+8
|
||||
EbskUATTks+P0cL0KuzcnhgO9W5DN5T3VcOokGElANXU/AHCwCZjx/UPXlij4Gsy
|
||||
dAJmOTDA20P1gpAh1UNd+YGD5RCVo+Smo2JIsTJIZ0p8we1/ynoGFgXN4G3PLguy
|
||||
PeMXJ6ibxq6sSIMpE1auwjvQ8VnCoQqqna/C8417zGN5FNMOAHDJmxtoh/IW7XO5
|
||||
upU8FH7X6eLkGuTs/Wor30Cfn9DwfW5wuj2SpnMiXACX90BVTg9mTt5qtibBKuU/
|
||||
lDCjBWHXK7P0fK4Wc8du5XHePRN84+xWy84z9fWID1zNry5ZAoIBAQDLRm6kfwg3
|
||||
afEv1jp0zbnfoS3Bvj0CudZd4QOxIIIJ8xpfCw+LT6N2r5O1fdJKNlQ1If/BSKnE
|
||||
1LH1682Gz3UhUTsMMyXfOFxMNjXzCtyPh220fIfhdhI6UaIke5U9EWSWDhGYtXy3
|
||||
OSRfK2MQeI4TQr0Bt+8cr2dbSmXVN8PO0D/EtAvZDhmYH/QFjr1a+VxM6OM1u98N
|
||||
GTz7LEsxJwMCRTWmaMXcIGT/8ArFBts1iDplGMsKmUsqmvD+v0gZMzjFcEK2Bsns
|
||||
c93N9cw9kKrX1G0bZOpDWzhawCSnVvksTS6sjYhn0UxTvAQFvYlB9PedH1RLyhT2
|
||||
uf69H/Nq6kBdAoIBAQCxhuAhoPpNSTbZ0h2JYp56T/qu+vbPqnQeJziLbPBTppJ3
|
||||
nH1D73xSS1Cij98fHdK0tzwIGuh/wqOdE4lxVJajdNKSzFiMkq5vQcEVsHmX/ecZ
|
||||
GixsrVopYiU3E7ZBh7r40Ht9+XtMGyP0TAqF7oFakrfixdROvVWGud+p1Ib1pqRA
|
||||
5FIF3YCpAHLpV3Gi0DeT9PebiX9Q+AKwQHbCWgHDHbX3/UdqcEJ4e0C1f2mxkVOD
|
||||
D+qwp9MddOwCF3pOW/cZQPzbE4aKAg1sMSPKzjtY26Did5TGPRo9T7ECbdKKNEvH
|
||||
qozsu++t4Gn4GhMQaCNz655MreNfSX8uVErDxZk5AoIBAHjs5mcvNukYOiXWEKJp
|
||||
vDh//uNx04OqoA7rDDIz/4gBud9uigm7D6hMXNesCQyi80dEeYw3ON9iFJgSdgrL
|
||||
oYd3dmQUjWQUeDvSSBfUj09HJoknSAJlJgTRiV6gsjBJB4iIyAkLdizdbni7K8yh
|
||||
mwt0OVIrZLCw3BCf2qKT8QU4N6dB4IZ4b9tirt0bhCP5keIi7P+LSu7U48UcHYMZ
|
||||
DR4to/PiAkqobs0etJlvMbrP1sVLkYY1mVS4JcO2cSKkqRIzbUwyD699YibjIWRC
|
||||
io+ozFtuljoJqyW5hlSSVdr381stO0dfaEOr5syva1b0btVmzNPlXMFF9nf16O3i
|
||||
evUCggEAVjhMGQdofd4DYDsZjR6NuB4Qt3KlWAvw6tE13vE8bJSlEeovy7Cxd6c3
|
||||
Aim067IYZVEyXtegYxkFY2PBboc9aYeF6UbYtvIEeIouCq6hz1RaYKDkiXUBYUVK
|
||||
LJs8I/9whxxNQ/Y58FZZ2tj6cEjcw7oLr1ov1agQQL/OrLpjNnUbzRIz7JJUnLVS
|
||||
/HtinGCCPKUonjiNln+vWNpPb3Cf4eaYV9FcospGzBZNZgMW2uop//N5/WDytThe
|
||||
dIECXbjXpYXH5ArCIgZbJ30DnlantBU/v2T9NJX/++5MA913BS37pE1E9aDh4b9Q
|
||||
duLqkhI3kwCuIs0mj6cfdnqOQP8JUA==
|
||||
-----END PRIVATE KEY-----
|
||||
37
samba-api/start.sh
Normal file
37
samba-api/start.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Samba API Startup ==="
|
||||
echo "HOST: ${HOST:-0.0.0.0}"
|
||||
echo "HTTP PORT: ${PORT:-8000}"
|
||||
echo "HTTPS PORT: ${HTTPS_PORT:-8443}"
|
||||
echo "USE_HTTPS: ${USE_HTTPS:-false}"
|
||||
|
||||
# Prepare reload flag
|
||||
if [ "${DEBUG:-false}" = "true" ]; then
|
||||
RELOAD_FLAG="--reload"
|
||||
else
|
||||
RELOAD_FLAG=""
|
||||
fi
|
||||
|
||||
# Check if SSL certificates exist and HTTPS is enabled
|
||||
if [ -f "/app/ssl/server.key" ] && [ -f "/app/ssl/server.crt" ] && [ "${USE_HTTPS}" = "true" ]; then
|
||||
echo "✓ SSL certificates found"
|
||||
echo "✓ HTTPS enabled"
|
||||
echo "🚀 Starting Samba API with HTTPS on port ${HTTPS_PORT:-8443}"
|
||||
exec python -m uvicorn main:app \
|
||||
--host ${HOST:-0.0.0.0} \
|
||||
--port ${HTTPS_PORT:-8443} \
|
||||
--ssl-keyfile /app/ssl/server.key \
|
||||
--ssl-certfile /app/ssl/server.crt \
|
||||
${RELOAD_FLAG}
|
||||
else
|
||||
if [ "${USE_HTTPS}" = "true" ]; then
|
||||
echo "⚠️ HTTPS requested but SSL certificates not found, falling back to HTTP"
|
||||
fi
|
||||
echo "🚀 Starting Samba API with HTTP on port ${PORT:-8000}"
|
||||
exec python -m uvicorn main:app \
|
||||
--host ${HOST:-0.0.0.0} \
|
||||
--port ${PORT:-8000} \
|
||||
${RELOAD_FLAG}
|
||||
fi
|
||||
50
samba-api/test-endpoints.sh
Executable file
50
samba-api/test-endpoints.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔍 Testing Samba API Endpoints"
|
||||
echo "================================"
|
||||
|
||||
# Test HTTP endpoint
|
||||
echo "📡 Testing HTTP endpoint..."
|
||||
HTTP_RESPONSE=$(curl -s -w "%{http_code}" http://localhost:8000/ || echo "ERROR")
|
||||
if [ "$HTTP_RESPONSE" = '{"message":"Samba API is running","version":"1.0.0"}200' ]; then
|
||||
echo "✅ HTTP (port 8000): Working"
|
||||
else
|
||||
echo "❌ HTTP (port 8000): Failed - $HTTP_RESPONSE"
|
||||
fi
|
||||
|
||||
# Test HTTPS endpoint
|
||||
echo "🔒 Testing HTTPS endpoint..."
|
||||
HTTPS_RESPONSE=$(curl -k -s -w "%{http_code}" https://localhost:8443/ || echo "ERROR")
|
||||
if [ "$HTTPS_RESPONSE" = '{"message":"Samba API is running","version":"1.0.0"}200' ]; then
|
||||
echo "✅ HTTPS (port 8443): Working"
|
||||
else
|
||||
echo "❌ HTTPS (port 8443): Failed - $HTTPS_RESPONSE"
|
||||
fi
|
||||
|
||||
# Test Health endpoint over HTTPS
|
||||
echo "🏥 Testing Health endpoint..."
|
||||
HEALTH_RESPONSE=$(curl -k -s -w "%{http_code}" https://localhost:8443/health || echo "ERROR")
|
||||
if [[ "$HEALTH_RESPONSE" =~ "200"$ ]]; then
|
||||
echo "✅ Health check: Working"
|
||||
else
|
||||
echo "❌ Health check: Failed - $HEALTH_RESPONSE"
|
||||
fi
|
||||
|
||||
# Test API Documentation
|
||||
echo "📚 Testing API Documentation..."
|
||||
DOCS_RESPONSE=$(curl -k -s -w "%{http_code}" https://localhost:8443/docs | tail -c 3)
|
||||
if [ "$DOCS_RESPONSE" = "200" ]; then
|
||||
echo "✅ API Docs: Working"
|
||||
else
|
||||
echo "❌ API Docs: Failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔗 Available Endpoints:"
|
||||
echo " • HTTP API: http://localhost:8000"
|
||||
echo " • HTTPS API: https://localhost:8443"
|
||||
echo " • API Docs: https://localhost:8443/docs"
|
||||
echo " • Health: https://localhost:8443/health"
|
||||
echo ""
|
||||
echo "💡 Use 'curl -k' for HTTPS requests with self-signed certificates"
|
||||
echo "💡 Access API documentation in browser: https://localhost:8443/docs"
|
||||
2
samba-api/tests/__init__.py
Normal file
2
samba-api/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Tests package initialization
|
||||
# This file makes the tests directory a Python package
|
||||
51
samba-api/tests/conftest.py
Normal file
51
samba-api/tests/conftest.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from httpx import AsyncClient
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Test client fixture"""
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client():
|
||||
"""Async test client fixture"""
|
||||
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
"""Event loop fixture for async tests"""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_data():
|
||||
"""Mock user data for testing"""
|
||||
return {
|
||||
"username": "testuser",
|
||||
"password": "TestPassword123!",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": "testuser@example.com",
|
||||
"description": "Test user account"
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_group_data():
|
||||
"""Mock group data for testing"""
|
||||
return {
|
||||
"name": "testgroup",
|
||||
"description": "Test group",
|
||||
"group_type": "security",
|
||||
"scope": "global"
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_jwt_token():
|
||||
"""Mock JWT token for authentication tests"""
|
||||
return "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTcwMDAwMDAwMCwic2NvcGVzIjpbInJlYWQiLCJ3cml0ZSJdfQ.test_token"
|
||||
111
samba-api/tests/test_auth.py
Normal file
111
samba-api/tests/test_auth.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from jose import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.routers.auth import create_access_token, verify_token, authenticate_user
|
||||
from src.models.auth import TokenData
|
||||
from src.core.config import settings
|
||||
from src.core.exceptions import AuthenticationException
|
||||
|
||||
class TestAuthService:
|
||||
"""Test authentication functionality"""
|
||||
|
||||
def test_create_access_token(self):
|
||||
"""Test JWT token creation"""
|
||||
data = {"sub": "testuser", "scopes": ["read", "write"]}
|
||||
token = create_access_token(data)
|
||||
|
||||
# Decode token to verify content
|
||||
decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
assert decoded["sub"] == "testuser"
|
||||
assert decoded["scopes"] == ["read", "write"]
|
||||
assert "exp" in decoded
|
||||
|
||||
def test_create_access_token_with_expiry(self):
|
||||
"""Test JWT token creation with custom expiry"""
|
||||
data = {"sub": "testuser"}
|
||||
expires_delta = timedelta(minutes=60)
|
||||
token = create_access_token(data, expires_delta)
|
||||
|
||||
decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
|
||||
# Check that expiry is approximately 60 minutes from now
|
||||
exp_time = datetime.fromtimestamp(decoded["exp"])
|
||||
expected_time = datetime.utcnow() + expires_delta
|
||||
time_diff = abs((exp_time - expected_time).total_seconds())
|
||||
|
||||
assert time_diff < 10 # Within 10 seconds
|
||||
|
||||
def test_verify_token_valid(self):
|
||||
"""Test token verification with valid token"""
|
||||
data = {"sub": "testuser", "scopes": ["read"]}
|
||||
token = create_access_token(data)
|
||||
|
||||
token_data = verify_token(token)
|
||||
|
||||
assert isinstance(token_data, TokenData)
|
||||
assert token_data.username == "testuser"
|
||||
assert token_data.scopes == ["read"]
|
||||
|
||||
def test_verify_token_invalid(self):
|
||||
"""Test token verification with invalid token"""
|
||||
invalid_token = "invalid.jwt.token"
|
||||
|
||||
with pytest.raises(AuthenticationException):
|
||||
verify_token(invalid_token)
|
||||
|
||||
def test_verify_token_expired(self):
|
||||
"""Test token verification with expired token"""
|
||||
# Create token with past expiry
|
||||
data = {"sub": "testuser", "exp": datetime.utcnow() - timedelta(minutes=1)}
|
||||
expired_token = jwt.encode(data, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
with pytest.raises(AuthenticationException):
|
||||
verify_token(expired_token)
|
||||
|
||||
@patch('src.routers.auth.user_service.get_user')
|
||||
@patch('ldap3.Connection')
|
||||
@patch('ldap3.Server')
|
||||
async def test_authenticate_user_success(self, mock_server, mock_connection, mock_get_user):
|
||||
"""Test successful user authentication"""
|
||||
# Mock LDAP connection
|
||||
mock_conn_instance = MagicMock()
|
||||
mock_conn_instance.bound = True
|
||||
mock_connection.return_value = mock_conn_instance
|
||||
|
||||
# Mock user data
|
||||
from src.models.users import UserResponse, UserStatus
|
||||
mock_user = UserResponse(
|
||||
username="testuser",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="testuser@example.com",
|
||||
dn="CN=testuser,CN=Users,DC=example,DC=com",
|
||||
status=UserStatus.ACTIVE,
|
||||
groups=["Domain Users"]
|
||||
)
|
||||
mock_get_user.return_value = mock_user
|
||||
|
||||
result = await authenticate_user("testuser", "password123")
|
||||
|
||||
assert result is not None
|
||||
assert result["username"] == "testuser"
|
||||
assert result["full_name"] == "Test User"
|
||||
assert result["email"] == "testuser@example.com"
|
||||
assert "read" in result["permissions"]
|
||||
assert "write" in result["permissions"]
|
||||
|
||||
@patch('ldap3.Connection')
|
||||
@patch('ldap3.Server')
|
||||
async def test_authenticate_user_invalid_credentials(self, mock_server, mock_connection):
|
||||
"""Test authentication with invalid credentials"""
|
||||
# Mock failed LDAP connection
|
||||
mock_conn_instance = MagicMock()
|
||||
mock_conn_instance.bound = False
|
||||
mock_connection.return_value = mock_conn_instance
|
||||
|
||||
result = await authenticate_user("testuser", "wrongpassword")
|
||||
|
||||
assert result is None
|
||||
66
samba-api/tests/test_main.py
Normal file
66
samba-api/tests/test_main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
def test_app_startup(client):
|
||||
"""Test application startup and health endpoint"""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "healthy", "service": "samba-api"}
|
||||
|
||||
def test_root_endpoint(client):
|
||||
"""Test root endpoint"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["message"] == "Samba API is running"
|
||||
assert data["version"] == "1.0.0"
|
||||
|
||||
def test_docs_endpoint(client):
|
||||
"""Test API documentation endpoint"""
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_openapi_schema(client):
|
||||
"""Test OpenAPI schema endpoint"""
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert schema["info"]["title"] == "Samba API"
|
||||
assert schema["info"]["version"] == "1.0.0"
|
||||
|
||||
class TestAPIEndpoints:
|
||||
"""Test API endpoint structure"""
|
||||
|
||||
def test_user_endpoints_exist(self, client):
|
||||
"""Test that user endpoints exist (will return 401 without auth)"""
|
||||
# These should return 401/403 without authentication, not 404
|
||||
response = client.get("/api/v1/users")
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
response = client.post("/api/v1/users", json={})
|
||||
assert response.status_code in [401, 403, 422]
|
||||
|
||||
def test_auth_endpoints_exist(self, client):
|
||||
"""Test that auth endpoints exist"""
|
||||
# Login endpoint should exist and return 422 for invalid data
|
||||
response = client.post("/api/v1/auth/login", json={})
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
# Me endpoint should return 401 without auth
|
||||
response = client.get("/api/v1/auth/me")
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
def test_group_endpoints_exist(self, client):
|
||||
"""Test that group endpoints exist"""
|
||||
response = client.get("/api/v1/groups")
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
def test_ou_endpoints_exist(self, client):
|
||||
"""Test that OU endpoints exist"""
|
||||
response = client.get("/api/v1/ous")
|
||||
assert response.status_code in [401, 403]
|
||||
|
||||
def test_computer_endpoints_exist(self, client):
|
||||
"""Test that computer endpoints exist"""
|
||||
response = client.get("/api/v1/computers")
|
||||
assert response.status_code in [401, 403]
|
||||
78
samba-api/tests/test_user_service.py
Normal file
78
samba-api/tests/test_user_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
from src.services.user_service import user_service
|
||||
from src.models.users import UserCreate, UserUpdate, UserStatus
|
||||
from src.core.exceptions import UserNotFoundException, SambaAPIException
|
||||
|
||||
class TestUserService:
|
||||
"""Test user service functionality"""
|
||||
|
||||
@patch('src.services.user_service.samba_service._run_samba_tool')
|
||||
@patch('src.services.user_service.user_service.get_user')
|
||||
async def test_create_user_success(self, mock_get_user, mock_run_samba_tool):
|
||||
"""Test successful user creation"""
|
||||
# Mock data
|
||||
user_data = UserCreate(
|
||||
username="testuser",
|
||||
password="TestPassword123!",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="testuser@example.com"
|
||||
)
|
||||
|
||||
# Mock samba-tool command
|
||||
mock_run_samba_tool.return_value = {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
# Mock get_user response
|
||||
from src.models.users import UserResponse
|
||||
mock_user_response = UserResponse(
|
||||
username="testuser",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
email="testuser@example.com",
|
||||
dn="CN=testuser,CN=Users,DC=example,DC=com",
|
||||
status=UserStatus.ACTIVE,
|
||||
groups=[]
|
||||
)
|
||||
mock_get_user.return_value = mock_user_response
|
||||
|
||||
# Test user creation
|
||||
result = await user_service.create_user(user_data)
|
||||
|
||||
assert result.username == "testuser"
|
||||
assert result.first_name == "Test"
|
||||
assert result.last_name == "User"
|
||||
mock_run_samba_tool.assert_called_once()
|
||||
|
||||
@patch('src.services.user_service.samba_service._get_connection')
|
||||
async def test_get_user_not_found(self, mock_get_connection):
|
||||
"""Test get user when user doesn't exist"""
|
||||
# Mock LDAP connection with empty results
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.entries = []
|
||||
mock_get_connection.return_value = mock_conn
|
||||
|
||||
with pytest.raises(UserNotFoundException):
|
||||
await user_service.get_user("nonexistent")
|
||||
|
||||
@patch('src.services.user_service.samba_service._run_samba_tool')
|
||||
async def test_delete_user_success(self, mock_run_samba_tool):
|
||||
"""Test successful user deletion"""
|
||||
mock_run_samba_tool.return_value = {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
result = await user_service.delete_user("testuser")
|
||||
|
||||
assert result is True
|
||||
mock_run_samba_tool.assert_called_once_with(['user', 'delete', 'testuser'])
|
||||
|
||||
@patch('src.services.user_service.samba_service._run_samba_tool')
|
||||
async def test_change_password_success(self, mock_run_samba_tool):
|
||||
"""Test successful password change"""
|
||||
mock_run_samba_tool.return_value = {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
result = await user_service.change_password("testuser", "NewPassword123!")
|
||||
|
||||
assert result is True
|
||||
mock_run_samba_tool.assert_called_once_with([
|
||||
'user', 'setpassword', 'testuser', '--newpassword', 'NewPassword123!'
|
||||
])
|
||||
Reference in New Issue
Block a user