diff --git a/DOCKER-DEPLOYMENT.md b/DOCKER-DEPLOYMENT.md new file mode 100644 index 0000000..c69a093 --- /dev/null +++ b/DOCKER-DEPLOYMENT.md @@ -0,0 +1,213 @@ +# Docker Deployment Instructions + +This document describes how to deploy the Active Directory Management API using Docker. + +## Dockerfile + +Below is the updated Dockerfile that includes support for all the enhanced features: + +```dockerfile +FROM node:20-slim AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy the rest of the source code +COPY . . + +# Build the application (optimized for production) +RUN npm run build + +# Production stage +FROM node:20-slim AS runner + +# Install necessary system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Set environment to production +ENV NODE_ENV=production + +# Copy package files and install production dependencies +COPY package*.json ./ +RUN npm ci --production + +# Copy build artifacts from the builder stage +COPY --from=builder /app/dist ./dist + +# Copy schema files for Drizzle +COPY ./shared/schema.ts ./shared/ +COPY ./drizzle.config.ts ./ + +# Create a directory for logs +RUN mkdir -p /app/logs + +# Set default environment variables (these should be overridden at runtime) +ENV DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres +ENV REDIS_URL=redis://redis:6379 +ENV SESSION_SECRET=changeme + +# Set rate limiting configuration +ENV RATE_LIMIT_WINDOW_MS=900000 +ENV RATE_LIMIT_MAX_REQUESTS=100 + +# Set compression level (1-9, where 9 is maximum compression) +ENV COMPRESSION_LEVEL=6 + +# Set logging level (debug, info, warn, error) +ENV LOG_LEVEL=info + +# Expose the port the app runs on +EXPOSE 5000 + +# Health check to ensure the application is running properly +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Start the application +CMD ["node", "dist/index.js"] +``` + +## Docker Compose + +For deploying the full stack with PostgreSQL and Redis, create a `docker-compose.yml` file: + +```yaml +version: '3.8' + +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "5000:5000" + environment: + - NODE_ENV=production + - DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres + - REDIS_URL=redis://redis:6379 + - SESSION_SECRET=your_secure_session_secret + - RATE_LIMIT_WINDOW_MS=900000 + - RATE_LIMIT_MAX_REQUESTS=100 + - COMPRESSION_LEVEL=6 + - LOG_LEVEL=info + depends_on: + - postgres + - redis + restart: unless-stopped + networks: + - app-network + + postgres: + image: postgres:15 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + volumes: + - postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - app-network + + redis: + image: redis:7 + command: redis-server --appendonly yes + volumes: + - redis-data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + postgres-data: + redis-data: +``` + +## Environment Variables + +The application supports the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `NODE_ENV` | Application environment | `production` | +| `DATABASE_URL` | PostgreSQL connection string | `postgres://postgres:postgres@postgres:5432/postgres` | +| `REDIS_URL` | Redis connection string | `redis://redis:6379` | +| `SESSION_SECRET` | Secret for session encryption | `changeme` (replace in production!) | +| `RATE_LIMIT_WINDOW_MS` | Rate limiting window in milliseconds | `900000` (15 minutes) | +| `RATE_LIMIT_MAX_REQUESTS` | Maximum requests per window | `100` | +| `COMPRESSION_LEVEL` | Response compression level (1-9) | `6` | +| `LOG_LEVEL` | Logging level | `info` | + +## Deployment Instructions + +1. Create the Dockerfile and docker-compose.yml as specified above +2. Build and start the containers: + ``` + docker-compose up -d + ``` +3. Run database migrations: + ``` + docker-compose exec app npm run db:push + ``` +4. Access the application at http://localhost:5000 +5. API documentation is available at http://localhost:5000/api/docs + +## Health Checks + +The application includes a health check endpoint at `/api/health` that returns HTTP 200 when the application is running normally. The Docker container is configured to use this endpoint for health checks. + +## Scaling Considerations + +- The application can be horizontally scaled by adding more instances of the app service +- For high-availability, consider using a managed PostgreSQL service instead of the containerized version +- For production, use a proper Redis cluster or managed Redis service +- Consider using a reverse proxy like Nginx or a load balancer in front of multiple app instances + +## Security Notes + +- Always replace the default `SESSION_SECRET` with a secure random string +- Use appropriate firewall rules to restrict access to PostgreSQL and Redis +- Consider setting up SSL/TLS for production deployments +- Adjust rate limiting parameters based on your expected traffic + +## Troubleshooting + +- If the application fails to start, check the logs: + ``` + docker-compose logs app + ``` +- For database connection issues: + ``` + docker-compose logs postgres + ``` +- For Redis connection issues: + ``` + docker-compose logs redis + ``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8838ae6..496f59f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,17 @@ RUN npm ci # Copy the rest of the source code COPY . . -# Build the application +# Build the application (optimized for production) RUN npm run build # Production stage FROM node:20-slim AS runner +# Install necessary system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app # Set environment to production @@ -33,14 +38,35 @@ COPY --from=builder /app/dist ./dist COPY ./shared/schema.ts ./shared/ COPY ./drizzle.config.ts ./ -# Set the database URL environment variable (this will be overridden at runtime) -ENV DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres +# Create a directory for logs +RUN mkdir -p /app/logs -# Set session secret (should be overridden at runtime) +# Set default environment variables (these should be overridden at runtime) +ENV DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres +ENV REDIS_URL=redis://redis:6379 ENV SESSION_SECRET=changeme +# Set rate limiting configuration +ENV RATE_LIMIT_WINDOW_MS=900000 +ENV RATE_LIMIT_MAX_REQUESTS=100 + +# Set compression level (1-9, where 9 is maximum compression) +ENV COMPRESSION_LEVEL=6 + +# Set logging level (debug, info, warn, error) +ENV LOG_LEVEL=info + # Expose the port the app runs on EXPOSE 5000 -# Start the application +# Add startup script with health check +COPY ./docker-entrypoint.sh / +RUN chmod +x /docker-entrypoint.sh + +# Health check to ensure the application is running properly +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Start the application using the entrypoint script +ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/Dockerfile.new b/Dockerfile.new new file mode 100644 index 0000000..03425f1 --- /dev/null +++ b/Dockerfile.new @@ -0,0 +1,67 @@ +FROM node:20-slim AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy the rest of the source code +COPY . . + +# Build the application (optimized for production) +RUN npm run build + +# Production stage +FROM node:20-slim AS runner + +# Install necessary system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Set environment to production +ENV NODE_ENV=production + +# Copy package files and install production dependencies +COPY package*.json ./ +RUN npm ci --production + +# Copy build artifacts from the builder stage +COPY --from=builder /app/dist ./dist + +# Copy schema files for Drizzle +COPY ./shared/schema.ts ./shared/ +COPY ./drizzle.config.ts ./ + +# Create a directory for logs +RUN mkdir -p /app/logs + +# Set default environment variables (these should be overridden at runtime) +ENV DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres +ENV REDIS_URL=redis://redis:6379 +ENV SESSION_SECRET=changeme + +# Set rate limiting configuration +ENV RATE_LIMIT_WINDOW_MS=900000 +ENV RATE_LIMIT_MAX_REQUESTS=100 + +# Set compression level (1-9, where 9 is maximum compression) +ENV COMPRESSION_LEVEL=6 + +# Set logging level (debug, info, warn, error) +ENV LOG_LEVEL=info + +# Expose the port the app runs on +EXPOSE 5000 + +# Health check to ensure the application is running properly +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:5000/api/health || exit 1 + +# Start the application +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c124759..02d6a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@types/compression": "^1.7.5", "@types/debug": "^4.1.12", "@types/jsonwebtoken": "^9.0.9", + "@types/ldapjs": "^3.0.6", "@types/morgan": "^1.9.9", "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", @@ -65,6 +66,7 @@ "input-otp": "^1.2.4", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", "morgan": "^1.10.0", @@ -1458,6 +1460,101 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, "node_modules/@neondatabase/serverless": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz", @@ -3541,6 +3638,15 @@ "@types/node": "*" } }, + "node_modules/@types/ldapjs": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz", + "integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3730,6 +3836,12 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3816,6 +3928,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -3854,6 +3975,18 @@ "postcss": "^8.1.0" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4313,6 +4446,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5490,6 +5629,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", @@ -6148,6 +6296,29 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -7161,6 +7332,20 @@ "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", "license": "MIT" }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8897,6 +9082,32 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "node_modules/vaul": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.1.tgz", @@ -8910,6 +9121,20 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0" } }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/package.json b/package.json index 03d1ca8..de036a7 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@types/compression": "^1.7.5", "@types/debug": "^4.1.12", "@types/jsonwebtoken": "^9.0.9", + "@types/ldapjs": "^3.0.6", "@types/morgan": "^1.9.9", "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", @@ -67,6 +68,7 @@ "input-otp": "^1.2.4", "ioredis": "^5.6.0", "jsonwebtoken": "^9.0.2", + "ldapjs": "^3.0.7", "lucide-react": "^0.453.0", "memorystore": "^1.6.7", "morgan": "^1.10.0", diff --git a/server/ldap-filter-builder.ts b/server/ldap-filter-builder.ts new file mode 100644 index 0000000..cf77de2 --- /dev/null +++ b/server/ldap-filter-builder.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; + +// Define the operator types for LDAP filters +export enum LdapOperator { + // Logical operators + AND = "and", + OR = "or", + NOT = "not", + + // Comparison operators + EQUALS = "equals", + NOT_EQUALS = "notEquals", + STARTS_WITH = "startsWith", + ENDS_WITH = "endsWith", + CONTAINS = "contains", + GREATER_THAN = "greaterThan", + LESS_THAN = "lessThan", + PRESENT = "present", // attribute exists + APPROX = "approx", // approximately equals +} + +// Define the condition schema for LDAP filter conditions +export type LdapCondition = { + operator: LdapOperator; + attribute?: string; // Not required for logical operators (AND, OR, NOT) + value?: string; // Not required for some operators (PRESENT, logical operators) + conditions?: LdapCondition[]; // For nested conditions with logical operators +}; + +export const ldapConditionSchema: z.ZodType = z.object({ + operator: z.nativeEnum(LdapOperator), + attribute: z.string().optional(), // Not required for logical operators (AND, OR, NOT) + value: z.string().optional(), // Not required for some operators (PRESENT, logical operators) + conditions: z.array(z.lazy(() => ldapConditionSchema)).optional(), // For nested conditions with logical operators +}); + +// Define the query builder schema +export const ldapQueryBuilderSchema = z.object({ + targetObject: z.enum(["users", "groups", "computers", "ous"]), + filter: ldapConditionSchema, +}); + +// Type definitions for the query builder +export type LdapQueryBuilder = z.infer; + +/** + * Convert a condition object to a valid LDAP filter string + */ +export function buildLdapFilter(condition: LdapCondition): string { + // Handle logical operators + if (condition.operator === LdapOperator.AND && condition.conditions) { + const subConditions = condition.conditions.map(buildLdapFilter).join(""); + return `(&${subConditions})`; + } + + if (condition.operator === LdapOperator.OR && condition.conditions) { + const subConditions = condition.conditions.map(buildLdapFilter).join(""); + return `(|${subConditions})`; + } + + if (condition.operator === LdapOperator.NOT && condition.conditions && condition.conditions.length > 0) { + return `(!${buildLdapFilter(condition.conditions[0])})`; + } + + // Handle comparison operators + if (!condition.attribute) { + throw new Error(`Attribute is required for operator ${condition.operator}`); + } + + switch (condition.operator) { + case LdapOperator.EQUALS: + return `(${condition.attribute}=${escapeFilterValue(condition.value || "")})`; + case LdapOperator.NOT_EQUALS: + return `(!(${condition.attribute}=${escapeFilterValue(condition.value || "")}))`; + case LdapOperator.STARTS_WITH: + return `(${condition.attribute}=${escapeFilterValue(condition.value || "")}*)`; + case LdapOperator.ENDS_WITH: + return `(${condition.attribute}=*${escapeFilterValue(condition.value || "")})`; + case LdapOperator.CONTAINS: + return `(${condition.attribute}=*${escapeFilterValue(condition.value || "")}*)`; + case LdapOperator.GREATER_THAN: + return `(${condition.attribute}>${escapeFilterValue(condition.value || "")})`; + case LdapOperator.LESS_THAN: + return `(${condition.attribute}<${escapeFilterValue(condition.value || "")})`; + case LdapOperator.PRESENT: + return `(${condition.attribute}=*)`; + case LdapOperator.APPROX: + return `(${condition.attribute}~=${escapeFilterValue(condition.value || "")})`; + default: + throw new Error(`Unsupported operator: ${condition.operator}`); + } +} + +/** + * Get common LDAP object classes for different target object types + */ +export function getObjectClassFilter(targetObject: string): string { + switch (targetObject) { + case "users": + return "(&(objectClass=user)(!(objectClass=computer)))"; + case "groups": + return "(objectClass=group)"; + case "computers": + return "(objectClass=computer)"; + case "ous": + return "(objectClass=organizationalUnit)"; + default: + return "(objectClass=*)"; + } +} + +/** + * Generate a combined LDAP filter by joining the user-defined filter with the appropriate object class filter + */ +export function generateLdapFilter(queryBuilder: LdapQueryBuilder): string { + const objectClassFilter = getObjectClassFilter(queryBuilder.targetObject); + const userFilter = buildLdapFilter(queryBuilder.filter); + + // Combine the two filters with AND + return `(&${objectClassFilter}${userFilter})`; +} + +/** + * Escape special characters in LDAP filter values according to RFC 4515 + */ +function escapeFilterValue(value: string): string { + return value + .replace(/\\/g, "\\5c") // Must be first to avoid double escaping + .replace(/\*/g, "\\2a") + .replace(/\(/g, "\\28") + .replace(/\)/g, "\\29") + .replace(/\0/g, "\\00") + .replace(/\//g, "\\2f"); +} + +/** + * Validate a query builder object and ensure it has the required properties + */ +export function validateQueryBuilder(queryBuilder: unknown): LdapQueryBuilder { + return ldapQueryBuilderSchema.parse(queryBuilder); +} + +/** + * Get human-readable text representation of an LDAP filter condition + */ +export function getHumanReadableFilter(condition: LdapCondition, indent = 0): string { + const spaces = " ".repeat(indent); + + switch (condition.operator) { + case LdapOperator.AND: + return `${spaces}ALL of the following conditions:\n` + + (condition.conditions?.map(c => getHumanReadableFilter(c, indent + 2)).join("\n") || ""); + + case LdapOperator.OR: + return `${spaces}ANY of the following conditions:\n` + + (condition.conditions?.map(c => getHumanReadableFilter(c, indent + 2)).join("\n") || ""); + + case LdapOperator.NOT: + return `${spaces}NOT the following condition:\n` + + (condition.conditions && condition.conditions.length > 0 + ? getHumanReadableFilter(condition.conditions[0], indent + 2) + : ""); + + case LdapOperator.EQUALS: + return `${spaces}${condition.attribute} equals "${condition.value}"`; + + case LdapOperator.NOT_EQUALS: + return `${spaces}${condition.attribute} does not equal "${condition.value}"`; + + case LdapOperator.STARTS_WITH: + return `${spaces}${condition.attribute} starts with "${condition.value}"`; + + case LdapOperator.ENDS_WITH: + return `${spaces}${condition.attribute} ends with "${condition.value}"`; + + case LdapOperator.CONTAINS: + return `${spaces}${condition.attribute} contains "${condition.value}"`; + + case LdapOperator.GREATER_THAN: + return `${spaces}${condition.attribute} is greater than "${condition.value}"`; + + case LdapOperator.LESS_THAN: + return `${spaces}${condition.attribute} is less than "${condition.value}"`; + + case LdapOperator.PRESENT: + return `${spaces}${condition.attribute} exists`; + + case LdapOperator.APPROX: + return `${spaces}${condition.attribute} is approximately equal to "${condition.value}"`; + + default: + return `${spaces}Unknown condition`; + } +} \ No newline at end of file diff --git a/server/ldap-query-builder-routes.ts b/server/ldap-query-builder-routes.ts new file mode 100644 index 0000000..bf41552 --- /dev/null +++ b/server/ldap-query-builder-routes.ts @@ -0,0 +1,558 @@ +import { Router } from "express"; +import { + validateQueryBuilder, + generateLdapFilter, + getHumanReadableFilter, + buildLdapFilter, + LdapQueryBuilder +} from "./ldap-filter-builder"; +import { connectToLdap, searchLdap } from "./ldap"; +import { IStorage } from "./storage"; +import { InsertLdapQuery, LdapQuery, InsertLdapQueryVersion } from "@shared/schema"; + +// Use middleware to check permissions +function hasPermission(permission: string) { + return (req: any, res: any, next: any) => { + // For now, we'll just allow all requests through + // In a real implementation, this would check the user's permissions + return next(); + }; +} + +export function registerLdapQueryBuilderRoutes(router: Router, storage: IStorage) { + /** + * @swagger + * /ldap-queries: + * get: + * summary: Get all saved LDAP queries + * description: Retrieve a list of all saved LDAP query filters + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: A list of LDAP queries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQuery' + */ + router.get("/ldap-queries", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const queries = await storage.getLdapQueries(); + res.json(queries); + } catch (error) { + console.error("Error getting LDAP queries:", error); + res.status(500).json({ error: "Failed to retrieve LDAP queries" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * get: + * summary: Get a saved LDAP query by ID + * description: Retrieve a specific LDAP query filter by its ID + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: LDAP query found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQuery(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + res.json(query); + } catch (error) { + console.error("Error getting LDAP query:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/versions: + * get: + * summary: Get version history for an LDAP query + * description: Retrieve the revision history for a specific LDAP query + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: List of query versions + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQueryVersion' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id/versions", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQuery(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + const versions = await storage.getLdapQueryVersions(id); + res.json(versions); + } catch (error) { + console.error("Error getting LDAP query versions:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query versions" }); + } + }); + + /** + * @swagger + * /ldap-queries: + * post: + * summary: Create a new LDAP query + * description: Save a new LDAP query filter + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 201: + * description: Query created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + */ + router.post("/ldap-queries", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + const insertQuery: InsertLdapQuery = { + name, + description: description || "", + targetObject, + filterJson: filter as any, // Type safety is handled by schema validation + ldapFilter, + readableFilter, + createdBy: req.user?.id || 1, // Default to 1 if no user + }; + + const query = await storage.createLdapQuery(insertQuery); + + res.status(201).json(query); + } catch (error: any) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error creating LDAP query:", error); + res.status(500).json({ error: "Failed to create LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * put: + * summary: Update an LDAP query + * description: Update an existing LDAP query and create a new version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 200: + * description: Query updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + * 404: + * description: Query not found + */ + router.put("/ldap-queries/:id", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + // Create a version entry for the previous state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + modifiedBy: req.user?.id || 1 // Default to 1 if no user + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with new values + const updatedQuery = await storage.updateLdapQuery(id, { + name, + description: description || "", + targetObject, + filterJson: filter as any, + ldapFilter, + readableFilter, + modifiedBy: req.user?.id || 1, // Default to 1 if no user + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error: any) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error updating LDAP query:", error); + res.status(500).json({ error: "Failed to update LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * delete: + * summary: Delete an LDAP query + * description: Delete an LDAP query and all its versions + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 204: + * description: Query deleted successfully + * 404: + * description: Query not found + */ + router.delete("/ldap-queries/:id", hasPermission("delete:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Delete the query - versions will be deleted via cascading foreign key constraints + await storage.deleteLdapQuery(id); + + res.status(204).send(); + } catch (error) { + console.error("Error deleting LDAP query:", error); + res.status(500).json({ error: "Failed to delete LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/revert/{version}: + * post: + * summary: Revert to a previous version + * description: Revert an LDAP query to a specific previous version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * - in: path + * name: version + * required: true + * schema: + * type: integer + * description: Version to revert to + * responses: + * 200: + * description: Query reverted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query or version not found + */ + router.post("/ldap-queries/:id/revert/:version", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + const versionNumber = parseInt(req.params.version); + + if (isNaN(id) || isNaN(versionNumber)) { + return res.status(400).json({ error: "Invalid query ID or version" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Find the version to revert to - we need to get all versions and find the matching one + const allVersions = await storage.getLdapQueryVersions(id); + const versionToRevert = allVersions.find(v => v.version === versionNumber); + + if (!versionToRevert) { + return res.status(404).json({ error: "Version not found" }); + } + + // Create a version entry for the current state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + modifiedBy: req.user?.id || 1 // Default to 1 if no user + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with the version's values + const updatedQuery = await storage.updateLdapQuery(id, { + targetObject: versionToRevert.targetObject, + filterJson: versionToRevert.filterJson as any, + ldapFilter: versionToRevert.ldapFilter, + readableFilter: versionToRevert.readableFilter, + modifiedBy: req.user?.id || 1, // Default to 1 if no user + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error) { + console.error("Error reverting LDAP query:", error); + res.status(500).json({ error: "Failed to revert LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/test: + * post: + * summary: Test an LDAP query filter + * description: Test a filter against an LDAP connection to see what objects would be returned + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * connectionId: + * type: integer + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * limit: + * type: integer + * default: 100 + * properties: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Query results + * content: + * application/json: + * schema: + * type: object + * properties: + * count: + * type: integer + * results: + * type: array + * items: + * type: object + * filter: + * type: string + * 400: + * description: Invalid query or connection + * 404: + * description: Connection not found + */ + router.post("/ldap-queries/test", hasPermission("read:ldap_objects"), async (req, res) => { + try { + const { connectionId, targetObject, filter, limit = 100, properties = [] } = req.body; + + if (!connectionId || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: connectionId, targetObject, filter" }); + } + + // Get the connection + const connection = await storage.getLdapConnection(connectionId); + if (!connection) { + return res.status(404).json({ error: "LDAP connection not found" }); + } + + // Validate and generate the filter + let ldapFilter: string; + try { + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + ldapFilter = generateLdapFilter(queryBuilder); + } catch (error: any) { + return res.status(400).json({ error: "Invalid filter format", details: error.message }); + } + + // Connect to LDAP + const client = await connectToLdap(connection); + + try { + // Execute the search + const results = await searchLdap(client, { + filter: ldapFilter, + attributes: properties.length > 0 ? properties : undefined, + limit + }); + + // Return the results + res.json({ + count: results.length, + results, + filter: ldapFilter + }); + } finally { + // Always destroy the client + client.destroy(); + } + } catch (error: any) { + console.error("Error testing LDAP query:", error); + res.status(500).json({ error: "Failed to test LDAP query", details: error.message }); + } + }); +} \ No newline at end of file diff --git a/server/ldap-query-builder-routes.ts.bak b/server/ldap-query-builder-routes.ts.bak new file mode 100644 index 0000000..429e567 --- /dev/null +++ b/server/ldap-query-builder-routes.ts.bak @@ -0,0 +1,556 @@ +import { Router } from "express"; +import { + validateQueryBuilder, + generateLdapFilter, + getHumanReadableFilter, + buildLdapFilter, + LdapQueryBuilder +} from "./ldap-filter-builder"; +import { connectToLdap, searchLdap } from "./ldap"; +import { IStorage } from "./storage"; +import { InsertLdapQuery, LdapQuery, InsertLdapQueryVersion } from "@shared/schema"; +// Use middleware to check permissions +function hasPermission(permission: string) { + return (req: any, res: any, next: any) => { + // For now, we'll just allow all requests through + // In a real implementation, this would check the user's permissions + return next(); + }; +} + +export function registerLdapQueryBuilderRoutes(router: Router, storage: IStorage) { + /** + * @swagger + * /ldap-queries: + * get: + * summary: Get all saved LDAP queries + * description: Retrieve a list of all saved LDAP query filters + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: A list of LDAP queries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQuery' + */ + router.get("/ldap-queries", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const queries = await storage.getLdapQueries(); + res.json(queries); + } catch (error) { + console.error("Error getting LDAP queries:", error); + res.status(500).json({ error: "Failed to retrieve LDAP queries" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * get: + * summary: Get a saved LDAP query by ID + * description: Retrieve a specific LDAP query filter by its ID + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: LDAP query found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQueryById(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + res.json(query); + } catch (error) { + console.error("Error getting LDAP query:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/versions: + * get: + * summary: Get version history for an LDAP query + * description: Retrieve the revision history for a specific LDAP query + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: List of query versions + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQueryVersion' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id/versions", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQueryById(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + const versions = await storage.getLdapQueryVersions(id); + res.json(versions); + } catch (error) { + console.error("Error getting LDAP query versions:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query versions" }); + } + }); + + /** + * @swagger + * /ldap-queries: + * post: + * summary: Create a new LDAP query + * description: Save a new LDAP query filter + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 201: + * description: Query created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + */ + router.post("/ldap-queries", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + const insertQuery: InsertLdapQuery = { + name, + description: description || "", + targetObject, + filterJson: filter, + ldapFilter, + readableFilter, + createdBy: req.user?.id || null, + }; + + const query = await storage.createLdapQuery(insertQuery); + + res.status(201).json(query); + } catch (error) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error creating LDAP query:", error); + res.status(500).json({ error: "Failed to create LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * put: + * summary: Update an LDAP query + * description: Update an existing LDAP query and create a new version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 200: + * description: Query updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + * 404: + * description: Query not found + */ + router.put("/ldap-queries/:id", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQueryById(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + // Create a version entry for the previous state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + createdAt: existingQuery.createdAt + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with new values + const updatedQuery = await storage.updateLdapQuery(id, { + name, + description: description || "", + targetObject, + filterJson: filter, + ldapFilter, + readableFilter, + updatedBy: req.user?.id || null, + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error updating LDAP query:", error); + res.status(500).json({ error: "Failed to update LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * delete: + * summary: Delete an LDAP query + * description: Delete an LDAP query and all its versions + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 204: + * description: Query deleted successfully + * 404: + * description: Query not found + */ + router.delete("/ldap-queries/:id", hasPermission("delete:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQueryById(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Delete all versions and the query itself + await storage.deleteLdapQueryVersions(id); + await storage.deleteLdapQuery(id); + + res.status(204).send(); + } catch (error) { + console.error("Error deleting LDAP query:", error); + res.status(500).json({ error: "Failed to delete LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/revert/{version}: + * post: + * summary: Revert to a previous version + * description: Revert an LDAP query to a specific previous version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * - in: path + * name: version + * required: true + * schema: + * type: integer + * description: Version to revert to + * responses: + * 200: + * description: Query reverted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query or version not found + */ + router.post("/ldap-queries/:id/revert/:version", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + const version = parseInt(req.params.version); + + if (isNaN(id) || isNaN(version)) { + return res.status(400).json({ error: "Invalid query ID or version" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQueryById(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Find the version to revert to + const versionToRevert = await storage.getLdapQueryVersion(id, version); + if (!versionToRevert) { + return res.status(404).json({ error: "Version not found" }); + } + + // Create a version entry for the current state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + createdAt: existingQuery.createdAt + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with the version's values + const updatedQuery = await storage.updateLdapQuery(id, { + targetObject: versionToRevert.targetObject, + filterJson: versionToRevert.filterJson, + ldapFilter: versionToRevert.ldapFilter, + readableFilter: versionToRevert.readableFilter, + updatedBy: req.user?.id || null, + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error) { + console.error("Error reverting LDAP query:", error); + res.status(500).json({ error: "Failed to revert LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/test: + * post: + * summary: Test an LDAP query filter + * description: Test a filter against an LDAP connection to see what objects would be returned + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * connectionId: + * type: integer + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * limit: + * type: integer + * default: 100 + * properties: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Query results + * content: + * application/json: + * schema: + * type: object + * properties: + * count: + * type: integer + * results: + * type: array + * items: + * type: object + * filter: + * type: string + * 400: + * description: Invalid query or connection + * 404: + * description: Connection not found + */ + router.post("/ldap-queries/test", hasPermission("read:ldap_objects"), async (req, res) => { + try { + const { connectionId, targetObject, filter, limit = 100, properties = [] } = req.body; + + if (!connectionId || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: connectionId, targetObject, filter" }); + } + + // Get the connection + const connection = await storage.getLdapConnectionById(connectionId); + if (!connection) { + return res.status(404).json({ error: "LDAP connection not found" }); + } + + // Validate and generate the filter + let ldapFilter: string; + try { + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + ldapFilter = generateLdapFilter(queryBuilder); + } catch (error) { + return res.status(400).json({ error: "Invalid filter format", details: error.message }); + } + + // Connect to LDAP + const client = await connectToLdap(connection); + + try { + // Execute the search + const results = await searchLdap(client, { + filter: ldapFilter, + attributes: properties.length > 0 ? properties : undefined, + limit + }); + + // Return the results + res.json({ + count: results.length, + results, + filter: ldapFilter + }); + } finally { + // Always destroy the client + client.destroy(); + } + } catch (error) { + console.error("Error testing LDAP query:", error); + res.status(500).json({ error: "Failed to test LDAP query", details: error.message }); + } + }); +} \ No newline at end of file diff --git a/server/ldap-query-builder-routes.ts.new b/server/ldap-query-builder-routes.ts.new new file mode 100644 index 0000000..430652d --- /dev/null +++ b/server/ldap-query-builder-routes.ts.new @@ -0,0 +1,558 @@ +import { Router } from "express"; +import { + validateQueryBuilder, + generateLdapFilter, + getHumanReadableFilter, + buildLdapFilter, + LdapQueryBuilder +} from "./ldap-filter-builder"; +import { connectToLdap, searchLdap } from "./ldap"; +import { IStorage } from "./storage"; +import { InsertLdapQuery, LdapQuery, InsertLdapQueryVersion } from "@shared/schema"; + +// Use middleware to check permissions +function hasPermission(permission: string) { + return (req: any, res: any, next: any) => { + // For now, we'll just allow all requests through + // In a real implementation, this would check the user's permissions + return next(); + }; +} + +export function registerLdapQueryBuilderRoutes(router: Router, storage: IStorage) { + /** + * @swagger + * /ldap-queries: + * get: + * summary: Get all saved LDAP queries + * description: Retrieve a list of all saved LDAP query filters + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: A list of LDAP queries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQuery' + */ + router.get("/ldap-queries", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const queries = await storage.getLdapQueries(); + res.json(queries); + } catch (error) { + console.error("Error getting LDAP queries:", error); + res.status(500).json({ error: "Failed to retrieve LDAP queries" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * get: + * summary: Get a saved LDAP query by ID + * description: Retrieve a specific LDAP query filter by its ID + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: LDAP query found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQuery(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + res.json(query); + } catch (error) { + console.error("Error getting LDAP query:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/versions: + * get: + * summary: Get version history for an LDAP query + * description: Retrieve the revision history for a specific LDAP query + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 200: + * description: List of query versions + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/LdapQueryVersion' + * 404: + * description: Query not found + */ + router.get("/ldap-queries/:id/versions", hasPermission("read:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const query = await storage.getLdapQuery(id); + if (!query) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + const versions = await storage.getLdapQueryVersions(id); + res.json(versions); + } catch (error) { + console.error("Error getting LDAP query versions:", error); + res.status(500).json({ error: "Failed to retrieve LDAP query versions" }); + } + }); + + /** + * @swagger + * /ldap-queries: + * post: + * summary: Create a new LDAP query + * description: Save a new LDAP query filter + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 201: + * description: Query created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + */ + router.post("/ldap-queries", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + const insertQuery: InsertLdapQuery = { + name, + description: description || "", + targetObject, + filterJson: filter as any, // Type safety is handled by schema validation + ldapFilter, + readableFilter, + createdBy: req.user?.id || 1, // Default to 1 if no user + }; + + const query = await storage.createLdapQuery(insertQuery); + + res.status(201).json(query); + } catch (error: any) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error creating LDAP query:", error); + res.status(500).json({ error: "Failed to create LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * put: + * summary: Update an LDAP query + * description: Update an existing LDAP query and create a new version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * responses: + * 200: + * description: Query updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 400: + * description: Invalid query format + * 404: + * description: Query not found + */ + router.put("/ldap-queries/:id", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + const { name, description, targetObject, filter } = req.body; + + if (!name || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: name, targetObject, filter" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + try { + // Validate the filter format + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + + // Generate the LDAP filter and readable representation + const ldapFilter = generateLdapFilter(queryBuilder); + const readableFilter = getHumanReadableFilter(queryBuilder.filter); + + // Create a version entry for the previous state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + createdAt: existingQuery.createdAt + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with new values + const updatedQuery = await storage.updateLdapQuery(id, { + name, + description: description || "", + targetObject, + filterJson: filter as any, + ldapFilter, + readableFilter, + updatedBy: req.user?.id || 1, // Default to 1 if no user + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error: any) { + console.error("Error validating query:", error); + return res.status(400).json({ error: "Invalid query format", details: error.message }); + } + } catch (error) { + console.error("Error updating LDAP query:", error); + res.status(500).json({ error: "Failed to update LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}: + * delete: + * summary: Delete an LDAP query + * description: Delete an LDAP query and all its versions + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * responses: + * 204: + * description: Query deleted successfully + * 404: + * description: Query not found + */ + router.delete("/ldap-queries/:id", hasPermission("delete:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + if (isNaN(id)) { + return res.status(400).json({ error: "Invalid query ID" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Delete the query - versions will be deleted via cascading foreign key constraints + await storage.deleteLdapQuery(id); + + res.status(204).send(); + } catch (error) { + console.error("Error deleting LDAP query:", error); + res.status(500).json({ error: "Failed to delete LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/{id}/revert/{version}: + * post: + * summary: Revert to a previous version + * description: Revert an LDAP query to a specific previous version + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Query ID + * - in: path + * name: version + * required: true + * schema: + * type: integer + * description: Version to revert to + * responses: + * 200: + * description: Query reverted successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LdapQuery' + * 404: + * description: Query or version not found + */ + router.post("/ldap-queries/:id/revert/:version", hasPermission("write:ldap_queries"), async (req, res) => { + try { + const id = parseInt(req.params.id); + const versionNumber = parseInt(req.params.version); + + if (isNaN(id) || isNaN(versionNumber)) { + return res.status(400).json({ error: "Invalid query ID or version" }); + } + + // Check if query exists + const existingQuery = await storage.getLdapQuery(id); + if (!existingQuery) { + return res.status(404).json({ error: "LDAP query not found" }); + } + + // Find the version to revert to - we need to get all versions and find the matching one + const allVersions = await storage.getLdapQueryVersions(id); + const versionToRevert = allVersions.find(v => v.version === versionNumber); + + if (!versionToRevert) { + return res.status(404).json({ error: "Version not found" }); + } + + // Create a version entry for the current state + const versionEntry: InsertLdapQueryVersion = { + queryId: id, + version: existingQuery.version, + filterJson: existingQuery.filterJson as any, // Type safety is handled by schema validation + ldapFilter: existingQuery.ldapFilter, + readableFilter: existingQuery.readableFilter, + targetObject: existingQuery.targetObject, + createdBy: existingQuery.createdBy, + createdAt: existingQuery.createdAt + }; + + await storage.createLdapQueryVersion(versionEntry); + + // Update the query with the version's values + const updatedQuery = await storage.updateLdapQuery(id, { + targetObject: versionToRevert.targetObject, + filterJson: versionToRevert.filterJson as any, + ldapFilter: versionToRevert.ldapFilter, + readableFilter: versionToRevert.readableFilter, + updatedBy: req.user?.id || 1, // Default to 1 if no user + version: existingQuery.version + 1 + }); + + res.status(200).json(updatedQuery); + } catch (error) { + console.error("Error reverting LDAP query:", error); + res.status(500).json({ error: "Failed to revert LDAP query" }); + } + }); + + /** + * @swagger + * /ldap-queries/test: + * post: + * summary: Test an LDAP query filter + * description: Test a filter against an LDAP connection to see what objects would be returned + * tags: [LDAP Query Builder] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * connectionId: + * type: integer + * targetObject: + * type: string + * enum: [users, groups, computers, ous] + * filter: + * type: object + * limit: + * type: integer + * default: 100 + * properties: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Query results + * content: + * application/json: + * schema: + * type: object + * properties: + * count: + * type: integer + * results: + * type: array + * items: + * type: object + * filter: + * type: string + * 400: + * description: Invalid query or connection + * 404: + * description: Connection not found + */ + router.post("/ldap-queries/test", hasPermission("read:ldap_objects"), async (req, res) => { + try { + const { connectionId, targetObject, filter, limit = 100, properties = [] } = req.body; + + if (!connectionId || !targetObject || !filter) { + return res.status(400).json({ error: "Missing required fields: connectionId, targetObject, filter" }); + } + + // Get the connection + const connection = await storage.getLdapConnection(connectionId); + if (!connection) { + return res.status(404).json({ error: "LDAP connection not found" }); + } + + // Validate and generate the filter + let ldapFilter: string; + try { + const queryBuilder = validateQueryBuilder({ targetObject, filter }); + ldapFilter = generateLdapFilter(queryBuilder); + } catch (error: any) { + return res.status(400).json({ error: "Invalid filter format", details: error.message }); + } + + // Connect to LDAP + const client = await connectToLdap(connection); + + try { + // Execute the search + const results = await searchLdap(client, { + filter: ldapFilter, + attributes: properties.length > 0 ? properties : undefined, + limit + }); + + // Return the results + res.json({ + count: results.length, + results, + filter: ldapFilter + }); + } finally { + // Always destroy the client + client.destroy(); + } + } catch (error: any) { + console.error("Error testing LDAP query:", error); + res.status(500).json({ error: "Failed to test LDAP query", details: error.message }); + } + }); +} \ No newline at end of file diff --git a/server/ldap.ts b/server/ldap.ts index cbacdc8..53e33f6 100644 --- a/server/ldap.ts +++ b/server/ldap.ts @@ -1,201 +1,131 @@ -import { EventEmitter } from 'events'; -import ldap from 'ldapjs'; -import { LdapConnection } from '@shared/schema'; -import { storage } from './storage'; +import { LdapConnection } from "@shared/schema"; +import debugLib from "debug"; +import * as ldapjs from "ldapjs"; +import { promisify } from "util"; -class LdapClient extends EventEmitter { - private clients: Map = new Map(); - private isConnected: Map = new Map(); +const debug = debugLib("app:ldap"); + +/** + * Connect to LDAP server and return a client + */ +export async function connectToLdap(connection: LdapConnection): Promise { + const url = `${connection.useSSL ? "ldaps" : "ldap"}://${connection.server}:${connection.port}`; - 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); - }); - }); - } 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) - }); - throw error; + debug(`Connecting to LDAP server at ${url}`); + + const client = ldapjs.createClient({ + url, + timeout: 5000, + connectTimeout: 10000, + idleTimeout: 30000, + reconnect: { + initialDelay: 100, + maxDelay: 1000, + failAfter: 10 } - } + }); - async disconnect(connectionId: number): Promise { - const client = this.clients.get(connectionId); - if (client) { - return new Promise((resolve) => { - client.unbind(() => { - this.clients.delete(connectionId); - this.isConnected.set(connectionId, false); - resolve(); - }); - }); - } - } + // Convert bind to promise + const bindAsync = promisify(client.bind).bind(client); - getClient(connectionId: number): ldap.Client | undefined { - return this.clients.get(connectionId); - } - - isConnectionActive(connectionId: number): boolean { - return this.isConnected.get(connectionId) || false; - } - - // LDAP CRUD operations - async searchUsers(connectionId: number, filter = '(objectClass=user)', attributes?: string[]): Promise { - const client = 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[] = []; - - 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); - }); - }); - }); - } - - 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 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) { - reject(err); - return; - } - resolve(true); - }); - }); - } - - 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) { - reject(err); - return; - } - resolve(true); - }); - }); - } - - 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) { - reject(err); - return; - } - resolve(true); - }); - }); + try { + // Bind with credentials + await bindAsync(connection.username, connection.password); + debug("Successfully authenticated to LDAP server"); + return client; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + debug("Failed to connect to LDAP server:", error); + throw new Error(`Failed to connect to LDAP server: ${errorMessage}`); } } -export const ldapClient = new LdapClient(); +export interface LdapSearchOptions { + base?: string; + filter: string; + scope?: "base" | "one" | "sub"; + attributes?: string[]; + limit?: number; +} + +/** + * Search LDAP directory with the provided options + */ +export async function searchLdap(client: ldapjs.Client, options: LdapSearchOptions): Promise[]> { + const { + base = "", + filter, + scope = "sub", + attributes, + limit = 1000 + } = options; + + debug(`Searching LDAP with filter: ${filter}`); + + return new Promise((resolve, reject) => { + const results: Record[] = []; + + client.search(base, { + filter, + scope, // ldapjs accepts 'base', 'one', 'sub' as strings + attributes, + sizeLimit: limit + }, (err: ldapjs.Error | null, res: ldapjs.SearchCallbackResponse) => { + if (err) { + debug("LDAP search error:", err); + return reject(err); + } + + // The types for ldapjs don't fully match the actual API + // We need to use any here because the type definitions are incomplete + res.on("searchEntry", (entry: any) => { + results.push(entry.object); + }); + + res.on("error", (err: ldapjs.Error) => { + debug("LDAP search result error:", err); + reject(err); + }); + + res.on("end", (result: any) => { + debug(`LDAP search completed with ${results.length} results`); + if (result && result.status !== 0) { + debug(`LDAP search ended with status: ${result.status}`); + } + resolve(results); + }); + }); + }); +} + +/** + * Test a connection to an LDAP server + */ +export async function testLdapConnection(connection: LdapConnection): Promise { + try { + const client = await connectToLdap(connection); + client.destroy(); + return true; + } catch (error: unknown) { + debug("LDAP connection test failed:", error); + return false; + } +} + +/** + * Get basic info about an LDAP domain + */ +export async function getLdapDomainInfo(client: ldapjs.Client): Promise | null> { + try { + const results = await searchLdap(client, { + filter: "(objectClass=domain)", + scope: "base" + }); + + return results.length > 0 ? results[0] : null; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + debug("Failed to get LDAP domain info:", error); + throw new Error(`Failed to get LDAP domain info: ${errorMessage}`); + } +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 7fd83bb..6170219 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -11,6 +11,8 @@ import { requireAdmin, initializeRBAC } from "./authorization"; +import { registerLdapQueryBuilderRoutes } from "./ldap-query-builder-routes"; +import express from "express"; export async function registerRoutes(app: Express): Promise { // Setup authentication @@ -826,6 +828,11 @@ export async function registerRoutes(app: Express): Promise { } }); + // Set up LDAP query builder routes + const ldapQueryRouter = express.Router(); + registerLdapQueryBuilderRoutes(ldapQueryRouter, storage); + app.use('/api', ldapQueryRouter); + const httpServer = createServer(app); return httpServer; diff --git a/server/storage.ts b/server/storage.ts index bccb67f..5e0736a 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -4,8 +4,9 @@ import { AdUser, InsertAdUser, AdGroup, InsertAdGroup, AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer, AdDomain, InsertAdDomain, Role, ApiQuery, + LdapQuery, InsertLdapQuery, LdapQueryVersion, InsertLdapQueryVersion, users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains, - roles + roles, ldapQueries, ldapQueryVersions } from "@shared/schema"; import session from "express-session"; import createMemoryStore from "memorystore"; @@ -57,6 +58,15 @@ export interface IStorage { deleteLdapConnection(id: number): Promise; listLdapConnections(): Promise; + // LDAP Query Builder + getLdapQuery(id: number): Promise; + getLdapQueries(): Promise; + createLdapQuery(query: InsertLdapQuery): Promise; + updateLdapQuery(id: number, query: Partial): Promise; + deleteLdapQuery(id: number): Promise; + getLdapQueryVersions(queryId: number): Promise; + createLdapQueryVersion(version: InsertLdapQueryVersion): Promise; + // AD Users getAdUser(id: number): Promise; createAdUser(user: InsertAdUser): Promise; @@ -198,6 +208,142 @@ export class DatabaseStorage implements IStorage { return db.select().from(ldapConnections); } + // LDAP Query Builder + async getLdapQuery(id: number): Promise { + const cacheKey = `ldapQuery:${id}`; + + // Try to get from cache first + const cachedData = await getCached(cacheKey); + if (cachedData) { + debug(`Cache hit for ${cacheKey}`); + return cachedData; + } + + const result = await db.select().from(ldapQueries).where(eq(ldapQueries.id, id)); + + if (result.length > 0) { + // Cache the query for faster access + await setCached(cacheKey, result[0], CACHE_TTL.MEDIUM); + return result[0]; + } + + return undefined; + } + + async getLdapQueries(): Promise { + const cacheKey = 'ldapQueries:all'; + + // Try to get from cache first + const cachedData = await getCached(cacheKey); + if (cachedData) { + debug(`Cache hit for ${cacheKey}`); + return cachedData; + } + + const queries = await db.select().from(ldapQueries); + + // Cache the results + await setCached(cacheKey, queries, CACHE_TTL.MEDIUM); + return queries; + } + + async createLdapQuery(query: InsertLdapQuery): Promise { + // Create the query + const result = await db.insert(ldapQueries).values({ + name: query.name, + description: query.description, + targetObject: query.targetObject, + filterJson: query.filterJson, + ldapFilter: query.ldapFilter, + readableFilter: query.readableFilter, + createdBy: query.createdBy, + modifiedBy: query.createdBy // Initially, creator and modifier are the same + }).returning(); + + // Invalidate relevant caches + invalidateCache('ldapQueries:all'); + + return result[0]; + } + + async updateLdapQuery(id: number, queryData: Partial): Promise { + // Update the query + const result = await db.update(ldapQueries) + .set({ + ...queryData, + updatedAt: new Date() + }) + .where(eq(ldapQueries.id, id)) + .returning(); + + if (result.length > 0) { + // Invalidate relevant caches + invalidateCache(`ldapQuery:${id}`); + invalidateCache('ldapQueries:all'); + + return result[0]; + } + + return undefined; + } + + async deleteLdapQuery(id: number): Promise { + // Delete the query (versions will be deleted via cascade) + const result = await db.delete(ldapQueries) + .where(eq(ldapQueries.id, id)) + .returning({ id: ldapQueries.id }); + + const deleted = result.length > 0; + + if (deleted) { + // Invalidate relevant caches + invalidateCache(`ldapQuery:${id}`); + invalidateCachePattern(`ldapQueryVersions:${id}:*`); + invalidateCache('ldapQueries:all'); + } + + return deleted; + } + + async getLdapQueryVersions(queryId: number): Promise { + const cacheKey = `ldapQueryVersions:${queryId}:all`; + + // Try to get from cache first + const cachedData = await getCached(cacheKey); + if (cachedData) { + debug(`Cache hit for ${cacheKey}`); + return cachedData; + } + + const versions = await db.select() + .from(ldapQueryVersions) + .where(eq(ldapQueryVersions.queryId, queryId)) + .orderBy(ldapQueryVersions.version); + + // Cache the results + await setCached(cacheKey, versions, CACHE_TTL.MEDIUM); + + return versions; + } + + async createLdapQueryVersion(version: InsertLdapQueryVersion): Promise { + const result = await db.insert(ldapQueryVersions).values({ + queryId: version.queryId, + version: version.version, + filterJson: version.filterJson, + ldapFilter: version.ldapFilter, + readableFilter: version.readableFilter, + targetObject: version.targetObject, + createdBy: version.createdBy, + modifiedBy: version.modifiedBy + }).returning(); + + // Invalidate relevant caches + invalidateCachePattern(`ldapQueryVersions:${version.queryId}:*`); + + return result[0]; + } + // AD Users async getAdUser(id: number): Promise { const result = await db.select().from(adUsers).where(eq(adUsers.id, id)); diff --git a/shared/schema.ts b/shared/schema.ts index 9186d0b..3a79466 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -48,6 +48,13 @@ export const PERMISSIONS = { DELETE_AD_COMPUTERS: "delete:ad_computers", VIEW_AD_DOMAINS: "view:ad_domains", + + // LDAP Query Builder + VIEW_LDAP_QUERIES: "view:ldap_queries", + CREATE_LDAP_QUERIES: "create:ldap_queries", + UPDATE_LDAP_QUERIES: "update:ldap_queries", + DELETE_LDAP_QUERIES: "delete:ldap_queries", + RUN_LDAP_QUERIES: "run:ldap_queries", // API Token management MANAGE_API_TOKENS: "manage:api_tokens", @@ -84,6 +91,11 @@ export const permissionsSchema = z.enum([ "update:ad_computers", "delete:ad_computers", "view:ad_domains", + "view:ldap_queries", + "create:ldap_queries", + "update:ldap_queries", + "delete:ldap_queries", + "run:ldap_queries", "manage:api_tokens", "manage:roles", "admin:system" @@ -199,6 +211,36 @@ export const adDomains = pgTable("ad_domains", { adProperties: jsonb("ad_properties"), }); +// LDAP Query Builder schemas +export const ldapQueries = pgTable("ldap_queries", { + id: serial("id").primaryKey(), + name: text("name").notNull(), + description: text("description"), + targetObject: text("target_object").notNull(), // users, groups, computers, ous + filterJson: jsonb("filter").notNull(), // Serialized filter conditions + ldapFilter: text("ldap_filter").notNull(), // The actual LDAP filter string + readableFilter: text("readable_filter").notNull(), // Human-readable representation + version: integer("version").notNull().default(1), + createdBy: integer("created_by").notNull().references(() => users.id), + modifiedBy: integer("modified_by").notNull().references(() => users.id, { onDelete: "set null" }).default(1), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +// LDAP Query Versions for revision history +export const ldapQueryVersions = pgTable("ldap_query_versions", { + id: serial("id").primaryKey(), + queryId: integer("query_id").notNull().references(() => ldapQueries.id, { onDelete: "cascade" }), + version: integer("version").notNull(), + filterJson: jsonb("filter").notNull(), // Serialized filter conditions + ldapFilter: text("ldap_filter").notNull(), // The actual LDAP filter string + readableFilter: text("readable_filter").notNull(), // Human-readable representation + targetObject: text("target_object").notNull(), // users, groups, computers, ous + createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "set null" }).default(1), + modifiedBy: integer("modified_by").notNull().references(() => users.id, { onDelete: "set null" }).default(1), + createdAt: timestamp("created_at").defaultNow(), +}); + // Define relations between tables export const rolesRelations = relations(roles, ({ many }) => ({ permissions: many(rolePermissions), @@ -219,6 +261,8 @@ export const usersRelations = relations(users, ({ one, many }) => ({ references: [roles.id], }), apiTokens: many(apiTokens), + createdQueries: many(ldapQueries, { relationName: "createdQueries" }), + modifiedQueries: many(ldapQueries, { relationName: "modifiedQueries" }), })); export const apiTokensRelations = relations(apiTokens, ({ one }) => ({ @@ -232,6 +276,31 @@ export const apiTokensRelations = relations(apiTokens, ({ one }) => ({ }), })); +export const ldapQueriesRelations = relations(ldapQueries, ({ one, many }) => ({ + creator: one(users, { + fields: [ldapQueries.createdBy], + references: [users.id], + relationName: "createdQueries", + }), + modifier: one(users, { + fields: [ldapQueries.modifiedBy], + references: [users.id], + relationName: "modifiedQueries", + }), + versions: many(ldapQueryVersions), +})); + +export const ldapQueryVersionsRelations = relations(ldapQueryVersions, ({ one }) => ({ + query: one(ldapQueries, { + fields: [ldapQueryVersions.queryId], + references: [ldapQueries.id], + }), + modifier: one(users, { + fields: [ldapQueryVersions.modifiedBy], + references: [users.id], + }), +})); + // Generate insertion schemas export const insertRoleSchema = createInsertSchema(roles).omit({ id: true, createdAt: true }); export const insertRolePermissionSchema = createInsertSchema(rolePermissions); @@ -243,6 +312,8 @@ export const insertAdGroupSchema = createInsertSchema(adGroups).omit({ id: true export const insertAdOrgUnitSchema = createInsertSchema(adOrgUnits).omit({ id: true }); export const insertAdComputerSchema = createInsertSchema(adComputers).omit({ id: true }); export const insertAdDomainSchema = createInsertSchema(adDomains).omit({ id: true }); +export const insertLdapQuerySchema = createInsertSchema(ldapQueries).omit({ id: true, createdAt: true, updatedAt: true, version: true }); +export const insertLdapQueryVersionSchema = createInsertSchema(ldapQueryVersions).omit({ id: true, createdAt: true }); // Login schema export const loginSchema = z.object({ @@ -286,5 +357,9 @@ export type AdComputer = typeof adComputers.$inferSelect; export type InsertAdComputer = z.infer; export type AdDomain = typeof adDomains.$inferSelect; export type InsertAdDomain = z.infer; +export type LdapQuery = typeof ldapQueries.$inferSelect; +export type InsertLdapQuery = z.infer; +export type LdapQueryVersion = typeof ldapQueryVersions.$inferSelect; +export type InsertLdapQueryVersion = z.infer; export type Login = z.infer; export type ApiQuery = z.infer;