Initialisation depot

This commit is contained in:
Serge NOEL
2026-02-10 12:12:11 +01:00
commit c3176e8d79
818 changed files with 52573 additions and 0 deletions

31
samba-api/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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:

View 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:

View 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
View 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"

View 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
View 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

View 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
View 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
View 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

View 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
View 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"
)

View 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;
}
}
}

View 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

View File

@@ -0,0 +1 @@
# Samba API main package initialization

View File

@@ -0,0 +1 @@
# Core package initialization

View 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()

View 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
View 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"
)

View File

@@ -0,0 +1 @@
# Models package initialization

View 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

View 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

View 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"}

View 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()

View 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

View File

@@ -0,0 +1 @@
# Routers package initialization

View 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")

View 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")

View 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")

View 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")

View 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)}")

View File

@@ -0,0 +1 @@
# Services package initialization

View 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()

View 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
View 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
View 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
View 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
View 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"

View File

@@ -0,0 +1,2 @@
# Tests package initialization
# This file makes the tests directory a Python package

View 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"

View 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

View 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]

View 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!'
])