diff --git a/.env.schema b/.env.schema index 08994b5..e916325 100644 --- a/.env.schema +++ b/.env.schema @@ -1,4 +1,6 @@ +ALLOWED_ORIGINS= AUDIT_ENABLED= +CORS_ENABLED= DB_HOST= DB_NAME= DB_PASSWORD= @@ -9,5 +11,5 @@ ID_CUSTOM_SIZE= ID_USELOCAL= LECTERN_URL= LOG_LEVEL= -PORT=3030 PLURALIZE_SCHEMAS_ENABLED= +PORT=3030 diff --git a/README.md b/README.md index 3c81135..80dc494 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ The structure of this monorepo is app centric having `apps/` folder to keep depl - [Lectern](https://github.com/overture-stack/lectern) Dictionary Management and validation - [Postgres Database](https://www.postgresql.org/) For data storage +> Note: A `docker-compose.yml` file is provided to help spin up the system dependencies + ## Local development ### Development tools @@ -72,21 +74,23 @@ Create a `.env` file based on `.env.schema` located on the root folder and set t The Environment Variables used for this application are listed in the table bellow -| Name | Description | Default | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | -| `AUDIT_ENABLED` | Ensures that any modifications to the submitted data are logged, providing a way to identify who made changes and when they were made. | true | -| `DB_HOST` | Database Hostname | | -| `DB_NAME` | Database Name | | -| `DB_PASSWORD` | Database Password | | -| `DB_PORT` | Database Port | | -| `DB_USER` | Database User | | -| `ID_CUSTOM_ALPHABET` | Custom Alphabet for local ID generation | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' | -| `ID_CUSTOM_SIZE` | Custom size of ID for local ID generation | 21 | -| `ID_USELOCAL` | Generate ID locally | true | -| `LECTERN_URL` | Schema Service (Lectern) URL | | -| `LOG_LEVEL` | Log Level | 'info' | -| `PLURALIZE_SCHEMAS_ENABLED` | This feature automatically convert schema names to their plural forms when handling compound documents. Pluralization assumes the words are in English | true | -| `PORT` | Server Port. | 3030 | +| Name | Description | Default | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| `ALLOWED_ORIGINS` | Specifies a list of permitted origins for Cross-Origin Resource Sharing (CORS). These origins, separated by commas, are allowed to make requests to the server, ensuring only trusted domains can access resources. (Example: https://www.example.com,https://subdomain.example.com) | | +| `AUDIT_ENABLED` | Ensures that any modifications to the submitted data are logged, providing a way to identify who made changes and when they were made. | true | +| `CORS_ENABLED` | Controls whether the CORS functionality is enabled or disabled. | false | +| `DB_HOST` | Database Hostname | | +| `DB_NAME` | Database Name | | +| `DB_PASSWORD` | Database Password | | +| `DB_PORT` | Database Port | | +| `DB_USER` | Database User | | +| `ID_CUSTOM_ALPHABET` | Custom Alphabet for local ID generation | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' | +| `ID_CUSTOM_SIZE` | Custom size of ID for local ID generation | 21 | +| `ID_USELOCAL` | Generate ID locally | true | +| `LECTERN_URL` | Schema Service (Lectern) URL | | +| `LOG_LEVEL` | Log Level | 'info' | +| `PLURALIZE_SCHEMAS_ENABLED` | This feature automatically convert schema names to their plural forms when handling compound documents. Pluralization assumes the words are in English | true | +| `PORT` | Server Port. | 3030 | ## Script commands (Workspace) diff --git a/apps/server/package.json b/apps/server/package.json index e75399f..6dd4964 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,6 +23,7 @@ "private": true, "dependencies": { "@overture-stack/lyric": "workspace:^", + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "helmet": "^7.1.0", @@ -31,6 +32,7 @@ "winston": "^3.13.1" }, "devDependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/express-serve-static-core": "^4.19.5", "@types/qs": "^6.9.15", diff --git a/apps/server/src/config/server.ts b/apps/server/src/config/app.ts similarity index 87% rename from apps/server/src/config/server.ts rename to apps/server/src/config/app.ts index f45ba09..d101b05 100644 --- a/apps/server/src/config/server.ts +++ b/apps/server/src/config/app.ts @@ -5,6 +5,8 @@ import { AppConfig } from '@overture-stack/lyric'; export const getServerConfig = () => { return { port: process.env.PORT || 3030, + allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || [], + corsEnabled: getBoolean(process.env.CORS_ENABLED, false), }; }; @@ -27,7 +29,10 @@ const getRequiredConfig = (name: string) => { return value; }; -export const defaultAppConfig: AppConfig = { +export const appConfig: AppConfig = { + auth: { + enabled: false, + }, db: { host: getRequiredConfig('DB_HOST'), port: Number(getRequiredConfig('DB_PORT')), @@ -48,10 +53,10 @@ export const defaultAppConfig: AppConfig = { customAlphabet: process.env.ID_CUSTOM_ALPHABET || '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', customSize: Number(process.env.ID_CUSTOM_SIZE) || 21, }, - schemaService: { - url: getRequiredConfig('LECTERN_URL'), - }, logger: { level: process.env.LOG_LEVEL || 'info', }, + schemaService: { + url: getRequiredConfig('LECTERN_URL'), + }, }; diff --git a/apps/server/src/config/swagger.ts b/apps/server/src/config/swagger.ts index e035cde..36fc495 100644 --- a/apps/server/src/config/swagger.ts +++ b/apps/server/src/config/swagger.ts @@ -2,16 +2,16 @@ import swaggerJSDoc from 'swagger-jsdoc'; import { version } from './manifest.js'; -const swaggerDefinition = { +const swaggerDefinition: swaggerJSDoc.OAS3Definition = { failOnErrors: true, // Whether or not to throw when parsing errors. Defaults to false. - openapi: '3.0.0', + openapi: '3.0.1', info: { title: 'Lyric', version, }, }; -const options = { +const options: swaggerJSDoc.OAS3Options = { swaggerDefinition, // Paths to files containing OpenAPI definitions apis: ['./src/routes/*.ts', './swagger/*.yml'], diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a02a053..1d7d813 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,23 +1,42 @@ +import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import { serve, setup } from 'swagger-ui-express'; import { errorHandler, provider } from '@overture-stack/lyric'; -import { defaultAppConfig, getServerConfig } from './config/server.js'; +import { appConfig, getServerConfig } from './config/app.js'; import swaggerDoc from './config/swagger.js'; import healthRouter from './routes/health.js'; import pingRouter from './routes/ping.js'; -const serverConfig = getServerConfig(); +const { allowedOrigins, port, corsEnabled } = getServerConfig(); -const lyricProvider = provider(defaultAppConfig); +const lyricProvider = provider(appConfig); // Create Express server const app = express(); app.use(helmet()); +app.use( + cors({ + origin: function (origin, callback) { + // If CORS is disabled, allow the request regardless of the origin + if (!corsEnabled) { + return callback(null, true); + } + + // If the origin is in the allowed origins list, allow the request + if (origin && allowedOrigins.indexOf(origin) !== -1) { + return callback(null, true); + } + const msg = 'The CORS policy for this site does not allow access from the specified Origin.'; + return callback(new Error(msg), false); + }, + }), +); + // Ping Route app.use('/ping', pingRouter); @@ -35,6 +54,6 @@ app.use('/health', healthRouter); app.use(errorHandler); // running the server -app.listen(serverConfig.port, () => { - console.log(`Starting Express server on http://localhost:${serverConfig.port}`); +app.listen(port, () => { + console.log(`Starting ExpressJS server on port ${port}`); }); diff --git a/apps/server/swagger/schemas.yml b/apps/server/swagger/schemas.yml index f61618d..87c80c0 100644 --- a/apps/server/swagger/schemas.yml +++ b/apps/server/swagger/schemas.yml @@ -19,6 +19,13 @@ components: type: string decription: Description of the error + Forbidden: + description: Error response indicating invalid access due to inadequate permissions + content: + application/json: + schema: + $ref: '#/components/responses/Error' + NotFound: description: Requested resource could not be found content: diff --git a/docker-compose.yml b/docker-compose.yml index f50c075..bba0978 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ version: '3.9' services: postgres: + container_name: lyric.db image: postgres:15-alpine ports: - 5432:5432 @@ -11,3 +12,30 @@ services: - POSTGRES_PASSWORD=secret - POSTGRES_USER=postgres - POSTGRES_DB=lyric + lectern_mongo: + container_name: lyric.lectern.db + image: bitnami/mongodb:4.0 + ports: + - 27017:27017 + volumes: + - mongodb_data:/bitnami + environment: + MONGODB_USERNAME: admin + MONGODB_PASSWORD: password + MONGODB_DATABASE: lectern + MONGODB_ROOT_PASSWORD: password123 + lectern_service: + container_name: lyric.lectern.service + image: ghcr.io/overture-stack/lectern:latest + ports: + - 3000:3000 + environment: + MONGO_HOST: lectern_mongo + MONGO_PORT: 27017 + MONGO_DB: lectern + MONGO_USER: admin + MONGO_PASS: password +volumes: + mongodb_data: + name: lectern-mongo-data + driver: local diff --git a/packages/data-provider/README.md b/packages/data-provider/README.md index cb13989..0c780c4 100644 --- a/packages/data-provider/README.md +++ b/packages/data-provider/README.md @@ -14,27 +14,38 @@ npm i @overture-stack/lyric Import `AppConfig` and `provider` from `@overture-stack/lyric` module to initialize the provider with custom configuration: -``` +```javascript import { AppConfig, provider } from '@overture-stack/lyric'; const appConfig: AppConfig = { + auth: { + enabled: false, + } db: { - host: [INSERT_DB_HOST], - port: [INSERT_DB_PORT], - database: [INSERT_DB_NAME], - user:[INSERT_DB_USER], - password: [INSERT_DB_PASSWORD], + host: 'localhost', // Database hostname or IP address + port: 5432, // Database port + database: 'my_database', // Name of the database + user: 'db_user', // Username for database authentication + password: 'secure_password', // Password for database authentication }, features: { audit: { - enabled: [INSERT_AUDIT_ENABLED] - } + enabled: true, // Enable audit functionality (true/false) + }, + recordHierarchy: { + pluralizeSchemasName: false, // Enable or disable automatic schema name pluralization (true/false) + }, }, - schemaService: { - url: [INSERT_LECTERN_URL], + idService: { + useLocal: true, // Use local ID generation (true/false) + customAlphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', // Custom alphabet for ID generation + customSize: 12, // Size of the generated ID }, logger: { - level: [INSERT_LOG_LEVEL], + level: 'info', // Logging level (e.g., 'debug', 'info', 'warn', 'error') + }, + schemaService: { + url: 'https://api.lectern-service.com', // URL of the schema service }, }; @@ -42,6 +53,92 @@ const appConfig: AppConfig = { const lyricProvider = provider(appConfig); ``` +### Auth Custom Handler + +The **authentication custom handler** is a customizable function that can be used to verify user authentication and grant write permissions to organizations. It is used by the auth middleware to process incoming requests before any operation is executed. + +The handler receives an argument of type `Request` and returns a `UserSessionResult` response type, which provides information about the user's session or any errors encountered during the process. + +This result `UserSessionResult` object may include the following: + +- **user**: A `UserSession` object containing details of the authenticated user: + + ```javascript + { + username: string; + isAdmin: boolean; + allowedWriteOrganizations: string[]; + } + ``` + + - **username**: A string representing the user's identifier (e.g., email address). + - **isAdmin**: A boolean value indicating whether the user has admin privileges. If `true`, the user has write access to all organizations. + - **allowedWriteOrganization**: An array of strings representing the organizations to which the user is allowed to write data. + + When the handler function returns this `user` object, the user has read access to all endpoints within the application. Otherwise, it should return the following error details: + +- **errorCode**: A numeric code representing an error that occurred while processing the session request. +- **errorMessage**: A descriptive message detailing the specific error, if an errorCode is provided. + +Example how to implement a custom auth handler: + +```javascript +import { type Request } from 'express'; +import { type UserSessionResult } from '@overture-stack/lyric'; +import jwt from 'jsonwebtoken'; + +const authHandler = (req: Request): UserSessionResult => { + // Extract the token from the request header + const authHeader = req.headers['authorization']; + + // Check if the Authorization header exists and starts with "Bearer" + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return { + errorCode: 401, + errorMessage: 'Unauthorized: No token provided' + } + } + + // Extract the token by removing the "Bearer " prefix + const token = authHeader.split(' ')[1]; + + try { + // Verify the token using a public key + const publicKey = process.env.JWT_PUBLIC_KEY!; + const decodedToken = jwt.verify(token, publicKey); + + // Return the user session information after successfully verifying the token + return { + user: { + username: decodedToken.username, // Extract username from the decoded token + isAdmin: decodedToken.isAdmin, // Check if the user has admin privileges + allowedWriteOrganizations: decodedToken.scopes, // Get the list of organizations the user can write to + }, + }; + } catch (err) { + // If the token is invalid or an error occurs, return a forbidden error + return { + errorCode: 403, + errorMessage: 'Forbidden: Invalid token' + } + } +}; +``` + +To enable the authentication handler function, it must be enabled and added in the `AppConfig` object as follows. + +```javascript +import { AppConfig, provider, UserSession } from '@overture-stack/lyric'; + +const appConfig: AppConfig = { + ...// Other configuration + auth: { + enabled: true, + customAuthHandler: authHandler, + }; +} +``` + ### On Finish Commit Callback function The `onFinishCommit` callback function is executed automatically when a commit event is completed. This function provides the ability to customize the behavior or perform any additional actions after the commit is finished, using the result of the commit operation. @@ -81,9 +178,7 @@ const appConfig: AppConfig = { Use any of the resources available on provider on a Express server: -- Import a router: - -``` +```javascript import express from 'express'; const app = express(); @@ -91,6 +186,22 @@ const app = express(); app.use('/submission', lyricProvider.routers.submission); ``` +### Database Migrations + +Import `migrate` function from `@overture-stack/lyric` module to run Database migrations + +```javascript +import { migrate } from '@overture-stack/lyric'; + +migrate({ + host: 'localhost', // Database hostname or IP address + port: 5432, // Database port + database: 'my_database', // Name of the database + user: 'db_user', // Username for database authentication + password: 'secure_password', // Password for database authentication +}); +``` + ## Support & Contributions - Developer documentation [docs](https://github.com/overture-stack/lyric/blob/main/packages/data-provider/docs/add-new-resources.md) diff --git a/packages/data-provider/index.ts b/packages/data-provider/index.ts index 369f049..1ae74e1 100644 --- a/packages/data-provider/index.ts +++ b/packages/data-provider/index.ts @@ -1,6 +1,7 @@ // config export { type AppConfig } from './src/config/config.js'; export { default as provider } from './src/core/provider.js'; +export { type UserSession, type UserSessionResult } from './src/middleware/auth.js'; export { errorHandler } from './src/middleware/errorHandler.js'; export { type DbConfig, migrate } from '@overture-stack/lyric-data-model'; diff --git a/packages/data-provider/src/config/config.ts b/packages/data-provider/src/config/config.ts index f0b1d77..bbeb011 100644 --- a/packages/data-provider/src/config/config.ts +++ b/packages/data-provider/src/config/config.ts @@ -3,6 +3,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type { DbConfig } from '@overture-stack/lyric-data-model'; import * as schema from '@overture-stack/lyric-data-model/models'; +import type { AuthConfig } from '../middleware/auth.js'; import type { ResultOnCommit } from '../utils/types.js'; import { Logger } from './logger.js'; @@ -39,12 +40,13 @@ export type IdServiceConfig = { * (database, external services, logger, etc) */ export type AppConfig = { + auth: AuthConfig; db: DbConfig; features?: FeaturesConfig; idService: IdServiceConfig; logger: LoggerConfig; - schemaService: SchemaServiceConfig; onFinishCommit?: (resultOnCommit: ResultOnCommit) => void; + schemaService: SchemaServiceConfig; }; /** @@ -55,6 +57,6 @@ export interface BaseDependencies { features?: FeaturesConfig; idService: IdServiceConfig; logger: Logger; - schemaService: SchemaServiceConfig; onFinishCommit?: (resultOnCommit: ResultOnCommit) => void; + schemaService: SchemaServiceConfig; } diff --git a/packages/data-provider/src/controllers/submissionController.ts b/packages/data-provider/src/controllers/submissionController.ts index 7707f2f..87bf2ba 100644 --- a/packages/data-provider/src/controllers/submissionController.ts +++ b/packages/data-provider/src/controllers/submissionController.ts @@ -1,9 +1,11 @@ import { isEmpty } from 'lodash-es'; import { BaseDependencies } from '../config/config.js'; +import type { AuthConfig } from '../middleware/auth.js'; import submissionService from '../services/submission/submission.js'; import submittedDataService from '../services/submittedData/submmittedData.js'; -import { BadRequest, NotFound } from '../utils/errors.js'; +import { hasUserWriteAccess } from '../utils/authUtils.js'; +import { BadRequest, Forbidden, NotFound } from '../utils/errors.js'; import { validateRequest } from '../utils/requestValidation.js'; import { dataDeleteBySystemIdRequestSchema, @@ -18,10 +20,16 @@ import { } from '../utils/schemas.js'; import { SUBMISSION_ACTION_TYPE } from '../utils/types.js'; -const controller = (dependencies: BaseDependencies) => { - const service = submissionService(dependencies); - const dataService = submittedDataService(dependencies); - const { logger } = dependencies; +const controller = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}) => { + const service = submissionService(baseDependencies); + const dataService = submittedDataService(baseDependencies); + const { logger } = baseDependencies; const defaultPage = 1; const defaultPageSize = 20; const LOG_MODULE = 'SUBMISSION_CONTROLLER'; @@ -30,12 +38,21 @@ const controller = (dependencies: BaseDependencies) => { try { const categoryId = Number(req.params.categoryId); const submissionId = Number(req.params.submissionId); - - // TODO: get userName from auth - const userName = ''; + const user = req.user; logger.info(LOG_MODULE, `Request Commit Active Submission '${submissionId}' on category '${categoryId}'`); + const submission = await service.getSubmissionById(submissionId); + if (!submission) { + throw new BadRequest(`Submission '${submissionId}' not found`); + } + + if (authConfig.enabled && !hasUserWriteAccess(submission.organization, user)) { + throw new Forbidden(`User is not authorized to commit the submission from '${submission.organization}'`); + } + + const userName = user?.username || ''; + const commitSubmission = await service.commitSubmission(categoryId, submissionId, userName); return res.status(200).send(commitSubmission); @@ -46,11 +63,20 @@ const controller = (dependencies: BaseDependencies) => { delete: validateRequest(submissionDeleteRequestSchema, async (req, res, next) => { try { const submissionId = Number(req.params.submissionId); + const user = req.user; logger.info(LOG_MODULE, `Request Delete Active Submission '${submissionId}'`); - // TODO: get userName from auth - const userName = ''; + const submission = await service.getSubmissionById(submissionId); + if (!submission) { + throw new BadRequest(`Submission '${submissionId}' not found`); + } + + if (authConfig.enabled && !hasUserWriteAccess(submission.organization, user)) { + throw new Forbidden(`User is not authorized to delete the submission from '${submission.organization}'`); + } + + const userName = user?.username || ''; const activeSubmissionDelete = await service.deleteActiveSubmissionById(submissionId, userName); @@ -67,17 +93,25 @@ const controller = (dependencies: BaseDependencies) => { try { const submissionId = Number(req.params.submissionId); const actionType = SUBMISSION_ACTION_TYPE.parse(req.params.actionType.toUpperCase()); - const entityName = req.query.entityName; const index = req.query.index ? parseInt(req.query.index) : null; + const user = req.user; logger.info( LOG_MODULE, `Request Delete '${entityName ? entityName : 'all'}' records on '{${actionType}}' Active Submission '${submissionId}'`, ); - // TODO: get userName from auth - const userName = ''; + const submission = await service.getSubmissionById(submissionId); + if (!submission) { + throw new BadRequest(`Submission '${submissionId}' not found`); + } + + if (authConfig.enabled && !hasUserWriteAccess(submission.organization, user)) { + throw new Forbidden(`User is not authorized to delete the submission data from '${submission.organization}'`); + } + + const userName = user?.username || ''; const activeSubmission = await service.deleteActiveSubmissionEntity(submissionId, userName, { actionType, @@ -98,22 +132,30 @@ const controller = (dependencies: BaseDependencies) => { try { const categoryId = Number(req.params.categoryId); const systemId = req.params.systemId; + const user = req.user; logger.info(LOG_MODULE, `Request Delete Submitted Data systemId '${systemId}' on categoryId '${categoryId}'`); - // TODO: get userName from auth - const userName = ''; + // get SubmittedData by SystemId + const foundRecordToDelete = await dataService.getSubmittedDataBySystemId(categoryId, systemId, { + view: 'flat', + }); + + if (!foundRecordToDelete.result) { + throw new BadRequest(`No Submitted data found with systemId '${systemId}'`); + } - const deletedRecordsResult = await dataService.deleteSubmittedDataBySystemId(categoryId, systemId, userName); + if (authConfig.enabled && !hasUserWriteAccess(foundRecordToDelete.result.organization, user)) { + throw new Forbidden( + `User is not authorized to delete data from '${foundRecordToDelete.result?.organization}'`, + ); + } - const response = { - status: deletedRecordsResult.status, - description: deletedRecordsResult.description, - inProcessEntities: deletedRecordsResult.inProcessEntities, - submissionId: deletedRecordsResult.submissionId, - }; + const userName = user?.username || ''; - return res.status(200).send(response); + const deletedRecordsResult = await dataService.deleteSubmittedDataBySystemId(categoryId, systemId, userName); + + return res.status(200).send(deletedRecordsResult); } catch (error) { next(error); } @@ -125,18 +167,22 @@ const controller = (dependencies: BaseDependencies) => { const entityName = req.query.entityName; const organization = req.query.organization; const payload = req.body; + const user = req.user; logger.info(LOG_MODULE, `Request Edit Submitted Data`); - // TODO: get userName from auth - const userName = ''; - if (!payload || payload.length == 0) { throw new BadRequest( 'The "payload" parameter is missing or empty. Please include the records in the request for processing.', ); } + if (authConfig.enabled && !hasUserWriteAccess(organization, user)) { + throw new Forbidden(`User is not authorized to edit data from '${organization}'`); + } + + const userName = user?.username || ''; + const editSubmittedDataResult = await dataService.editSubmittedData({ records: payload, entityName, @@ -158,6 +204,7 @@ const controller = (dependencies: BaseDependencies) => { const organization = req.query.organization; const page = parseInt(String(req.query.page)) || defaultPage; const pageSize = parseInt(String(req.query.pageSize)) || defaultPageSize; + const user = req.user; logger.info( LOG_MODULE, @@ -167,8 +214,7 @@ const controller = (dependencies: BaseDependencies) => { `organization '${organization}'`, ); - // TODO: get userName from auth - const userName = ''; + const userName = user?.username || ''; const submissionsResult = await service.getSubmissionsByCategory( categoryId, @@ -201,9 +247,6 @@ const controller = (dependencies: BaseDependencies) => { logger.info(LOG_MODULE, `Request Active Submission submissionId '${submissionId}'`); - // TODO: get userName from auth - // const userName = ''; - const submission = await service.getSubmissionById(submissionId); if (isEmpty(submission)) { @@ -225,8 +268,8 @@ const controller = (dependencies: BaseDependencies) => { `Request Active Submission categoryId '${categoryId}' and organization '${organization}'`, ); - // TODO: get userName from auth - const userName = ''; + // Get userName from auth + const userName = req.user?.username || ''; const activeSubmission = await service.getActiveSubmissionByOrganization({ categoryId, @@ -249,9 +292,7 @@ const controller = (dependencies: BaseDependencies) => { const entityName = req.query.entityName; const organization = req.query.organization; const payload = req.body; - - // TODO: get userName from auth - const userName = ''; + const user = req.user; logger.info( LOG_MODULE, @@ -266,6 +307,12 @@ const controller = (dependencies: BaseDependencies) => { ); } + if (authConfig.enabled && !hasUserWriteAccess(organization, user)) { + throw new Forbidden(`User is not authorized to submit data to '${organization}'`); + } + + const userName = user?.username || ''; + const resultSubmission = await service.submit({ records: payload, entityName, diff --git a/packages/data-provider/src/core/provider.ts b/packages/data-provider/src/core/provider.ts index 9ec583d..eea65e2 100644 --- a/packages/data-provider/src/core/provider.ts +++ b/packages/data-provider/src/core/provider.ts @@ -49,17 +49,20 @@ const provider = (configData: AppConfig) => { return { configs: baseDeps, routers: { - audit: auditRouter(baseDeps), - category: categoryRouter(baseDeps), - dictionary: dictionaryRouter(baseDeps), - submission: submissionRouter(baseDeps), - submittedData: submittedDataRouter(baseDeps), + audit: auditRouter({ baseDependencies: baseDeps, authConfig: configData.auth }), + category: categoryRouter({ baseDependencies: baseDeps, authConfig: configData.auth }), + dictionary: dictionaryRouter({ baseDependencies: baseDeps, authConfig: configData.auth }), + submission: submissionRouter({ baseDependencies: baseDeps, authConfig: configData.auth }), + submittedData: submittedDataRouter({ baseDependencies: baseDeps, authConfig: configData.auth }), }, controllers: { audit: auditController(baseDeps), category: categoryController(baseDeps), dictionary: dictionaryController(baseDeps), - submission: submissionController(baseDeps), + submission: submissionController({ + baseDependencies: baseDeps, + authConfig: { enabled: configData.auth.enabled }, + }), submittedData: submittedDataController(baseDeps), }, services: { diff --git a/packages/data-provider/src/middleware/auth.ts b/packages/data-provider/src/middleware/auth.ts index 4ea9925..b8f15ac 100644 --- a/packages/data-provider/src/middleware/auth.ts +++ b/packages/data-provider/src/middleware/auth.ts @@ -1,13 +1,57 @@ import { NextFunction, Request, Response } from 'express'; +export type UserSession = { + username: string; + isAdmin: boolean; + allowedWriteOrganizations: string[]; +}; + +export type UserSessionResult = { + user?: UserSession; + errorCode?: number; + errorMessage?: string; +}; + +export type AuthConfig = { + enabled: boolean; + customAuthHandler?: (req: Request) => UserSessionResult; +}; + +// Extends the Request interface to include a custom `user` object +declare module 'express-serve-static-core' { + interface Request { + user?: UserSession; + } +} + /** - * Authorization Middleware - * @param req Incoming HTTP Request object - * @param res HTTP Response Object - * @param next Next middleware function + * Middleware to handle authentication based on the provided auth configuration. + * It verifies the user's authentication implemented by the custom authentication handler + * If authentication is valid, it attaches the user information to the request object; + * Otherwise, it returns the appropriate error codes. + * @param authConfig + * @returns */ -export const auth = async (req: Request, res: Response, next: NextFunction) => { - // TODO: auth here - console.log(`auth`); - next(); +export const authMiddleware = (authConfig: AuthConfig) => { + return (req: Request, res: Response, next: NextFunction) => { + // proceed to the next middleware or route handler if auth is disabled + if (!authConfig.enabled) { + return next(); + } + + try { + const authResult: UserSessionResult = + typeof authConfig.customAuthHandler === 'function' ? authConfig.customAuthHandler(req) : {}; + + if (authResult.errorCode) { + return res.status(authResult.errorCode).json({ message: authResult.errorMessage }); + } + + req.user = authResult.user; + return next(); + } catch (error) { + console.error(`Error verifying token ${error}`); + return res.status(403).json({ message: 'Forbidden: Invalid token' }); + } + }; }; diff --git a/packages/data-provider/src/middleware/errorHandler.ts b/packages/data-provider/src/middleware/errorHandler.ts index 3366c35..b424b82 100644 --- a/packages/data-provider/src/middleware/errorHandler.ts +++ b/packages/data-provider/src/middleware/errorHandler.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from 'express'; import { BadRequest, + Forbidden, InternalServerError, NotFound, NotImplemented, @@ -27,6 +28,9 @@ export const errorHandler = (err: Error, req: Request, res: Response, _next: Nex case err instanceof BadRequest: status = 400; break; + case err instanceof Forbidden: + status = 403; + break; case err instanceof NotFound: status = 404; break; diff --git a/packages/data-provider/src/routers/auditRouter.ts b/packages/data-provider/src/routers/auditRouter.ts index 411d6d6..d3e7854 100644 --- a/packages/data-provider/src/routers/auditRouter.ts +++ b/packages/data-provider/src/routers/auditRouter.ts @@ -2,17 +2,24 @@ import { json, Router, urlencoded } from 'express'; import { BaseDependencies } from '../config/config.js'; import auditController from '../controllers/auditController.js'; -import { auth } from '../middleware/auth.js'; +import { type AuthConfig, authMiddleware } from '../middleware/auth.js'; -const router = (dependencies: BaseDependencies): Router => { +const router = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}): Router => { const router = Router(); router.use(urlencoded({ extended: false })); router.use(json()); + router.use(authMiddleware(authConfig)); + router.get( '/category/:categoryId/organization/:organization', - auth, - auditController(dependencies).byCategoryIdAndOrganization, + auditController(baseDependencies).byCategoryIdAndOrganization, ); return router; }; diff --git a/packages/data-provider/src/routers/categoryRouter.ts b/packages/data-provider/src/routers/categoryRouter.ts index fd0f63b..be39820 100644 --- a/packages/data-provider/src/routers/categoryRouter.ts +++ b/packages/data-provider/src/routers/categoryRouter.ts @@ -2,15 +2,23 @@ import { json, Router, urlencoded } from 'express'; import { BaseDependencies } from '../config/config.js'; import categoryController from '../controllers/categoryController.js'; -import { auth } from '../middleware/auth.js'; +import { type AuthConfig, authMiddleware } from '../middleware/auth.js'; -const router = (dependencies: BaseDependencies): Router => { +const router = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}): Router => { const router = Router(); router.use(urlencoded({ extended: false })); router.use(json()); - router.get('/', auth, categoryController(dependencies).listAll); - router.get('/:categoryId', auth, categoryController(dependencies).getDetails); + router.use(authMiddleware(authConfig)); + + router.get('/', categoryController(baseDependencies).listAll); + router.get('/:categoryId', categoryController(baseDependencies).getDetails); return router; }; diff --git a/packages/data-provider/src/routers/dictionaryRouter.ts b/packages/data-provider/src/routers/dictionaryRouter.ts index af9e065..21bafdc 100644 --- a/packages/data-provider/src/routers/dictionaryRouter.ts +++ b/packages/data-provider/src/routers/dictionaryRouter.ts @@ -2,14 +2,22 @@ import { json, Router, urlencoded } from 'express'; import { BaseDependencies } from '../config/config.js'; import dictionaryController from '../controllers/dictionaryController.js'; -import { auth } from '../middleware/auth.js'; +import { type AuthConfig, authMiddleware } from '../middleware/auth.js'; -const router = (dependencies: BaseDependencies): Router => { +const router = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}): Router => { const router = Router(); router.use(urlencoded({ extended: false })); router.use(json()); - router.post('/register', auth, dictionaryController(dependencies).registerDictionary); + router.use(authMiddleware(authConfig)); + + router.post('/register', dictionaryController(baseDependencies).registerDictionary); return router; }; diff --git a/packages/data-provider/src/routers/submissionRouter.ts b/packages/data-provider/src/routers/submissionRouter.ts index caf0f0b..7878550 100644 --- a/packages/data-provider/src/routers/submissionRouter.ts +++ b/packages/data-provider/src/routers/submissionRouter.ts @@ -2,38 +2,92 @@ import { json, Router, urlencoded } from 'express'; import { BaseDependencies } from '../config/config.js'; import submissionController from '../controllers/submissionController.js'; -import { auth } from '../middleware/auth.js'; +import { type AuthConfig, authMiddleware } from '../middleware/auth.js'; -const router = (dependencies: BaseDependencies): Router => { +const router = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}): Router => { const router = Router(); router.use(urlencoded({ extended: false })); router.use(json()); - router.get('/:submissionId', auth, submissionController(dependencies).getSubmissionById); + router.use(authMiddleware(authConfig)); - router.delete('/:submissionId', auth, submissionController(dependencies).delete); + router.get( + '/:submissionId', + submissionController({ + baseDependencies, + authConfig, + }).getSubmissionById, + ); + + router.delete( + '/:submissionId', + submissionController({ + baseDependencies, + authConfig, + }).delete, + ); - router.delete('/:submissionId/:actionType', auth, submissionController(dependencies).deleteEntityName); + router.delete( + '/:submissionId/:actionType', + submissionController({ + baseDependencies, + authConfig, + }).deleteEntityName, + ); - router.get('/category/:categoryId', auth, submissionController(dependencies).getSubmissionsByCategory); + router.get( + '/category/:categoryId', + submissionController({ + baseDependencies, + authConfig, + }).getSubmissionsByCategory, + ); router.get( '/category/:categoryId/organization/:organization', - auth, - submissionController(dependencies).getActiveByOrganization, + submissionController({ + baseDependencies, + authConfig, + }).getActiveByOrganization, ); - router.post('/category/:categoryId/data', submissionController(dependencies).submit); + router.post( + '/category/:categoryId/data', + submissionController({ + baseDependencies, + authConfig, + }).submit, + ); router.delete( `/category/:categoryId/data/:systemId`, - auth, - submissionController(dependencies).deleteSubmittedDataBySystemId, + submissionController({ + baseDependencies, + authConfig, + }).deleteSubmittedDataBySystemId, ); - router.put(`/category/:categoryId/data`, auth, submissionController(dependencies).editSubmittedData); + router.put( + `/category/:categoryId/data`, + submissionController({ + baseDependencies, + authConfig, + }).editSubmittedData, + ); - router.post('/category/:categoryId/commit/:submissionId', auth, submissionController(dependencies).commit); + router.post( + '/category/:categoryId/commit/:submissionId', + submissionController({ + baseDependencies, + authConfig, + }).commit, + ); return router; }; diff --git a/packages/data-provider/src/routers/submittedDataRouter.ts b/packages/data-provider/src/routers/submittedDataRouter.ts index 24d8bd8..434668b 100644 --- a/packages/data-provider/src/routers/submittedDataRouter.ts +++ b/packages/data-provider/src/routers/submittedDataRouter.ts @@ -2,31 +2,36 @@ import { json, Router, urlencoded } from 'express'; import { BaseDependencies } from '../config/config.js'; import submittedDataController from '../controllers/submittedDataController.js'; -import { auth } from '../middleware/auth.js'; - -const router = (dependencies: BaseDependencies): Router => { +import { type AuthConfig, authMiddleware } from '../middleware/auth.js'; + +const router = ({ + baseDependencies, + authConfig, +}: { + baseDependencies: BaseDependencies; + authConfig: AuthConfig; +}): Router => { const router = Router(); router.use(urlencoded({ extended: false })); router.use(json()); - router.get('/category/:categoryId', auth, submittedDataController(dependencies).getSubmittedDataByCategory); + router.use(authMiddleware(authConfig)); + + router.get('/category/:categoryId', submittedDataController(baseDependencies).getSubmittedDataByCategory); router.get( '/category/:categoryId/organization/:organization', - auth, - submittedDataController(dependencies).getSubmittedDataByOrganization, + submittedDataController(baseDependencies).getSubmittedDataByOrganization, ); router.post( '/category/:categoryId/organization/:organization/query', - auth, - submittedDataController(dependencies).getSubmittedDataByQuery, + submittedDataController(baseDependencies).getSubmittedDataByQuery, ); router.get( '/category/:categoryId/id/:systemId', - auth, - submittedDataController(dependencies).getSubmittedDataBySystemId, + submittedDataController(baseDependencies).getSubmittedDataBySystemId, ); return router; diff --git a/packages/data-provider/src/services/submission/processor.ts b/packages/data-provider/src/services/submission/processor.ts index 3c95971..c465c98 100644 --- a/packages/data-provider/src/services/submission/processor.ts +++ b/packages/data-provider/src/services/submission/processor.ts @@ -546,7 +546,11 @@ const processor = (dependencies: BaseDependencies) => { userName, }); } catch (error) { - logger.error(`There was an error processing records on entity '${schema.name}'`, JSON.stringify(error)); + logger.error( + LOG_MODULE, + `There was an error processing records on entity '${schema.name}'`, + JSON.stringify(error), + ); } logger.info(LOG_MODULE, `Finished validating files`); }; @@ -672,7 +676,11 @@ const processor = (dependencies: BaseDependencies) => { userName, }); } catch (error) { - logger.error(`There was an error processing records on entity '${schema.name}'`, JSON.stringify(error)); + logger.error( + LOG_MODULE, + `There was an error processing records on entity '${schema.name}'`, + JSON.stringify(error), + ); } logger.info(LOG_MODULE, `Finished validating files`); }; diff --git a/packages/data-provider/src/services/submittedData/viewMode.ts b/packages/data-provider/src/services/submittedData/viewMode.ts index b3d585b..4cf34a5 100644 --- a/packages/data-provider/src/services/submittedData/viewMode.ts +++ b/packages/data-provider/src/services/submittedData/viewMode.ts @@ -48,7 +48,7 @@ const viewMode = (dependencies: BaseDependencies) => { record.data = { ...record.data, ...childNodes, ...parentNodes }; } catch (error) { - logger.error(`Error converting record ${record.systemId} into compound document`, error); + logger.error(LOG_MODULE, `Error converting record ${record.systemId} into compound document`, error); throw new InternalServerError(`An error occurred while converting records into compound view`); } return record; diff --git a/packages/data-provider/src/utils/authUtils.ts b/packages/data-provider/src/utils/authUtils.ts new file mode 100644 index 0000000..12d69f9 --- /dev/null +++ b/packages/data-provider/src/utils/authUtils.ts @@ -0,0 +1,20 @@ +import type { UserSession } from '../middleware/auth.js'; + +/** + * checks if a user has write access to a specific organization. + * @param organization + * @param user + * @returns + */ +export const hasUserWriteAccess = (organization: string, user?: UserSession): boolean => { + if (!user) { + return false; + } + + if (user.isAdmin) { + // if user is admin should have access to write all organization + return true; + } + + return user.allowedWriteOrganizations.includes(organization); +}; diff --git a/packages/data-provider/src/utils/errors.ts b/packages/data-provider/src/utils/errors.ts index 5cfca53..26b05f0 100644 --- a/packages/data-provider/src/utils/errors.ts +++ b/packages/data-provider/src/utils/errors.ts @@ -6,6 +6,13 @@ export class BadRequest extends Error { } } +export class Forbidden extends Error { + constructor(msg: string) { + super(msg); + this.name = 'Forbidden'; + } +} + export class NotFound extends Error { constructor(msg: string) { super(msg); diff --git a/packages/data-provider/test/utils/auth.spec.ts b/packages/data-provider/test/utils/auth.spec.ts new file mode 100644 index 0000000..eef7c24 --- /dev/null +++ b/packages/data-provider/test/utils/auth.spec.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { UserSession } from '../../src/middleware/auth.js'; +import { hasUserWriteAccess } from '../../src/utils/authUtils.js'; + +describe('Auth utils', () => { + it('should return false if user is not provided', () => { + const result = hasUserWriteAccess('org1'); + expect(result).to.eql(false); + }); + + it('should return false if user is not an admin and not allowed to write to the organization', () => { + const user: UserSession = { + username: 'John Doe', + isAdmin: false, + allowedWriteOrganizations: ['org2'], + }; + const result = hasUserWriteAccess('org1', user); + expect(result).to.eql(false); + }); + + it('should return true if user is not an admin but is allowed to write to the organization', () => { + const user: UserSession = { + username: 'John Doe', + isAdmin: false, + allowedWriteOrganizations: ['org1', 'org3'], + }; + const result = hasUserWriteAccess('org1', user); + expect(result).to.eql(true); + }); + + it('should return true if user is an admin', () => { + const user: UserSession = { + username: 'John Doe', + isAdmin: true, + allowedWriteOrganizations: ['org1', 'org2'], + }; + const result = hasUserWriteAccess('org1', user); + expect(result).to.eql(true); + }); + + it('should return false if user is admin and allowedWriteOrganizations does not include the organization', () => { + const user: UserSession = { + username: 'John Doe', + isAdmin: true, + allowedWriteOrganizations: ['org1', 'org2'], + }; + const result = hasUserWriteAccess('org3', user); + expect(result).to.eql(true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92f9403..e6701d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@overture-stack/lyric': specifier: workspace:^ version: link:../../packages/data-provider + cors: + specifier: ^2.8.5 + version: 2.8.5 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -84,6 +87,9 @@ importers: specifier: ^3.13.1 version: 3.13.1 devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.17 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -1131,6 +1137,12 @@ packages: '@types/node': 20.14.10 dev: true + /@types/cors@2.8.17: + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + dependencies: + '@types/node': 20.14.10 + dev: true + /@types/deep-freeze@0.1.5: resolution: {integrity: sha512-KZtR+jtmgkCpgE0f+We/QEI2Fi0towBV/tTkvHVhMzx+qhUVGXMx7pWvAtDp6vEWIjdKLTKpqbI/sORRCo8TKg==} dev: true @@ -1752,6 +1764,14 @@ packages: /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + /cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + dev: false + /cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -3233,6 +3253,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: false + /object-inspect@1.13.2: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'}