From e327e56fcb1ede7d01e901cfad7c20dbe2421805 Mon Sep 17 00:00:00 2001 From: alphaeusmote <41258468-alphaeusmote@users.noreply.replit.com> Date: Wed, 9 Apr 2025 18:49:54 +0000 Subject: [PATCH] Add support for querying Active Directory sites and subnets Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/38d85472-a91b-4a98-977a-cd9ab8f43196.jpg --- package-lock.json | 21 + package.json | 2 + server/ldap.ts | 88 ++++ server/routes.ts | 1081 ++++++++++++++++++++++++++++++++++++++++++++- server/storage.ts | 155 ++++++- server/swagger.ts | 39 ++ shared/schema.ts | 41 +- 7 files changed, 1423 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c96b995..3f1f170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^10.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -88,6 +89,7 @@ "swagger-ui-express": "^5.0.1", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", "vaul": "^1.1.0", "wouter": "^3.3.5", "ws": "^8.18.0", @@ -3852,6 +3854,12 @@ "@types/serve-static": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -9110,6 +9118,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/validator": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", diff --git a/package.json b/package.json index 39b9027..31fe5e6 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/passport-jwt": "^4.0.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^10.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -90,6 +91,7 @@ "swagger-ui-express": "^5.0.1", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", "vaul": "^1.1.0", "wouter": "^3.3.5", "ws": "^8.18.0", diff --git a/server/ldap.ts b/server/ldap.ts index 9fbdd2f..2f81108 100644 --- a/server/ldap.ts +++ b/server/ldap.ts @@ -152,6 +152,94 @@ class LdapClient extends EventEmitter { return this.searchUsers(connectionId, filter, attributes || defaultAttributes); } + async searchSites(connectionId: number, filter = '(objectClass=site)', attributes?: string[]): Promise { + const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'managedBy']; + // Sites are typically stored in the Configuration naming context + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + + // Sites are located in the Configuration container + const baseDN = 'CN=Sites,CN=Configuration,' + this.getDomainDN(connection); + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + return new Promise((resolve, reject) => { + const results: any[] = []; + + client.search(baseDN, { + filter, + scope: 'sub', + attributes: searchAttributes + }, (err, res) => { + if (err) { + reject(err); + return; + } + + res.on('searchEntry', (entry) => { + results.push(entry.object); + }); + + res.on('error', (err) => { + reject(err); + }); + + res.on('end', (result) => { + resolve(results); + }); + }); + }); + } + + async searchSubnets(connectionId: number, filter = '(objectClass=subnet)', attributes?: string[]): Promise { + const defaultAttributes = ['cn', 'distinguishedName', 'description', 'location', 'siteObject', 'managedBy']; + // Subnets are typically stored in the Configuration naming context + const client = this.getClient(connectionId); + if (!client) throw new Error('LDAP connection not established'); + + const connection = await storage.getLdapConnection(connectionId); + if (!connection) throw new Error('LDAP connection not found'); + + // Subnets are located in the Configuration container + const baseDN = 'CN=Subnets,CN=Sites,CN=Configuration,' + this.getDomainDN(connection); + const searchAttributes = attributes?.length ? attributes : defaultAttributes; + + return new Promise((resolve, reject) => { + const results: any[] = []; + + client.search(baseDN, { + filter, + scope: 'sub', + attributes: searchAttributes + }, (err, res) => { + if (err) { + reject(err); + return; + } + + res.on('searchEntry', (entry) => { + results.push(entry.object); + }); + + res.on('error', (err) => { + reject(err); + }); + + res.on('end', (result) => { + resolve(results); + }); + }); + }); + } + + // Helper function to extract domain DN from connection + getDomainDN(connection: LdapConnection): string { + const domainParts = connection.domain.split('.'); + return domainParts.map(part => `DC=${part}`).join(','); + } + async createEntry(connectionId: number, dn: string, attributes: any): Promise { const client = this.getClient(connectionId); if (!client) throw new Error('LDAP connection not established'); diff --git a/server/routes.ts b/server/routes.ts index 26b84b5..2c2743a 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -15,7 +15,9 @@ import { adGroups, adOrgUnits, adComputers, - adDomains + adDomains, + adSites, + adSubnets } from "@shared/schema"; import { ZodError } from "zod"; import rateLimit from "express-rate-limit"; @@ -33,6 +35,7 @@ import { parsePagination } from "./query-parser"; import { eq, sql } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; // Extend Express Request to include user property interface Request extends ExpressRequest { @@ -3531,6 +3534,1082 @@ export async function registerRoutes(app: Express): Promise { * 404: * $ref: '#/components/responses/NotFoundError' */ + // AD Sites endpoints + /** + * @swagger + * /api/connections/{connectionId}/ad-sites: + * get: + * summary: List AD sites from the specified LDAP connection + * tags: [AD Sites] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - $ref: '#/components/parameters/filterParam' + * - $ref: '#/components/parameters/selectParam' + * - $ref: '#/components/parameters/expandParam' + * - $ref: '#/components/parameters/orderByParam' + * - $ref: '#/components/parameters/topParam' + * - $ref: '#/components/parameters/skipParam' + * responses: + * 200: + * description: A list of AD sites + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/AdSite' + * metadata: + * $ref: '#/components/responses/PaginatedResponse/content/application~1json/schema/properties/metadata' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.get("/api/connections/:connectionId/ad-sites", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + const query = parseQueryParams(req); + const sites = await storage.listAdSites(connectionId, query); + + // Count total records for pagination metadata + const countQuery = db.select({ count: sql`count(*)` }).from(adSites) + .where(eq(adSites.connectionId, connectionId)); + + // Apply filters if present + if (query && query.filter) { + const conditions = parseFilter(query.filter); + const whereClause = applyFilterConditions(adSites, conditions); + if (whereClause) { + countQuery.where(whereClause); + } + } + + const [countResult] = await countQuery; + const totalRecords = Number(countResult?.count || 0); + + // Add objectType to each result + const sitesWithObjectType = sites.map(site => ({ + ...site, + objectType: 'site' + })); + + // Generate pagination metadata + const { limit, offset } = parsePagination(query?.top, query?.skip); + const paginationMetadata = generatePaginationMetadata( + totalRecords, + limit, + offset, + `${req.protocol}://${req.get('host')}${req.originalUrl}` + ); + + res.json({ + data: sitesWithObjectType, + metadata: paginationMetadata + }); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-sites/{objectGUID}: + * get: + * summary: Get details of a specific AD site + * tags: [AD Sites] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Details of the AD site + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSite' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.get("/api/connections/:connectionId/ad-sites/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); + + if (!site) { + return res.status(404).json({ message: "AD site not found" }); + } + + // Add objectType to the result + const siteWithObjectType = { + ...site, + objectType: 'site' + }; + + res.json(siteWithObjectType); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-sites: + * post: + * summary: Create a new AD site + * tags: [AD Sites] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * location: + * type: string + * responses: + * 201: + * description: Successfully created AD site + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSite' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.post("/api/connections/:connectionId/ad-sites", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // First create the site in AD + const siteName = req.body.name; + const description = req.body.description || ''; + const location = req.body.location || ''; + + // Create site in AD using LDAP + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Create a CN for the new site + const siteDN = `CN=${siteName},CN=Sites,CN=Configuration,${ldapClient.getDomainDN(connection)}`; + + // Attributes for the new site + const siteAttributes = { + objectClass: ['site'], + cn: siteName, + description: description, + location: location + }; + + try { + const success = await ldapClient.createEntry(connectionId, siteDN, siteAttributes); + + if (!success) { + return res.status(500).json({ message: "Failed to create AD site" }); + } + + // Search for the site to get all its attributes + const siteResults = await ldapClient.searchSites(connectionId, `(cn=${siteName})`); + + if (!siteResults || siteResults.length === 0) { + return res.status(500).json({ message: "Site created but could not retrieve details" }); + } + + const siteData = siteResults[0]; + + // Create entry in our database + const newSite = await storage.createAdSite({ + connectionId, + name: siteName, + objectGUID: siteData.objectGUID || uuidv4(), + objectType: 'site', + dn: siteDN, + canonicalName: `${connection.domain}/Configuration/Sites/${siteName}`, + description: description, + location: location, + managedBy: null, + adProperties: siteData + }); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'create', + targetId: newSite.id.toString(), + details: { type: 'site', name: siteName, dn: siteDN }, + userId: req.user?.id, + connectionId + }); + + res.status(201).json(newSite); + } catch (err) { + console.error("Error creating AD site:", err); + return res.status(500).json({ message: `Error creating AD site: ${err.message}` }); + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-sites/{objectGUID}: + * patch: + * summary: Update an AD site + * tags: [AD Sites] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * description: + * type: string + * location: + * type: string + * managedBy: + * type: string + * responses: + * 200: + * description: Successfully updated AD site + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSite' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.patch("/api/connections/:connectionId/ad-sites/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); + + if (!site) { + return res.status(404).json({ message: "AD site not found" }); + } + + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // Connect to LDAP if needed + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Prepare changes + const changes = []; + const updateData: Partial = {}; + + if (req.body.description !== undefined) { + changes.push({ + operation: 'replace', + modification: { + description: req.body.description + } + }); + updateData.description = req.body.description; + } + + if (req.body.location !== undefined) { + changes.push({ + operation: 'replace', + modification: { + location: req.body.location + } + }); + updateData.location = req.body.location; + } + + // Handle managedBy separately + if (req.body.managedBy !== undefined) { + // Set the managedBy attribute + const success = await ldapClient.setManagedBy(connectionId, site.dn, req.body.managedBy); + + if (!success) { + return res.status(500).json({ message: "Failed to update managedBy attribute" }); + } + + updateData.managedBy = req.body.managedBy; + } + + // Apply changes to LDAP if there are any attribute changes + if (changes.length > 0) { + try { + const success = await ldapClient.updateEntry(connectionId, site.dn, changes); + + if (!success) { + return res.status(500).json({ message: "Failed to update AD site" }); + } + } catch (err) { + console.error("Error updating AD site:", err); + return res.status(500).json({ message: `Error updating AD site: ${err.message}` }); + } + } + + // Update our database record + const updatedSite = await storage.updateAdSiteByObjectGUID(connectionId, objectGUID, updateData); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'update', + targetId: site.id.toString(), + details: { type: 'site', name: site.name, dn: site.dn, changes: updateData }, + userId: req.user?.id, + connectionId + }); + + res.json(updatedSite); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-sites/{objectGUID}: + * delete: + * summary: Delete an AD site + * tags: [AD Sites] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Successfully deleted AD site + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.delete("/api/connections/:connectionId/ad-sites/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const site = await storage.getAdSiteByObjectGUID(connectionId, objectGUID); + + if (!site) { + return res.status(404).json({ message: "AD site not found" }); + } + + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // Connect to LDAP if needed + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Delete from LDAP + try { + const success = await ldapClient.deleteEntry(connectionId, site.dn); + + if (!success) { + return res.status(500).json({ message: "Failed to delete AD site" }); + } + + // Delete from our database + await storage.deleteAdSite(site.id); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'delete', + targetId: site.id.toString(), + details: { type: 'site', name: site.name, dn: site.dn }, + userId: req.user?.id, + connectionId + }); + + res.json({ success: true }); + } catch (err) { + console.error("Error deleting AD site:", err); + return res.status(500).json({ message: `Error deleting AD site: ${err.message}` }); + } + } catch (error) { + next(error); + } + }); + + // AD Subnets endpoints + /** + * @swagger + * /api/connections/{connectionId}/ad-subnets: + * get: + * summary: List AD subnets from the specified LDAP connection + * tags: [AD Subnets] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - $ref: '#/components/parameters/filterParam' + * - $ref: '#/components/parameters/selectParam' + * - $ref: '#/components/parameters/expandParam' + * - $ref: '#/components/parameters/orderByParam' + * - $ref: '#/components/parameters/topParam' + * - $ref: '#/components/parameters/skipParam' + * responses: + * 200: + * description: A list of AD subnets + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/AdSubnet' + * metadata: + * $ref: '#/components/responses/PaginatedResponse/content/application~1json/schema/properties/metadata' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.get("/api/connections/:connectionId/ad-subnets", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + const query = parseQueryParams(req); + const subnets = await storage.listAdSubnets(connectionId, query); + + // Count total records for pagination metadata + const countQuery = db.select({ count: sql`count(*)` }).from(adSubnets) + .where(eq(adSubnets.connectionId, connectionId)); + + // Apply filters if present + if (query && query.filter) { + const conditions = parseFilter(query.filter); + const whereClause = applyFilterConditions(adSubnets, conditions); + if (whereClause) { + countQuery.where(whereClause); + } + } + + const [countResult] = await countQuery; + const totalRecords = Number(countResult?.count || 0); + + // Add objectType to each result + const subnetsWithObjectType = subnets.map(subnet => ({ + ...subnet, + objectType: 'subnet' + })); + + // Generate pagination metadata + const { limit, offset } = parsePagination(query?.top, query?.skip); + const paginationMetadata = generatePaginationMetadata( + totalRecords, + limit, + offset, + `${req.protocol}://${req.get('host')}${req.originalUrl}` + ); + + res.json({ + data: subnetsWithObjectType, + metadata: paginationMetadata + }); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-subnets/{objectGUID}: + * get: + * summary: Get details of a specific AD subnet + * tags: [AD Subnets] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Details of the AD subnet + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSubnet' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.get("/api/connections/:connectionId/ad-subnets/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); + + if (!subnet) { + return res.status(404).json({ message: "AD subnet not found" }); + } + + // Add objectType to the result + const subnetWithObjectType = { + ...subnet, + objectType: 'subnet' + }; + + res.json(subnetWithObjectType); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-subnets: + * post: + * summary: Create a new AD subnet + * tags: [AD Subnets] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: The subnet name (e.g., 192.168.1.0/24) + * description: + * type: string + * location: + * type: string + * siteObject: + * type: string + * description: The distinguishedName of the site this subnet belongs to + * responses: + * 201: + * description: Successfully created AD subnet + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSubnet' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.post("/api/connections/:connectionId/ad-subnets", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // First create the subnet in AD + const subnetName = req.body.name; + const description = req.body.description || ''; + const location = req.body.location || ''; + const siteObject = req.body.siteObject || ''; + + if (!subnetName) { + return res.status(400).json({ message: "Subnet name is required" }); + } + + // Create subnet in AD using LDAP + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Create a CN for the new subnet + const subnetDN = `CN=${subnetName},CN=Subnets,CN=Sites,CN=Configuration,${ldapClient.getDomainDN(connection)}`; + + // Attributes for the new subnet + const subnetAttributes = { + objectClass: ['subnet'], + cn: subnetName, + description: description, + location: location + }; + + // Add siteObject if provided + if (siteObject) { + subnetAttributes.siteObject = siteObject; + } + + try { + const success = await ldapClient.createEntry(connectionId, subnetDN, subnetAttributes); + + if (!success) { + return res.status(500).json({ message: "Failed to create AD subnet" }); + } + + // Search for the subnet to get all its attributes + const subnetResults = await ldapClient.searchSubnets(connectionId, `(cn=${subnetName})`); + + if (!subnetResults || subnetResults.length === 0) { + return res.status(500).json({ message: "Subnet created but could not retrieve details" }); + } + + const subnetData = subnetResults[0]; + + // Create entry in our database + const newSubnet = await storage.createAdSubnet({ + connectionId, + name: subnetName, + objectGUID: subnetData.objectGUID || uuidv4(), + objectType: 'subnet', + dn: subnetDN, + canonicalName: `${connection.domain}/Configuration/Sites/Subnets/${subnetName}`, + description: description, + location: location, + siteObject: siteObject, + managedBy: null, + adProperties: subnetData + }); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'create', + targetId: newSubnet.id.toString(), + details: { type: 'subnet', name: subnetName, dn: subnetDN }, + userId: req.user?.id, + connectionId + }); + + res.status(201).json(newSubnet); + } catch (err) { + console.error("Error creating AD subnet:", err); + return res.status(500).json({ message: `Error creating AD subnet: ${err.message}` }); + } + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-subnets/{objectGUID}: + * patch: + * summary: Update an AD subnet + * tags: [AD Subnets] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * description: + * type: string + * location: + * type: string + * siteObject: + * type: string + * description: The distinguishedName of the site this subnet belongs to + * managedBy: + * type: string + * responses: + * 200: + * description: Successfully updated AD subnet + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AdSubnet' + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.patch("/api/connections/:connectionId/ad-subnets/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); + + if (!subnet) { + return res.status(404).json({ message: "AD subnet not found" }); + } + + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // Connect to LDAP if needed + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Prepare changes + const changes = []; + const updateData: Partial = {}; + + if (req.body.description !== undefined) { + changes.push({ + operation: 'replace', + modification: { + description: req.body.description + } + }); + updateData.description = req.body.description; + } + + if (req.body.location !== undefined) { + changes.push({ + operation: 'replace', + modification: { + location: req.body.location + } + }); + updateData.location = req.body.location; + } + + if (req.body.siteObject !== undefined) { + changes.push({ + operation: 'replace', + modification: { + siteObject: req.body.siteObject + } + }); + updateData.siteObject = req.body.siteObject; + } + + // Handle managedBy separately + if (req.body.managedBy !== undefined) { + // Set the managedBy attribute + const success = await ldapClient.setManagedBy(connectionId, subnet.dn, req.body.managedBy); + + if (!success) { + return res.status(500).json({ message: "Failed to update managedBy attribute" }); + } + + updateData.managedBy = req.body.managedBy; + } + + // Apply changes to LDAP if there are any attribute changes + if (changes.length > 0) { + try { + const success = await ldapClient.updateEntry(connectionId, subnet.dn, changes); + + if (!success) { + return res.status(500).json({ message: "Failed to update AD subnet" }); + } + } catch (err) { + console.error("Error updating AD subnet:", err); + return res.status(500).json({ message: `Error updating AD subnet: ${err.message}` }); + } + } + + // Update our database record + const updatedSubnet = await storage.updateAdSubnetByObjectGUID(connectionId, objectGUID, updateData); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'update', + targetId: subnet.id.toString(), + details: { type: 'subnet', name: subnet.name, dn: subnet.dn, changes: updateData }, + userId: req.user?.id, + connectionId + }); + + res.json(updatedSubnet); + } catch (error) { + next(error); + } + }); + + /** + * @swagger + * /api/connections/{connectionId}/ad-subnets/{objectGUID}: + * delete: + * summary: Delete an AD subnet + * tags: [AD Subnets] + * security: + * - bearerAuth: [] + * - cookieAuth: [] + * parameters: + * - name: connectionId + * in: path + * required: true + * schema: + * type: integer + * - name: objectGUID + * in: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Successfully deleted AD subnet + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * 401: + * $ref: '#/components/responses/UnauthorizedError' + * 404: + * $ref: '#/components/responses/NotFoundError' + */ + app.delete("/api/connections/:connectionId/ad-subnets/:objectGUID", authenticateApiToken, async (req, res, next) => { + try { + if (!req.isAuthenticated() && !req.headers.authorization) { + return res.status(401).json({ message: "Authentication required" }); + } + + const connectionId = parseInt(req.params.connectionId); + const objectGUID = req.params.objectGUID; + + const subnet = await storage.getAdSubnetByObjectGUID(connectionId, objectGUID); + + if (!subnet) { + return res.status(404).json({ message: "AD subnet not found" }); + } + + const connection = await storage.getLdapConnection(connectionId); + + if (!connection) { + return res.status(404).json({ message: "LDAP connection not found" }); + } + + // Connect to LDAP if needed + const client = ldapClient.getClient(connectionId); + + if (!client) { + const connected = await ldapClient.connect(connection); + if (!connected) { + return res.status(500).json({ message: "Failed to connect to LDAP server" }); + } + } + + // Delete from LDAP + try { + const success = await ldapClient.deleteEntry(connectionId, subnet.dn); + + if (!success) { + return res.status(500).json({ message: "Failed to delete AD subnet" }); + } + + // Delete from our database + await storage.deleteAdSubnet(subnet.id); + + // Create audit log entry + await storage.createAuditLogEntry({ + action: 'delete', + targetId: subnet.id.toString(), + details: { type: 'subnet', name: subnet.name, dn: subnet.dn }, + userId: req.user?.id, + connectionId + }); + + res.json({ success: true }); + } catch (err) { + console.error("Error deleting AD subnet:", err); + return res.status(500).json({ message: `Error deleting AD subnet: ${err.message}` }); + } + } catch (error) { + next(error); + } + }); + app.get("/api/connections/:connectionId/audit-logs", authenticateApiToken, async (req, res, next) => { try { const connectionId = parseInt(req.params.connectionId); diff --git a/server/storage.ts b/server/storage.ts index f82b6b9..dfd4065 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -3,11 +3,11 @@ import { LdapConnection, InsertLdapConnection, AdUser, InsertAdUser, AdGroup, InsertAdGroup, AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer, - AdDomain, InsertAdDomain, Role, ApiQuery, + AdDomain, InsertAdDomain, AdSite, InsertAdSite, AdSubnet, InsertAdSubnet, Role, ApiQuery, LdapFilter, InsertLdapFilter, LdapFilterRevision, InsertLdapFilterRevision, LdapAttribute, InsertLdapAttribute, AuditLog, InsertAuditLog, users, apiTokens, ldapConnections, adUsers, adGroups, adOrgUnits, adComputers, adDomains, - roles, ldapFilters, ldapFilterRevisions, ldapAttributes, auditLogs + adSites, adSubnets, roles, ldapFilters, ldapFilterRevisions, ldapAttributes, auditLogs } from "@shared/schema"; import session from "express-session"; import createMemoryStore from "memorystore"; @@ -121,6 +121,24 @@ export interface IStorage { deleteAdDomain(id: number): Promise; listAdDomains(connectionId: number, query?: any): Promise; + // AD Sites + getAdSite(id: number): Promise; + getAdSiteByObjectGUID(connectionId: number, objectGUID: string): Promise; + createAdSite(site: InsertAdSite): Promise; + updateAdSite(id: number, site: Partial): Promise; + updateAdSiteByObjectGUID(connectionId: number, objectGUID: string, site: Partial): Promise; + deleteAdSite(id: number): Promise; + listAdSites(connectionId: number, query?: any): Promise; + + // AD Subnets + getAdSubnet(id: number): Promise; + getAdSubnetByObjectGUID(connectionId: number, objectGUID: string): Promise; + createAdSubnet(subnet: InsertAdSubnet): Promise; + updateAdSubnet(id: number, subnet: Partial): Promise; + updateAdSubnetByObjectGUID(connectionId: number, objectGUID: string, subnet: Partial): Promise; + deleteAdSubnet(id: number): Promise; + listAdSubnets(connectionId: number, query?: any): Promise; + // Audit logging createAuditLogEntry(entry: InsertAuditLog): Promise; getAuditLogs(connectionId?: number, userId?: number, page?: number, pageSize?: number): Promise<{ data: AuditLog[], metadata: { currentPage: number, totalPages: number, totalRecords: number, nextPage: number | null, prevPage: number | null } }>; @@ -548,6 +566,12 @@ export class DatabaseStorage implements IStorage { case 'domain': results = await this.listAdDomains(connectionId); break; + case 'site': + results = await this.listAdSites(connectionId); + break; + case 'subnet': + results = await this.listAdSubnets(connectionId); + break; default: throw new Error(`Unsupported object class: ${objectClass}`); } @@ -651,6 +675,11 @@ export class DatabaseStorage implements IStorage { if (selectedFields && selectedFields.length > 0) { const result = users.map(user => { const filtered: Partial = { id: user.id }; + + // Always include managedBy field regardless of selection + filtered.managedBy = user.managedBy; + + // Add selected fields selectedFields.forEach(field => { if (field in user) { filtered[field as keyof AdUser] = user[field as keyof AdUser]; @@ -765,6 +794,10 @@ export class DatabaseStorage implements IStorage { if (selectedFields && selectedFields.length > 0) { const result = groups.map(group => { const filtered: Partial = { id: group.id }; + + // Always include managedBy field regardless of selection + filtered.managedBy = group.managedBy; + selectedFields.forEach(field => { if (field in group) { filtered[field as keyof AdGroup] = group[field as keyof AdGroup]; @@ -845,6 +878,10 @@ export class DatabaseStorage implements IStorage { return orgUnits.map(ou => { const result: any = { id: ou.id }; + + // Always include managedBy field regardless of selection + result.managedBy = ou.managedBy; + properties.forEach((prop: string) => { if ((ou as any)[prop] !== undefined) { result[prop] = (ou as any)[prop]; @@ -913,6 +950,10 @@ export class DatabaseStorage implements IStorage { return computers.map(computer => { const result: any = { id: computer.id }; + + // Always include managedBy field regardless of selection + result.managedBy = computer.managedBy; + properties.forEach((prop: string) => { if ((computer as any)[prop] !== undefined) { result[prop] = (computer as any)[prop]; @@ -968,6 +1009,116 @@ export class DatabaseStorage implements IStorage { return adDomainsQuery; } + // AD Sites + async getAdSite(id: number): Promise { + const result = await db.select().from(adSites).where(eq(adSites.id, id)); + return result.length > 0 ? result[0] : undefined; + } + + async getAdSiteByObjectGUID(connectionId: number, objectGUID: string): Promise { + const result = await db.select().from(adSites).where( + and( + eq(adSites.connectionId, connectionId), + eq(adSites.objectGUID, objectGUID) + ) + ); + return result.length > 0 ? result[0] : undefined; + } + + async createAdSite(site: InsertAdSite): Promise { + const result = await db.insert(adSites).values(site).returning(); + return result[0]; + } + + async updateAdSite(id: number, site: Partial): Promise { + const result = await db.update(adSites).set(site).where(eq(adSites.id, id)).returning(); + return result.length > 0 ? result[0] : undefined; + } + + async updateAdSiteByObjectGUID(connectionId: number, objectGUID: string, site: Partial): Promise { + const result = await db.update(adSites) + .set(site) + .where( + and( + eq(adSites.connectionId, connectionId), + eq(adSites.objectGUID, objectGUID) + ) + ) + .returning(); + return result.length > 0 ? result[0] : undefined; + } + + async deleteAdSite(id: number): Promise { + const result = await db.delete(adSites).where(eq(adSites.id, id)).returning({ id: adSites.id }); + return result.length > 0; + } + + async listAdSites(connectionId: number, query?: any): Promise { + let adSitesQuery = db.select().from(adSites).where(eq(adSites.connectionId, connectionId)); + + // Apply query options (filtering, pagination, etc.) + if (query) { + adSitesQuery = applyQueryOptions(adSitesQuery, query, adSites); + } + + return adSitesQuery; + } + + // AD Subnets + async getAdSubnet(id: number): Promise { + const result = await db.select().from(adSubnets).where(eq(adSubnets.id, id)); + return result.length > 0 ? result[0] : undefined; + } + + async getAdSubnetByObjectGUID(connectionId: number, objectGUID: string): Promise { + const result = await db.select().from(adSubnets).where( + and( + eq(adSubnets.connectionId, connectionId), + eq(adSubnets.objectGUID, objectGUID) + ) + ); + return result.length > 0 ? result[0] : undefined; + } + + async createAdSubnet(subnet: InsertAdSubnet): Promise { + const result = await db.insert(adSubnets).values(subnet).returning(); + return result[0]; + } + + async updateAdSubnet(id: number, subnet: Partial): Promise { + const result = await db.update(adSubnets).set(subnet).where(eq(adSubnets.id, id)).returning(); + return result.length > 0 ? result[0] : undefined; + } + + async updateAdSubnetByObjectGUID(connectionId: number, objectGUID: string, subnet: Partial): Promise { + const result = await db.update(adSubnets) + .set(subnet) + .where( + and( + eq(adSubnets.connectionId, connectionId), + eq(adSubnets.objectGUID, objectGUID) + ) + ) + .returning(); + return result.length > 0 ? result[0] : undefined; + } + + async deleteAdSubnet(id: number): Promise { + const result = await db.delete(adSubnets).where(eq(adSubnets.id, id)).returning({ id: adSubnets.id }); + return result.length > 0; + } + + async listAdSubnets(connectionId: number, query?: any): Promise { + let adSubnetsQuery = db.select().from(adSubnets).where(eq(adSubnets.connectionId, connectionId)); + + // Apply query options (filtering, pagination, etc.) + if (query) { + adSubnetsQuery = applyQueryOptions(adSubnetsQuery, query, adSubnets); + } + + return adSubnetsQuery; + } + // Audit logging async createAuditLogEntry(entry: InsertAuditLog): Promise { debug(`Creating audit log entry: ${JSON.stringify(entry)}`); diff --git a/server/swagger.ts b/server/swagger.ts index c361aef..65403b2 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -105,6 +105,7 @@ const swaggerOptions = { enabled: { type: "boolean" }, lastLogon: { type: "string", format: "date-time" }, memberOf: { type: "array", items: { type: "string" } }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this object" }, adProperties: { type: "object" }, objectType: { type: "string", enum: ["user"] }, }, @@ -122,6 +123,7 @@ const swaggerOptions = { groupType: { type: "string" }, description: { type: "string" }, members: { type: "array", items: { type: "string" } }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this object" }, adProperties: { type: "object" }, objectType: { type: "string", enum: ["group"] }, }, @@ -137,6 +139,7 @@ const swaggerOptions = { cn: { type: "string" }, name: { type: "string" }, description: { type: "string" }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this object" }, adProperties: { type: "object" }, objectType: { type: "string", enum: ["organizationalUnit"] }, }, @@ -156,6 +159,7 @@ const swaggerOptions = { operatingSystemVersion: { type: "string" }, lastLogon: { type: "string", format: "date-time" }, enabled: { type: "boolean" }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this object" }, adProperties: { type: "object" }, objectType: { type: "string", enum: ["computer"] }, }, @@ -177,6 +181,41 @@ const swaggerOptions = { objectType: { type: "string", enum: ["domain"] }, }, }, + AdSite: { + type: "object", + properties: { + id: { type: "integer" }, + connectionId: { type: "integer" }, + objectGUID: { type: "string" }, + distinguishedName: { type: "string" }, + cn: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + location: { type: "string" }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this site" }, + adProperties: { type: "object" }, + objectType: { type: "string", enum: ["site"] }, + }, + }, + AdSubnet: { + type: "object", + properties: { + id: { type: "integer" }, + connectionId: { type: "integer" }, + objectGUID: { type: "string" }, + distinguishedName: { type: "string" }, + cn: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + siteObject: { type: "string", description: "Distinguished name of the site this subnet belongs to" }, + location: { type: "string" }, + networkAddress: { type: "string" }, + networkMask: { type: "string" }, + managedBy: { type: "string", description: "Distinguished name of the user or group managing this subnet" }, + adProperties: { type: "object" }, + objectType: { type: "string", enum: ["subnet"] }, + }, + }, LdapAttribute: { type: "object", properties: { diff --git a/shared/schema.ts b/shared/schema.ts index 6e99c8e..1261e68 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -226,6 +226,39 @@ export const adDomains = pgTable("ad_domains", { adProperties: jsonb("ad_properties"), }); +// AD Sites table for Sites and Services +export const adSites = pgTable("ad_sites", { + id: serial("id").primaryKey(), + connectionId: integer("connection_id").notNull(), + objectGUID: text("object_guid").notNull(), + distinguishedName: text("distinguished_name").notNull(), + cn: text("cn"), + name: text("name").notNull(), + description: text("description"), + location: text("location"), + managedBy: text("managed_by"), + adProperties: jsonb("ad_properties"), + objectType: text("object_type").default("site").notNull(), +}); + +// AD Subnets table for Sites and Services +export const adSubnets = pgTable("ad_subnets", { + id: serial("id").primaryKey(), + connectionId: integer("connection_id").notNull(), + objectGUID: text("object_guid").notNull(), + distinguishedName: text("distinguished_name").notNull(), + cn: text("cn"), + name: text("name").notNull(), + description: text("description"), + siteObject: text("site_object"), + location: text("location"), + networkAddress: text("network_address"), + networkMask: text("network_mask"), + managedBy: text("managed_by"), + adProperties: jsonb("ad_properties"), + objectType: text("object_type").default("subnet").notNull(), +}); + // Define relations between tables export const rolesRelations = relations(roles, ({ many }) => ({ permissions: many(rolePermissions), @@ -270,6 +303,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 insertAdSiteSchema = createInsertSchema(adSites).omit({ id: true }); +export const insertAdSubnetSchema = createInsertSchema(adSubnets).omit({ id: true }); // Login schema export const loginSchema = z.object({ @@ -339,6 +374,10 @@ export type AdComputer = typeof adComputers.$inferSelect; export type InsertAdComputer = z.infer; export type AdDomain = typeof adDomains.$inferSelect; export type InsertAdDomain = z.infer; +export type AdSite = typeof adSites.$inferSelect; +export type InsertAdSite = z.infer; +export type AdSubnet = typeof adSubnets.$inferSelect; +export type InsertAdSubnet = z.infer; export type Login = z.infer; export type ApiQuery = z.infer; export type MoveComputer = z.infer; @@ -348,7 +387,7 @@ export type RemoveFromGroup = z.infer; // LDAP Query Builder schemas -export const ldapFilterObjectClasses = ["user", "group", "organizationalUnit", "computer", "domain"] as const; +export const ldapFilterObjectClasses = ["user", "group", "organizationalUnit", "computer", "domain", "site", "subnet"] as const; // LDAP Filter schema export const ldapFilters = pgTable("ldap_filters", {