diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e0d48c1..51d442f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -288,6 +288,7 @@ model auto_enrollment_tokens { last_used_at DateTime? expires_at DateTime? metadata Json? + scopes Json? users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull) host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull) diff --git a/backend/src/middleware/apiAuth.js b/backend/src/middleware/apiAuth.js new file mode 100644 index 0000000..8c9bbe2 --- /dev/null +++ b/backend/src/middleware/apiAuth.js @@ -0,0 +1,113 @@ +const { getPrismaClient } = require("../config/prisma"); +const bcrypt = require("bcryptjs"); + +const prisma = getPrismaClient(); + +/** + * Middleware factory to authenticate API tokens using Basic Auth + * @param {string} integrationType - The expected integration type (e.g., "api", "gethomepage") + * @returns {Function} Express middleware function + */ +const authenticateApiToken = (integrationType) => { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Basic ")) { + return res + .status(401) + .json({ error: "Missing or invalid authorization header" }); + } + + // Decode base64 credentials + const base64Credentials = authHeader.split(" ")[1]; + const credentials = Buffer.from(base64Credentials, "base64").toString( + "ascii", + ); + const [apiKey, apiSecret] = credentials.split(":"); + + if (!apiKey || !apiSecret) { + return res.status(401).json({ error: "Invalid credentials format" }); + } + + // Find the token in database + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: apiKey }, + include: { + users: { + select: { + id: true, + username: true, + role: true, + }, + }, + }, + }); + + if (!token) { + console.log(`API key not found: ${apiKey}`); + return res.status(401).json({ error: "Invalid API key" }); + } + + // Check if token is active + if (!token.is_active) { + return res.status(401).json({ error: "API key is disabled" }); + } + + // Check if token has expired + if (token.expires_at && new Date(token.expires_at) < new Date()) { + return res.status(401).json({ error: "API key has expired" }); + } + + // Check if token is for the expected integration type + if (token.metadata?.integration_type !== integrationType) { + return res.status(401).json({ error: "Invalid API key type" }); + } + + // Verify the secret + const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret); + if (!isValidSecret) { + return res.status(401).json({ error: "Invalid API secret" }); + } + + // Check IP restrictions if any + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const clientIp = req.ip || req.connection.remoteAddress; + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + // Get the actual client IP (considering proxies) + const actualClientIp = forwardedFor + ? forwardedFor.split(",")[0].trim() + : realIp || clientIp; + + const isAllowedIp = token.allowed_ip_ranges.some((range) => { + // Simple IP range check (can be enhanced for CIDR support) + return actualClientIp.startsWith(range) || actualClientIp === range; + }); + + if (!isAllowedIp) { + console.log( + `IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`, + ); + return res.status(403).json({ error: "IP address not allowed" }); + } + } + + // Update last used timestamp + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { last_used_at: new Date() }, + }); + + // Attach token info to request + req.apiToken = token; + next(); + } catch (error) { + console.error("API key authentication error:", error); + res.status(500).json({ error: "Authentication failed" }); + } + }; +}; + +module.exports = { authenticateApiToken }; diff --git a/backend/src/middleware/apiScope.js b/backend/src/middleware/apiScope.js new file mode 100644 index 0000000..69950e7 --- /dev/null +++ b/backend/src/middleware/apiScope.js @@ -0,0 +1,76 @@ +/** + * Middleware factory to validate API token scopes + * Only applies to tokens with metadata.integration_type === "api" + * @param {string} resource - The resource being accessed (e.g., "host") + * @param {string} action - The action being performed (e.g., "get", "put", "patch", "update", "delete") + * @returns {Function} Express middleware function + */ +const requireApiScope = (resource, action) => { + return async (req, res, next) => { + try { + const token = req.apiToken; + + // If no token attached, this should have been caught by auth middleware + if (!token) { + return res.status(401).json({ error: "Unauthorized" }); + } + + // Only validate scopes for API type tokens + if (token.metadata?.integration_type !== "api") { + // For non-API tokens, skip scope validation + return next(); + } + + // Check if token has scopes field + if (!token.scopes || typeof token.scopes !== "object") { + console.warn( + `API token ${token.token_key} missing scopes field for ${resource}:${action}`, + ); + return res.status(403).json({ + error: "Access denied", + message: "This API key does not have the required permissions", + }); + } + + // Check if resource exists in scopes + if (!token.scopes[resource]) { + console.warn( + `API token ${token.token_key} missing resource ${resource} for ${action}`, + ); + return res.status(403).json({ + error: "Access denied", + message: `This API key does not have access to ${resource}`, + }); + } + + // Check if action exists in resource scopes + if (!Array.isArray(token.scopes[resource])) { + console.warn( + `API token ${token.token_key} has invalid scopes structure for ${resource}`, + ); + return res.status(403).json({ + error: "Access denied", + message: "Invalid API key permissions configuration", + }); + } + + if (!token.scopes[resource].includes(action)) { + console.warn( + `API token ${token.token_key} missing action ${action} for resource ${resource}`, + ); + return res.status(403).json({ + error: "Access denied", + message: `This API key does not have permission to ${action} ${resource}`, + }); + } + + // Scope validation passed + next(); + } catch (error) { + console.error("Scope validation error:", error); + res.status(500).json({ error: "Scope validation failed" }); + } + }; +}; + +module.exports = { requireApiScope }; diff --git a/backend/src/routes/apiHostsRoutes.js b/backend/src/routes/apiHostsRoutes.js new file mode 100644 index 0000000..f4d9f02 --- /dev/null +++ b/backend/src/routes/apiHostsRoutes.js @@ -0,0 +1,143 @@ +const express = require("express"); +const { getPrismaClient } = require("../config/prisma"); +const { authenticateApiToken } = require("../middleware/apiAuth"); +const { requireApiScope } = require("../middleware/apiScope"); + +const router = express.Router(); +const prisma = getPrismaClient(); + +// Helper function to check if a string is a valid UUID +const isUUID = (str) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(str); +}; + +// GET /api/v1/api/hosts - List hosts with IP and groups +router.get( + "/hosts", + authenticateApiToken("api"), + requireApiScope("host", "get"), + async (req, res) => { + try { + const { hostgroup } = req.query; + + let whereClause = {}; + let filterValues = []; + + // Parse hostgroup filter (comma-separated names or UUIDs) + if (hostgroup) { + filterValues = hostgroup.split(",").map((g) => g.trim()); + + // Separate UUIDs from names + const uuidFilters = []; + const nameFilters = []; + + for (const value of filterValues) { + if (isUUID(value)) { + uuidFilters.push(value); + } else { + nameFilters.push(value); + } + } + + // Find host group IDs from names + const groupIds = [...uuidFilters]; + + if (nameFilters.length > 0) { + const groups = await prisma.host_groups.findMany({ + where: { + name: { + in: nameFilters, + }, + }, + select: { + id: true, + name: true, + }, + }); + + // Add found group IDs + groupIds.push(...groups.map((g) => g.id)); + + // Check if any name filters didn't match + const foundNames = groups.map((g) => g.name); + const notFoundNames = nameFilters.filter( + (name) => !foundNames.includes(name), + ); + + if (notFoundNames.length > 0) { + console.warn(`Host groups not found: ${notFoundNames.join(", ")}`); + } + } + + // Filter hosts by group memberships + if (groupIds.length > 0) { + whereClause = { + host_group_memberships: { + some: { + host_group_id: { + in: groupIds, + }, + }, + }, + }; + } else { + // No valid groups found, return empty result + return res.json({ + hosts: [], + total: 0, + filtered_by_groups: filterValues, + }); + } + } + + // Query hosts with groups + const hosts = await prisma.hosts.findMany({ + where: whereClause, + select: { + id: true, + friendly_name: true, + hostname: true, + ip: true, + host_group_memberships: { + include: { + host_groups: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + orderBy: { + friendly_name: "asc", + }, + }); + + // Format response + const formattedHosts = hosts.map((host) => ({ + id: host.id, + friendly_name: host.friendly_name, + hostname: host.hostname, + ip: host.ip, + host_groups: host.host_group_memberships.map((membership) => ({ + id: membership.host_groups.id, + name: membership.host_groups.name, + })), + })); + + res.json({ + hosts: formattedHosts, + total: formattedHosts.length, + filtered_by_groups: filterValues.length > 0 ? filterValues : undefined, + }); + } catch (error) { + console.error("Error fetching hosts:", error); + res.status(500).json({ error: "Failed to fetch hosts" }); + } + }, +); + +module.exports = router; diff --git a/backend/src/routes/autoEnrollmentRoutes.js b/backend/src/routes/autoEnrollmentRoutes.js index d20564f..3df5fae 100644 --- a/backend/src/routes/autoEnrollmentRoutes.js +++ b/backend/src/routes/autoEnrollmentRoutes.js @@ -125,6 +125,10 @@ router.post( .optional({ nullable: true, checkFalsy: true }) .isISO8601() .withMessage("Invalid date format"), + body("scopes") + .optional() + .isObject() + .withMessage("Scopes must be an object"), ], async (req, res) => { try { @@ -140,6 +144,7 @@ router.post( default_host_group_id, expires_at, metadata = {}, + scopes, } = req.body; // Validate host group if provided @@ -153,6 +158,32 @@ router.post( } } + // Validate scopes for API tokens + if (metadata.integration_type === "api" && scopes) { + // Validate scopes structure + if (typeof scopes !== "object" || scopes === null) { + return res.status(400).json({ error: "Scopes must be an object" }); + } + + // Validate each resource in scopes + for (const [resource, actions] of Object.entries(scopes)) { + if (!Array.isArray(actions)) { + return res.status(400).json({ + error: `Scopes for resource "${resource}" must be an array of actions`, + }); + } + + // Validate action names + for (const action of actions) { + if (typeof action !== "string") { + return res.status(400).json({ + error: `All actions in scopes must be strings`, + }); + } + } + } + } + const { token_key, token_secret } = generate_auto_enrollment_token(); const hashed_secret = await bcrypt.hash(token_secret, 10); @@ -168,6 +199,7 @@ router.post( default_host_group_id: default_host_group_id || null, expires_at: expires_at ? new Date(expires_at) : null, metadata: { integration_type: "proxmox-lxc", ...metadata }, + scopes: metadata.integration_type === "api" ? scopes || null : null, updated_at: new Date(), }, include: { @@ -201,6 +233,7 @@ router.post( default_host_group: token.host_groups, created_by: token.users, expires_at: token.expires_at, + scopes: token.scopes, }, warning: "⚠️ Save the token_secret now - it cannot be retrieved later!", }); @@ -232,6 +265,7 @@ router.get( created_at: true, default_host_group_id: true, metadata: true, + scopes: true, host_groups: { select: { id: true, @@ -314,6 +348,10 @@ router.patch( body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }), body("allowed_ip_ranges").optional().isArray(), body("expires_at").optional().isISO8601(), + body("scopes") + .optional() + .isObject() + .withMessage("Scopes must be an object"), ], async (req, res) => { try { @@ -323,6 +361,16 @@ router.patch( } const { tokenId } = req.params; + + // First, get the existing token to check its integration type + const existing_token = await prisma.auto_enrollment_tokens.findUnique({ + where: { id: tokenId }, + }); + + if (!existing_token) { + return res.status(404).json({ error: "Token not found" }); + } + const update_data = { updated_at: new Date() }; if (req.body.is_active !== undefined) @@ -334,6 +382,41 @@ router.patch( if (req.body.expires_at !== undefined) update_data.expires_at = new Date(req.body.expires_at); + // Handle scopes updates for API tokens only + if (req.body.scopes !== undefined) { + if (existing_token.metadata?.integration_type === "api") { + // Validate scopes structure + const scopes = req.body.scopes; + if (typeof scopes !== "object" || scopes === null) { + return res.status(400).json({ error: "Scopes must be an object" }); + } + + // Validate each resource in scopes + for (const [resource, actions] of Object.entries(scopes)) { + if (!Array.isArray(actions)) { + return res.status(400).json({ + error: `Scopes for resource "${resource}" must be an array of actions`, + }); + } + + // Validate action names + for (const action of actions) { + if (typeof action !== "string") { + return res.status(400).json({ + error: `All actions in scopes must be strings`, + }); + } + } + } + + update_data.scopes = scopes; + } else { + return res.status(400).json({ + error: "Scopes can only be updated for API integration tokens", + }); + } + } + const token = await prisma.auto_enrollment_tokens.update({ where: { id: tokenId }, data: update_data, diff --git a/backend/src/routes/gethomepageRoutes.js b/backend/src/routes/gethomepageRoutes.js index c015d9e..d5305dd 100644 --- a/backend/src/routes/gethomepageRoutes.js +++ b/backend/src/routes/gethomepageRoutes.js @@ -1,113 +1,12 @@ const express = require("express"); const { getPrismaClient } = require("../config/prisma"); -const bcrypt = require("bcryptjs"); +const { authenticateApiToken } = require("../middleware/apiAuth"); const router = express.Router(); const prisma = getPrismaClient(); -// Middleware to authenticate API key -const authenticateApiKey = async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith("Basic ")) { - return res - .status(401) - .json({ error: "Missing or invalid authorization header" }); - } - - // Decode base64 credentials - const base64Credentials = authHeader.split(" ")[1]; - const credentials = Buffer.from(base64Credentials, "base64").toString( - "ascii", - ); - const [apiKey, apiSecret] = credentials.split(":"); - - if (!apiKey || !apiSecret) { - return res.status(401).json({ error: "Invalid credentials format" }); - } - - // Find the token in database - const token = await prisma.auto_enrollment_tokens.findUnique({ - where: { token_key: apiKey }, - include: { - users: { - select: { - id: true, - username: true, - role: true, - }, - }, - }, - }); - - if (!token) { - console.log(`API key not found: ${apiKey}`); - return res.status(401).json({ error: "Invalid API key" }); - } - - // Check if token is active - if (!token.is_active) { - return res.status(401).json({ error: "API key is disabled" }); - } - - // Check if token has expired - if (token.expires_at && new Date(token.expires_at) < new Date()) { - return res.status(401).json({ error: "API key has expired" }); - } - - // Check if token is for gethomepage integration - if (token.metadata?.integration_type !== "gethomepage") { - return res.status(401).json({ error: "Invalid API key type" }); - } - - // Verify the secret - const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret); - if (!isValidSecret) { - return res.status(401).json({ error: "Invalid API secret" }); - } - - // Check IP restrictions if any - if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { - const clientIp = req.ip || req.connection.remoteAddress; - const forwardedFor = req.headers["x-forwarded-for"]; - const realIp = req.headers["x-real-ip"]; - - // Get the actual client IP (considering proxies) - const actualClientIp = forwardedFor - ? forwardedFor.split(",")[0].trim() - : realIp || clientIp; - - const isAllowedIp = token.allowed_ip_ranges.some((range) => { - // Simple IP range check (can be enhanced for CIDR support) - return actualClientIp.startsWith(range) || actualClientIp === range; - }); - - if (!isAllowedIp) { - console.log( - `IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`, - ); - return res.status(403).json({ error: "IP address not allowed" }); - } - } - - // Update last used timestamp - await prisma.auto_enrollment_tokens.update({ - where: { id: token.id }, - data: { last_used_at: new Date() }, - }); - - // Attach token info to request - req.apiToken = token; - next(); - } catch (error) { - console.error("API key authentication error:", error); - res.status(500).json({ error: "Authentication failed" }); - } -}; - // Get homepage widget statistics -router.get("/stats", authenticateApiKey, async (_req, res) => { +router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => { try { // Get total hosts count const totalHosts = await prisma.hosts.count({ @@ -235,7 +134,7 @@ router.get("/stats", authenticateApiKey, async (_req, res) => { }); // Health check endpoint for the API -router.get("/health", authenticateApiKey, async (req, res) => { +router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString(), diff --git a/backend/src/server.js b/backend/src/server.js index f8feb8c..e92cfd9 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -71,6 +71,7 @@ const wsRoutes = require("./routes/wsRoutes"); const agentVersionRoutes = require("./routes/agentVersionRoutes"); const metricsRoutes = require("./routes/metricsRoutes"); const userPreferencesRoutes = require("./routes/userPreferencesRoutes"); +const apiHostsRoutes = require("./routes/apiHostsRoutes"); const { initSettings } = require("./services/settingsService"); const { queueManager } = require("./services/automation"); const { authenticateToken, requireAdmin } = require("./middleware/auth"); @@ -480,6 +481,7 @@ app.use(`/api/${apiVersion}/ws`, wsRoutes); app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); app.use(`/api/${apiVersion}/metrics`, metricsRoutes); app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes); +app.use(`/api/${apiVersion}/api`, authLimiter, apiHostsRoutes); // Bull Board - will be populated after queue manager initializes let bullBoardRouter = null;