diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index ed063cd..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Docker - -on: - push: - branches: [ "main" ] - tags: [ 'v*.*.*' ] - pull_request: - branches: [ "main" ] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: docker.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - # This is needed for token authentication - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Workaround: https://github.com/docker/build-push-action/issues/461 - - name: Setup Docker buildx - uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf - - # Login against a Docker registry - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' - uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha - - # Build and push Docker image with Buildx - # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4fea112..0053f20 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -53,7 +53,7 @@ This document provides detailed instructions for deploying the Active Directory ```bash cp .env.example .env ``` - + Edit the .env file with your specific configuration: ```bash nano .env @@ -62,9 +62,9 @@ This document provides detailed instructions for deploying the Active Directory 3. **SSL Configuration** For production environments, the application is designed to work behind a reverse proxy or load balancer that handles SSL termination. - + If you need to implement SSL directly in your environment, consider: - + - Using a reverse proxy like Nginx or Traefik in front of the application - Setting up SSL termination at the infrastructure level (e.g., using AWS Application Load Balancer) - Configuring your cloud provider's SSL management services @@ -391,6 +391,8 @@ For deployment on Microsoft Azure: Detailed Azure deployment instructions can be found in [AZURE_DEPLOYMENT.md](AZURE_DEPLOYMENT.md). +For comprehensive Kubernetes deployment instructions for any cloud provider, refer to the [Kubernetes Deployment Guide](docs/kubernetes-deployment.md). + ### Google Cloud Deployment For deployment on Google Cloud Platform: @@ -411,6 +413,8 @@ For deployment on Google Cloud Platform: Detailed GCP deployment instructions can be found in [GCP_DEPLOYMENT.md](GCP_DEPLOYMENT.md). +For comprehensive Kubernetes deployment instructions for any cloud provider, refer to the [Kubernetes Deployment Guide](docs/kubernetes-deployment.md). + ## Production Best Practices ### Security Considerations diff --git a/README.md b/README.md index dedcb57..e270459 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ The Active Directory Management Platform provides the following key features: ### Core Functionality - **User Management**: Create, read, update, and delete Active Directory users with comprehensive attribute support + - Password reset with policy validation options + - Account enabling/disabling + - Bulk account management operations - **Group Management**: Manage security and distribution groups, including nested group structures - **Organizational Unit (OU) Management**: Create and manage the hierarchical OU structure - **Computer Management**: Manage computer accounts with detailed property configuration @@ -76,6 +79,8 @@ The Active Directory Management Platform provides the following key features: ### DevOps - **Docker**: Containerization platform for consistent deployment +- **Kubernetes**: Container orchestration for scalable deployments +- **Infrastructure as Code (IaC)**: Deployment configurations for Docker and Kubernetes ## Architecture @@ -111,8 +116,8 @@ The application follows a modern web architecture with clear separation of conce 3. Create a .env file from the example: ```bash - cp .env.example .env - # Edit .env with your configuration + cp iac/docker/.env.example iac/docker/.env + # Edit iac/docker/.env with your configuration ``` 4. Set up the database: @@ -142,8 +147,8 @@ The application follows a modern web architecture with clear separation of conce 2. Create a .env file from the example: ```bash - cp .env.example .env - # Edit .env with your production configuration + cp iac/docker/.env.example iac/docker/.env + # Edit iac/docker/.env with your production configuration ``` 3. Set up environment variables for production: @@ -155,6 +160,7 @@ The application follows a modern web architecture with clear separation of conce 4. Start the application stack: ```bash + cd iac/docker docker-compose up -d ``` @@ -187,6 +193,10 @@ The application is also available as a pre-built Docker image: For manual deployment instructions, refer to the [Deployment Guide](DEPLOYMENT.md). +#### Kubernetes Deployment + +For Kubernetes deployment instructions, refer to the [Kubernetes Deployment Guide](docs/kubernetes-deployment.md). + ## API Documentation The API is thoroughly documented using the OpenAPI specification (Swagger): @@ -202,6 +212,10 @@ The API is thoroughly documented using the OpenAPI specification (Swagger): - **Response Format**: Consistent JSON structure with appropriate status codes - **Error Handling**: Descriptive error messages and appropriate HTTP status codes +### API Examples + +For examples of using the API, including the password reset and account management endpoints, refer to the [API Examples Guide](docs/api-examples.md). + ### Filterable Endpoints All list endpoints support filtering with a flexible query syntax: diff --git a/docs/api-examples.md b/docs/api-examples.md new file mode 100644 index 0000000..ecd562f --- /dev/null +++ b/docs/api-examples.md @@ -0,0 +1,319 @@ +# API Examples + +This document provides examples of using the Active Directory Management API, including the new password reset and account management endpoints. + +## Table of Contents + +- [Authentication](#authentication) +- [User Management](#user-management) +- [Password Management](#password-management) +- [Account Status Management](#account-status-management) +- [Group Management](#group-management) +- [Bulk Operations](#bulk-operations) +- [Error Handling](#error-handling) + +## Authentication + +### Login + +```bash +curl -X POST http://localhost:5000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "your-password" + }' +``` + +Response: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "fullName": "System Administrator", + "role": "admin" + } +} +``` + +### Using the Token + +For all subsequent requests, include the token in the Authorization header: + +```bash +curl -X GET http://localhost:5000/api/users \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +## User Management + +### Get All Users + +```bash +curl -X GET http://localhost:5000/api/connections/1/ad-users \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +### Get User by ObjectGUID + +```bash +curl -X GET http://localhost:5000/api/connections/1/ad-users/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +### Create User + +```bash +curl -X POST http://localhost:5000/api/connections/1/ad-users \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "sAMAccountName": "jdoe", + "givenName": "John", + "sn": "Doe", + "displayName": "John Doe", + "mail": "jdoe@example.com", + "userPrincipalName": "jdoe@example.com", + "parentOU": "OU=Users,DC=example,DC=com", + "password": "SecurePassword123!", + "enabled": true + }' +``` + +### Update User + +```bash +curl -X PATCH http://localhost:5000/api/connections/1/ad-users/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "displayName": "John A. Doe", + "mail": "john.doe@example.com", + "telephoneNumber": "+1 (555) 123-4567" + }' +``` + +### Delete User + +```bash +curl -X DELETE http://localhost:5000/api/connections/1/ad-users/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +## Password Management + +### Reset User Password + +```bash +curl -X POST http://localhost:5000/api/connections/1/reset-password \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userObjectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "newPassword": "NewSecurePassword456!", + "skipValidation": false, + "requirePasswordChangeAtNextLogon": true + }' +``` + +Response: + +```json +{ + "success": true, + "message": "Password reset successful for user with GUID a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +### Reset Password with Skip Validation + +For scenarios where you need to bypass password policy validation: + +```bash +curl -X POST http://localhost:5000/api/connections/1/reset-password \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userObjectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "newPassword": "TempPass123", + "skipValidation": true, + "requirePasswordChangeAtNextLogon": true + }' +``` + +### Reset Password Without Requiring Change at Next Logon + +```bash +curl -X POST http://localhost:5000/api/connections/1/reset-password \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userObjectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "newPassword": "NewSecurePassword456!", + "requirePasswordChangeAtNextLogon": false + }' +``` + +## Account Status Management + +### Enable User Account + +```bash +curl -X POST http://localhost:5000/api/connections/1/enable-user-account \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userObjectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "enabled": true + }' +``` + +Response: + +```json +{ + "success": true, + "message": "User account enabled successfully for user with GUID a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +### Disable User Account + +```bash +curl -X POST http://localhost:5000/api/connections/1/enable-user-account \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userObjectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "enabled": false + }' +``` + +Response: + +```json +{ + "success": true, + "message": "User account disabled successfully for user with GUID a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +### Bulk Enable User Accounts + +```bash +curl -X POST http://localhost:5000/api/connections/1/bulk-enable-user-accounts \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userDNs": [ + "CN=John Doe,OU=Users,DC=example,DC=com", + "CN=Jane Smith,OU=Users,DC=example,DC=com", + "CN=Bob Johnson,OU=Users,DC=example,DC=com" + ], + "enabled": true + }' +``` + +Response: + +```json +{ + "success": [ + "CN=John Doe,OU=Users,DC=example,DC=com", + "CN=Jane Smith,OU=Users,DC=example,DC=com", + "CN=Bob Johnson,OU=Users,DC=example,DC=com" + ], + "failed": [] +} +``` + +### Bulk Disable User Accounts + +```bash +curl -X POST http://localhost:5000/api/connections/1/bulk-enable-user-accounts \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "userDNs": [ + "CN=John Doe,OU=Users,DC=example,DC=com", + "CN=Jane Smith,OU=Users,DC=example,DC=com", + "CN=Bob Johnson,OU=Users,DC=example,DC=com" + ], + "enabled": false + }' +``` + +## Group Management + +### Add User to Group + +```bash +curl -X POST http://localhost:5000/api/connections/1/add-to-group \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "objectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "groupObjectGUID": "g1h2i3j4-k5l6-7890-mnop-qr1234567890", + "objectType": "user" + }' +``` + +### Remove User from Group + +```bash +curl -X POST http://localhost:5000/api/connections/1/remove-from-group \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + -d '{ + "objectGUID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "groupObjectGUID": "g1h2i3j4-k5l6-7890-mnop-qr1234567890", + "objectType": "user" + }' +``` + +## Error Handling + +### Invalid Request + +```json +{ + "errors": [ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": ["userObjectGUID"], + "message": "User ObjectGUID is required" + } + ] +} +``` + +### User Not Found + +```json +{ + "message": "User not found" +} +``` + +### Permission Denied + +```json +{ + "message": "You do not have permission to perform this action" +} +``` + +### LDAP Operation Failed + +```json +{ + "message": "Failed to reset password", + "error": "LDAP operation failed: Constraint violation" +} +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..eda1c35 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,316 @@ +# Deployment Guide + +This guide provides instructions for deploying the Active Directory Management application using Docker, Docker Compose, and Kubernetes. + +## Table of Contents + +- [Docker Deployment](#docker-deployment) + - [Building the Docker Image](#building-the-docker-image) + - [Running with Docker](#running-with-docker) + - [Environment Variables](#environment-variables) +- [Docker Compose Deployment](#docker-compose-deployment) + - [Configuration](#configuration) + - [Starting the Services](#starting-the-services) + - [Stopping the Services](#stopping-the-services) +- [Kubernetes Deployment](#kubernetes-deployment) + - [Prerequisites](#prerequisites) + - [Configuration](#kubernetes-configuration) + - [Deployment](#deployment) + - [Accessing the Application](#accessing-the-application) + - [Scaling](#scaling) + - [Updating](#updating) + - [Monitoring](#monitoring) +- [Production Considerations](#production-considerations) + - [Security](#security) + - [Backups](#backups) + - [High Availability](#high-availability) + +## Docker Deployment + +### Building the Docker Image + +To build the Docker image using the provided build scripts: + +#### Linux/macOS: +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Make the script executable +chmod +x build.sh + +# Build the image with default settings +./build.sh + +# Build with custom tag +./build.sh --tag v1.0.0 + +# Build and push to a registry +./build.sh --registry your-registry.com --tag v1.0.0 --push +``` + +#### Windows: +```powershell +# Navigate to the iac/docker directory +cd iac\docker + +# Build the image with default settings +.\build.ps1 + +# Build with custom tag +.\build.ps1 -tag v1.0.0 + +# Build and push to a registry +.\build.ps1 -registry your-registry.com -tag v1.0.0 -push +``` + +#### Manually: +```bash +# Navigate to the project root +cd /path/to/project + +# Build the image +docker build -t ad-management:latest -f iac/docker/Dockerfile . +``` + +### Running with Docker + +To run the application with Docker: + +```bash +docker run -d --name ad-management \ + -p 5000:5000 \ + -e NODE_ENV=production \ + -e POSTGRES_HOST=your-postgres-host \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_DB=admgr \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=your-password \ + -e REDIS_HOST=your-redis-host \ + -e REDIS_PORT=6379 \ + -e JWT_SECRET=your-jwt-secret \ + -e SESSION_SECRET=your-session-secret \ + ad-management:latest +``` + +### Environment Variables + +The application uses the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `NODE_ENV` | Environment mode | `development` | +| `PORT` | Port to run the server on | `5000` | +| `BASE_URL` | Base URL for the application | `http://localhost:5000` | +| `POSTGRES_HOST` | PostgreSQL host | `localhost` | +| `POSTGRES_PORT` | PostgreSQL port | `5432` | +| `POSTGRES_DB` | PostgreSQL database name | `admgr` | +| `POSTGRES_USER` | PostgreSQL username | `postgres` | +| `POSTGRES_PASSWORD` | PostgreSQL password | - | +| `REDIS_HOST` | Redis host | `localhost` | +| `REDIS_PORT` | Redis port | `6379` | +| `JWT_SECRET` | Secret for JWT tokens | - | +| `SESSION_SECRET` | Secret for session cookies | - | +| `DEFAULT_ADMIN_USERNAME` | Default admin username | `admin` | +| `DEFAULT_ADMIN_PASSWORD` | Default admin password | - | +| `DEFAULT_ADMIN_EMAIL` | Default admin email | - | +| `DEFAULT_ADMIN_FULLNAME` | Default admin full name | `System Administrator` | +| `DISABLE_REGISTRATION` | Disable user registration | `false` | + +## Docker Compose Deployment + +### Configuration + +The application includes a `docker-compose.yml` file for easy deployment. You can customize the environment variables by creating or modifying the `.env` file in the `iac/docker` directory. + +Example `.env` file: + +``` +NODE_ENV=production +PORT=5000 +BASE_URL=http://localhost:5000 +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=admgr +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your-postgres-password +REDIS_HOST=redis +REDIS_PORT=6379 +JWT_SECRET=your-jwt-secret +SESSION_SECRET=your-session-secret +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=your-admin-password +DEFAULT_ADMIN_EMAIL=admin@example.com +DEFAULT_ADMIN_FULLNAME=System Administrator +DISABLE_REGISTRATION=false +``` + +### Starting the Services + +To start all services: + +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Start the application stack +docker-compose up -d +``` + +This will start the application, PostgreSQL, and Redis containers. + +### Stopping the Services + +To stop all services: + +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Stop the application stack +docker-compose down +``` + +To stop and remove all data volumes: + +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Stop the application stack and remove volumes +docker-compose down -v +``` + +## Kubernetes Deployment + +### Prerequisites + +- Kubernetes cluster (v1.19+) +- kubectl configured to communicate with your cluster +- [Kustomize](https://kustomize.io/) (v4.0+) or kubectl v1.14+ which includes kustomize +- Optional: Ingress controller (e.g., NGINX Ingress Controller) +- Optional: cert-manager for TLS certificates + +### Kubernetes Configuration + +The Kubernetes manifests are located in the `iac/kubernetes/` directory. Before deploying, you should customize the following files: + +1. `iac/kubernetes/configmap.yaml`: Update environment variables +2. `iac/kubernetes/secrets.yaml`: Update secrets (use base64-encoded values) +3. `iac/kubernetes/deployment.yaml`: Update resource limits if needed +4. `iac/kubernetes/ingress.yaml`: Update host name and TLS configuration + +### Deployment + +To deploy the application to Kubernetes: + +```bash +# Navigate to the iac/kubernetes directory +cd iac/kubernetes + +# Apply all resources +kubectl apply -k . + +# Or from anywhere in the project +kubectl apply -k iac/kubernetes +``` + +This will create: +- A namespace called `ad-management` +- ConfigMap and Secret for environment variables +- PostgreSQL StatefulSet with persistent storage +- Redis StatefulSet with persistent storage +- Application Deployment with 2 replicas +- Services for all components +- Ingress for external access + +### Accessing the Application + +If you've configured the Ingress, the application will be available at the hostname specified in `kubernetes/ingress.yaml`. + +Without Ingress, you can use port-forwarding to access the application: + +```bash +kubectl port-forward -n ad-management svc/ad-management 5000:80 +``` + +Then access the application at http://localhost:5000. + +### Scaling + +To scale the application: + +```bash +kubectl scale -n ad-management deployment/ad-management --replicas=3 +``` + +### Updating + +To update the application to a new version: + +1. Build and push the new Docker image +2. Update the image tag in `kubernetes/deployment.yaml` +3. Apply the changes: + +```bash +kubectl apply -k kubernetes/ +``` + +Or use kubectl set image: + +```bash +kubectl set image -n ad-management deployment/ad-management ad-management=ad-management:new-tag +``` + +### Monitoring + +You can monitor the application using: + +```bash +# Check pod status +kubectl get pods -n ad-management + +# View logs +kubectl logs -n ad-management deployment/ad-management + +# Describe deployment +kubectl describe deployment -n ad-management ad-management +``` + +## Production Considerations + +### Security + +For production deployments: + +1. Use strong, unique passwords for all secrets +2. Enable TLS for all connections +3. Use network policies to restrict traffic between components +4. Set up proper RBAC for Kubernetes resources +5. Regularly update all components and dependencies +6. Consider using a secrets management solution like HashiCorp Vault + +### Backups + +Set up regular backups for: + +1. PostgreSQL database +2. Redis data (if persistence is enabled) +3. Application configuration + +Example PostgreSQL backup: + +```bash +kubectl exec -n ad-management postgres-0 -- pg_dump -U postgres admgr > backup.sql +``` + +### High Availability + +For high availability: + +1. Run multiple replicas of the application +2. Use a PostgreSQL cluster with replication +3. Configure Redis with replication or cluster mode +4. Deploy across multiple availability zones +5. Use a load balancer or ingress controller with multiple backends +6. Implement proper health checks and readiness probes diff --git a/docs/kubernetes-deployment.md b/docs/kubernetes-deployment.md new file mode 100644 index 0000000..a00ae97 --- /dev/null +++ b/docs/kubernetes-deployment.md @@ -0,0 +1,324 @@ +# Kubernetes Deployment Guide + +This guide provides detailed instructions for deploying the Active Directory Management application on Kubernetes. + +## Prerequisites + +- Kubernetes cluster (v1.19+) +- kubectl configured to communicate with your cluster +- [Kustomize](https://kustomize.io/) (v4.0+) or kubectl v1.14+ which includes kustomize +- Optional: Ingress controller (e.g., NGINX Ingress Controller) +- Optional: cert-manager for TLS certificates + +## Deployment Architecture + +The Kubernetes deployment consists of: + +- **Application Deployment**: The main application with multiple replicas +- **PostgreSQL StatefulSet**: Database with persistent storage +- **Redis StatefulSet**: Cache and session storage with persistent storage +- **Services**: For internal communication between components +- **Ingress**: For external access to the application +- **ConfigMap and Secrets**: For configuration and sensitive data + +All Kubernetes manifests are located in the `iac/kubernetes/` directory. + +## Step-by-Step Deployment + +### 1. Customize Configuration + +Before deploying, customize the configuration files in the `iac/kubernetes/` directory: + +#### ConfigMap (`iac/kubernetes/configmap.yaml`) + +Update environment variables according to your environment: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: ad-management-config + namespace: ad-management +data: + NODE_ENV: "production" + PORT: "5000" + BASE_URL: "https://your-domain.com" # Update this + DEFAULT_ADMIN_USERNAME: "admin" + DEFAULT_ADMIN_FULLNAME: "System Administrator" + DISABLE_REGISTRATION: "false" + POSTGRES_DB: "admgr" + POSTGRES_USER: "postgres" +``` + +#### Secrets (`kubernetes/secrets.yaml`) + +Update the secrets with your own base64-encoded values: + +```bash +# Generate base64-encoded secrets +echo -n "your-jwt-secret" | base64 +echo -n "your-session-secret" | base64 +echo -n "your-admin-password" | base64 +echo -n "admin@your-domain.com" | base64 +echo -n "your-postgres-password" | base64 +``` + +Then update `iac/kubernetes/secrets.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: ad-management-secrets + namespace: ad-management +type: Opaque +data: + JWT_SECRET: "base64-encoded-jwt-secret" + SESSION_SECRET: "base64-encoded-session-secret" + DEFAULT_ADMIN_PASSWORD: "base64-encoded-admin-password" + DEFAULT_ADMIN_EMAIL: "base64-encoded-admin-email" + POSTGRES_PASSWORD: "base64-encoded-postgres-password" +``` + +#### Ingress (`iac/kubernetes/ingress.yaml`) + +Update the hostname and TLS configuration: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ad-management-ingress + namespace: ad-management + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + tls: + - hosts: + - your-domain.com # Update this + secretName: ad-management-tls + rules: + - host: your-domain.com # Update this + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ad-management + port: + name: http +``` + +### 2. Deploy the Application + +Deploy all resources using Kustomize: + +```bash +# Navigate to the iac/kubernetes directory +cd iac/kubernetes + +# Apply all resources +kubectl apply -k . + +# Or from anywhere in the project +kubectl apply -k iac/kubernetes +``` + +### 3. Verify Deployment + +Check that all resources are created and running: + +```bash +# Check namespace +kubectl get namespace ad-management + +# Check pods +kubectl get pods -n ad-management + +# Check services +kubectl get services -n ad-management + +# Check deployments and statefulsets +kubectl get deployment,statefulset -n ad-management + +# Check ingress +kubectl get ingress -n ad-management +``` + +### 4. Access the Application + +If you've configured the Ingress, the application will be available at your specified domain. + +Without Ingress, use port-forwarding: + +```bash +kubectl port-forward -n ad-management svc/ad-management 5000:80 +``` + +Then access the application at http://localhost:5000. + +## Common Operations + +### Scaling the Application + +To scale the application horizontally: + +```bash +kubectl scale -n ad-management deployment/ad-management --replicas=3 +``` + +### Updating the Application + +To update the application to a new version: + +```bash +# Update the image +kubectl set image -n ad-management deployment/ad-management ad-management=ad-management:new-tag + +# Check rollout status +kubectl rollout status -n ad-management deployment/ad-management +``` + +### Viewing Logs + +To view application logs: + +```bash +# View logs from all pods +kubectl logs -n ad-management -l app=ad-management + +# View logs from a specific pod +kubectl logs -n ad-management pod/ad-management-xxxx-yyyy +``` + +### Restarting Components + +To restart the application: + +```bash +kubectl rollout restart -n ad-management deployment/ad-management +``` + +To restart PostgreSQL or Redis: + +```bash +kubectl rollout restart -n ad-management statefulset/postgres +kubectl rollout restart -n ad-management statefulset/redis +``` + +## Troubleshooting + +### Pod Startup Issues + +If pods are not starting: + +```bash +# Check pod status +kubectl get pods -n ad-management + +# Describe the pod for more details +kubectl describe pod -n ad-management pod/ad-management-xxxx-yyyy + +# Check container logs +kubectl logs -n ad-management pod/ad-management-xxxx-yyyy +``` + +### Database Connection Issues + +If the application can't connect to the database: + +1. Check if the PostgreSQL pod is running: + ```bash + kubectl get pods -n ad-management -l app=postgres + ``` + +2. Check PostgreSQL logs: + ```bash + kubectl logs -n ad-management pod/postgres-0 + ``` + +3. Verify the connection from the application pod: + ```bash + kubectl exec -it -n ad-management pod/ad-management-xxxx-yyyy -- nc -zv postgres 5432 + ``` + +### Ingress Issues + +If the Ingress is not working: + +1. Check the Ingress status: + ```bash + kubectl describe ingress -n ad-management ad-management-ingress + ``` + +2. Check the Ingress controller logs: + ```bash + kubectl logs -n ingress-nginx deployment/ingress-nginx-controller + ``` + +## Examples + +### Example: Setting Up TLS with cert-manager + +1. Install cert-manager: + ```bash + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.9.1/cert-manager.yaml + ``` + +2. Create a ClusterIssuer for Let's Encrypt: + ```yaml + apiVersion: cert-manager.io/v1 + kind: ClusterIssuer + metadata: + name: letsencrypt-prod + spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: your-email@example.com + privateKeySecretRef: + name: letsencrypt-prod + solvers: + - http01: + ingress: + class: nginx + ``` + +3. Apply the ClusterIssuer: + ```bash + kubectl apply -f cluster-issuer.yaml + ``` + +### Example: Setting Up Monitoring with Prometheus and Grafana + +1. Install Prometheus Operator: + ```bash + kubectl apply -f https://github.com/prometheus-operator/kube-prometheus/releases/download/v0.10.0/manifests-0.10.0.tar.gz + ``` + +2. Create a ServiceMonitor for the application: + ```yaml + apiVersion: monitoring.coreos.com/v1 + kind: ServiceMonitor + metadata: + name: ad-management + namespace: monitoring + spec: + selector: + matchLabels: + app: ad-management + namespaceSelector: + matchNames: + - ad-management + endpoints: + - port: http + path: /metrics + interval: 15s + ``` + +3. Apply the ServiceMonitor: + ```bash + kubectl apply -f service-monitor.yaml + ``` diff --git a/iac/README.md b/iac/README.md new file mode 100644 index 0000000..1d9fe8c --- /dev/null +++ b/iac/README.md @@ -0,0 +1,257 @@ +# Infrastructure as Code (IaC) + +This directory contains all the infrastructure as code configurations for deploying the Active Directory Management application. + +## Directory Structure + +- **docker/**: Docker-related files + - `Dockerfile`: Multi-stage Docker build file + - `docker-compose.yml`: Docker Compose configuration + - `build.sh`: Bash script for building and pushing Docker images (Linux/macOS) + - `build.ps1`: PowerShell script for building and pushing Docker images (Windows) + - `.env`: Environment variables for Docker deployment + - `.env.example`: Example environment variables + - `.env.production`: Production environment variables + +- **version.txt**: Central version file in yyyy.MM.dd.HHmm format +- **update-version.sh**: Bash script to update version.txt (Linux/macOS) +- **update-version.ps1**: PowerShell script to update version.txt (Windows) +- **update-all.sh**: Comprehensive script to update all version references (Linux/macOS) +- **update-all.ps1**: Comprehensive script to update all version references (Windows) + +- **kubernetes/**: Kubernetes manifests + - `namespace.yaml`: Namespace definition + - `configmap.yaml`: ConfigMap for non-sensitive configuration + - `secrets.yaml`: Secrets for sensitive configuration + - `postgres.yaml`: PostgreSQL StatefulSet and Service + - `redis.yaml`: Redis StatefulSet and Service + - `deployment.yaml`: Application Deployment and Service + - `ingress.yaml`: Ingress for external access + - `kustomization.yaml`: Kustomize configuration + +## Docker + +### Versioning + +The application uses a versioning scheme in the format `yyyy.MM.dd.HHmm` (year, month, day, hour, minute). + +#### Comprehensive Version Update + +To update the version and apply it to all components (recommended): + +On Linux/macOS: +```bash +# Navigate to the iac directory +cd iac + +# Make the script executable +chmod +x update-all.sh + +# Update all version references +./update-all.sh +``` + +On Windows: +```powershell +# Navigate to the iac directory +cd iac + +# Update all version references +.\update-all.ps1 +``` + +#### Manual Version Update + +To update only the version.txt file: + +On Linux/macOS: +```bash +# Navigate to the iac directory +cd iac + +# Make the script executable +chmod +x update-version.sh + +# Update the version +./update-version.sh +``` + +On Windows: +```powershell +# Navigate to the iac directory +cd iac + +# Update the version +.\update-version.ps1 +``` + +### Building Docker Images + +#### Using the build scripts + +On Linux/macOS: +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Make the script executable +chmod +x build.sh + +# Build the image with version from version.txt +./build.sh + +# Build with custom tag +./build.sh --tag 2025.05.21.1430 + +# Build and push to a registry +./build.sh --registry your-registry.com --push +``` + +On Windows: +```powershell +# Navigate to the iac/docker directory +cd iac\docker + +# Build the image with version from version.txt +.\build.ps1 + +# Build with custom tag +.\build.ps1 -tag 2025.05.21.1430 + +# Build and push to a registry +.\build.ps1 -registry your-registry.com -push +``` + +#### Manually + +```bash +# Navigate to the project root +cd /path/to/project + +# Get the version from version.txt +VERSION=$(cat iac/version.txt) + +# Build the image +docker build -t ActiveDirectoryManager:$VERSION -f iac/docker/Dockerfile . + +# Tag the image for a registry +docker tag ActiveDirectoryManager:$VERSION your-registry.com/ActiveDirectoryManager:$VERSION + +# Push to registry +docker push your-registry.com/ActiveDirectoryManager:$VERSION +``` + +### Running with Docker Compose + +```bash +# Navigate to the iac/docker directory +cd iac/docker + +# Create a .env file from the example if you haven't already +cp .env.example .env +# Edit the .env file with your configuration + +# Load the version from version.txt +source .env.version + +# Start the application stack +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the application stack +docker-compose down +``` + +## Kubernetes + +### Prerequisites + +- Kubernetes cluster (v1.19+) +- kubectl configured to communicate with your cluster +- Kustomize (v4.0+) or kubectl v1.14+ which includes kustomize + +### Updating Deployment Version + +Before deploying, you should update the Kubernetes deployment to use the current version: + +On Linux/macOS: +```bash +# Navigate to the iac/kubernetes directory +cd iac/kubernetes + +# Make the script executable +chmod +x update-deployment-version.sh + +# Update the deployment version +./update-deployment-version.sh +``` + +On Windows: +```powershell +# Navigate to the iac/kubernetes directory +cd iac\kubernetes + +# Update the deployment version +.\update-deployment-version.ps1 +``` + +### Deployment + +```bash +# Navigate to the iac/kubernetes directory +cd iac/kubernetes + +# Apply all resources +kubectl apply -k . + +# Or from anywhere in the project +kubectl apply -k iac/kubernetes +``` + +### Customization + +To customize the deployment for different environments: + +1. Create a new directory for your environment: + ```bash + mkdir -p iac/kubernetes/environments/production + ``` + +2. Create a kustomization.yaml file that references the base configuration: + ```yaml + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + + namespace: ad-management-production + + resources: + - ../../ # Reference the base configuration + + patches: + # Add your patches here + ``` + +3. Apply the environment-specific configuration: + ```bash + kubectl apply -k iac/kubernetes/environments/production + ``` + +## CI/CD Integration + +The Docker build scripts are designed to be used in CI/CD pipelines. Example integration with GitHub Actions: + +```yaml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build and push Docker image + run: | + cd iac/docker + chmod +x build.sh + ./build.sh --registry ghcr.io --tag ${{ github.sha }} --push +``` diff --git a/iac/docker/.env b/iac/docker/.env new file mode 100644 index 0000000..48f6573 --- /dev/null +++ b/iac/docker/.env @@ -0,0 +1,21 @@ +# Environment Variables for Docker Deployment +# Copy from .env.example and customize as needed + +# Database Configuration +POSTGRES_USER=admanagement +POSTGRES_PASSWORD=change_this_to_a_secure_password +POSTGRES_DB=admanagement + +# Application Configuration +NODE_ENV=production +PORT=5000 +BASE_URL=http://localhost:5000 +SESSION_SECRET=change_this_to_a_random_string +JWT_SECRET=change_this_to_a_different_random_string + +# Default Admin User Configuration +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=change_this_to_a_secure_password +DEFAULT_ADMIN_EMAIL=admin@example.com +DEFAULT_ADMIN_FULLNAME=System Administrator +DISABLE_REGISTRATION=false diff --git a/.env.example b/iac/docker/.env.example similarity index 99% rename from .env.example rename to iac/docker/.env.example index 58bc72f..5017a66 100644 --- a/.env.example +++ b/iac/docker/.env.example @@ -53,4 +53,4 @@ DISABLE_REGISTRATION=false # OIDC_USERINFO_URL=https://openidconnect.googleapis.com/v1/userinfo # OIDC_CLIENT_ID=your_client_id # OIDC_CLIENT_SECRET=your_client_secret -# OIDC_CALLBACK_URL=http://localhost:5000/api/auth/oidc/callback \ No newline at end of file +# OIDC_CALLBACK_URL=http://localhost:5000/api/auth/oidc/callback diff --git a/.env.production b/iac/docker/.env.production similarity index 94% rename from .env.production rename to iac/docker/.env.production index ce7499a..a4916bb 100644 --- a/.env.production +++ b/iac/docker/.env.production @@ -22,4 +22,4 @@ SESSION_COOKIE_SECURE=true SESSION_COOKIE_HTTPONLY=true # API Settings -SWAGGER_ENABLED=true \ No newline at end of file +SWAGGER_ENABLED=true diff --git a/iac/docker/.env.version b/iac/docker/.env.version new file mode 100644 index 0000000..4265d59 --- /dev/null +++ b/iac/docker/.env.version @@ -0,0 +1,3 @@ +# This file is used to set the VERSION environment variable for docker-compose +# It reads the version from the version.txt file +VERSION=$(cat ../version.txt) diff --git a/Dockerfile b/iac/docker/Dockerfile similarity index 97% rename from Dockerfile rename to iac/docker/Dockerfile index d4b56cc..aef0911 100644 --- a/Dockerfile +++ b/iac/docker/Dockerfile @@ -62,4 +62,4 @@ USER appuser EXPOSE 5000 # Start the application -CMD ["node", "dist/server/index.js"] \ No newline at end of file +CMD ["node", "dist/server/index.js"] diff --git a/iac/docker/build.ps1 b/iac/docker/build.ps1 new file mode 100644 index 0000000..8562add --- /dev/null +++ b/iac/docker/build.ps1 @@ -0,0 +1,74 @@ +# PowerShell script for building Docker images (Windows) +param ( + [string]$tag = "", + [string]$registry = "", + [switch]$push = $false, + [string]$imageName = "ActiveDirectoryManager" +) + +# If no tag is provided, use the version from version.txt +if (-not $tag) { + $versionFile = Join-Path (Split-Path $PSScriptRoot) "version.txt" + if (Test-Path $versionFile) { + $tag = Get-Content $versionFile -Raw + $tag = $tag.Trim() + } else { + # If version.txt doesn't exist, use current date/time in yyyy.MM.dd.HHmm format + $tag = Get-Date -Format "yyyy.MM.dd.HHmm" + } +} + +# Set error action preference to stop on any error +$ErrorActionPreference = "Stop" + +# Display build information +Write-Host "Building Docker image: $imageName" -ForegroundColor Green +Write-Host "Tag: $tag" -ForegroundColor Green +if ($registry) { + Write-Host "Registry: $registry" -ForegroundColor Green +} + +# Determine the full image name +$fullImageName = if ($registry) { "$registry/$imageName" } else { $imageName } + +# Check if Docker is available +try { + docker --version +} +catch { + Write-Host "Docker is not available. Please install Docker and try again." -ForegroundColor Red + exit 1 +} + +# Build the Docker image +try { + Write-Host "Building image: $fullImageName`:$tag" -ForegroundColor Cyan + + # Navigate to the root directory (two levels up from the script location) + $rootDir = (Get-Item $PSScriptRoot).Parent.Parent.FullName + + # Build the Docker image + docker build -t "$fullImageName`:$tag" -f "$PSScriptRoot/Dockerfile" $rootDir + + if ($LASTEXITCODE -ne 0) { + throw "Docker build failed with exit code $LASTEXITCODE" + } + + Write-Host "Successfully built image: $fullImageName`:$tag" -ForegroundColor Green + + # Push the image if requested + if ($push) { + Write-Host "Pushing image to registry: $fullImageName`:$tag" -ForegroundColor Cyan + docker push "$fullImageName`:$tag" + + if ($LASTEXITCODE -ne 0) { + throw "Docker push failed with exit code $LASTEXITCODE" + } + + Write-Host "Successfully pushed image: $fullImageName`:$tag" -ForegroundColor Green + } +} +catch { + Write-Host "Error: $_" -ForegroundColor Red + exit 1 +} diff --git a/iac/docker/build.sh b/iac/docker/build.sh new file mode 100644 index 0000000..ded0c4b --- /dev/null +++ b/iac/docker/build.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Bash script for building Docker images (Linux/macOS) + +# Default values +TAG="" +REGISTRY="" +PUSH=false +IMAGE_NAME="ActiveDirectoryManager" + +# If no tag is provided, use the version from version.txt +if [ -z "$TAG" ]; then + VERSION_FILE="$(dirname "$(dirname "$0")")/version.txt" + if [ -f "$VERSION_FILE" ]; then + TAG=$(cat "$VERSION_FILE" | tr -d '[:space:]') + else + # If version.txt doesn't exist, use current date/time in yyyy.MM.dd.HHmm format + TAG=$(date +"%Y.%m.%d.%H%M") + fi +fi + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tag) + TAG="$2" + shift 2 + ;; + --registry) + REGISTRY="$2" + shift 2 + ;; + --push) + PUSH=true + shift + ;; + --image-name) + IMAGE_NAME="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Determine the full image name +if [ -n "$REGISTRY" ]; then + FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME" +else + FULL_IMAGE_NAME="$IMAGE_NAME" +fi + +# Display build information +echo -e "\e[32mBuilding Docker image: $IMAGE_NAME\e[0m" +echo -e "\e[32mTag: $TAG\e[0m" +if [ -n "$REGISTRY" ]; then + echo -e "\e[32mRegistry: $REGISTRY\e[0m" +fi + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo -e "\e[31mDocker is not available. Please install Docker and try again.\e[0m" + exit 1 +fi + +# Build the Docker image +echo -e "\e[36mBuilding image: $FULL_IMAGE_NAME:$TAG\e[0m" + +# Navigate to the root directory (two levels up from the script location) +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Build the Docker image +if ! docker build -t "$FULL_IMAGE_NAME:$TAG" -f "$(dirname "${BASH_SOURCE[0]}")/Dockerfile" "$ROOT_DIR"; then + echo -e "\e[31mDocker build failed\e[0m" + exit 1 +fi + +echo -e "\e[32mSuccessfully built image: $FULL_IMAGE_NAME:$TAG\e[0m" + +# Push the image if requested +if [ "$PUSH" = true ]; then + echo -e "\e[36mPushing image to registry: $FULL_IMAGE_NAME:$TAG\e[0m" + + if ! docker push "$FULL_IMAGE_NAME:$TAG"; then + echo -e "\e[31mDocker push failed\e[0m" + exit 1 + fi + + echo -e "\e[32mSuccessfully pushed image: $FULL_IMAGE_NAME:$TAG\e[0m" +fi diff --git a/docker-compose.yml b/iac/docker/docker-compose.yml similarity index 87% rename from docker-compose.yml rename to iac/docker/docker-compose.yml index b2acf7b..f817fd6 100644 --- a/docker-compose.yml +++ b/iac/docker/docker-compose.yml @@ -1,9 +1,10 @@ services: app: build: - context: . - dockerfile: Dockerfile - container_name: ad-management-api + context: ../../ + dockerfile: iac/docker/Dockerfile + image: ActiveDirectoryManager:${VERSION:-latest} + container_name: ActiveDirectoryManager-api restart: unless-stopped ports: - "5000:5000" @@ -34,7 +35,7 @@ services: postgres: image: postgres:15-alpine - container_name: ad-management-postgres + container_name: ActiveDirectoryManager-postgres restart: unless-stopped environment: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} @@ -55,7 +56,7 @@ services: redis: image: redis:7-alpine - container_name: ad-management-redis + container_name: ActiveDirectoryManager-redis restart: unless-stopped command: redis-server --appendonly yes volumes: @@ -76,4 +77,4 @@ networks: volumes: postgres-data: - redis-data: \ No newline at end of file + redis-data: diff --git a/iac/kubernetes/configmap.yaml b/iac/kubernetes/configmap.yaml new file mode 100644 index 0000000..6a9ecdd --- /dev/null +++ b/iac/kubernetes/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ad-management-config + namespace: ad-management +data: + NODE_ENV: "production" + PORT: "5000" + BASE_URL: "https://ad-management.example.com" + DEFAULT_ADMIN_USERNAME: "admin" + DEFAULT_ADMIN_FULLNAME: "System Administrator" + DISABLE_REGISTRATION: "false" + POSTGRES_DB: "admgr" + POSTGRES_USER: "postgres" diff --git a/iac/kubernetes/deployment.yaml b/iac/kubernetes/deployment.yaml new file mode 100644 index 0000000..dd6899a --- /dev/null +++ b/iac/kubernetes/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ad-management + namespace: ad-management +spec: + replicas: 2 + selector: + matchLabels: + app: ad-management + template: + metadata: + labels: + app: ad-management + spec: + containers: + - name: activedirectorymanager + image: ActiveDirectoryManager:2025.05.21.1430 + imagePullPolicy: Always + ports: + - containerPort: 5000 + name: http + envFrom: + - configMapRef: + name: ad-management-config + - secretRef: + name: ad-management-secrets + env: + - name: REDIS_HOST + value: "redis" + - name: REDIS_PORT + value: "6379" + - name: POSTGRES_HOST + value: "postgres" + - name: POSTGRES_PORT + value: "5432" + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "300m" + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 1 +--- +apiVersion: v1 +kind: Service +metadata: + name: ad-management + namespace: ad-management +spec: + selector: + app: ad-management + ports: + - port: 80 + targetPort: 5000 + name: http + type: ClusterIP diff --git a/iac/kubernetes/ingress.yaml b/iac/kubernetes/ingress.yaml new file mode 100644 index 0000000..26b2b2f --- /dev/null +++ b/iac/kubernetes/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ad-management-ingress + namespace: ad-management + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "50m" +spec: + tls: + - hosts: + - ad-management.example.com + secretName: ad-management-tls + rules: + - host: ad-management.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: ad-management + port: + name: http diff --git a/iac/kubernetes/kustomization.yaml b/iac/kubernetes/kustomization.yaml new file mode 100644 index 0000000..6a5a295 --- /dev/null +++ b/iac/kubernetes/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: ad-management + +resources: + - namespace.yaml + - configmap.yaml + - secrets.yaml + - postgres.yaml + - redis.yaml + - deployment.yaml + - ingress.yaml diff --git a/iac/kubernetes/namespace.yaml b/iac/kubernetes/namespace.yaml new file mode 100644 index 0000000..7f7b1b6 --- /dev/null +++ b/iac/kubernetes/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ad-management + labels: + name: ad-management diff --git a/iac/kubernetes/postgres.yaml b/iac/kubernetes/postgres.yaml new file mode 100644 index 0000000..eb7f65c --- /dev/null +++ b/iac/kubernetes/postgres.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: ad-management +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15-alpine + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: ad-management-config + key: POSTGRES_DB + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: ad-management-config + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: ad-management-secrets + key: POSTGRES_PASSWORD + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m" + livenessProbe: + exec: + command: + - pg_isready + - -U + - postgres + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - pg_isready + - -U + - postgres + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 1 + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 10Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: ad-management +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 + clusterIP: None # Headless service for StatefulSet diff --git a/iac/kubernetes/redis.yaml b/iac/kubernetes/redis.yaml new file mode 100644 index 0000000..3a3b4c1 --- /dev/null +++ b/iac/kubernetes/redis.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + namespace: ad-management +spec: + serviceName: redis + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + name: redis + volumeMounts: + - name: redis-data + mountPath: /data + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 1 + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: ad-management +spec: + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + clusterIP: None # Headless service for StatefulSet diff --git a/iac/kubernetes/secrets.yaml b/iac/kubernetes/secrets.yaml new file mode 100644 index 0000000..ba54619 --- /dev/null +++ b/iac/kubernetes/secrets.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ad-management-secrets + namespace: ad-management +type: Opaque +data: + # These are example values. In production, replace with your own base64-encoded secrets + # Example: echo -n "your-secret-value" | base64 + JWT_SECRET: Y2hhbmdlLXRoaXMtaW4tcHJvZHVjdGlvbg== + SESSION_SECRET: Y2hhbmdlLXRoaXMtaW4tcHJvZHVjdGlvbg== + DEFAULT_ADMIN_PASSWORD: cGFzc3dvcmQ= + DEFAULT_ADMIN_EMAIL: YWRtaW5AZXhhbXBsZS5jb20= + POSTGRES_PASSWORD: cG9zdGdyZXM= diff --git a/iac/kubernetes/update-deployment-version.ps1 b/iac/kubernetes/update-deployment-version.ps1 new file mode 100644 index 0000000..243cfcf --- /dev/null +++ b/iac/kubernetes/update-deployment-version.ps1 @@ -0,0 +1,40 @@ +# PowerShell script to update the Kubernetes deployment with the current version + +# Get the directory of this script +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$parentDir = Split-Path -Parent $scriptDir + +# Read the version from version.txt +$versionFile = Join-Path $parentDir "version.txt" +if (-not (Test-Path $versionFile)) { + Write-Host "Error: version.txt not found at $versionFile" -ForegroundColor Red + exit 1 +} + +$version = Get-Content $versionFile -Raw +$version = $version.Trim() +Write-Host "Using version: $version" -ForegroundColor Cyan + +# Update the deployment.yaml file +$deploymentFile = Join-Path $scriptDir "deployment.yaml" +if (-not (Test-Path $deploymentFile)) { + Write-Host "Error: deployment.yaml not found at $deploymentFile" -ForegroundColor Red + exit 1 +} + +# Read the deployment file +$content = Get-Content $deploymentFile -Raw + +# Replace the image version +$pattern1 = 'image: ActiveDirectoryManager:[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}' +$replacement1 = "image: ActiveDirectoryManager:$version" +$content = $content -replace $pattern1, $replacement1 + +$pattern2 = 'image: ActiveDirectoryManager:latest' +$replacement2 = "image: ActiveDirectoryManager:$version" +$content = $content -replace $pattern2, $replacement2 + +# Write the updated content back to the file +$content | Set-Content $deploymentFile + +Write-Host "Updated deployment.yaml with version $version" -ForegroundColor Green diff --git a/iac/kubernetes/update-deployment-version.sh b/iac/kubernetes/update-deployment-version.sh new file mode 100644 index 0000000..b1a5698 --- /dev/null +++ b/iac/kubernetes/update-deployment-version.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Script to update the Kubernetes deployment with the current version + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PARENT_DIR="$(dirname "$SCRIPT_DIR")" + +# Read the version from version.txt +VERSION_FILE="$PARENT_DIR/version.txt" +if [ ! -f "$VERSION_FILE" ]; then + echo "Error: version.txt not found at $VERSION_FILE" + exit 1 +fi + +VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') +echo "Using version: $VERSION" + +# Update the deployment.yaml file +DEPLOYMENT_FILE="$SCRIPT_DIR/deployment.yaml" +if [ ! -f "$DEPLOYMENT_FILE" ]; then + echo "Error: deployment.yaml not found at $DEPLOYMENT_FILE" + exit 1 +fi + +# Use sed to replace the image version +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS requires a different sed syntax + sed -i '' "s|image: ActiveDirectoryManager:[0-9]\{4\}\.[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{4\}|image: ActiveDirectoryManager:$VERSION|g" "$DEPLOYMENT_FILE" + sed -i '' "s|image: ActiveDirectoryManager:latest|image: ActiveDirectoryManager:$VERSION|g" "$DEPLOYMENT_FILE" +else + # Linux + sed -i "s|image: ActiveDirectoryManager:[0-9]\{4\}\.[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{4\}|image: ActiveDirectoryManager:$VERSION|g" "$DEPLOYMENT_FILE" + sed -i "s|image: ActiveDirectoryManager:latest|image: ActiveDirectoryManager:$VERSION|g" "$DEPLOYMENT_FILE" +fi + +echo "Updated deployment.yaml with version $VERSION" diff --git a/iac/update-all.ps1 b/iac/update-all.ps1 new file mode 100644 index 0000000..4ad7026 --- /dev/null +++ b/iac/update-all.ps1 @@ -0,0 +1,24 @@ +# PowerShell script to update version and apply it to all components + +# Get the directory of this script +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Update version.txt with current date/time +Write-Host "Updating version.txt..." -ForegroundColor Cyan +& "$scriptDir\update-version.ps1" + +# Read the new version +$version = Get-Content "$scriptDir\version.txt" -Raw +$version = $version.Trim() +Write-Host "New version: $version" -ForegroundColor Green + +# Update Kubernetes deployment +Write-Host "Updating Kubernetes deployment..." -ForegroundColor Cyan +& "$scriptDir\kubernetes\update-deployment-version.ps1" + +Write-Host "Version update complete. New version: $version" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Yellow +Write-Host "1. Build Docker image: cd $scriptDir\docker; .\build.ps1" -ForegroundColor Yellow +Write-Host "2. Push Docker image: cd $scriptDir\docker; .\build.ps1 -push" -ForegroundColor Yellow +Write-Host "3. Deploy to Kubernetes: cd $scriptDir\kubernetes; kubectl apply -k ." -ForegroundColor Yellow diff --git a/iac/update-all.sh b/iac/update-all.sh new file mode 100644 index 0000000..39b5e34 --- /dev/null +++ b/iac/update-all.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Comprehensive script to update version and apply it to all components + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Update version.txt with current date/time +echo "Updating version.txt..." +"$SCRIPT_DIR/update-version.sh" + +# Read the new version +VERSION=$(cat "$SCRIPT_DIR/version.txt" | tr -d '[:space:]') +echo "New version: $VERSION" + +# Update Kubernetes deployment +echo "Updating Kubernetes deployment..." +"$SCRIPT_DIR/kubernetes/update-deployment-version.sh" + +echo "Version update complete. New version: $VERSION" +echo "" +echo "Next steps:" +echo "1. Build Docker image: cd $SCRIPT_DIR/docker && ./build.sh" +echo "2. Push Docker image: cd $SCRIPT_DIR/docker && ./build.sh --push" +echo "3. Deploy to Kubernetes: cd $SCRIPT_DIR/kubernetes && kubectl apply -k ." diff --git a/iac/update-version.ps1 b/iac/update-version.ps1 new file mode 100644 index 0000000..ea93351 --- /dev/null +++ b/iac/update-version.ps1 @@ -0,0 +1,10 @@ +# PowerShell script to update the version.txt file with the current date and time + +# Generate version in yyyy.MM.dd.HHmm format +$version = Get-Date -Format "yyyy.MM.dd.HHmm" + +# Update version.txt +$versionFile = Join-Path $PSScriptRoot "version.txt" +$version | Out-File -FilePath $versionFile -NoNewline + +Write-Host "Version updated to: $version" diff --git a/iac/update-version.sh b/iac/update-version.sh new file mode 100644 index 0000000..5379c1f --- /dev/null +++ b/iac/update-version.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Script to update the version.txt file with the current date and time + +# Generate version in yyyy.MM.dd.HHmm format +VERSION=$(date +"%Y.%m.%d.%H%M") + +# Update version.txt +echo "$VERSION" > "$(dirname "$0")/version.txt" + +echo "Version updated to: $VERSION" diff --git a/iac/version.txt b/iac/version.txt new file mode 100644 index 0000000..55d03ad --- /dev/null +++ b/iac/version.txt @@ -0,0 +1 @@ +2025.05.21.1430 diff --git a/server/ldap.ts b/server/ldap.ts index 2f81108..fbb9ebe 100644 --- a/server/ldap.ts +++ b/server/ldap.ts @@ -3,247 +3,535 @@ import ldap from 'ldapjs'; import { LdapConnection } from '@shared/schema'; import { storage } from './storage'; +// DN escaping utilities +const DN_SPECIAL_CHARS = /[,=+<>#;\\"\n]/; +const RDN_SPECIAL_CHARS = /[,=+<>#;\\"\n/]/; + +// Escape a string for use in a DN +function escapeDN(value: string): string { + if (!value) return ''; + + // If the value doesn't contain special characters, return it as is + if (!DN_SPECIAL_CHARS.test(value)) return value; + + // Otherwise, escape each special character + return value.replace(/([\\\,\=\+\<\>\#\;\"\n])/g, '\\$1'); +} + +// Escape a string for use in an RDN +function escapeRDN(value: string): string { + if (!value) return ''; + + // If the value doesn't contain special characters, return it as is + if (!RDN_SPECIAL_CHARS.test(value)) return value; + + // Otherwise, escape each special character + return value.replace(/([\\\,\=\+\<\>\#\;\"\n\/])/g, '\\$1'); +} + +// Parse a DN into its component parts +function parseDN(dn: string): { attribute: string, value: string }[] { + if (!dn) return []; + + const parts = []; + let inEscape = false; + let currentPart = ''; + + for (let i = 0; i < dn.length; i++) { + const char = dn[i]; + + if (inEscape) { + currentPart += char; + inEscape = false; + } else if (char === '\\') { + inEscape = true; + } else if (char === ',' && !inEscape) { + if (currentPart) { + const [attribute, value] = currentPart.split('=', 2); + parts.push({ attribute: attribute.trim(), value: value.trim() }); + currentPart = ''; + } + } else { + currentPart += char; + } + } + + if (currentPart) { + const [attribute, value] = currentPart.split('=', 2); + parts.push({ attribute: attribute.trim(), value: value.trim() }); + } + + return parts; +} + +// Build a DN from component parts +function buildDN(parts: { attribute: string, value: string }[]): string { + return parts.map(part => `${part.attribute}=${escapeDN(part.value)}`).join(','); +} + +// Connection pool configuration +interface ConnectionPoolConfig { + maxConnections: number; + idleTimeout: number; // in milliseconds + acquireTimeout: number; // in milliseconds +} + +// Default connection pool configuration +const DEFAULT_POOL_CONFIG: ConnectionPoolConfig = { + maxConnections: 10, + idleTimeout: 60000, // 1 minute + acquireTimeout: 10000 // 10 seconds +}; + +// Connection pool entry +interface PoolEntry { + client: ldap.Client; + lastUsed: number; + inUse: boolean; +} + class LdapClient extends EventEmitter { - private clients: Map = new Map(); - private isConnected: Map = new Map(); - + private readonly clients: Map = new Map(); + private readonly isConnected: Map = new Map(); + private readonly connectionPools: Map = new Map(); + private readonly poolConfig: ConnectionPoolConfig; + + constructor(poolConfig?: Partial) { + super(); + this.poolConfig = { ...DEFAULT_POOL_CONFIG, ...poolConfig }; + + // Start the idle connection cleanup timer + setInterval(() => this.cleanupIdleConnections(), this.poolConfig.idleTimeout); + } + + // Clean up idle connections + private cleanupIdleConnections(): void { + const now = Date.now(); + + for (const [connectionId, pool] of this.connectionPools.entries()) { + // Filter out idle connections that exceed the idle timeout + const activeConnections = pool.filter(entry => { + const isIdle = !entry.inUse && (now - entry.lastUsed > this.poolConfig.idleTimeout); + + if (isIdle) { + try { + // Unbind the idle connection + entry.client.unbind(); + console.log(`Closed idle LDAP connection for connection ID ${connectionId}`); + } catch (error) { + console.error(`Error closing idle LDAP connection: ${error instanceof Error ? error.message : String(error)}`); + } + return false; + } + + return true; + }); + + // Update the pool with only active connections + this.connectionPools.set(connectionId, activeConnections); + + // If all connections are gone, update the connection status + if (activeConnections.length === 0 && this.isConnected.get(connectionId)) { + this.isConnected.set(connectionId, false); + } + } + } + + // Get a connection from the pool or create a new one + private async getPooledConnection(connection: LdapConnection): Promise { + const connectionId = connection.id; + let pool = this.connectionPools.get(connectionId) || []; + + // Look for an available connection in the pool + const availableEntry = pool.find(entry => !entry.inUse); + + if (availableEntry) { + // Mark the connection as in use + availableEntry.inUse = true; + availableEntry.lastUsed = Date.now(); + return availableEntry.client; + } + + // Check if we've reached the maximum number of connections + if (pool.length >= this.poolConfig.maxConnections) { + // Wait for a connection to become available + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for an available LDAP connection after ${this.poolConfig.acquireTimeout}ms`)); + }, this.poolConfig.acquireTimeout); + + const checkForAvailableConnection = () => { + const pool = this.connectionPools.get(connectionId) || []; + const availableEntry = pool.find(entry => !entry.inUse); + + if (availableEntry) { + clearTimeout(timeout); + availableEntry.inUse = true; + availableEntry.lastUsed = Date.now(); + resolve(availableEntry.client); + } else { + // Check again in 100ms + setTimeout(checkForAvailableConnection, 100); + } + }; + + checkForAvailableConnection(); + }); + } + + // Create a new connection + const clientOptions: ldap.ClientOptions = { + url: `${connection.useTLS ? 'ldaps' : 'ldap'}://${connection.server}:${connection.port}`, + reconnect: { + initialDelay: 1000, + maxDelay: 10000, + failAfter: 10 + }, + timeout: 5000, + connectTimeout: 10000 + }; + + const client = ldap.createClient(clientOptions); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Connection timeout after ${this.poolConfig.acquireTimeout}ms`)); + }, this.poolConfig.acquireTimeout); + + client.on('error', async (err) => { + console.error(`LDAP connection error for ${connection.name}:`, err); + + // Remove this client from the pool if it exists + const pool = this.connectionPools.get(connectionId) || []; + const updatedPool = pool.filter(entry => entry.client !== client); + this.connectionPools.set(connectionId, updatedPool); + + if (updatedPool.length === 0) { + this.isConnected.set(connectionId, false); + await storage.updateLdapConnection(connectionId, { status: 'disconnected' }); + this.emit('status', { + connectionId, + status: 'disconnected', + error: err.message + }); + } + }); + + client.bind(connection.username, connection.password, (err) => { + clearTimeout(timeout); + + if (err) { + console.error(`LDAP bind error for ${connection.name}:`, err); + this.isConnected.set(connectionId, false); + + // Update connection status in database + storage.updateLdapConnection(connectionId, { status: 'disconnected' }) + .catch(dbErr => console.error(`Error updating LDAP connection status: ${dbErr}`)); + + this.emit('status', { + connectionId, + status: 'disconnected', + error: err.message + }); + + reject(err); + return; + } + + // Add the new connection to the pool + const newEntry: PoolEntry = { + client, + lastUsed: Date.now(), + inUse: true + }; + + pool.push(newEntry); + this.connectionPools.set(connectionId, pool); + this.isConnected.set(connectionId, true); + + // Update connection status in database + storage.updateLdapConnection(connectionId, { + status: 'connected', + lastConnected: new Date() + }).catch(dbErr => console.error(`Error updating LDAP connection status: ${dbErr}`)); + + this.emit('status', { + connectionId, + status: 'connected' + }); + + resolve(client); + }); + }); + } + + // Release a connection back to the pool + private releaseConnection(connectionId: number, client: ldap.Client): void { + const pool = this.connectionPools.get(connectionId); + if (!pool) return; + + const entry = pool.find(e => e.client === client); + if (entry) { + entry.inUse = false; + entry.lastUsed = Date.now(); + } + } + async connect(connection: LdapConnection): Promise { try { - const clientOptions: ldap.ClientOptions = { - url: `${connection.useTLS ? 'ldaps' : 'ldap'}://${connection.server}:${connection.port}`, - reconnect: { - initialDelay: 1000, - maxDelay: 10000, - failAfter: 10 - }, - timeout: 5000, - connectTimeout: 10000 - }; - - const client = ldap.createClient(clientOptions); - - return new Promise((resolve, reject) => { - client.on('error', async (err) => { - console.error(`LDAP connection error for ${connection.name}:`, err); - this.isConnected.set(connection.id, false); - await storage.updateLdapConnection(connection.id, { status: 'disconnected' }); - this.emit('status', { - connectionId: connection.id, - status: 'disconnected', - error: err.message - }); - }); - - client.bind(connection.username, connection.password, async (err) => { - if (err) { - console.error(`LDAP bind error for ${connection.name}:`, err); - this.isConnected.set(connection.id, false); - await storage.updateLdapConnection(connection.id, { status: 'disconnected' }); - this.emit('status', { - connectionId: connection.id, - status: 'disconnected', - error: err.message - }); - reject(err); - return; - } - - this.clients.set(connection.id, client); - this.isConnected.set(connection.id, true); - await storage.updateLdapConnection(connection.id, { - status: 'connected', - lastConnected: new Date() - }); - - this.emit('status', { - connectionId: connection.id, - status: 'connected' - }); - - resolve(true); - }); - }); + await this.getPooledConnection(connection); + return true; } catch (error) { console.error(`LDAP connection error for ${connection.name}:`, error); this.isConnected.set(connection.id, false); await storage.updateLdapConnection(connection.id, { status: 'disconnected' }); - this.emit('status', { - connectionId: connection.id, - status: 'disconnected', - error: error instanceof Error ? error.message : String(error) + this.emit('status', { + connectionId: connection.id, + status: 'disconnected', + error: error instanceof Error ? error.message : String(error) }); throw error; } } - + async disconnect(connectionId: number): Promise { + // Close all connections in the pool + const pool = this.connectionPools.get(connectionId) || []; + + for (const entry of pool) { + try { + entry.client.unbind(); + } catch (error) { + console.error(`Error unbinding LDAP connection: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Clear the pool + this.connectionPools.delete(connectionId); + this.isConnected.set(connectionId, false); + + // Also handle legacy client if it exists const client = this.clients.get(connectionId); if (client) { - return new Promise((resolve) => { - client.unbind(() => { - this.clients.delete(connectionId); - this.isConnected.set(connectionId, false); - resolve(); - }); - }); + try { + client.unbind(); + this.clients.delete(connectionId); + } catch (error) { + console.error(`Error unbinding legacy LDAP connection: ${error instanceof Error ? error.message : String(error)}`); + } } } - - getClient(connectionId: number): ldap.Client | undefined { + + async getClient(connectionId: number): Promise { + // First check if we have a connection in the pool + const pool = this.connectionPools.get(connectionId) || []; + const availableEntry = pool.find(entry => !entry.inUse); + + if (availableEntry) { + availableEntry.inUse = true; + availableEntry.lastUsed = Date.now(); + return availableEntry.client; + } + + // Fall back to legacy client return this.clients.get(connectionId); } - + isConnectionActive(connectionId: number): boolean { + // Check if we have any active connections in the pool + const pool = this.connectionPools.get(connectionId) || []; + if (pool.length > 0) return true; + + // Fall back to legacy connection status return this.isConnected.get(connectionId) || false; } - - // LDAP CRUD operations - async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[]): Promise { - const client = this.getClient(connectionId); + + // LDAP CRUD operations with paging support + async searchWithPaging( + connectionId: number, + baseDN: string, + filter: string, + attributes: string[], + pageSize = 1000 + ): Promise { + const client = await this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - - const connection = await storage.getLdapConnection(connectionId); - if (!connection) throw new Error('LDAP connection not found'); - - const baseDN = connection.baseDN || ''; - const defaultAttributes = ['cn', 'sAMAccountName', 'mail', 'distinguishedName']; - const searchAttributes = attributes?.length ? attributes : defaultAttributes; - + return new Promise((resolve, reject) => { - const results: any[] = []; - + const allResults: any[] = []; + let finished = false; + + // Create a paged search control + const controls = [ + new ldap.PagedResultsControl({ value: { size: pageSize } }) + ]; + + const processPage = (err: any, res: any) => { + if (err) { + reject(new Error(`LDAP search error: ${err.message}`)); + return; + } + + let cookie: Buffer | undefined; + + res.on('searchEntry', (entry: any) => { + allResults.push(entry.object); + }); + + res.on('error', (err: any) => { + reject(new Error(`LDAP search error: ${err.message}`)); + }); + + res.on('end', (result: any) => { + if (finished) return; + + // Check if we need to get more pages + if (result && result.controls) { + const control = result.controls.find( + (c: any) => c instanceof ldap.PagedResultsControl + ); + + if (control && control.value.cookie && control.value.cookie.length > 0) { + cookie = control.value.cookie; + + // Get the next page + const nextControls = [ + new ldap.PagedResultsControl({ value: { size: pageSize, cookie } }) + ]; + + client.search(baseDN, { + filter, + scope: 'sub', + attributes, + controls: nextControls + }, processPage); + } else { + // No more pages, we're done + finished = true; + resolve(allResults); + } + } else { + // No paging control in response, we're done + finished = true; + resolve(allResults); + } + }); + }; + + // Start the paged search client.search(baseDN, { filter, scope: 'sub', - attributes: searchAttributes - }, (err, res) => { - if (err) { - reject(err); - return; - } - - res.on('searchEntry', (entry) => { - results.push(entry.object); - }); - - res.on('error', (err) => { - reject(err); - }); - - res.on('end', (result) => { - resolve(results); - }); - }); + attributes, + controls + }, processPage); }); } - - async searchGroups(connectionId: number, filter = '(objectClass=group)', attributes?: string[]): Promise { - const defaultAttributes = ['cn', 'distinguishedName', 'member']; - return this.searchUsers(connectionId, filter, attributes || defaultAttributes); - } - - async searchOUs(connectionId: number, filter = '(objectClass=organizationalUnit)', attributes?: string[]): Promise { - const defaultAttributes = ['ou', 'distinguishedName']; - return this.searchUsers(connectionId, filter, attributes || defaultAttributes); - } - - async searchComputers(connectionId: number, filter = '(objectClass=computer)', attributes?: string[]): Promise { - const defaultAttributes = ['cn', 'distinguishedName', 'operatingSystem']; - return this.searchUsers(connectionId, filter, attributes || defaultAttributes); - } - - async searchSites(connectionId: number, filter = '(objectClass=site)', attributes?: string[]): Promise { - const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'managedBy']; - // Sites are typically stored in the Configuration naming context - const client = this.getClient(connectionId); - if (!client) throw new Error('LDAP connection not established'); - + + async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[], pageSize = 1000): Promise { const connection = await storage.getLdapConnection(connectionId); if (!connection) throw new Error('LDAP connection not found'); - + + const baseDN = connection.baseDN || ''; + const defaultAttributes = ['cn', 'sAMAccountName', 'mail', 'distinguishedName', 'objectGUID', 'userAccountControl']; + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search users: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async searchGroups(connectionId: number, filter = '(objectClass=group)', attributes?: string[], pageSize = 1000): Promise { + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + + const baseDN = connection.baseDN || ''; + const defaultAttributes = ['cn', 'distinguishedName', 'member', 'objectGUID', 'sAMAccountName']; + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search groups: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async searchOUs(connectionId: number, filter = '(objectClass=organizationalUnit)', attributes?: string[], pageSize = 1000): Promise { + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + + const baseDN = connection.baseDN || ''; + const defaultAttributes = ['ou', 'distinguishedName', 'objectGUID']; + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search OUs: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async searchComputers(connectionId: number, filter = '(objectClass=computer)', attributes?: string[], pageSize = 1000): Promise { + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + + const baseDN = connection.baseDN || ''; + const defaultAttributes = ['cn', 'distinguishedName', 'dNSHostName', 'operatingSystem', 'objectGUID', 'userAccountControl']; + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search computers: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async searchSites(connectionId: number, filter = '(objectClass=site)', attributes?: string[], pageSize = 1000): Promise { + const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'managedBy', 'objectGUID']; + + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + // Sites are located in the Configuration container const baseDN = 'CN=Sites,CN=Configuration,' + this.getDomainDN(connection); const searchAttributes = attributes?.length ? attributes : defaultAttributes; - - return new Promise((resolve, reject) => { - const results: any[] = []; - - client.search(baseDN, { - filter, - scope: 'sub', - attributes: searchAttributes - }, (err, res) => { - if (err) { - reject(err); - return; - } - - res.on('searchEntry', (entry) => { - results.push(entry.object); - }); - - res.on('error', (err) => { - reject(err); - }); - - res.on('end', (result) => { - resolve(results); - }); - }); - }); + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search sites: ${error instanceof Error ? error.message : String(error)}`); + } } - - async searchSubnets(connectionId: number, filter = '(objectClass=subnet)', attributes?: string[]): Promise { - const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'siteObject', 'managedBy']; - // Subnets are typically stored in the Configuration naming context - const client = this.getClient(connectionId); - if (!client) throw new Error('LDAP connection not established'); - + + async searchSubnets(connectionId: number, filter = '(objectClass=subnet)', attributes?: string[], pageSize = 1000): Promise { + const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'siteObject', 'managedBy', 'objectGUID']; + const connection = await storage.getLdapConnection(connectionId); if (!connection) throw new Error('LDAP connection not found'); - + // Subnets are located in the Configuration container const baseDN = 'CN=Subnets,CN=Sites,CN=Configuration,' + this.getDomainDN(connection); const searchAttributes = attributes?.length ? attributes : defaultAttributes; - - return new Promise((resolve, reject) => { - const results: any[] = []; - - client.search(baseDN, { - filter, - scope: 'sub', - attributes: searchAttributes - }, (err, res) => { - if (err) { - reject(err); - return; - } - - res.on('searchEntry', (entry) => { - results.push(entry.object); - }); - - res.on('error', (err) => { - reject(err); - }); - - res.on('end', (result) => { - resolve(results); - }); - }); - }); + + try { + return await this.searchWithPaging(connectionId, baseDN, filter, searchAttributes, pageSize); + } catch (error) { + throw new Error(`Failed to search subnets: ${error instanceof Error ? error.message : String(error)}`); + } } - + // Helper function to extract domain DN from connection getDomainDN(connection: LdapConnection): string { const domainParts = connection.domain.split('.'); return domainParts.map(part => `DC=${part}`).join(','); } - + async createEntry(connectionId: number, dn: string, attributes: any): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - + return new Promise((resolve, reject) => { client.add(dn, attributes, (err) => { if (err) { @@ -254,11 +542,11 @@ class LdapClient extends EventEmitter { }); }); } - + async updateEntry(connectionId: number, dn: string, changes: any[]): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - + return new Promise((resolve, reject) => { client.modify(dn, changes, (err) => { if (err) { @@ -269,11 +557,95 @@ class LdapClient extends EventEmitter { }); }); } - + + // Update a single attribute of an LDAP entry + async updateAttribute( + connectionId: number, + dn: string, + attributeName: string, + attributeValue: string | string[] | null, + operation: 'add' | 'replace' | 'delete' = 'replace' + ): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DN is properly escaped + const escapedDN = buildDN(parseDN(dn)); + + let change: any; + + if (attributeValue === null) { + // If value is null, we're removing the attribute + change = new ldap.Change({ + operation: 'delete', + modification: { + [attributeName]: [] + } + }); + } else { + // Otherwise, we're adding or replacing the attribute + change = new ldap.Change({ + operation, + modification: { + [attributeName]: Array.isArray(attributeValue) ? attributeValue : [attributeValue] + } + }); + } + + try { + return await this.updateEntry(connectionId, escapedDN, [change]); + } catch (error) { + throw new Error(`Failed to update attribute ${attributeName}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Update multiple attributes of an LDAP entry in a single operation + async updateAttributes( + connectionId: number, + dn: string, + attributes: Record, + operation: 'add' | 'replace' | 'delete' = 'replace' + ): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DN is properly escaped + const escapedDN = buildDN(parseDN(dn)); + + const changes: any[] = []; + + // Create a change for each attribute + for (const [attributeName, attributeValue] of Object.entries(attributes)) { + if (attributeValue === null) { + // If value is null, we're removing the attribute + changes.push(new ldap.Change({ + operation: 'delete', + modification: { + [attributeName]: [] + } + })); + } else { + // Otherwise, we're adding or replacing the attribute + changes.push(new ldap.Change({ + operation, + modification: { + [attributeName]: Array.isArray(attributeValue) ? attributeValue : [attributeValue] + } + })); + } + } + + try { + return await this.updateEntry(connectionId, escapedDN, changes); + } catch (error) { + throw new Error(`Failed to update attributes: ${error instanceof Error ? error.message : String(error)}`); + } + } + async deleteEntry(connectionId: number, dn: string): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - + return new Promise((resolve, reject) => { client.del(dn, (err) => { if (err) { @@ -289,7 +661,7 @@ class LdapClient extends EventEmitter { async getManagedBy(connectionId: number, dn: string): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - + return new Promise((resolve, reject) => { client.search(dn, { scope: 'base', @@ -299,9 +671,9 @@ class LdapClient extends EventEmitter { reject(err); return; } - + let managedBy: string | null = null; - + res.on('searchEntry', (entry) => { const attrs = entry.attributes; for (const attr of attrs) { @@ -310,11 +682,11 @@ class LdapClient extends EventEmitter { } } }); - + res.on('error', (err) => { reject(err); }); - + res.on('end', (result) => { if (result.status !== 0) { reject(new Error(`LDAP search error: ${result.errorMessage}`)); @@ -325,14 +697,14 @@ class LdapClient extends EventEmitter { }); }); } - + // Set the managed by attribute for an object async setManagedBy(connectionId: number, dn: string, managerDn: string | null): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); - + const changes = []; - + if (managerDn === null) { // Remove the managedBy attribute changes.push(new ldap.Change({ @@ -350,9 +722,608 @@ class LdapClient extends EventEmitter { } })); } - + return this.updateEntry(connectionId, dn, changes); } + + // Validate password against common AD password policies + private validatePassword(password: string): { valid: boolean; message?: string } { + if (!password) { + return { valid: false, message: 'Password cannot be empty' }; + } + + if (password.length < 8) { + return { valid: false, message: 'Password must be at least 8 characters long' }; + } + + // Check for complexity requirements (at least 3 of 4 character types) + let complexity = 0; + if (/[a-z]/.test(password)) complexity++; // lowercase + if (/[A-Z]/.test(password)) complexity++; // uppercase + if (/[0-9]/.test(password)) complexity++; // digits + if (/[^a-zA-Z0-9]/.test(password)) complexity++; // special chars + + if (complexity < 3) { + return { + valid: false, + message: 'Password must contain at least 3 of the following: lowercase letters, uppercase letters, digits, and special characters' + }; + } + + // Check for common patterns + if (/(.)\1{2,}/.test(password)) { + return { valid: false, message: 'Password cannot contain repeated character sequences' }; + } + + return { valid: true }; + } + + // Reset a user's password + async resetUserPassword(connectionId: number, userDN: string, newPassword: string, skipValidation = false): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Validate the password against common AD policies + if (!skipValidation) { + const validation = this.validatePassword(newPassword); + if (!validation.valid) { + throw new Error(`Password validation failed: ${validation.message}`); + } + } + + // Ensure the DN is properly escaped + const escapedUserDN = buildDN(parseDN(userDN)); + + // Unicode password format for Active Directory + const unicodePassword = Buffer.from(`"${newPassword}"`, 'utf16le'); + + const changes = [ + new ldap.Change({ + operation: 'replace', + modification: { + unicodePwd: unicodePassword + } + }) + ]; + + try { + return await this.updateEntry(connectionId, escapedUserDN, changes); + } catch (error) { + throw new Error(`Failed to reset password: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Enable or disable a user account + async setUserAccountStatus(connectionId: number, userDN: string, enabled: boolean): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure the DN is properly escaped + const escapedUserDN = buildDN(parseDN(userDN)); + + // First, get the current userAccountControl value + const users = await this.searchUsers(connectionId, `(distinguishedName=${escapedUserDN})`, ['userAccountControl']); + if (!users || users.length === 0) { + throw new Error('User not found'); + } + + let userAccountControl = parseInt(users[0].userAccountControl ?? '0'); + + // The ACCOUNTDISABLE flag is 0x2 (2) + if (enabled) { + // Remove the ACCOUNTDISABLE flag + userAccountControl &= ~2; + } else { + // Add the ACCOUNTDISABLE flag + userAccountControl |= 2; + } + + const changes = [ + new ldap.Change({ + operation: 'replace', + modification: { + userAccountControl: userAccountControl.toString() + } + }) + ]; + + try { + return await this.updateEntry(connectionId, escapedUserDN, changes); + } catch (error) { + throw new Error(`Failed to ${enabled ? 'enable' : 'disable'} user account: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Move an object to a different OU + async moveObject(connectionId: number, objectDN: string, targetOU: string): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Extract the RDN (Relative Distinguished Name) from the DN + const dnParts = parseDN(objectDN); + if (!dnParts.length) throw new Error('Invalid distinguished name format'); + + // The first part is the RDN + const rdn = `${dnParts[0].attribute}=${escapeDN(dnParts[0].value)}`; + + // Ensure the target OU is properly escaped + const escapedTargetOU = targetOU.split(',').map(part => { + const [attr, val] = part.split('=', 2); + return `${attr}=${escapeDN(val.trim())}`; + }).join(','); + + return new Promise((resolve, reject) => { + client.modifyDN(objectDN, { + newRdn: rdn, + deleteOldRdn: true, + newSuperior: escapedTargetOU + }, (err) => { + if (err) { + reject(new Error(`Failed to move object: ${err.message}`)); + return; + } + resolve(true); + }); + }); + } + + // Check if a user is a member of a group (direct or nested) + async isUserMemberOfGroup(connectionId: number, userDN: string, groupDN: string, maxDepth = 10): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DNs are properly escaped + const escapedUserDN = buildDN(parseDN(userDN)); + const escapedGroupDN = buildDN(parseDN(groupDN)); + + // First check direct membership + try { + const groups = await this.searchGroups( + connectionId, + `(&(distinguishedName=${escapedGroupDN})(member=${escapedUserDN}))`, + ['distinguishedName'] + ); + + if (groups && groups.length > 0) { + // User is directly in the group + return true; + } + } catch (error) { + console.warn(`Error checking direct group membership: ${error instanceof Error ? error.message : String(error)}`); + } + + // If not a direct member, check nested groups + return this.checkNestedGroupMembership(connectionId, userDN, groupDN, 0, maxDepth); + } + + // Recursively check nested group membership + private async checkNestedGroupMembership( + connectionId: number, + memberDN: string, + groupDN: string, + currentDepth: number, + maxDepth: number, + visitedGroups: Set = new Set() + ): Promise { + // Prevent infinite recursion + if (currentDepth >= maxDepth) return false; + + // Prevent cycles + if (visitedGroups.has(groupDN)) return false; + visitedGroups.add(groupDN); + + // Ensure DNs are properly escaped + const escapedMemberDN = buildDN(parseDN(memberDN)); + const escapedGroupDN = buildDN(parseDN(groupDN)); + + try { + // Get all groups that the member is directly a member of + const memberGroups = await this.searchGroups( + connectionId, + `(member=${escapedMemberDN})`, + ['distinguishedName'] + ); + + // For each of these groups, check if they are members of the target group + for (const memberGroup of memberGroups) { + // Check if this group is a direct member of the target group + const isDirectMember = await this.searchGroups( + connectionId, + `(&(distinguishedName=${escapedGroupDN})(member=${memberGroup.distinguishedName}))`, + ['distinguishedName'] + ); + + if (isDirectMember && isDirectMember.length > 0) { + return true; + } + + // Recursively check if this group is a nested member of the target group + const isNestedMember = await this.checkNestedGroupMembership( + connectionId, + memberGroup.distinguishedName, + groupDN, + currentDepth + 1, + maxDepth, + visitedGroups + ); + + if (isNestedMember) { + return true; + } + } + } catch (error) { + console.warn(`Error checking nested group membership: ${error instanceof Error ? error.message : String(error)}`); + } + + return false; + } + + // Get all groups a user is a member of (direct and nested) + async getUserGroups(connectionId: number, userDN: string, maxDepth = 10): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DN is properly escaped + const escapedUserDN = buildDN(parseDN(userDN)); + + // Get direct group memberships + const directGroups = await this.searchGroups( + connectionId, + `(member=${escapedUserDN})`, + ['distinguishedName', 'cn', 'objectGUID', 'sAMAccountName'] + ); + + // Set to track all groups to avoid duplicates + const allGroupDNs = new Set(); + const allGroups: any[] = []; + + // Add direct groups to the result + for (const group of directGroups) { + if (!allGroupDNs.has(group.distinguishedName)) { + allGroupDNs.add(group.distinguishedName); + allGroups.push({ + ...group, + membershipType: 'direct' + }); + } + } + + // Get nested groups + const nestedGroups = await this.getNestedGroups( + connectionId, + directGroups.map(g => g.distinguishedName), + 1, + maxDepth, + allGroupDNs + ); + + // Add nested groups to the result + for (const group of nestedGroups) { + allGroups.push({ + ...group, + membershipType: 'nested' + }); + } + + return allGroups; + } + + // Recursively get nested groups + private async getNestedGroups( + connectionId: number, + groupDNs: string[], + currentDepth: number, + maxDepth: number, + visitedGroups: Set + ): Promise { + if (currentDepth >= maxDepth || groupDNs.length === 0) return []; + + const nestedGroups: any[] = []; + + for (const groupDN of groupDNs) { + // Ensure DN is properly escaped + const escapedGroupDN = buildDN(parseDN(groupDN)); + + // Get groups that this group is a member of + const parentGroups = await this.searchGroups( + connectionId, + `(member=${escapedGroupDN})`, + ['distinguishedName', 'cn', 'objectGUID', 'sAMAccountName'] + ); + + // Filter out groups we've already seen + const newGroups = parentGroups.filter(g => !visitedGroups.has(g.distinguishedName)); + + // Add new groups to the result and visited set + for (const group of newGroups) { + visitedGroups.add(group.distinguishedName); + nestedGroups.push(group); + } + + // Recursively get the next level of nested groups + if (newGroups.length > 0) { + const nextLevelGroups = await this.getNestedGroups( + connectionId, + newGroups.map(g => g.distinguishedName), + currentDepth + 1, + maxDepth, + visitedGroups + ); + + nestedGroups.push(...nextLevelGroups); + } + } + + return nestedGroups; + } + + // Add a member to a group + async addToGroup(connectionId: number, groupDN: string, memberDN: string): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DNs are properly escaped + const escapedGroupDN = buildDN(parseDN(groupDN)); + const escapedMemberDN = buildDN(parseDN(memberDN)); + + // First check if the member is already in the group to avoid errors + try { + const groups = await this.searchGroups( + connectionId, + `(&(distinguishedName=${escapedGroupDN})(member=${escapedMemberDN}))`, + ['distinguishedName'] + ); + + if (groups && groups.length > 0) { + // Member is already in the group + return true; + } + + // Also check for nested membership to warn about it + const isNestedMember = await this.isUserMemberOfGroup(connectionId, memberDN, groupDN); + if (isNestedMember) { + console.warn(`Member ${memberDN} is already a nested member of group ${groupDN}`); + // We still add the direct membership even if there's a nested membership + } + } catch (error) { + // Continue with the add operation even if the check fails + console.warn(`Error checking group membership: ${error instanceof Error ? error.message : String(error)}`); + } + + const changes = [ + new ldap.Change({ + operation: 'add', + modification: { + member: escapedMemberDN + } + }) + ]; + + try { + return await this.updateEntry(connectionId, escapedGroupDN, changes); + } catch (error) { + throw new Error(`Failed to add member to group: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Remove a member from a group + async removeFromGroup(connectionId: number, groupDN: string, memberDN: string): Promise { + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + // Ensure DNs are properly escaped + const escapedGroupDN = buildDN(parseDN(groupDN)); + const escapedMemberDN = buildDN(parseDN(memberDN)); + + // First check if the member is in the group to avoid errors + try { + const groups = await this.searchGroups( + connectionId, + `(&(distinguishedName=${escapedGroupDN})(member=${escapedMemberDN}))`, + ['distinguishedName'] + ); + + if (!groups || groups.length === 0) { + // Member is not directly in the group + + // Check if it's a nested member + const isNestedMember = await this.isUserMemberOfGroup(connectionId, memberDN, groupDN); + if (isNestedMember) { + throw new Error(`Cannot remove nested group membership. Member ${memberDN} is a nested member of group ${groupDN}, not a direct member.`); + } + + // Not a member at all + return true; + } + } catch (error) { + // Only continue if it's not our specific nested membership error + if (error instanceof Error && error.message.includes('Cannot remove nested group membership')) { + throw error; + } + + // Continue with the remove operation for other errors + console.warn(`Error checking group membership: ${error instanceof Error ? error.message : String(error)}`); + } + + const changes = [ + new ldap.Change({ + operation: 'delete', + modification: { + member: escapedMemberDN + } + }) + ]; + + try { + return await this.updateEntry(connectionId, escapedGroupDN, changes); + } catch (error) { + throw new Error(`Failed to remove member from group: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Bulk Operations + + // Move multiple objects to a target OU + async bulkMoveObjects(connectionId: number, objectDNs: string[], targetOU: string): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> { + const results = { + success: [] as string[], + failed: [] as { dn: string; error: string }[] + }; + + for (const dn of objectDNs) { + try { + const success = await this.moveObject(connectionId, dn, targetOU); + if (success) { + results.success.push(dn); + } else { + results.failed.push({ dn, error: 'Unknown error' }); + } + } catch (error) { + results.failed.push({ + dn, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + // Add multiple members to a group + async bulkAddToGroup(connectionId: number, groupDN: string, memberDNs: string[]): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> { + const results = { + success: [] as string[], + failed: [] as { dn: string; error: string }[] + }; + + for (const dn of memberDNs) { + try { + const success = await this.addToGroup(connectionId, groupDN, dn); + if (success) { + results.success.push(dn); + } else { + results.failed.push({ dn, error: 'Unknown error' }); + } + } catch (error) { + results.failed.push({ + dn, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + // Remove multiple members from a group + async bulkRemoveFromGroup(connectionId: number, groupDN: string, memberDNs: string[]): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> { + const results = { + success: [] as string[], + failed: [] as { dn: string; error: string }[] + }; + + for (const dn of memberDNs) { + try { + const success = await this.removeFromGroup(connectionId, groupDN, dn); + if (success) { + results.success.push(dn); + } else { + results.failed.push({ dn, error: 'Unknown error' }); + } + } catch (error) { + results.failed.push({ + dn, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + // Enable or disable multiple user accounts + async bulkSetUserAccountStatus(connectionId: number, userDNs: string[], enabled: boolean): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> { + const results = { + success: [] as string[], + failed: [] as { dn: string; error: string }[] + }; + + for (const dn of userDNs) { + try { + const success = await this.setUserAccountStatus(connectionId, dn, enabled); + if (success) { + results.success.push(dn); + } else { + results.failed.push({ dn, error: 'Unknown error' }); + } + } catch (error) { + results.failed.push({ + dn, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + // Update the same attribute for multiple objects + async bulkUpdateAttribute( + connectionId: number, + dns: string[], + attributeName: string, + attributeValue: string | string[] | null, + operation: 'add' | 'replace' | 'delete' = 'replace' + ): Promise<{ success: string[]; failed: { dn: string; error: string }[] }> { + const results = { + success: [] as string[], + failed: [] as { dn: string; error: string }[] + }; + + for (const dn of dns) { + try { + const success = await this.updateAttribute(connectionId, dn, attributeName, attributeValue, operation); + if (success) { + results.success.push(dn); + } else { + results.failed.push({ dn, error: 'Unknown error' }); + } + } catch (error) { + results.failed.push({ + dn, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + return results; + } + + // Helper method to handle LDAP errors with more detailed information + private handleLdapError(err: any): Error { + // Map common LDAP error codes to more user-friendly messages + const errorMap: Record = { + '32': 'No such object exists in the directory', + '49': 'Invalid credentials', + '50': 'Insufficient access rights', + '53': 'Unable to perform operation, directory server unwilling to process request', + '65': 'Object class violation', + '68': 'Entry already exists', + '69': 'Object class modifications prohibited', + '71': 'Affects multiple DSAs (directory server agents)', + '80': 'Other error' + }; + + const code = err.code?.toString(); + const message = errorMap[code] || err.message || 'Unknown LDAP error'; + + // Create a more detailed error + const error = new Error(`LDAP Error ${code}: ${message}`); + (error as any).ldapCode = code; + (error as any).originalError = err; + + return error; + } } export const ldapClient = new LdapClient(); diff --git a/server/routes.ts b/server/routes.ts index 9554589..899e0af 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4,13 +4,20 @@ import { setupAuth } from "./auth"; import { setupSwagger } from "./swagger"; import { storage } from "./storage"; import { db } from "./db"; -import { - apiQuerySchema, - PERMISSIONS, - moveComputerSchema, - moveUserSchema, - addToGroupSchema, +import { + apiQuerySchema, + PERMISSIONS, + moveComputerSchema, + moveUserSchema, + addToGroupSchema, removeFromGroupSchema, + resetPasswordSchema, + enableUserAccountSchema, + bulkMoveObjectsSchema, + bulkAddToGroupSchema, + bulkRemoveFromGroupSchema, + bulkEnableUserAccountsSchema, + bulkUpdateAttributeSchema, adUsers, adGroups, adOrgUnits, @@ -21,19 +28,19 @@ import { } from "@shared/schema"; import { ZodError } from "zod"; import rateLimit from "express-rate-limit"; -import { - requireAuth, - requirePermission, - requireAdmin, +import { + requireAuth, + requirePermission, + requireAdmin, initializeRBAC, checkPermission } from "./authorization"; import { ldapClient } from "./ldap"; -import { - applyFilterConditions, - generatePaginationMetadata, - parseFilter, - parsePagination +import { + applyFilterConditions, + generatePaginationMetadata, + parseFilter, + parsePagination } from "./query-parser"; import { eq, sql, count } from "drizzle-orm"; @@ -67,12 +74,12 @@ export async function registerRoutes(app: Express): Promise { // Initialize Role Based Access Control system await initializeRBAC(); - + // Authentication info and providers app.get("/api/auth/providers", (req, res) => { const ldapEnabled = process.env.LDAP_ENABLED === "true"; const oidcEnabled = process.env.OIDC_ENABLED === "true"; - + res.json({ ldap: { enabled: ldapEnabled, @@ -86,7 +93,7 @@ export async function registerRoutes(app: Express): Promise { registrationEnabled: process.env.DISABLE_REGISTRATION !== "true" }); }); - + // Apply rate limiting middleware for API routes const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -153,13 +160,13 @@ export async function registerRoutes(app: Express): Promise { app.get("/api/ldap-connections", requirePermission(PERMISSIONS.VIEW_LDAP_CONNECTIONS, { allowApiToken: true }), async (req, res, next) => { try { const connections = await storage.listLdapConnections(); - + // Hide sensitive fields like password const safeConnections = connections.map(conn => { const { password, ...safeConn } = conn; return safeConn; }); - + res.json(safeConnections); } catch (error) { next(error); @@ -218,10 +225,10 @@ export async function registerRoutes(app: Express): Promise { app.post("/api/ldap-connections", requireAdmin, async (req, res, next) => { try { const connection = await storage.createLdapConnection(req.body); - + // Hide password in response const { password, ...safeConn } = connection; - + res.status(201).json(safeConn); } catch (error) { next(error); @@ -259,16 +266,16 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated()) { return res.status(401).json({ message: "Not authenticated" }); } - + const connection = await storage.getLdapConnection(parseInt(req.params.id)); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Hide password in response const { password, ...safeConn } = connection; - + res.json(safeConn); } catch (error) { next(error); @@ -327,14 +334,14 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/ldap-connections/:id", requireAdmin, async (req, res, next) => { try { const updatedConnection = await storage.updateLdapConnection(parseInt(req.params.id), req.body); - + if (!updatedConnection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Hide password in response const { password, ...safeConn } = updatedConnection; - + res.json(safeConn); } catch (error) { next(error); @@ -366,11 +373,11 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/ldap-connections/:id", requireAdmin, async (req, res, next) => { try { const deleted = await storage.deleteLdapConnection(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "LDAP connection not found" }); } - + res.json({ success: true }); } catch (error) { next(error); @@ -383,7 +390,7 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated()) { return res.status(401).json({ message: "Not authenticated" }); } - + const tokens = await storage.listApiTokensByUserId(req.user.id); res.json(tokens); } catch (error) { @@ -397,29 +404,29 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated()) { return res.status(401).json({ message: "Not authenticated" }); } - + const token = await storage.getApiToken(parseInt(req.params.id)); - + if (!token) { return res.status(404).json({ message: "Token not found" }); } - + // Only allow users to delete their own tokens unless they're admin if (token.userId !== req.user.id) { // Get the user's role const userRole = await storage.getRole(req.user.roleId!); - + if (userRole?.name !== "admin") { return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" }); } } - + const deleted = await storage.deleteApiToken(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "Token not found" }); } - + res.json({ success: true }); } catch (error) { next(error); @@ -430,13 +437,13 @@ export async function registerRoutes(app: Express): Promise { app.get("/api/users", requireAdmin, async (req, res, next) => { try { const users = await storage.listUsers(); - + // Remove passwords from response const safeUsers = users.map(user => { const { password, ...safeUser } = user; return safeUser; }); - + res.json(safeUsers); } catch (error) { next(error); @@ -486,21 +493,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const users = await storage.listAdUsers(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adUsers) .where(eq(adUsers.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -509,16 +516,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const usersWithObjectType = users.map(user => ({ ...user, objectType: 'user' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -527,7 +534,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + // Return data with pagination metadata res.json({ data: usersWithObjectType, @@ -598,34 +605,34 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Extract user data from request const { name, samAccountName, description, email, firstName, lastName, ou, enabled = true, password } = req.body; - + if (!name || !samAccountName || !ou) { return res.status(400).json({ message: "Name, samAccountName, and OU are required" }); } - + // Create user in AD using LDAP const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Create a DN for the new user const userDN = `CN=${name},${ou}`; - + // Attributes for the new user const userAttributes = { objectClass: ['user', 'person', 'organizationalPerson', 'top'], @@ -638,30 +645,30 @@ export async function registerRoutes(app: Express): Promise { mail: email || '', userAccountControl: enabled ? '512' : '514' // 512 = enabled, 514 = disabled }; - + // Add password if provided if (password) { // Unicode password format for Active Directory const unicodePassword = Buffer.from(`"${password}"`, 'utf16le'); userAttributes.unicodePwd = unicodePassword; } - + try { const success = await ldapClient.createEntry(connectionId, userDN, userAttributes); - + if (!success) { return res.status(500).json({ message: "Failed to create AD user" }); } - + // Search for the user to get all its attributes const userResults = await ldapClient.searchUsers(connectionId, `(cn=${name})`); - + if (!userResults || userResults.length === 0) { return res.status(500).json({ message: "User created but could not retrieve details" }); } - + const userData = userResults[0]; - + // Create entry in our database const newUser = await storage.createAdUser({ connectionId, @@ -681,7 +688,7 @@ export async function registerRoutes(app: Express): Promise { managedBy: null, adProperties: userData }); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'create', @@ -690,7 +697,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.status(201).json(newUser); } catch (err) { console.error("Error creating AD user:", err); @@ -739,13 +746,13 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const user = await storage.getAdUser(parseInt(req.params.id)); - + if (!user || user.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD user not found" }); } - + // Apply property selection if specified let result = user; if (req.query.select) { @@ -758,7 +765,7 @@ export async function registerRoutes(app: Express): Promise { }); result = selectedUser as typeof user; } - + res.json(result); } catch (error) { next(error); @@ -851,7 +858,7 @@ export async function registerRoutes(app: Express): Promise { * schema: * type: object * properties: - * givenName: + * givenName: * type: string * surname: * type: string @@ -880,18 +887,18 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/connections/:connectionId/ad-users/:id", authenticateApiToken, async (req, res, next) => { try { const user = await storage.getAdUser(parseInt(req.params.id)); - + if (!user || user.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD user not found" }); } - + const updatedUser = await storage.updateAdUser(parseInt(req.params.id), req.body); res.json(updatedUser); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-users/{id}/managed-by: @@ -940,19 +947,19 @@ export async function registerRoutes(app: Express): Promise { const connectionId = parseInt(req.params.connectionId); const userId = parseInt(req.params.id); const { managerDistinguishedName } = req.body; - + // Get the connection const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get the user const user = await storage.getAdUser(userId); if (!user || user.connectionId !== connectionId) { return res.status(404).json({ message: "AD user not found" }); } - + // Connect to LDAP try { await ldapClient.connect(connection); @@ -960,14 +967,14 @@ export async function registerRoutes(app: Express): Promise { console.error("LDAP connection error:", error); return res.status(500).json({ message: "Failed to connect to LDAP server", error: error.message }); } - + try { // Set the managedBy attribute await ldapClient.setManagedBy(connectionId, user.distinguishedName, managerDistinguishedName); - + // Update the user in the database const updatedUser = await storage.updateAdUser(userId, { managedBy: managerDistinguishedName }); - + // Log the action await storage.createAuditLogEntry({ action: managerDistinguishedName ? "update_manager" : "remove_manager", @@ -980,7 +987,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + return res.status(200).json(updatedUser); } catch (error) { console.error("Error updating managedBy attribute:", error); @@ -1022,17 +1029,17 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/connections/:connectionId/ad-users/:id", authenticateApiToken, async (req, res, next) => { try { const user = await storage.getAdUser(parseInt(req.params.id)); - + if (!user || user.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD user not found" }); } - + const deleted = await storage.deleteAdUser(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "AD user not found" }); } - + res.json({ success: true }); } catch (error) { next(error); @@ -1101,18 +1108,18 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/connections/:connectionId/ad-groups/:id", authenticateApiToken, async (req, res, next) => { try { const group = await storage.getAdGroup(parseInt(req.params.id)); - + if (!group || group.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD group not found" }); } - + const updatedGroup = await storage.updateAdGroup(parseInt(req.params.id), req.body); res.json(updatedGroup); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-groups/{id}/managed-by: @@ -1161,19 +1168,19 @@ export async function registerRoutes(app: Express): Promise { const connectionId = parseInt(req.params.connectionId); const groupId = parseInt(req.params.id); const { managerDistinguishedName } = req.body; - + // Get the connection const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get the group const group = await storage.getAdGroup(groupId); if (!group || group.connectionId !== connectionId) { return res.status(404).json({ message: "AD group not found" }); } - + // Connect to LDAP try { await ldapClient.connect(connection); @@ -1181,14 +1188,14 @@ export async function registerRoutes(app: Express): Promise { console.error("LDAP connection error:", error); return res.status(500).json({ message: "Failed to connect to LDAP server", error: error.message }); } - + try { // Set the managedBy attribute await ldapClient.setManagedBy(connectionId, group.distinguishedName, managerDistinguishedName); - + // Update the group in the database const updatedGroup = await storage.updateAdGroup(groupId, { managedBy: managerDistinguishedName }); - + // Log the action await storage.createAuditLogEntry({ action: managerDistinguishedName ? "update_manager" : "remove_manager", @@ -1201,7 +1208,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + return res.status(200).json(updatedGroup); } catch (error) { console.error("Error updating managedBy attribute:", error); @@ -1243,23 +1250,23 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/connections/:connectionId/ad-groups/:id", authenticateApiToken, async (req, res, next) => { try { const group = await storage.getAdGroup(parseInt(req.params.id)); - + if (!group || group.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD group not found" }); } - + const deleted = await storage.deleteAdGroup(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "AD group not found" }); } - + res.json({ success: true }); } catch (error) { next(error); } }); - + // Similar endpoints for AD Groups /** * @swagger @@ -1306,21 +1313,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const groups = await storage.listAdGroups(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adGroups) .where(eq(adGroups.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -1329,16 +1336,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const groupsWithObjectType = groups.map(group => ({ ...group, objectType: 'group' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -1347,7 +1354,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + // Return data with pagination metadata res.json({ data: groupsWithObjectType, @@ -1357,7 +1364,7 @@ export async function registerRoutes(app: Express): Promise { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-groups: @@ -1419,7 +1426,7 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const { name, parentDN, description, groupType = 'Global', groupCategory = 'Security' } = req.body; - + // Basic validation if (!name || !parentDN) { return res.status(400).json({ message: "Group name and parent DN are required" }); @@ -1430,15 +1437,15 @@ export async function registerRoutes(app: Express): Promise { if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Create the DN for the new group const groupDN = `CN=${name},${parentDN}`; - + // Set the group type value based on type and category - const groupTypeValue = - (groupCategory === 'Security' ? 0x80000000 : 0) | + const groupTypeValue = + (groupCategory === 'Security' ? 0x80000000 : 0) | (groupType === 'Global' ? 0x2 : groupType === 'Universal' ? 0x8 : 0x4); - + // Attributes for the new group const groupAttributes = { objectClass: ['top', 'group'], @@ -1447,21 +1454,21 @@ export async function registerRoutes(app: Express): Promise { description: description || '', groupType: groupTypeValue.toString() }; - + // Create the group in AD await ldapClient.createEntry(connectionId, groupDN, groupAttributes); - + // Search for the newly created group to get its attributes const searchResults = await ldapClient.searchGroups(connectionId, `(&(objectClass=group)(cn=${name}))`, [ 'objectGUID', 'distinguishedName', 'canonicalName', 'cn', 'sAMAccountName', 'description', 'groupType' ]); - + if (!searchResults || searchResults.length === 0) { return res.status(500).json({ message: "Group created but could not retrieve details" }); } - + const adGroup = searchResults[0]; - + // Store in database const storedGroup = await storage.createAdGroup({ connectionId, @@ -1475,7 +1482,7 @@ export async function registerRoutes(app: Express): Promise { members: [], // No members initially adProperties: adGroup // Store all attributes }); - + // Add audit log await storage.createAuditLogEntry({ userId: req.user.id, @@ -1489,7 +1496,7 @@ export async function registerRoutes(app: Express): Promise { groupType: groupCategory + ' ' + groupType } }); - + res.status(201).json(storedGroup); } catch (err) { console.error("Error creating AD group:", err); @@ -1554,18 +1561,18 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/connections/:connectionId/ad-org-units/:id", authenticateApiToken, async (req, res, next) => { try { const orgUnit = await storage.getAdOrgUnit(parseInt(req.params.id)); - + if (!orgUnit || orgUnit.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD organizational unit not found" }); } - + const updatedOrgUnit = await storage.updateAdOrgUnit(parseInt(req.params.id), req.body); res.json(updatedOrgUnit); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-org-units/{id}/managed-by: @@ -1614,19 +1621,19 @@ export async function registerRoutes(app: Express): Promise { const connectionId = parseInt(req.params.connectionId); const ouId = parseInt(req.params.id); const { managerDistinguishedName } = req.body; - + // Get the connection const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get the OU const ou = await storage.getAdOrgUnit(ouId); if (!ou || ou.connectionId !== connectionId) { return res.status(404).json({ message: "AD organizational unit not found" }); } - + // Connect to LDAP try { await ldapClient.connect(connection); @@ -1634,14 +1641,14 @@ export async function registerRoutes(app: Express): Promise { console.error("LDAP connection error:", error); return res.status(500).json({ message: "Failed to connect to LDAP server", error: error.message }); } - + try { // Set the managedBy attribute await ldapClient.setManagedBy(connectionId, ou.distinguishedName, managerDistinguishedName); - + // Update the OU in the database const updatedOU = await storage.updateAdOrgUnit(ouId, { managedBy: managerDistinguishedName }); - + // Log the action await storage.createAuditLogEntry({ action: managerDistinguishedName ? "update_manager" : "remove_manager", @@ -1654,7 +1661,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + return res.status(200).json(updatedOU); } catch (error) { console.error("Error updating managedBy attribute:", error); @@ -1696,23 +1703,23 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/connections/:connectionId/ad-org-units/:id", authenticateApiToken, async (req, res, next) => { try { const orgUnit = await storage.getAdOrgUnit(parseInt(req.params.id)); - + if (!orgUnit || orgUnit.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD organizational unit not found" }); } - + const deleted = await storage.deleteAdOrgUnit(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "AD organizational unit not found" }); } - + res.json({ success: true }); } catch (error) { next(error); } }); - + // Organizational Units endpoints /** * @swagger @@ -1759,21 +1766,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const orgUnits = await storage.listAdOrgUnits(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adOrgUnits) .where(eq(adOrgUnits.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -1782,16 +1789,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const orgUnitsWithObjectType = orgUnits.map(ou => ({ ...ou, objectType: 'organizationalUnit' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -1800,7 +1807,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + // Return data with pagination metadata res.json({ data: orgUnitsWithObjectType, @@ -1871,18 +1878,18 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/connections/:connectionId/ad-computers/:id", authenticateApiToken, async (req, res, next) => { try { const computer = await storage.getAdComputer(parseInt(req.params.id)); - + if (!computer || computer.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD computer not found" }); } - + const updatedComputer = await storage.updateAdComputer(parseInt(req.params.id), req.body); res.json(updatedComputer); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-computers/{id}/managed-by: @@ -1931,19 +1938,19 @@ export async function registerRoutes(app: Express): Promise { const connectionId = parseInt(req.params.connectionId); const computerId = parseInt(req.params.id); const { managerDistinguishedName } = req.body; - + // Get the connection const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get the computer const computer = await storage.getAdComputer(computerId); if (!computer || computer.connectionId !== connectionId) { return res.status(404).json({ message: "AD computer not found" }); } - + // Connect to LDAP try { await ldapClient.connect(connection); @@ -1951,14 +1958,14 @@ export async function registerRoutes(app: Express): Promise { console.error("LDAP connection error:", error); return res.status(500).json({ message: "Failed to connect to LDAP server", error: error.message }); } - + try { // Set the managedBy attribute await ldapClient.setManagedBy(connectionId, computer.distinguishedName, managerDistinguishedName); - + // Update the computer in the database const updatedComputer = await storage.updateAdComputer(computerId, { managedBy: managerDistinguishedName }); - + // Log the action await storage.createAuditLogEntry({ action: managerDistinguishedName ? "update_manager" : "remove_manager", @@ -1971,7 +1978,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + return res.status(200).json(updatedComputer); } catch (error) { console.error("Error updating managedBy attribute:", error); @@ -2010,7 +2017,7 @@ export async function registerRoutes(app: Express): Promise { * 404: * $ref: '#/components/responses/NotFoundError' */ - + /** * @swagger * /api/connections/{connectionId}/ad-ous: @@ -2057,7 +2064,7 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const { name, parentDN, description } = req.body; - + // Basic validation if (!name || !parentDN) { return res.status(400).json({ message: "OU name and parent DN are required" }); @@ -2068,31 +2075,31 @@ export async function registerRoutes(app: Express): Promise { if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Create the DN for the new OU const ouDN = `OU=${name},${parentDN}`; - + // Attributes for the new organizational unit const ouAttributes = { objectClass: ['top', 'organizationalUnit'], ou: name, description: description || '' }; - + // Create the OU in AD await ldapClient.createEntry(connectionId, ouDN, ouAttributes); - + // Search for the newly created OU to get its attributes const searchResults = await ldapClient.searchOUs(connectionId, `(&(objectClass=organizationalUnit)(ou=${name}))`, [ 'objectGUID', 'distinguishedName', 'canonicalName', 'ou', 'description', 'name' ]); - + if (!searchResults || searchResults.length === 0) { return res.status(500).json({ message: "OU created but could not retrieve details" }); } - + const adOU = searchResults[0]; - + // Store in database const storedOU = await storage.createAdOrgUnit({ connectionId, @@ -2103,7 +2110,7 @@ export async function registerRoutes(app: Express): Promise { description: adOU.description, adProperties: adOU // Store all attributes }); - + // Add audit log await storage.createAuditLogEntry({ userId: req.user.id, @@ -2117,7 +2124,7 @@ export async function registerRoutes(app: Express): Promise { description: description } }); - + res.status(201).json(storedOU); } catch (err) { console.error("Error creating AD organizational unit:", err); @@ -2130,23 +2137,23 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/connections/:connectionId/ad-computers/:id", authenticateApiToken, async (req, res, next) => { try { const computer = await storage.getAdComputer(parseInt(req.params.id)); - + if (!computer || computer.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD computer not found" }); } - + const deleted = await storage.deleteAdComputer(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "AD computer not found" }); } - + res.json({ success: true }); } catch (error) { next(error); } }); - + // Computers endpoints /** * @swagger @@ -2193,21 +2200,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const computers = await storage.listAdComputers(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adComputers) .where(eq(adComputers.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -2216,16 +2223,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const computersWithObjectType = computers.map(computer => ({ ...computer, objectType: 'computer' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -2234,7 +2241,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + // Return data with pagination metadata res.json({ data: computersWithObjectType, @@ -2244,7 +2251,7 @@ export async function registerRoutes(app: Express): Promise { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-computers: @@ -2308,16 +2315,16 @@ export async function registerRoutes(app: Express): Promise { app.post("/api/connections/:connectionId/ad-computers", authenticateApiToken, requirePermission(PERMISSIONS.CREATE_AD_COMPUTERS), async (req: Request, res, next) => { try { const connectionId = parseInt(req.params.connectionId); - const { - name, - parentDN, - description, - dnsHostName, - operatingSystem, - operatingSystemVersion, - enabled = true + const { + name, + parentDN, + description, + dnsHostName, + operatingSystem, + operatingSystemVersion, + enabled = true } = req.body; - + // Basic validation if (!name || !parentDN) { return res.status(400).json({ message: "Computer name and parent DN are required" }); @@ -2328,21 +2335,21 @@ export async function registerRoutes(app: Express): Promise { if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Create the DN for the new computer const computerDN = `CN=${name},${parentDN}`; - + // The sAMAccountName needs to end with $ const sAMAccountName = name.endsWith('$') ? name : `${name}$`; - + // Create the userAccountControl value // 4096 = WORKSTATION_TRUST_ACCOUNT // 2 = ACCOUNTDISABLE (if not enabled) const userAccountControl = enabled ? 4096 : 4098; - + // Set the dnsHostName if not provided const actualDnsHostName = dnsHostName || `${name}.${connection.domain}`; - + // Attributes for the new computer const computerAttributes: { objectClass: string[]; @@ -2361,31 +2368,31 @@ export async function registerRoutes(app: Express): Promise { description: description || '', dNSHostName: actualDnsHostName }; - + // Add operating system information if provided if (operatingSystem) { computerAttributes.operatingSystem = operatingSystem; } - + if (operatingSystemVersion) { computerAttributes.operatingSystemVersion = operatingSystemVersion; } - + // Create the computer in AD await ldapClient.createEntry(connectionId, computerDN, computerAttributes); - + // Search for the newly created computer to get its attributes const searchResults = await ldapClient.searchComputers(connectionId, `(&(objectClass=computer)(cn=${name}))`, [ 'objectGUID', 'distinguishedName', 'canonicalName', 'cn', 'sAMAccountName', 'description', 'dNSHostName', 'operatingSystem', 'operatingSystemVersion', 'userAccountControl' ]); - + if (!searchResults || searchResults.length === 0) { return res.status(500).json({ message: "Computer created but could not retrieve details" }); } - + const adComputer = searchResults[0]; - + // Store in database const storedComputer = await storage.createAdComputer({ connectionId, @@ -2401,7 +2408,7 @@ export async function registerRoutes(app: Express): Promise { enabled: (parseInt(adComputer.userAccountControl) & 2) === 0, // Check if ACCOUNTDISABLE flag is not set adProperties: adComputer // Store all attributes }); - + // Add audit log await storage.createAuditLogEntry({ userId: req.user.id, @@ -2414,7 +2421,7 @@ export async function registerRoutes(app: Express): Promise { name: name } }); - + res.status(201).json(storedComputer); } catch (err) { console.error("Error creating AD computer:", err); @@ -2481,11 +2488,11 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/connections/:connectionId/ad-domains/:id", authenticateApiToken, async (req, res, next) => { try { const domain = await storage.getAdDomain(parseInt(req.params.id)); - + if (!domain || domain.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD domain not found" }); } - + const updatedDomain = await storage.updateAdDomain(parseInt(req.params.id), req.body); res.json(updatedDomain); } catch (error) { @@ -2524,23 +2531,23 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/connections/:connectionId/ad-domains/:id", authenticateApiToken, async (req, res, next) => { try { const domain = await storage.getAdDomain(parseInt(req.params.id)); - + if (!domain || domain.connectionId !== parseInt(req.params.connectionId)) { return res.status(404).json({ message: "AD domain not found" }); } - + const deleted = await storage.deleteAdDomain(parseInt(req.params.id)); - + if (!deleted) { return res.status(404).json({ message: "AD domain not found" }); } - + res.json({ success: true }); } catch (error) { next(error); } }); - + // Domains endpoints /** * @swagger @@ -2582,7 +2589,7 @@ export async function registerRoutes(app: Express): Promise { * 404: * $ref: '#/components/responses/NotFoundError' */ - + /** * @swagger * /api/connections/{connectionId}/ad-domains: @@ -2638,21 +2645,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const domains = await storage.listAdDomains(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adDomains) .where(eq(adDomains.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -2661,16 +2668,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const domainsWithObjectType = domains.map(domain => ({ ...domain, objectType: 'domain' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -2679,7 +2686,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + // Return data with pagination metadata res.json({ data: domainsWithObjectType, @@ -2689,7 +2696,7 @@ export async function registerRoutes(app: Express): Promise { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ad-domains/{objectGUID}: @@ -2739,7 +2746,7 @@ export async function registerRoutes(app: Express): Promise { * 500: * description: Server error */ - + /** * @swagger * /api/connections/{connectionId}/ad-domains/{objectGUID}: @@ -2774,7 +2781,7 @@ export async function registerRoutes(app: Express): Promise { * 500: * description: Server error */ - + // Domain PATCH endpoint app.patch("/api/connections/:connectionId/ad-domains/:objectGUID", authenticateApiToken, async (req, res, next) => { try { @@ -2783,51 +2790,51 @@ export async function registerRoutes(app: Express): Promise { if (!hasPermission) { return res.status(403).json({ message: "Not authorized to update domains" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get domain to update const domain = await storage.getAdDomain(connectionId, objectGUID); if (!domain) { return res.status(404).json({ message: "Domain not found" }); } - + try { // Connect to LDAP server const ldapClient = await connectToLdap(connection); - + // Create modification object based on the request body const changes = {}; - + if (req.body.description !== undefined) { changes.description = req.body.description; } - + if (req.body.netBIOSName !== undefined) { changes.netBIOSName = req.body.netBIOSName; } - + // Skip modification if no changes if (Object.keys(changes).length === 0) { return res.status(400).json({ message: "No valid attributes provided for update" }); } - + // Modify domain in LDAP await ldapClient.modify(domain.distinguishedName, changes); - + // Update domain in database const updatedDomain = await storage.updateAdDomain(connectionId, objectGUID, { ...req.body, adProperties: { ...domain.adProperties, ...changes } }); - + // Add audit log await storage.createAuditLogEntry({ action: "update_domain", @@ -2840,7 +2847,7 @@ export async function registerRoutes(app: Express): Promise { changes: changes } }); - + res.json({ ...updatedDomain, objectType: 'domain' @@ -2862,44 +2869,44 @@ export async function registerRoutes(app: Express): Promise { if (!hasPermission) { return res.status(403).json({ message: "Not authorized to delete domains" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Get domain to delete const domain = await storage.getAdDomain(connectionId, objectGUID); if (!domain) { return res.status(404).json({ message: "Domain not found" }); } - + try { // Connect to LDAP server const ldapClient = await connectToLdap(connection); - + // First check if there are any children under this domain const searchResults = await ldapClient.search(domain.distinguishedName, { scope: 'one', filter: '(objectClass=*)' }); - + if (searchResults.searchEntries && searchResults.searchEntries.length > 0) { - return res.status(409).json({ - message: "Cannot delete domain with child objects. Remove all child objects first." + return res.status(409).json({ + message: "Cannot delete domain with child objects. Remove all child objects first." }); } - + // Delete domain from LDAP await ldapClient.del(domain.distinguishedName); - + // Delete domain from database await storage.deleteAdDomain(connectionId, objectGUID); - + // Add audit log await storage.createAuditLogEntry({ action: "delete_domain", @@ -2911,7 +2918,7 @@ export async function registerRoutes(app: Express): Promise { distinguishedName: domain.distinguishedName } }); - + res.json({ success: true }); } catch (err) { console.error("Error deleting domain:", err); @@ -2929,52 +2936,52 @@ export async function registerRoutes(app: Express): Promise { if (!hasPermission) { return res.status(403).json({ message: "Not authorized to create domains" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Validate required fields if (!req.body.name || !req.body.distinguishedName) { return res.status(400).json({ message: "Name and distinguishedName are required" }); } - + try { // Connect to LDAP server const ldapClient = await connectToLdap(connection); - + // Create domain attributes const domainAttributes = { objectClass: ['domain'], cn: req.body.name, description: req.body.description || `Domain ${req.body.name}` }; - + if (req.body.netBIOSName) { domainAttributes.netBIOSName = req.body.netBIOSName; } - + // Add domain to LDAP await ldapClient.add(req.body.distinguishedName, domainAttributes); - + // Get the created domain's GUID and details const searchResults = await ldapClient.search(req.body.distinguishedName, { scope: 'base', attributes: ['objectGUID', 'distinguishedName', 'cn', 'description', 'name', 'netBIOSName'] }); - + if (!searchResults.searchEntries || searchResults.searchEntries.length === 0) { return res.status(500).json({ message: "Domain was created but could not be retrieved" }); } - + const domainEntry = searchResults.searchEntries[0]; - + // Format objectGUID const objectGUID = Buffer.from(domainEntry.objectGUID).toString('hex'); - + // Create domain record in database const domainData = { name: req.body.name, @@ -2986,9 +2993,9 @@ export async function registerRoutes(app: Express): Promise { forestName: req.body.forestName, adProperties: domainEntry }; - + const createdDomain = await storage.createAdDomain(domainData); - + // Add audit log await storage.createAuditLogEntry({ action: "create_domain", @@ -3000,7 +3007,7 @@ export async function registerRoutes(app: Express): Promise { distinguishedName: req.body.distinguishedName } }); - + res.status(201).json({ ...createdDomain, objectType: 'domain' @@ -3011,7 +3018,7 @@ export async function registerRoutes(app: Express): Promise { if (err.message && err.message.includes('entryAlreadyExists')) { return res.status(409).json({ message: "Domain already exists" }); } - + return res.status(500).json({ message: `Error creating domain: ${err.message}` }); } } catch (error) { @@ -3057,24 +3064,24 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const objectClass = req.query.objectClass as string; - + if (!objectClass) { return res.status(400).json({ message: "objectClass parameter is required" }); } - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const attributes = await storage.getLdapAttributes(connectionId, objectClass); res.json(attributes); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ldap-attributes: @@ -3129,24 +3136,24 @@ export async function registerRoutes(app: Express): Promise { app.post("/api/connections/:connectionId/ldap-attributes", requireAdmin, async (req, res, next) => { try { const connectionId = parseInt(req.params.connectionId); - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const attribute = await storage.createLdapAttribute({ ...req.body, connectionId }); - + res.status(201).json(attribute); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-attributes/{id}: @@ -3196,17 +3203,17 @@ export async function registerRoutes(app: Express): Promise { try { const id = parseInt(req.params.id); const updatedAttribute = await storage.updateLdapAttribute(id, req.body); - + if (!updatedAttribute) { return res.status(404).json({ message: "LDAP attribute not found" }); } - + res.json(updatedAttribute); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-attributes/{id}: @@ -3233,17 +3240,17 @@ export async function registerRoutes(app: Express): Promise { try { const id = parseInt(req.params.id); const deleted = await storage.deleteLdapAttribute(id); - + if (!deleted) { return res.status(404).json({ message: "LDAP attribute not found" }); } - + res.json({ success: true }); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ldap-filters: @@ -3321,17 +3328,17 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const { ldapFilter, objectClass } = req.body; - + if (!ldapFilter || !objectClass) { return res.status(400).json({ message: "ldapFilter and objectClass are required" }); } - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Test the filter const results = await storage.testLdapFilter(connectionId, ldapFilter, objectClass); res.json(results); @@ -3343,20 +3350,20 @@ export async function registerRoutes(app: Express): Promise { app.get("/api/connections/:connectionId/ldap-filters", requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const connectionId = parseInt(req.params.connectionId); - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const filters = await storage.listLdapFilters(connectionId); res.json(filters); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/ldap-filters: @@ -3409,26 +3416,26 @@ export async function registerRoutes(app: Express): Promise { app.post("/api/connections/:connectionId/ldap-filters", requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const connectionId = parseInt(req.params.connectionId); - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const filter = await storage.createLdapFilter({ ...req.body, connectionId, createdBy: req.user.id, modifiedBy: req.user.id }); - + res.status(201).json(filter); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-filters/{id}: @@ -3459,17 +3466,17 @@ export async function registerRoutes(app: Express): Promise { try { const id = parseInt(req.params.id); const filter = await storage.getLdapFilter(id); - + if (!filter) { return res.status(404).json({ message: "LDAP filter not found" }); } - + res.json(filter); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-filters/{id}: @@ -3518,25 +3525,25 @@ export async function registerRoutes(app: Express): Promise { app.put("/api/ldap-filters/:id", requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const id = parseInt(req.params.id); - + // Check if the filter exists const existingFilter = await storage.getLdapFilter(id); if (!existingFilter) { return res.status(404).json({ message: "LDAP filter not found" }); } - + // Update with the current user as modifier const updatedFilter = await storage.updateLdapFilter(id, { ...req.body, modifiedBy: req.user.id }); - + res.json(updatedFilter); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-filters/{id}: @@ -3562,13 +3569,13 @@ export async function registerRoutes(app: Express): Promise { app.delete("/api/ldap-filters/:id", requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const id = parseInt(req.params.id); - + // Check if the filter exists and if the user is allowed to delete it const filter = await storage.getLdapFilter(id); if (!filter) { return res.status(404).json({ message: "LDAP filter not found" }); } - + // Only allow the creator or admins to delete if (filter.createdBy !== req.user.id) { const userRole = await storage.getRole(req.user.roleId!); @@ -3576,14 +3583,14 @@ export async function registerRoutes(app: Express): Promise { return res.status(403).json({ message: "You are not authorized to delete this filter" }); } } - + const deleted = await storage.deleteLdapFilter(id); res.json({ success: deleted }); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-filters/{filterId}/revisions: @@ -3615,20 +3622,20 @@ export async function registerRoutes(app: Express): Promise { app.get("/api/ldap-filters/:filterId/revisions", requireAuth, async (req: Request, res: Response, next: NextFunction) => { try { const filterId = parseInt(req.params.filterId); - + // Check if the filter exists const filter = await storage.getLdapFilter(filterId); if (!filter) { return res.status(404).json({ message: "LDAP filter not found" }); } - + const revisions = await storage.getLdapFilterRevisions(filterId); res.json(revisions); } catch (error) { next(error); } }); - + /** * @swagger * /api/ldap-filters/{filterId}/revert/{revisionId}: @@ -3664,13 +3671,13 @@ export async function registerRoutes(app: Express): Promise { try { const filterId = parseInt(req.params.filterId); const revisionId = parseInt(req.params.revisionId); - + // Check if the filter exists const filter = await storage.getLdapFilter(filterId); if (!filter) { return res.status(404).json({ message: "LDAP filter not found" }); } - + // Check if the user has permission to modify this filter if (filter.createdBy !== req.user.id) { const userRole = await storage.getRole(req.user.roleId!); @@ -3678,23 +3685,23 @@ export async function registerRoutes(app: Express): Promise { return res.status(403).json({ message: "You are not authorized to modify this filter" }); } } - + // First update the modifiedBy to the current user await storage.updateLdapFilter(filterId, { modifiedBy: req.user.id }); - + // Then perform the revert const revertedFilter = await storage.revertLdapFilterToRevision(filterId, revisionId); - + if (!revertedFilter) { return res.status(404).json({ message: "Failed to revert filter or revision not found" }); } - + res.json(revertedFilter); } catch (error) { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/test-ldap-filter: @@ -3742,17 +3749,17 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const { ldapFilter, objectClass } = req.body; - + if (!ldapFilter || !objectClass) { return res.status(400).json({ message: "ldapFilter and objectClass are required" }); } - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const results = await storage.testLdapFilter(connectionId, ldapFilter, objectClass); res.json(results); } catch (error) { @@ -3812,32 +3819,51 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + try { const data = moveComputerSchema.parse(req.body); - - // This would call a method in the LDAP client to move the computer - // For now we'll return a mock success response - // In a real implementation, this would interact with the Active Directory - + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the computer by GUID to get the distinguishedName + const computer = await storage.getAdComputerByObjectGUID(connectionId, data.computerObjectGUID); + if (!computer) { + return res.status(404).json({ message: "Computer not found" }); + } + + // Move the computer to the new OU + const success = await ldapClient.moveObject( + connectionId, + computer.distinguishedName, + data.targetOUDistinguishedName + ); + + if (!success) { + return res.status(500).json({ message: "Failed to move computer" }); + } + // Record the action in the audit log const auditEntry = { action: "MOVE_COMPUTER", targetId: data.computerObjectGUID, details: { - targetOU: data.targetOUDistinguishedName + targetOU: data.targetOUDistinguishedName, + sourceDN: computer.distinguishedName }, - userId: req.user?.id || null, + userId: req.user?.id ?? null, connectionId: connectionId }; - + // Save the audit entry to storage await storage.createAuditLogEntry(auditEntry); - + res.json({ success: true, message: `Computer with GUID ${data.computerObjectGUID} moved to ${data.targetOUDistinguishedName}` @@ -3905,32 +3931,51 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + try { const data = moveUserSchema.parse(req.body); - - // This would call a method in the LDAP client to move the user - // For now we'll return a mock success response - // In a real implementation, this would interact with the Active Directory - + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const users = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!users) { + return res.status(404).json({ message: "User not found" }); + } + + // Move the user to the new OU + const success = await ldapClient.moveObject( + connectionId, + users.distinguishedName, + data.targetOUDistinguishedName + ); + + if (!success) { + return res.status(500).json({ message: "Failed to move user" }); + } + // Record the action in the audit log const auditEntry = { action: "MOVE_USER", targetId: data.userObjectGUID, details: { - targetOU: data.targetOUDistinguishedName + targetOU: data.targetOUDistinguishedName, + sourceDN: users.distinguishedName }, userId: req.user?.id || null, connectionId: connectionId }; - + // Save the audit entry to storage await storage.createAuditLogEntry(auditEntry); - + res.json({ success: true, message: `User with GUID ${data.userObjectGUID} moved to ${data.targetOUDistinguishedName}` @@ -4002,33 +4047,71 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + try { const data = addToGroupSchema.parse(req.body); - - // This would call a method in the LDAP client to add the object to the group - // For now we'll return a mock success response - // In a real implementation, this would interact with the Active Directory - + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the group by GUID to get the distinguishedName + const group = await storage.getAdGroupByObjectGUID(connectionId, data.groupObjectGUID); + if (!group) { + return res.status(404).json({ message: "Group not found" }); + } + + // Find the object by GUID to get the distinguishedName + let objectDN = ''; + if (data.objectType === 'user') { + const user = await storage.getAdUserByObjectGUID(connectionId, data.objectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + objectDN = user.distinguishedName; + } else if (data.objectType === 'computer') { + const computer = await storage.getAdComputerByObjectGUID(connectionId, data.objectGUID); + if (!computer) { + return res.status(404).json({ message: "Computer not found" }); + } + objectDN = computer.distinguishedName; + } else { + return res.status(400).json({ message: "Invalid object type" }); + } + + // Add the object to the group + const success = await ldapClient.addToGroup( + connectionId, + group.distinguishedName, + objectDN + ); + + if (!success) { + return res.status(500).json({ message: `Failed to add ${data.objectType} to group` }); + } + // Record the action in the audit log const auditEntry = { action: "ADD_TO_GROUP", targetId: data.objectGUID, details: { groupGUID: data.groupObjectGUID, - objectType: data.objectType + objectType: data.objectType, + objectDN: objectDN, + groupDN: group.distinguishedName }, - userId: req.user?.id || null, + userId: req.user?.id ?? null, connectionId: connectionId }; - + // Save the audit entry to storage await storage.createAuditLogEntry(auditEntry); - + res.json({ success: true, message: `${data.objectType} with GUID ${data.objectGUID} added to group with GUID ${data.groupObjectGUID}` @@ -4136,21 +4219,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const sites = await storage.listAdSites(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adSites) .where(eq(adSites.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -4159,16 +4242,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const sitesWithObjectType = sites.map(site => ({ ...site, objectType: 'site' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -4177,7 +4260,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + res.json({ data: sitesWithObjectType, metadata: paginationMetadata @@ -4224,22 +4307,22 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); - + if (!site) { return res.status(404).json({ message: "AD site not found" }); } - + // Add objectType to the result const siteWithObjectType = { ...site, objectType: 'site' }; - + res.json(siteWithObjectType); } catch (error) { next(error); @@ -4291,32 +4374,32 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // First create the site in AD const siteName = req.body.name; const description = req.body.description || ''; const location = req.body.location || ''; - + // Create site in AD using LDAP const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Create a CN for the new site const siteDN = `CN=${siteName},CN=Sites,CN=Configuration,${ldapClient.getDomainDN(connection)}`; - + // Attributes for the new site const siteAttributes = { objectClass: ['site'], @@ -4324,23 +4407,23 @@ export async function registerRoutes(app: Express): Promise { description: description, location: location }; - + try { const success = await ldapClient.createEntry(connectionId, siteDN, siteAttributes); - + if (!success) { return res.status(500).json({ message: "Failed to create AD site" }); } - + // Search for the site to get all its attributes const siteResults = await ldapClient.searchSites(connectionId, `(cn=${siteName})`); - + if (!siteResults || siteResults.length === 0) { return res.status(500).json({ message: "Site created but could not retrieve details" }); } - + const siteData = siteResults[0]; - + // Create entry in our database const newSite = await storage.createAdSite({ connectionId, @@ -4354,7 +4437,7 @@ export async function registerRoutes(app: Express): Promise { managedBy: null, adProperties: siteData }); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'create', @@ -4363,7 +4446,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.status(201).json(newSite); } catch (err) { console.error("Error creating AD site:", err); @@ -4424,36 +4507,36 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); - + if (!site) { return res.status(404).json({ message: "AD site not found" }); } - + const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Connect to LDAP if needed const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Prepare changes const changes = []; const updateData: Partial = {}; - + if (req.body.description !== undefined) { changes.push({ operation: 'replace', @@ -4463,7 +4546,7 @@ export async function registerRoutes(app: Express): Promise { }); updateData.description = req.body.description; } - + if (req.body.location !== undefined) { changes.push({ operation: 'replace', @@ -4473,24 +4556,24 @@ export async function registerRoutes(app: Express): Promise { }); updateData.location = req.body.location; } - + // Handle managedBy separately if (req.body.managedBy !== undefined) { // Set the managedBy attribute const success = await ldapClient.setManagedBy(connectionId, site.dn, req.body.managedBy); - + if (!success) { return res.status(500).json({ message: "Failed to update managedBy attribute" }); } - + updateData.managedBy = req.body.managedBy; } - + // Apply changes to LDAP if there are any attribute changes if (changes.length > 0) { try { const success = await ldapClient.updateEntry(connectionId, site.dn, changes); - + if (!success) { return res.status(500).json({ message: "Failed to update AD site" }); } @@ -4499,10 +4582,10 @@ export async function registerRoutes(app: Express): Promise { return res.status(500).json({ message: `Error updating AD site: ${err.message}` }); } } - + // Update our database record const updatedSite = await storage.updateAdSiteByObjectGUID(connectionId, objectGUID, updateData); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'update', @@ -4511,7 +4594,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.json(updatedSite); } catch (error) { next(error); @@ -4558,43 +4641,43 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); - + if (!site) { return res.status(404).json({ message: "AD site not found" }); } - + const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Connect to LDAP if needed const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Delete from LDAP try { const success = await ldapClient.deleteEntry(connectionId, site.dn); - + if (!success) { return res.status(500).json({ message: "Failed to delete AD site" }); } - + // Delete from our database await storage.deleteAdSite(site.id); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'delete', @@ -4603,7 +4686,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.json({ success: true }); } catch (err) { console.error("Error deleting AD site:", err); @@ -4660,21 +4743,21 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const query = parseQueryParams(req); const subnets = await storage.listAdSubnets(connectionId, query); - + // Count total records for pagination metadata const countQuery = db.select({ count: sql`count(*)` }).from(adSubnets) .where(eq(adSubnets.connectionId, connectionId)); - + // Apply filters if present if (query && query.filter) { const conditions = parseFilter(query.filter); @@ -4683,16 +4766,16 @@ export async function registerRoutes(app: Express): Promise { countQuery.where(whereClause); } } - + const [countResult] = await countQuery; const totalRecords = Number(countResult?.count || 0); - + // Add objectType to each result const subnetsWithObjectType = subnets.map(subnet => ({ ...subnet, objectType: 'subnet' })); - + // Generate pagination metadata const { limit, offset } = parsePagination(query?.top, query?.skip); const paginationMetadata = generatePaginationMetadata( @@ -4701,7 +4784,7 @@ export async function registerRoutes(app: Express): Promise { offset, `${req.protocol}://${req.get('host')}${req.originalUrl}` ); - + res.json({ data: subnetsWithObjectType, metadata: paginationMetadata @@ -4748,22 +4831,22 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); - + if (!subnet) { return res.status(404).json({ message: "AD subnet not found" }); } - + // Add objectType to the result const subnetWithObjectType = { ...subnet, objectType: 'subnet' }; - + res.json(subnetWithObjectType); } catch (error) { next(error); @@ -4822,38 +4905,38 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // First create the subnet in AD const subnetName = req.body.name; const description = req.body.description || ''; const location = req.body.location || ''; const siteObject = req.body.siteObject || ''; const cidr = req.body.cidr || ''; - + if (!subnetName) { return res.status(400).json({ message: "Subnet name is required" }); } - + // Create subnet in AD using LDAP const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Create a CN for the new subnet const subnetDN = `CN=${subnetName},CN=Subnets,CN=Sites,CN=Configuration,${ldapClient.getDomainDN(connection)}`; - + // Attributes for the new subnet const subnetAttributes = { objectClass: ['subnet'], @@ -4861,28 +4944,28 @@ export async function registerRoutes(app: Express): Promise { description: description, location: location }; - + // Add siteObject if provided if (siteObject) { subnetAttributes.siteObject = siteObject; } - + try { const success = await ldapClient.createEntry(connectionId, subnetDN, subnetAttributes); - + if (!success) { return res.status(500).json({ message: "Failed to create AD subnet" }); } - + // Search for the subnet to get all its attributes const subnetResults = await ldapClient.searchSubnets(connectionId, `(cn=${subnetName})`); - + if (!subnetResults || subnetResults.length === 0) { return res.status(500).json({ message: "Subnet created but could not retrieve details" }); } - + const subnetData = subnetResults[0]; - + // Create entry in our database const newSubnet = await storage.createAdSubnet({ connectionId, @@ -4898,7 +4981,7 @@ export async function registerRoutes(app: Express): Promise { managedBy: null, adProperties: subnetData }); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'create', @@ -4907,7 +4990,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.status(201).json(newSubnet); } catch (err) { console.error("Error creating AD subnet:", err); @@ -4974,36 +5057,36 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); - + if (!subnet) { return res.status(404).json({ message: "AD subnet not found" }); } - + const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Connect to LDAP if needed const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Prepare changes const changes = []; const updateData: Partial = {}; - + if (req.body.description !== undefined) { changes.push({ operation: 'replace', @@ -5013,7 +5096,7 @@ export async function registerRoutes(app: Express): Promise { }); updateData.description = req.body.description; } - + if (req.body.location !== undefined) { changes.push({ operation: 'replace', @@ -5023,7 +5106,7 @@ export async function registerRoutes(app: Express): Promise { }); updateData.location = req.body.location; } - + if (req.body.siteObject !== undefined) { changes.push({ operation: 'replace', @@ -5033,28 +5116,28 @@ export async function registerRoutes(app: Express): Promise { }); updateData.siteObject = req.body.siteObject; } - + if (req.body.cidr !== undefined) { updateData.cidr = req.body.cidr; } - + // Handle managedBy separately if (req.body.managedBy !== undefined) { // Set the managedBy attribute const success = await ldapClient.setManagedBy(connectionId, subnet.dn, req.body.managedBy); - + if (!success) { return res.status(500).json({ message: "Failed to update managedBy attribute" }); } - + updateData.managedBy = req.body.managedBy; } - + // Apply changes to LDAP if there are any attribute changes if (changes.length > 0) { try { const success = await ldapClient.updateEntry(connectionId, subnet.dn, changes); - + if (!success) { return res.status(500).json({ message: "Failed to update AD subnet" }); } @@ -5063,10 +5146,10 @@ export async function registerRoutes(app: Express): Promise { return res.status(500).json({ message: `Error updating AD subnet: ${err.message}` }); } } - + // Update our database record const updatedSubnet = await storage.updateAdSubnetByObjectGUID(connectionId, objectGUID, updateData); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'update', @@ -5075,7 +5158,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.json(updatedSubnet); } catch (error) { next(error); @@ -5122,43 +5205,43 @@ export async function registerRoutes(app: Express): Promise { if (!req.isAuthenticated() && !req.headers.authorization) { return res.status(401).json({ message: "Authentication required" }); } - + const connectionId = parseInt(req.params.connectionId); const objectGUID = req.params.objectGUID; - + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); - + if (!subnet) { return res.status(404).json({ message: "AD subnet not found" }); } - + const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + // Connect to LDAP if needed const client = ldapClient.getClient(connectionId); - + if (!client) { const connected = await ldapClient.connect(connection); if (!connected) { return res.status(500).json({ message: "Failed to connect to LDAP server" }); } } - + // Delete from LDAP try { const success = await ldapClient.deleteEntry(connectionId, subnet.dn); - + if (!success) { return res.status(500).json({ message: "Failed to delete AD subnet" }); } - + // Delete from our database await storage.deleteAdSubnet(subnet.id); - + // Create audit log entry await storage.createAuditLogEntry({ action: 'delete', @@ -5167,7 +5250,7 @@ export async function registerRoutes(app: Express): Promise { userId: req.user?.id, connectionId }); - + res.json({ success: true }); } catch (err) { console.error("Error deleting AD subnet:", err); @@ -5182,18 +5265,18 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + const logs = await storage.getAuditLogs(connectionId); res.json(logs); } catch (error) { next(error); } }); - + /** * @swagger * /api/audit-logs: @@ -5240,7 +5323,7 @@ export async function registerRoutes(app: Express): Promise { next(error); } }); - + /** * @swagger * /api/connections/{connectionId}/remove-from-group: @@ -5297,33 +5380,71 @@ export async function registerRoutes(app: Express): Promise { try { const connectionId = parseInt(req.params.connectionId); const connection = await storage.getLdapConnection(connectionId); - + if (!connection) { return res.status(404).json({ message: "LDAP connection not found" }); } - + try { const data = removeFromGroupSchema.parse(req.body); - - // This would call a method in the LDAP client to remove the object from the group - // For now we'll return a mock success response - // In a real implementation, this would interact with the Active Directory - + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the group by GUID to get the distinguishedName + const group = await storage.getAdGroupByObjectGUID(connectionId, data.groupObjectGUID); + if (!group) { + return res.status(404).json({ message: "Group not found" }); + } + + // Find the object by GUID to get the distinguishedName + let objectDN = ''; + if (data.objectType === 'user') { + const user = await storage.getAdUserByObjectGUID(connectionId, data.objectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + objectDN = user.distinguishedName; + } else if (data.objectType === 'computer') { + const computer = await storage.getAdComputerByObjectGUID(connectionId, data.objectGUID); + if (!computer) { + return res.status(404).json({ message: "Computer not found" }); + } + objectDN = computer.distinguishedName; + } else { + return res.status(400).json({ message: "Invalid object type" }); + } + + // Remove the object from the group + const success = await ldapClient.removeFromGroup( + connectionId, + group.distinguishedName, + objectDN + ); + + if (!success) { + return res.status(500).json({ message: `Failed to remove ${data.objectType} from group` }); + } + // Record the action in the audit log const auditEntry = { action: "REMOVE_FROM_GROUP", targetId: data.objectGUID, details: { groupGUID: data.groupObjectGUID, - objectType: data.objectType + objectType: data.objectType, + objectDN: objectDN, + groupDN: group.distinguishedName }, - userId: req.user?.id || null, + userId: req.user?.id ?? null, connectionId: connectionId }; - + // Save the audit entry to storage await storage.createAuditLogEntry(auditEntry); - + res.json({ success: true, message: `${data.objectType} with GUID ${data.objectGUID} removed from group with GUID ${data.groupObjectGUID}` @@ -5340,7 +5461,7 @@ export async function registerRoutes(app: Express): Promise { }); // This dedicated API endpoint has been replaced by object-specific endpoints using the PATCH method - // See: /api/connections/:connectionId/ad-users/:id/managed-by, + // See: /api/connections/:connectionId/ad-users/:id/managed-by, // /api/connections/:connectionId/ad-groups/:id/managed-by, // /api/connections/:connectionId/ad-computers/:id/managed-by, // /api/connections/:connectionId/ad-org-units/:id/managed-by @@ -5351,6 +5472,979 @@ export async function registerRoutes(app: Express): Promise { // /api/connections/:connectionId/ad-computers/:objectGuid, // /api/connections/:connectionId/ad-org-units/:objectGuid + /** + * @swagger + * /api/connections/{connectionId}/reset-password: + * post: + * summary: Reset a user's password + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PasswordReset' + * responses: + * 200: + * description: Password reset successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/reset-password", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: Request, res: Response, next: NextFunction) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = resetPasswordSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Reset the user's password + const success = await ldapClient.resetUserPassword( + connectionId, + user.distinguishedName, + data.newPassword, + data.skipValidation + ); + + if (!success) { + return res.status(500).json({ message: "Failed to reset password" }); + } + + // If requirePasswordChangeAtNextLogon is true, set the pwdLastSet attribute to 0 + if (data.requirePasswordChangeAtNextLogon) { + await ldapClient.updateAttribute( + connectionId, + user.distinguishedName, + 'pwdLastSet', + '0', + 'replace' + ); + } + + // Record the action in the audit log + const auditEntry = { + action: "RESET_PASSWORD", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName, + requirePasswordChangeAtNextLogon: data.requirePasswordChangeAtNextLogon + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + res.json({ + success: true, + message: `Password reset successful for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/enable-user-account: + * post: + * summary: Enable or disable a user account + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/EnableUserAccount' + * responses: + * 200: + * description: Account status updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/enable-user-account", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: Request, res: Response, next: NextFunction) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = enableUserAccountSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Enable or disable the user account + const success = await ldapClient.setUserAccountStatus( + connectionId, + user.distinguishedName, + data.enabled + ); + + if (!success) { + return res.status(500).json({ message: `Failed to ${data.enabled ? 'enable' : 'disable'} user account` }); + } + + // Record the action in the audit log + const auditEntry = { + action: data.enabled ? "ENABLE_USER_ACCOUNT" : "DISABLE_USER_ACCOUNT", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + // Update the user in the database + await storage.updateAdUserByObjectGUID(connectionId, data.userObjectGUID, { enabled: data.enabled }); + + res.json({ + success: true, + message: `User account ${data.enabled ? 'enabled' : 'disabled'} successfully for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/bulk-enable-user-accounts: + * post: + * summary: Enable or disable multiple user accounts + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/BulkEnableUserAccounts' + * responses: + * 200: + * description: Bulk account status update results + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: array + * items: + * type: string + * failed: + * type: array + * items: + * type: object + * properties: + * dn: + * type: string + * error: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/bulk-enable-user-accounts", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: Request, res: Response, next: NextFunction) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = bulkEnableUserAccountsSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Enable or disable the user accounts + const results = await ldapClient.bulkSetUserAccountStatus( + connectionId, + data.userDNs, + data.enabled + ); + + // Record the action in the audit log + const auditEntry = { + action: data.enabled ? "BULK_ENABLE_USER_ACCOUNTS" : "BULK_DISABLE_USER_ACCOUNTS", + targetId: "multiple", + details: { + userDNs: data.userDNs, + results: results + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + // Update the users in the database + for (const dn of results.success) { + try { + // Find the user by DN + const users = await ldapClient.searchUsers(connectionId, `(distinguishedName=${dn})`, ['objectGUID']); + if (users && users.length > 0) { + const objectGUID = users[0].objectGUID; + await storage.updateAdUserByObjectGUID(connectionId, objectGUID, { enabled: data.enabled }); + } + } catch (error) { + console.error(`Failed to update user in database: ${error}`); + } + } + + res.json(results); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/reset-password: + * post: + * summary: Reset a user's password + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userObjectGUID + * - newPassword + * properties: + * userObjectGUID: + * type: string + * description: The ObjectGUID of the user + * newPassword: + * type: string + * description: The new password for the user + * skipValidation: + * type: boolean + * description: Whether to skip password policy validation + * default: false + * requirePasswordChangeAtNextLogon: + * type: boolean + * description: Whether to require the user to change their password at next logon + * default: true + * responses: + * 200: + * description: Password reset successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/reset-password", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: any, res: any, next: any) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = resetPasswordSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Reset the user's password + const success = await ldapClient.resetUserPassword( + connectionId, + user.distinguishedName, + data.newPassword, + data.skipValidation + ); + + if (!success) { + return res.status(500).json({ message: "Failed to reset password" }); + } + + // If requirePasswordChangeAtNextLogon is true, set the pwdLastSet attribute to 0 + if (data.requirePasswordChangeAtNextLogon) { + await ldapClient.updateAttribute( + connectionId, + user.distinguishedName, + 'pwdLastSet', + '0', + 'replace' + ); + } + + // Record the action in the audit log + const auditEntry = { + action: "RESET_PASSWORD", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName, + requirePasswordChangeAtNextLogon: data.requirePasswordChangeAtNextLogon + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + res.json({ + success: true, + message: `Password reset successful for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/enable-user-account: + * post: + * summary: Enable or disable a user account + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userObjectGUID + * - enabled + * properties: + * userObjectGUID: + * type: string + * description: The ObjectGUID of the user + * enabled: + * type: boolean + * description: Whether to enable or disable the account + * responses: + * 200: + * description: Account status updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/enable-user-account", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: any, res: any, next: any) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = enableUserAccountSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Enable or disable the user account + const success = await ldapClient.setUserAccountStatus( + connectionId, + user.distinguishedName, + data.enabled + ); + + if (!success) { + return res.status(500).json({ message: `Failed to ${data.enabled ? 'enable' : 'disable'} user account` }); + } + + // Record the action in the audit log + const auditEntry = { + action: data.enabled ? "ENABLE_USER_ACCOUNT" : "DISABLE_USER_ACCOUNT", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + // Update the user in the database + await storage.updateAdUser(connectionId, data.userObjectGUID, { enabled: data.enabled }); + + res.json({ + success: true, + message: `User account ${data.enabled ? 'enabled' : 'disabled'} successfully for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/bulk-enable-user-accounts: + * post: + * summary: Enable or disable multiple user accounts + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userDNs + * - enabled + * properties: + * userDNs: + * type: array + * items: + * type: string + * description: Array of user distinguished names + * enabled: + * type: boolean + * description: Whether to enable or disable the accounts + * responses: + * 200: + * description: Bulk account status update results + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: array + * items: + * type: string + * failed: + * type: array + * items: + * type: object + * properties: + * dn: + * type: string + * error: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/bulk-enable-user-accounts", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req: any, res: any, next: any) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = bulkEnableUserAccountsSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Enable or disable the user accounts + const results = await ldapClient.bulkSetUserAccountStatus( + connectionId, + data.userDNs, + data.enabled + ); + + // Record the action in the audit log + const auditEntry = { + action: data.enabled ? "BULK_ENABLE_USER_ACCOUNTS" : "BULK_DISABLE_USER_ACCOUNTS", + targetId: "multiple", + details: { + userDNs: data.userDNs, + results: results + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + // Update the users in the database + for (const dn of results.success) { + try { + // Find the user by DN + const user = await storage.getAdUserByDN(connectionId, dn); + if (user) { + await storage.updateAdUser(connectionId, user.objectGUID, { enabled: data.enabled }); + } + } catch (error) { + console.error(`Failed to update user in database: ${error}`); + } + } + + res.json(results); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/reset-password: + * post: + * summary: Reset a user's password + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userObjectGUID + * - newPassword + * properties: + * userObjectGUID: + * type: string + * description: The ObjectGUID of the user + * newPassword: + * type: string + * description: The new password for the user + * skipValidation: + * type: boolean + * description: Whether to skip password policy validation + * default: false + * requirePasswordChangeAtNextLogon: + * type: boolean + * description: Whether to require the user to change their password at next logon + * default: true + * responses: + * 200: + * description: Password reset successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/reset-password", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req, res, next) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = resetPasswordSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Reset the user's password + const success = await ldapClient.resetUserPassword( + connectionId, + user.distinguishedName, + data.newPassword, + data.skipValidation + ); + + if (!success) { + return res.status(500).json({ message: "Failed to reset password" }); + } + + // If requirePasswordChangeAtNextLogon is true, set the pwdLastSet attribute to 0 + if (data.requirePasswordChangeAtNextLogon) { + await ldapClient.updateAttribute( + connectionId, + user.distinguishedName, + 'pwdLastSet', + '0', + 'replace' + ); + } + + // Record the action in the audit log + const auditEntry = { + action: "RESET_PASSWORD", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName, + requirePasswordChangeAtNextLogon: data.requirePasswordChangeAtNextLogon + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + res.json({ + success: true, + message: `Password reset successful for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/enable-user-account: + * post: + * summary: Enable or disable a user account + * tags: [Active Directory] + * security: + * - cookieAuth: [] + * - apiTokenAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userObjectGUID + * - enabled + * properties: + * userObjectGUID: + * type: string + * description: The ObjectGUID of the user + * enabled: + * type: boolean + * description: Whether to enable or disable the account + * responses: + * 200: + * description: Account status updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid request + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * description: User not found + * 500: + * description: Server error + */ + app.post("/api/connections/:connectionId/enable-user-account", requirePermission(PERMISSIONS.UPDATE_AD_USERS, { allowApiToken: true }), async (req, res, next) => { + try { + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + try { + const data = enableUserAccountSchema.parse(req.body); + + // Connect to LDAP if not already connected + if (!ldapClient.isConnectionActive(connectionId)) { + await ldapClient.connect(connection); + } + + // Find the user by GUID to get the distinguishedName + const user = await storage.getAdUserByObjectGUID(connectionId, data.userObjectGUID); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Enable or disable the user account + const success = await ldapClient.setUserAccountStatus( + connectionId, + user.distinguishedName, + data.enabled + ); + + if (!success) { + return res.status(500).json({ message: `Failed to ${data.enabled ? 'enable' : 'disable'} user account` }); + } + + // Record the action in the audit log + const auditEntry = { + action: data.enabled ? "ENABLE_USER_ACCOUNT" : "DISABLE_USER_ACCOUNT", + targetId: data.userObjectGUID, + details: { + userDN: user.distinguishedName + }, + userId: req.user?.id ?? null, + connectionId: connectionId + }; + + // Save the audit entry to storage + await storage.createAuditLogEntry(auditEntry); + + // Update the user in the database + await storage.updateAdUser(connectionId, data.userObjectGUID, { enabled: data.enabled }); + + res.json({ + success: true, + message: `User account ${data.enabled ? 'enabled' : 'disabled'} successfully for user with GUID ${data.userObjectGUID}` + }); + } catch (error) { + if (error instanceof ZodError) { + return handleZodError(error, res); + } + throw error; + } + } catch (error) { + next(error); + } + }); + /** * @swagger * /api/audit-logs: @@ -5408,7 +6502,7 @@ export async function registerRoutes(app: Express): Promise { try { const page = req.query.page ? parseInt(req.query.page as string) : 1; const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 10; - + const result = await storage.getAuditLogs(undefined, undefined, page, pageSize); res.json(result); } catch (error) { @@ -5481,13 +6575,13 @@ export async function registerRoutes(app: Express): Promise { const connectionId = parseInt(req.params.connectionId, 10); const page = req.query.page ? parseInt(req.query.page as string) : 1; const pageSize = req.query.pageSize ? parseInt(req.query.pageSize as string) : 10; - + // Verify the connection exists const connection = await storage.getLdapConnection(connectionId); if (!connection) { return res.status(404).json({ message: "Connection not found" }); } - + const result = await storage.getAuditLogs(connectionId, undefined, page, pageSize); res.json(result); } catch (error) { @@ -5500,20 +6594,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const users = await storage.listAdUsers(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = users.map(user => ({ ...user, objectType: 'user' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5525,20 +6619,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const computers = await storage.listAdComputers(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = computers.map(computer => ({ ...computer, objectType: 'computer' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5550,20 +6644,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const groups = await storage.listAdGroups(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = groups.map(group => ({ ...group, objectType: 'group' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5575,20 +6669,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const ous = await storage.listAdOrgUnits(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = ous.map(ou => ({ ...ou, objectType: 'organizationalUnit' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5600,20 +6694,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const domains = await storage.listAdDomains(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = domains.map(domain => ({ ...domain, objectType: 'domain' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5625,20 +6719,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const sites = await storage.listAdSites(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = sites.map(site => ({ ...site, objectType: 'site' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5650,20 +6744,20 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ data: [], message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; const subnets = await storage.listAdSubnets(connectionId); - + // Add objectType to make filtering easier in the dashboard const result = subnets.map(subnet => ({ ...subnet, objectType: 'subnet' })); - + res.json({ data: result }); } catch (error) { next(error); @@ -5675,7 +6769,7 @@ export async function registerRoutes(app: Express): Promise { try { // Get data from the first available LDAP connection const connections = await storage.listLdapConnections(); - + if (connections.length === 0) { return res.status(200).json({ userCount: 0, @@ -5686,25 +6780,25 @@ export async function registerRoutes(app: Express): Promise { message: "No LDAP connections available" }); } - + const connectionId = connections[0].id; - + // Get counts from all AD objects const usersCount = await db.select({ count: count() }).from(adUsers) .where(eq(adUsers.connectionId, connectionId)); - + const computersCount = await db.select({ count: count() }).from(adComputers) .where(eq(adComputers.connectionId, connectionId)); - + const groupsCount = await db.select({ count: count() }).from(adGroups) .where(eq(adGroups.connectionId, connectionId)); - + const sitesCount = await db.select({ count: count() }).from(adSites) .where(eq(adSites.connectionId, connectionId)); - + const domainsCount = await db.select({ count: count() }).from(adDomains) .where(eq(adDomains.connectionId, connectionId)); - + // Return the counts res.json({ userCount: Number(usersCount[0]?.count || 0), @@ -5733,11 +6827,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const rule = await storage.getDynamicGroupRule(id); - + if (!rule) { return res.status(404).json({ error: 'Dynamic group rule not found' }); } - + res.json(rule); } catch (error) { console.error('Error fetching dynamic group rule:', error); @@ -5759,11 +6853,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const updatedRule = await storage.updateDynamicGroupRule(id, req.body); - + if (!updatedRule) { return res.status(404).json({ error: 'Dynamic group rule not found' }); } - + res.json(updatedRule); } catch (error) { console.error('Error updating dynamic group rule:', error); @@ -5775,11 +6869,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const deleted = await storage.deleteDynamicGroupRule(id); - + if (!deleted) { return res.status(404).json({ error: 'Dynamic group rule not found' }); } - + res.status(204).send(); } catch (error) { console.error('Error deleting dynamic group rule:', error); @@ -5850,11 +6944,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const scheduleRule = await storage.getScheduleRule(id); - + if (!scheduleRule) { return res.status(404).json({ error: 'Schedule rule not found' }); } - + res.json(scheduleRule); } catch (error) { console.error('Error fetching schedule rule:', error); @@ -5899,11 +6993,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const updatedRule = await storage.updateScheduleRule(id, req.body); - + if (!updatedRule) { return res.status(404).json({ error: 'Schedule rule not found' }); } - + res.json(updatedRule); } catch (error) { console.error('Error updating schedule rule:', error); @@ -5915,11 +7009,11 @@ export async function registerRoutes(app: Express): Promise { try { const id = Number(req.params.id); const deleted = await storage.deleteScheduleRule(id); - + if (!deleted) { return res.status(404).json({ error: 'Schedule rule not found' }); } - + res.status(204).send(); } catch (error) { console.error('Error deleting schedule rule:', error); diff --git a/server/swagger.ts b/server/swagger.ts index cc0c1ff..6a4a92c 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -35,6 +35,32 @@ const swaggerOptions = { description: "API base URL", }, ], + tags: [ + { + name: "LDAP Connections", + description: "Operations for managing LDAP connections" + }, + { + name: "AD Users", + description: "Operations for managing Active Directory users" + }, + { + name: "AD Groups", + description: "Operations for managing Active Directory groups" + }, + { + name: "AD Computers", + description: "Operations for managing Active Directory computers" + }, + { + name: "AD Organizational Units", + description: "Operations for managing Active Directory organizational units" + }, + { + name: "Active Directory", + description: "General Active Directory operations including password management and account status" + } + ], components: { securitySchemes: { bearerAuth: { @@ -299,7 +325,7 @@ const swaggerOptions = { id: { type: "integer" }, action: { type: "string" }, targetId: { type: "string" }, - details: { + details: { type: "object", additionalProperties: true }, @@ -308,6 +334,59 @@ const swaggerOptions = { connectionId: { type: "integer" } }, }, + PasswordReset: { + type: "object", + required: ["userObjectGUID", "newPassword"], + properties: { + userObjectGUID: { + type: "string", + description: "The ObjectGUID of the user" + }, + newPassword: { + type: "string", + description: "The new password for the user" + }, + skipValidation: { + type: "boolean", + description: "Whether to skip password policy validation", + default: false + }, + requirePasswordChangeAtNextLogon: { + type: "boolean", + description: "Whether to require the user to change their password at next logon", + default: true + } + } + }, + EnableUserAccount: { + type: "object", + required: ["userObjectGUID", "enabled"], + properties: { + userObjectGUID: { + type: "string", + description: "The ObjectGUID of the user" + }, + enabled: { + type: "boolean", + description: "Whether to enable or disable the account" + } + } + }, + BulkEnableUserAccounts: { + type: "object", + required: ["userDNs", "enabled"], + properties: { + userDNs: { + type: "array", + items: { type: "string" }, + description: "Array of user distinguished names" + }, + enabled: { + type: "boolean", + description: "Whether to enable or disable the accounts" + } + } + }, }, responses: { UnauthorizedError: { @@ -394,13 +473,13 @@ export function setupSwagger(app: Express) { // Mount at both paths for backward compatibility app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); - + // Provide the JSON spec at multiple paths app.get("/api/swagger.json", (req, res) => { res.setHeader("Content-Type", "application/json"); res.send(swaggerSpec); }); - + app.get("/api-docs/swagger.json", (req, res) => { res.setHeader("Content-Type", "application/json"); res.send(swaggerSpec); @@ -408,8 +487,8 @@ export function setupSwagger(app: Express) { // Add download endpoint for the OpenAPI specification app.get("/api/docs/download", (req, res) => { - const format = req.query.format?.toString().toLowerCase() || 'json'; - + const format = req.query.format?.toString().toLowerCase() ?? 'json'; + if (format === 'json') { res.setHeader('Content-Disposition', 'attachment; filename=openapi-spec.json'); res.setHeader('Content-Type', 'application/json'); @@ -455,7 +534,7 @@ export function setupSwagger(app: Express) {

