diff --git a/src/controllers/config.controller.js b/src/controllers/config.controller.js new file mode 100644 index 0000000..cc41a91 --- /dev/null +++ b/src/controllers/config.controller.js @@ -0,0 +1,107 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const httpStatus = require('http-status'); +const catchAsync = require('../utils/catchAsync'); +const configService = require('../services/config.service'); +const pick = require('../utils/pick'); + +/** + * Get configuration by key + */ +const getConfig = catchAsync(async (req, res) => { + const config = await configService.getConfig(req.params.key); + res.json(config); +}); + +/** + * Get all configurations by category + */ +const getConfigsByCategory = catchAsync(async (req, res) => { + const filter = pick(req.query, ['category']); + const configs = await configService.getConfigsByCategory(filter.category); + res.json(configs); +}); + +/** + * Get all configurations by group + */ +const getConfigsByGroup = catchAsync(async (req, res) => { + const configs = await configService.getConfigsByGroup(req.params.group); + res.json(configs); +}); + +/** + * Get all available providers + */ +const getAllProviders = catchAsync(async (req, res) => { + const providers = await configService.getAllProviders(); + res.json(providers); +}); + +/** + * Get provider configuration (generic) + */ +const getProviderConfig = catchAsync(async (req, res) => { + const shouldMask = req.query.mask !== 'false'; // Default to true, only false if explicitly set to 'false' + const config = await configService.getProviderConfig(req.params.provider, shouldMask); + // Return empty object if no config found, instead of 404 + // This allows the frontend to display the form with empty fields + res.json(config || {}); +}); + +/** + * Set provider configuration (generic) + */ +const setProviderConfig = catchAsync(async (req, res) => { + const { provider } = req.params; + const { config: configData, schema } = req.body; + const result = await configService.setProviderConfig(provider, configData, schema); + res.json(result); +}); + +/** + * Set/Update configuration + */ +const setConfig = catchAsync(async (req, res) => { + const { key, value, category, description, isSecret } = req.body; + const config = await configService.setConfig(key, value, { + category, + description, + isSecret, + }); + res.status(200).json(config); +}); + +/** + * Update configuration by key + */ +const updateConfig = catchAsync(async (req, res) => { + const config = await configService.updateConfig(req.params.key, req.body); + res.json(config); +}); + +/** + * Delete configuration by key + */ +const deleteConfig = catchAsync(async (req, res) => { + await configService.deleteConfig(req.params.key); + res.status(httpStatus.NO_CONTENT).send(); +}); + +module.exports = { + getConfig, + getConfigsByCategory, + getConfigsByGroup, + getAllProviders, + getProviderConfig, + setConfig, + setProviderConfig, + updateConfig, + deleteConfig, +}; diff --git a/src/models/config.model.js b/src/models/config.model.js new file mode 100644 index 0000000..f6b8a05 --- /dev/null +++ b/src/models/config.model.js @@ -0,0 +1,98 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const mongoose = require('mongoose'); +const { toJSON } = require('./plugins'); + +const configSchema = new mongoose.Schema( + { + key: { + type: String, + required: true, + unique: true, + trim: true, + index: true, + }, + value: { + type: String, + required: true, + }, + category: { + type: String, + default: 'general', + index: true, + }, + description: { + type: String, + default: '', + }, + isSecret: { + type: Boolean, + default: false, + }, + group: { + type: String, + default: null, + index: true, + comment: 'Grouping identifier for related configs (e.g., "azure_speech", "openai", "gemini")', + }, + metadata: { + type: mongoose.Schema.Types.Mixed, + default: {}, + comment: 'Additional metadata for the config (e.g., provider info, display name, validation rules)', + }, + }, + { + timestamps: true, + } +); + +configSchema.plugin(toJSON); + +/** + * Get configuration by key + * @param {string} key - Config key + * @returns {Promise} Config value + */ +configSchema.statics.getConfig = async function (key) { + const config = await this.findOne({ key }); + return config ? config.value : null; +}; + +/** + * Set configuration value + * @param {string} key - Config key + * @param {string} value - Config value + * @param {object} options - Additional options (category, description, isSecret, group, metadata) + * @returns {Promise} Updated/created config + */ +configSchema.statics.setConfig = async function (key, value, options = {}) { + const update = { + value, + ...options, + }; + + const config = await this.findOneAndUpdate( + { key }, + update, + { upsert: true, new: true, runValidators: true } + ); + + return config; +}; + +/** + * Get configurations by group + * @param {string} group - Group identifier + * @returns {Promise} Array of config objects + */ +configSchema.statics.getConfigsByGroup = async function (group) { + return this.find({ group }); +}; + +module.exports = mongoose.model('Config', configSchema); diff --git a/src/models/index.js b/src/models/index.js index 070883b..1fa6d48 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -21,3 +21,4 @@ module.exports.Schema = require('./schema.model'); module.exports.Relation = require('./relation.model'); module.exports.Instance = require('./instance.model'); module.exports.InstanceRelation = require('./instanceRelation.model'); +module.exports.Config = require('./config.model'); diff --git a/src/routes/v2/config.route.js b/src/routes/v2/config.route.js new file mode 100644 index 0000000..14ddf50 --- /dev/null +++ b/src/routes/v2/config.route.js @@ -0,0 +1,45 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const express = require('express'); +const router = express.Router(); +const configController = require('../../controllers/config.controller'); +const validate = require('../../middlewares/validate'); +const { configValidation } = require('../../validations'); +const auth = require('../../middlewares/auth'); +const { PERMISSIONS } = require('../../config/roles'); +const { checkPermission } = require('../../middlewares/permission'); + +// Admin-only endpoints for managing configurations +router + .route('/') + .get(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.getConfigsByCategory), configController.getConfigsByCategory) + .post(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.setConfig), configController.setConfig); + +// Get all available providers (admin only) +router.get('/providers', auth(), checkPermission(PERMISSIONS.ADMIN), configController.getAllProviders); + +// Generic provider config endpoints (admin only) - must be before /:key route +router + .route('/provider/:provider') + .get(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.getProviderConfig), configController.getProviderConfig) + .post(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.setProviderConfig), configController.setProviderConfig); + +// Group-based config endpoint (admin only) - must be before /:key route +router + .route('/group/:group') + .get(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.getConfigsByGroup), configController.getConfigsByGroup); + +// Single config by key (admin only) - this should be last since it matches any string +router + .route('/:key') + .get(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.getConfig), configController.getConfig) + .put(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.updateConfig), configController.updateConfig) + .delete(auth(), checkPermission(PERMISSIONS.ADMIN), validate(configValidation.deleteConfig), configController.deleteConfig); + +module.exports = router; diff --git a/src/routes/v2/index.js b/src/routes/v2/index.js index 96e918d..fe5b5c4 100644 --- a/src/routes/v2/index.js +++ b/src/routes/v2/index.js @@ -26,6 +26,7 @@ const schemaRoute = require('./schema.route'); const relationRoute = require('./relation.route'); const instanceRoute = require('./instance.route'); const instanceRelationRoute = require('./instanceRelation.route'); +const configRoute = require('./config.route'); const router = express.Router(); @@ -106,6 +107,10 @@ const defaultRoutes = [ path: '/inventory/instance-relations', route: instanceRelationRoute, }, + { + path: '/configs', + route: configRoute, + }, ]; defaultRoutes.forEach((route) => { diff --git a/src/services/config.service.js b/src/services/config.service.js new file mode 100644 index 0000000..625773b --- /dev/null +++ b/src/services/config.service.js @@ -0,0 +1,250 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const { Config } = require('../models'); +const ApiError = require('../utils/ApiError'); +const httpStatus = require('http-status'); + +/** + * Get configuration by key + * @param {string} key - Config key + * @returns {Promise} Config object + */ +const getConfig = async (key) => { + const config = await Config.findOne({ key }); + if (!config) { + throw new ApiError(httpStatus.NOT_FOUND, `Configuration '${key}' not found`); + } + return config; +}; + +/** + * Get configuration value by key + * @param {string} key - Config key + * @returns {Promise} Config value + */ +const getConfigValue = async (key) => { + const config = await Config.findOne({ key }); + return config ? config.value : null; +}; + +/** + * Get all configurations by category + * @param {string} category - Config category + * @returns {Promise} Array of config objects + */ +const getConfigsByCategory = async (category) => { + const query = category ? { category } : {}; + const configs = await Config.find(query); + // Mask secret values + return configs.map(config => { + if (config.isSecret && config.value) { + return { + ...config.toObject(), + value: maskSensitiveValue(config.value) + }; + } + return config; + }); +}; + +/** + * Get all configurations by group + * @param {string} group - Group identifier + * @returns {Promise} Object with grouped config key-value pairs (secrets masked) + */ +const getConfigsByGroup = async (group) => { + const configs = await Config.getConfigsByGroup(group); + const result = {}; + + configs.forEach(config => { + const keyWithoutGroup = config.key.replace(`${group}_`, ''); + result[keyWithoutGroup] = { + value: config.isSecret ? maskSensitiveValue(config.value) : config.value, + isSecret: config.isSecret, + description: config.description, + metadata: config.metadata || {}, + }; + }); + + return result; +}; + +/** + * Get all available providers (groups) + * @returns {Promise} Array of provider identifiers + */ +const getAllProviders = async () => { + const providers = await Config.distinct('group'); + // Filter out null/undefined and return only valid provider names + return providers.filter(p => p); +}; + +/** + * Get provider configuration (generic method for any service provider) + * @param {string} provider - Provider identifier (e.g., 'azure_speech', 'openai', 'gemini') + * @returns {Promise} Provider configuration with masked secrets + */ +/** + * Get provider configuration (generic method for any service provider) + * @param {string} provider - Provider identifier (e.g., 'azure_speech', 'openai', 'gemini') + * @param {boolean} shouldMask - Whether to mask secret values (default: true) + * @returns {Promise} Provider config object or null if not found + */ +const getProviderConfig = async (provider, shouldMask = true) => { + const configs = await Config.find({ group: provider }); + + if (configs.length === 0) { + return null; + } + + const result = {}; + configs.forEach(config => { + const keyWithoutPrefix = config.key.replace(`${provider}_`, ''); + result[keyWithoutPrefix] = (config.isSecret && shouldMask) ? maskSensitiveValue(config.value) : config.value; + }); + + return result; +}; + +/** + * Set configuration value + * @param {string} key - Config key + * @param {string} value - Config value + * @param {object} options - Additional options (category, description, isSecret) + * @returns {Promise} Updated/created config + */ +const setConfig = async (key, value, options = {}) => { + const config = await Config.setConfig(key, value, options); + return config; +}; + +/** + * Update configuration value + * @param {string} key - Config key + * @param {object} updateBody - Update data + * @returns {Promise} Updated config + */ +const updateConfig = async (key, updateBody) => { + const config = await Config.findOne({ key }); + if (!config) { + throw new ApiError(httpStatus.NOT_FOUND, `Configuration '${key}' not found`); + } + + Object.assign(config, updateBody); + await config.save(); + return config; +}; + +/** + * Delete configuration by key + * @param {string} key - Config key + * @returns {Promise} Deleted config + */ +const deleteConfig = async (key) => { + const config = await Config.findOne({ key }); + if (!config) { + throw new ApiError(httpStatus.NOT_FOUND, `Configuration '${key}' not found`); + } + await config.deleteOne(); + return config; +}; + +/** + * Mask sensitive data for display (shows first 4 characters + asterisks) + * @param {string} value - Value to mask + * @param {number} visibleChars - Number of characters to show (default: 4) + * @returns {string} Masked value + */ +const maskSensitiveValue = (value, visibleChars = 4) => { + if (!value || value.length <= visibleChars) { + return value; + } + return value.substring(0, visibleChars) + '*'.repeat(Math.max(8, value.length - visibleChars)); +}; + +/** + * Set provider configuration (generic method for any service provider) + * + * This is a flexible method that can be used to configure any service provider + * by defining a provider identifier, config data, and optional schema. + * + * Example usage for OpenAI: + * await setProviderConfig('openai', + * { api_key: 'sk-...', model: 'gpt-4', organization: 'org-...' }, + * { + * api_key: { isSecret: true, description: 'OpenAI API Key' }, + * model: { isSecret: false, description: 'Default model to use' }, + * organization: { isSecret: true, description: 'OpenAI Organization ID' } + * } + * ); + * + * Example usage for Gemini: + * await setProviderConfig('gemini', + * { api_key: 'AIza...', project_id: 'my-project' }, + * { + * api_key: { isSecret: true, description: 'Google Gemini API Key' }, + * project_id: { isSecret: false, description: 'GCP Project ID' } + * } + * ); + * + * Example usage for Azure Speech: + * await setProviderConfig('azure_speech', + * { key: 'your-key', region: 'swedencentral' }, + * { + * key: { isSecret: true, description: 'Azure Speech SDK API Key' }, + * region: { isSecret: false, description: 'Azure Speech SDK Region' } + * } + * ); + * + * @param {string} provider - Provider identifier (e.g., 'azure_speech', 'openai', 'gemini') + * @param {object} configData - Object with key-value pairs to set + * @param {object} schema - Schema defining each field (isSecret, description, metadata) + * @returns {Promise} Updated configs + */ +const setProviderConfig = async (provider, configData, schema = {}) => { + const updates = []; + + for (const [key, value] of Object.entries(configData)) { + const fieldSchema = schema[key] || {}; + const fullKey = `${provider}_${key}`; + + updates.push( + setConfig(fullKey, value, { + category: provider.split('_')[0], // First part as category (e.g., 'azure', 'openai') + description: fieldSchema.description || `${provider} ${key}`, + isSecret: fieldSchema.isSecret || false, + group: provider, + metadata: fieldSchema.metadata || {}, + }) + ); + } + + const results = await Promise.all(updates); + + // Return as object with original keys + const response = {}; + Object.keys(configData).forEach((key, index) => { + response[key] = results[index]; + }); + + return response; +}; + +module.exports = { + getConfig, + getConfigValue, + getConfigsByCategory, + getConfigsByGroup, + getAllProviders, + getProviderConfig, + setConfig, + setProviderConfig, + updateConfig, + deleteConfig, +}; diff --git a/src/validations/config.validation.js b/src/validations/config.validation.js new file mode 100644 index 0000000..1a63270 --- /dev/null +++ b/src/validations/config.validation.js @@ -0,0 +1,86 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const Joi = require('joi'); + +const getConfig = { + params: Joi.object().keys({ + key: Joi.string().required(), + }), +}; + +const getConfigsByCategory = { + query: Joi.object().keys({ + category: Joi.string(), + }), +}; + +const setConfig = { + body: Joi.object().keys({ + key: Joi.string().required(), + value: Joi.string().required(), + category: Joi.string(), + description: Joi.string(), + isSecret: Joi.boolean(), + group: Joi.string(), + metadata: Joi.object(), + }), +}; + +const getConfigsByGroup = { + params: Joi.object().keys({ + group: Joi.string().required(), + }), +}; + +const getProviderConfig = { + params: Joi.object().keys({ + provider: Joi.string().required(), + }), +}; + +const setProviderConfig = { + params: Joi.object().keys({ + provider: Joi.string().required(), + }), + body: Joi.object().keys({ + config: Joi.object().required(), + schema: Joi.object(), + }), +}; + +const updateConfig = { + params: Joi.object().keys({ + key: Joi.string().required(), + }), + body: Joi.object() + .keys({ + value: Joi.string(), + category: Joi.string(), + description: Joi.string(), + isSecret: Joi.boolean(), + }) + .min(1), +}; + +const deleteConfig = { + params: Joi.object().keys({ + key: Joi.string().required(), + }), +}; + +module.exports = { + getConfig, + getConfigsByCategory, + getConfigsByGroup, + getProviderConfig, + setConfig, + setProviderConfig, + updateConfig, + deleteConfig, +}; diff --git a/src/validations/index.js b/src/validations/index.js index 2355619..fb03048 100644 --- a/src/validations/index.js +++ b/src/validations/index.js @@ -23,3 +23,4 @@ module.exports.assetValidation = require('./asset.validation'); module.exports.schemaValidation = require('./schema.validation'); module.exports.relationValidation = require('./relation.validation'); module.exports.instanceRelationValidation = require('./instanceRelation.validation'); +module.exports.configValidation = require('./config.validation');