Active Directory Management API - Advanced Usage Guide

- +

API Documentation

The complete API documentation is available at /api/docs.

You can also download the OpenAPI specification:

@@ -463,28 +542,28 @@ export function setupSwagger(app: Express) { Download OpenAPI Spec (JSON) Download OpenAPI Spec (YAML)

- +

Query Parameters and Filtering

${queryParamsInfo}
- +

Authentication

This API supports two authentication methods:

  • Session-based authentication: Used when accessing the API from the web interface.
  • API Token authentication: Used when accessing the API programmatically.
- +

API Token Authentication

To authenticate using an API token, include the token in the Authorization header:

Authorization: Bearer YOUR_API_TOKEN
- +

Rate Limiting

The API has rate limiting in place to prevent abuse. The current limits are:

  • 100 requests per minute for authenticated users
  • 20 requests per minute for unauthenticated users
- +

Error Handling

The API returns consistent error responses with the following structure:

{
@@ -496,20 +575,20 @@ export function setupSwagger(app: Express) {
     }
   ]
 }
- +

Example Usage

Filter Users by Name

GET /api/connections/1/ad-users?filter=displayName contains 'John'
- +

Select Specific Fields

GET /api/connections/1/ad-users?select=id,displayName,email
- +

Pagination

GET /api/connections/1/ad-users?top=10&skip=20
- +

Combining Parameters

GET /api/connections/1/ad-users?filter=enabled eq true&select=id,displayName,email&orderBy=displayName asc&top=10
- +

Return to API Documentation

diff --git a/shared/schema.ts b/shared/schema.ts index 8a1fd1b..f993d57 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -19,48 +19,48 @@ export const PERMISSIONS = { CREATE_USERS: "create:users", UPDATE_USERS: "update:users", DELETE_USERS: "delete:users", - + // LDAP connections VIEW_LDAP_CONNECTIONS: "view:ldap_connections", CREATE_LDAP_CONNECTIONS: "create:ldap_connections", UPDATE_LDAP_CONNECTIONS: "update:ldap_connections", DELETE_LDAP_CONNECTIONS: "delete:ldap_connections", - + // Active Directory management VIEW_AD_USERS: "view:ad_users", CREATE_AD_USERS: "create:ad_users", UPDATE_AD_USERS: "update:ad_users", DELETE_AD_USERS: "delete:ad_users", MOVE_AD_USERS: "move:ad_users", - + VIEW_AD_GROUPS: "view:ad_groups", CREATE_AD_GROUPS: "create:ad_groups", UPDATE_AD_GROUPS: "update:ad_groups", DELETE_AD_GROUPS: "delete:ad_groups", MANAGE_GROUP_MEMBERSHIP: "manage:group_membership", - + VIEW_AD_OUS: "view:ad_ous", CREATE_AD_OUS: "create:ad_ous", UPDATE_AD_OUS: "update:ad_ous", DELETE_AD_OUS: "delete:ad_ous", - + VIEW_AD_COMPUTERS: "view:ad_computers", CREATE_AD_COMPUTERS: "create:ad_computers", UPDATE_AD_COMPUTERS: "update:ad_computers", DELETE_AD_COMPUTERS: "delete:ad_computers", MOVE_AD_COMPUTERS: "move:ad_computers", - + VIEW_AD_DOMAINS: "view:ad_domains", CREATE_AD_DOMAINS: "create:ad_domains", UPDATE_AD_DOMAINS: "update:ad_domains", DELETE_AD_DOMAINS: "delete:ad_domains", - + // Sites and Services management VIEW_AD_SITES: "view:ad_sites", CREATE_AD_SITES: "create:ad_sites", UPDATE_AD_SITES: "update:ad_sites", DELETE_AD_SITES: "delete:ad_sites", - + VIEW_AD_SUBNETS: "view:ad_subnets", CREATE_AD_SUBNETS: "create:ad_subnets", UPDATE_AD_SUBNETS: "update:ad_subnets", @@ -68,7 +68,7 @@ export const PERMISSIONS = { // API Token management MANAGE_API_TOKENS: "manage:api_tokens", - + // Administrative functions MANAGE_ROLES: "manage:roles", SYSTEM_ADMIN: "admin:system", @@ -175,10 +175,10 @@ export const ldapConnections = pgTable("ldap_connections", { export const scheduleFrequencies = [ "once", "minutely", - "hourly", - "daily", - "weekly", - "monthly", + "hourly", + "daily", + "weekly", + "monthly", "yearly" ] as const; @@ -234,7 +234,7 @@ export const dynamicGroupRules = pgTable("dynamic_group_rules", { // Rule conditions (filter logic) export const dynamicGroupConditions = pgTable("dynamic_group_conditions", { - id: serial("id").primaryKey(), + id: serial("id").primaryKey(), ruleId: integer("rule_id").references(() => dynamicGroupRules.id, { onDelete: "cascade" }).notNull(), parentId: integer("parent_id").references(() => dynamicGroupConditions.id, { onDelete: "cascade" }), type: text("type").notNull(), // "condition", "group" @@ -498,6 +498,53 @@ export const removeFromGroupSchema = z.object({ objectType: z.enum(["user", "computer"]), }); +// Password management schemas +export const resetPasswordSchema = z.object({ + userObjectGUID: z.string().min(1, "User ObjectGUID is required"), + newPassword: z.string().min(8, "Password must be at least 8 characters long"), + skipValidation: z.boolean().optional().default(false), + requirePasswordChangeAtNextLogon: z.boolean().optional().default(true), +}); + +export const enableUserAccountSchema = z.object({ + userObjectGUID: z.string().min(1, "User ObjectGUID is required"), + enabled: z.boolean(), +}); + +// Bulk operations schemas +export const bulkEnableUserAccountsSchema = z.object({ + userDNs: z.array(z.string().min(1, "User DN is required")), + enabled: z.boolean(), +}); + +// Bulk operations schemas +export const bulkMoveObjectsSchema = z.object({ + objectDNs: z.array(z.string().min(1, "Object DN is required")), + targetOU: z.string().min(1, "Target OU is required"), +}); + +export const bulkAddToGroupSchema = z.object({ + memberDNs: z.array(z.string().min(1, "Member DN is required")), + groupDN: z.string().min(1, "Group DN is required"), +}); + +export const bulkRemoveFromGroupSchema = z.object({ + memberDNs: z.array(z.string().min(1, "Member DN is required")), + groupDN: z.string().min(1, "Group DN is required"), +}); + +export const bulkEnableUserAccountsSchema = z.object({ + userDNs: z.array(z.string().min(1, "User DN is required")), + enabled: z.boolean(), +}); + +export const bulkUpdateAttributeSchema = z.object({ + objectDNs: z.array(z.string().min(1, "Object DN is required")), + attributeName: z.string().min(1, "Attribute name is required"), + attributeValue: z.union([z.string(), z.array(z.string()), z.null()]), + operation: z.enum(["add", "replace", "delete"]).default("replace"), +}); + // ManagedBy operation schema is no longer needed as it's been replaced by object-specific PATCH endpoints // Export types @@ -536,6 +583,9 @@ export type MoveComputer = z.infer; export type MoveUser = z.infer; export type AddToGroup = z.infer; export type RemoveFromGroup = z.infer; +export type ResetPassword = z.infer; +export type EnableUserAccount = z.infer; +export type BulkEnableUserAccounts = z.infer; export type DynamicGroupRule = typeof dynamicGroupRules.$inferSelect; export type InsertDynamicGroupRule = z.infer; export type DynamicGroupCondition = typeof dynamicGroupConditions.$inferSelect; @@ -651,22 +701,22 @@ export const ldapAttributesRelations = relations(ldapAttributes, ({ one }) => ({ })); // Generate insertion schemas -export const insertLdapFilterSchema = createInsertSchema(ldapFilters).omit({ - id: true, - createdAt: true, +export const insertLdapFilterSchema = createInsertSchema(ldapFilters).omit({ + id: true, + createdAt: true, modifiedAt: true, - currentVersion: true + currentVersion: true }); -export const insertLdapFilterRevisionSchema = createInsertSchema(ldapFilterRevisions).omit({ - id: true, - createdAt: true +export const insertLdapFilterRevisionSchema = createInsertSchema(ldapFilterRevisions).omit({ + id: true, + createdAt: true }); -export const insertLdapAttributeSchema = createInsertSchema(ldapAttributes).omit({ - id: true, - createdAt: true, - updatedAt: true +export const insertLdapAttributeSchema = createInsertSchema(ldapAttributes).omit({ + id: true, + createdAt: true, + updatedAt: true }); // Export types @@ -678,8 +728,8 @@ export type LdapAttribute = typeof ldapAttributes.$inferSelect; export type InsertLdapAttribute = z.infer; // Audit log schema and types -export const insertAuditLogSchema = createInsertSchema(auditLogs).omit({ - id: true, +export const insertAuditLogSchema = createInsertSchema(auditLogs).omit({ + id: true, timestamp: true }); export type AuditLog = typeof auditLogs.$inferSelect;