diff --git a/backend/README.md b/backend/README.md index f7744ef..5436c70 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,6 +33,22 @@ npm run start-dev The server runs on **port 8080** by default. +## Checks + +```bash +npm run lint +npm run test +npm run test:integration +npm run check:public-access +``` + +- `npm run test` runs fast unit tests without requiring PostgreSQL. +- `npm run test:integration` runs rollback-based PostgreSQL integration tests + when a valid database configuration is available; otherwise the tests skip. +- `npm run check:public-access` audits stale Public role/user permissions and + non-Public private production presentation grants. Review its output before + running `npm run fix:public-access`. + ## Environment Variables Create a `.env` file in the backend directory: @@ -172,13 +188,13 @@ Swagger UI available at: `http://localhost:8080/api-docs` ### Core Endpoints -| Endpoint | Description | -|----------|-------------| -| `POST /api/auth/signin/local` | Email/password login | -| `POST /api/auth/signup` | User registration | -| `GET /api/auth/me` | Current user info (JWT required) | -| `GET /api/auth/signin/google` | Google OAuth login | -| `GET /api/auth/signin/microsoft` | Microsoft OAuth login | +| Endpoint | Description | +| -------------------------------- | -------------------------------- | +| `POST /api/auth/signin/local` | Email/password login | +| `POST /api/auth/signup` | User registration | +| `GET /api/auth/me` | Current user info (JWT required) | +| `GET /api/auth/signin/google` | Google OAuth login | +| `GET /api/auth/signin/microsoft` | Microsoft OAuth login | ### Entity CRUD Pattern @@ -194,23 +210,23 @@ DELETE /api/{entity}/:id # Soft delete record ### Main Entities -| Entity | Description | -|--------|-------------| -| `projects` | Virtual tour projects | -| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) | -| `assets` | Uploaded media files | -| `asset_variants` | Resized/optimized asset versions | -| `element_type_defaults` | Global element default settings | -| `project_element_defaults` | Project-specific element settings | -| `project_audio_tracks` | Background audio for projects | -| `publish_events` | Publishing history and status tracking | -| `pwa_caches` | PWA cache manifests for offline support | -| `presigned_url_requests` | S3 presigned URL request tracking | -| `access_logs` | User access audit trail | -| `users` | User accounts | -| `roles` | User roles | -| `permissions` | Granular permissions | -| `project_memberships` | Team access per project | +| Entity | Description | +| -------------------------- | -------------------------------------------------------------------------------- | +| `projects` | Virtual tour projects | +| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) | +| `assets` | Uploaded media files | +| `asset_variants` | Resized/optimized asset versions | +| `element_type_defaults` | Global element default settings | +| `project_element_defaults` | Project-specific element settings | +| `project_audio_tracks` | Background audio for projects | +| `publish_events` | Publishing history and status tracking | +| `pwa_caches` | PWA cache manifests for offline support | +| `presigned_url_requests` | S3 presigned URL request tracking | +| `access_logs` | User access audit trail | +| `users` | User accounts | +| `roles` | User roles | +| `permissions` | Granular permissions | +| `project_memberships` | Team access per project | ### Element Defaults Hierarchy @@ -235,6 +251,7 @@ tour_pages.ui_schema_json (Instance) 3. **Instance** (`tour_pages.ui_schema_json`) - Page-specific elements with their settings stored inline. Created in constructor with project defaults applied. **Additional Endpoints:** + - `POST /api/project-element-defaults/:id/reset` - Reset to current global default - `GET /api/project-element-defaults/:id/diff` - Compare with global default @@ -248,6 +265,7 @@ POST /api/publish # Copy stage content to production (body: { pr ``` Pages have an `environment` field (`dev`, `stage`, or `production`) that determines visibility: + - **Constructor** (`/constructor?projectId=`) - Always shows `dev` environment - **Stage preview** (`/p/[slug]/stage`) - Shows `stage` environment - **Public runtime** (`/p/[slug]`) - Shows `production` environment @@ -313,15 +331,15 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS` ### Default Roles -| Role | Description | -|------|-------------| -| Administrator | Full access to all features (user/role/permission management) | -| Platform Owner | Full project access, user management | -| Account Manager | Project and asset management | -| Tour Designer | Create and edit tours, assets, pages | -| Content Reviewer | Review and update content (read/update access) | -| Analytics Viewer | Read-only access for viewing data | -| Public | Minimal access for public users | +| Role | Description | +| ---------------- | ------------------------------------------------------------- | +| Administrator | Full access to all features (user/role/permission management) | +| Platform Owner | Full project access, user management | +| Account Manager | Project and asset management | +| Tour Designer | Create and edit tours, assets, pages | +| Content Reviewer | Review and update content (read/update access) | +| Analytics Viewer | Read-only access for viewing data | +| Public | Minimal access for public users | ## Environment Detection @@ -329,21 +347,21 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS` The backend uses `NODE_ENV` to determine database configuration: -| Value | Database | Description | -|-------|----------|-------------| -| `production` | Production config | Live environment | -| `dev_stage` | Staging config | Staging environment | -| (other) | Development config | Local development | +| Value | Database | Description | +| ------------ | ------------------ | ------------------- | +| `production` | Production config | Live environment | +| `dev_stage` | Staging config | Staging environment | +| (other) | Development config | Local development | ### Content Environment (tour_pages.environment) Separate from server environment, tour pages have a content environment field: -| Value | Access | Description | -|-------|--------|-------------| -| `dev` | Constructor only | Editing/draft content | -| `stage` | Stage preview | Pre-production review | -| `production` | Public runtime | Published content | +| Value | Access | Description | +| ------------ | ---------------- | --------------------- | +| `dev` | Constructor only | Editing/draft content | +| `stage` | Stage preview | Pre-production review | +| `production` | Public runtime | Published content | The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.js` middleware resolves this for API requests. diff --git a/backend/package.json b/backend/package.json index f674d05..96f2f1f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,10 @@ "scripts": { "start": "npm run db:migrate && npm run db:seed && npm run watch", "start-dev": "cross-env NODE_ENV=production LOG_PRETTY=true DOTENV_CONFIG_PATH=.env NODE_OPTIONS=\"-r dotenv/config\" npm run start", + "test": "node --test tests/*.test.js", + "test:integration": "node --test tests/integration/*.test.js", + "check:public-access": "node scripts/check-public-access-hardening.js", + "fix:public-access": "node scripts/check-public-access-hardening.js --fix", "lint": "eslint . --ext .js", "db:migrate": "sequelize-cli db:migrate", "db:migrate:undo": "sequelize-cli db:migrate:undo", diff --git a/backend/scripts/check-public-access-hardening.js b/backend/scripts/check-public-access-hardening.js new file mode 100644 index 0000000..3be174f --- /dev/null +++ b/backend/scripts/check-public-access-hardening.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +const db = require('../src/db/models'); +const AccessPolicyAuditService = require('../src/services/access-policy-audit'); + +const shouldFix = process.argv.includes('--fix'); +const EXIT_TIMEOUT_MS = 1500; + +function summarizeReport(report) { + return { + publicRolePermissions: report.publicRolePermissions.length, + publicUsersWithCustomPermissions: + report.publicUsersWithCustomPermissions.length, + productionPresentationAccessForNonPublicUsers: + report.productionPresentationAccessForNonPublicUsers.length, + }; +} + +async function main() { + if (shouldFix) { + const result = await db.sequelize.transaction((transaction) => + AccessPolicyAuditService.cleanupViolations({ transaction }), + ); + + console.log( + JSON.stringify( + { + fixed: true, + summary: { + removedPublicRolePermissions: result.removedPublicRolePermissions, + clearedPublicUserCustomPermissions: + result.clearedPublicUserCustomPermissions, + removedNonPublicProductionPresentationGrants: + result.removedNonPublicProductionPresentationGrants, + }, + }, + null, + 2, + ), + ); + return; + } + + const report = await AccessPolicyAuditService.findViolations(); + const hasViolations = AccessPolicyAuditService.hasViolations(report); + + console.log( + JSON.stringify( + { + ok: !hasViolations, + summary: summarizeReport(report), + report, + }, + null, + 2, + ), + ); + + if (hasViolations) { + process.exitCode = 1; + } +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + try { + await Promise.race([ + db.sequelize.close(), + new Promise((resolve) => setTimeout(resolve, EXIT_TIMEOUT_MS)), + ]); + } finally { + process.exit(process.exitCode || 0); + } + }); diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index 3f411a2..7690f2a 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -1,6 +1,7 @@ const db = require('../models'); const Utils = require('../utils'); const { parse } = require('json2csv'); +const { logger } = require('../../utils/logger'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; @@ -45,6 +46,10 @@ class GenericDBApi { return ['id', 'createdAt']; } + static get SORTABLE_FIELDS() { + return Object.keys(this.MODEL.rawAttributes || {}); + } + static get AUTOCOMPLETE_FIELD() { return 'name'; } @@ -305,7 +310,7 @@ class GenericDBApi { static async findAll(filter = {}, options = {}) { filter = filter || {}; - const limit = filter.limit || 0; + const limit = Number(filter.limit) || 0; const currentPage = Number(filter.page) || 0; const offset = Math.max(currentPage - 1, 0) * limit; @@ -403,6 +408,12 @@ class GenericDBApi { } } + const sortField = this.SORTABLE_FIELDS.includes(filter.field) + ? filter.field + : 'createdAt'; + const sortDirection = + String(filter.sort || 'desc').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + try { if (options.countOnly) { const count = await this.MODEL.count({ @@ -422,10 +433,7 @@ class GenericDBApi { where, include, distinct: true, - order: - filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], + order: [[sortField, sortDirection]], transaction: options.transaction, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, @@ -437,7 +445,10 @@ class GenericDBApi { count, }; } catch (error) { - console.error('Error executing query:', error); + logger.error( + { err: error, table: this.TABLE_NAME }, + 'Error executing query', + ); throw error; } } diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index f9f8036..f6195c2 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -5,11 +5,20 @@ const Utils = require('../utils'); const bcrypt = require('bcrypt'); const config = require('../../config'); +const { logger } = require('../../utils/logger'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; module.exports = class UsersDBApi { + static get MODEL() { + return db.users; + } + + static get SORTABLE_FIELDS() { + return Object.keys(db.users.rawAttributes || {}); + } + /** * Default includes for findBy() - minimal set for single user lookup * Only loads avatar and app_role with permissions (needed for RBAC) @@ -652,15 +661,18 @@ module.exports = class UsersDBApi { } } + const sortField = this.SORTABLE_FIELDS.includes(filter.field) + ? filter.field + : 'createdAt'; + const sortDirection = + String(filter.sort || 'desc').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const queryOptions = { attributes: { exclude: this.SENSITIVE_FIELDS }, where, include, distinct: true, - order: - filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], + order: [[sortField, sortDirection]], transaction: options?.transaction, }; @@ -677,7 +689,7 @@ module.exports = class UsersDBApi { count: count, }; } catch (error) { - console.error('Error executing query:', error); + logger.error({ err: error, table: 'users' }, 'Error executing query'); throw error; } } diff --git a/backend/src/factories/router.factory.js b/backend/src/factories/router.factory.js index 9184b88..259f23e 100644 --- a/backend/src/factories/router.factory.js +++ b/backend/src/factories/router.factory.js @@ -1,7 +1,54 @@ const express = require('express'); -const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { + wrapAsync, + commonErrorHandler, + isUuidV4, + assertRouteIdMatchesBody, +} = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { parse } = require('json2csv'); +const { logger } = require('../utils/logger'); + +const DEFAULT_LIST_LIMIT = 50; +const MAX_LIST_LIMIT = 1000; +const MAX_AUTOCOMPLETE_LIMIT = 50; +const MAX_CSV_LIMIT = 1000; + +function clampLimit(value, { defaultLimit, maxLimit }) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultLimit; + return Math.min(parsed, maxLimit); +} + +function getSortableFields(DBApi) { + if (Array.isArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS; + if (DBApi.MODEL?.rawAttributes) return Object.keys(DBApi.MODEL.rawAttributes); + return []; +} + +function normalizeQuery(query = {}, DBApi, { csv = false } = {}) { + const normalized = { ...query }; + const maxLimit = csv ? MAX_CSV_LIMIT : MAX_LIST_LIMIT; + normalized.limit = clampLimit(normalized.limit, { + defaultLimit: DEFAULT_LIST_LIMIT, + maxLimit, + }); + + const page = Number.parseInt(normalized.page, 10); + normalized.page = Number.isFinite(page) && page > 0 ? page : 1; + + if (normalized.sort) { + const sort = String(normalized.sort).toUpperCase(); + normalized.sort = sort === 'ASC' ? 'ASC' : 'DESC'; + } + + const sortableFields = getSortableFields(DBApi); + if (normalized.field && !sortableFields.includes(normalized.field)) { + delete normalized.field; + } + + return normalized; +} function createEntityRouter(entityName, Service, DBApi, options = {}) { const router = express.Router(); @@ -41,7 +88,8 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.put( '/:id', wrapAsync(async (req, res) => { - await Service.update(req.body.data, req.body.id, req.currentUser); + assertRouteIdMatchesBody(req); + await Service.update(req.body.data, req.params.id, req.currentUser); res.status(200).send(true); }), ); @@ -68,8 +116,11 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { const filetype = req.query.filetype; const currentUser = req.currentUser; const runtimeContext = req.runtimeContext; + const normalizedQuery = normalizeQuery(req.query, DBApi, { + csv: filetype === 'csv', + }); - const payload = await DBApi.findAll(req.query, { + const payload = await DBApi.findAll(normalizedQuery, { currentUser, runtimeContext, }); @@ -82,7 +133,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { const csv = parse(payload.rows, opts); res.status(200).attachment('export.csv').send(csv); } catch (err) { - console.error(err); + logger.error({ err, entityName }, 'CSV export error'); res.status(500).send('CSV export error'); } } else { @@ -96,7 +147,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { wrapAsync(async (req, res) => { const currentUser = req.currentUser; const runtimeContext = req.runtimeContext; - const payload = await DBApi.findAll(req.query, { + const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext, @@ -108,9 +159,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.get( '/autocomplete', wrapAsync(async (req, res) => { + const limit = clampLimit(req.query.limit, { + defaultLimit: 20, + maxLimit: MAX_AUTOCOMPLETE_LIMIT, + }); const payload = await DBApi.findAllAutocomplete( req.query.query, - req.query.limit, + limit, req.query.offset, ); res.status(200).send(payload); diff --git a/backend/src/helpers.js b/backend/src/helpers.js index 2b2d4b7..475d403 100644 --- a/backend/src/helpers.js +++ b/backend/src/helpers.js @@ -1,5 +1,7 @@ const jwt = require('jsonwebtoken'); const config = require('./config'); +const ValidationError = require('./services/notifications/errors/validation'); +const { logger } = require('./utils/logger'); module.exports = class Helpers { static wrapAsync(fn) { @@ -15,7 +17,7 @@ module.exports = class Helpers { return res.status(statusCode).send(error.message); } - console.error(error); + logger.error({ err: error }, 'Unhandled route error'); return res.status(500).send('Internal server error'); } @@ -28,4 +30,11 @@ module.exports = class Helpers { value, ); } + + static assertRouteIdMatchesBody(req) { + const bodyId = req.body?.id || req.body?.data?.id; + if (bodyId && bodyId !== req.params.id) { + throw new ValidationError('Request body id does not match route id'); + } + } }; diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index 3e174d8..960e3ff 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -1,6 +1,7 @@ -const ValidationError = require('../services/notifications/errors/validation'); +const ForbiddenError = require('../services/notifications/errors/forbidden'); const RolesDBApi = require('../db/api/roles'); const { logger } = require('../utils/logger'); +const AccessPolicy = require('../services/access-policy'); // Cache for the 'Public' role object let publicRoleCache = null; @@ -52,49 +53,27 @@ function checkPermissions(permission) { return async (req, res, next) => { const { currentUser } = req; - // 1. Check self-access bypass (only if the user is authenticated) - if ( - currentUser && - (currentUser.id === req.params.id || currentUser.id === req.body.id) - ) { - return next(); // User has access to their own resource + if (await AccessPolicy.hasPermission(currentUser, permission)) { + return next(); } - // 2. Check Custom Permissions (only if the user is authenticated) - if (currentUser) { - // Ensure custom_permissions is an array before using find - const customPermissions = Array.isArray(currentUser.custom_permissions) - ? currentUser.custom_permissions - : []; - const userPermission = customPermissions.find( - (cp) => cp.name === permission, - ); - if (userPermission) { - return next(); // User has a custom permission - } + if (currentUser && AccessPolicy.isPublicUser(currentUser)) { + return next(new ForbiddenError()); } - // 3. Determine the "effective" role for permission check let effectiveRole = null; try { if (currentUser && currentUser.app_role) { - // User is authenticated and has an assigned role effectiveRole = currentUser.app_role; } else { - // User is NOT authenticated OR is authenticated but has no role - // Use the cached 'Public' role if (!publicRoleCache) { - // If the cache is unexpectedly empty (e.g., startup error caught), - // we can try fetching the role again synchronously (less ideal) or just deny access. const log = req.log || logger; log.warn( { role: 'Public' }, 'Role cache is empty, attempting synchronous fetch', ); - // Less efficient fallback option: - effectiveRole = await RolesDBApi.findBy({ name: 'Public' }); // Could be slow + effectiveRole = await RolesDBApi.findBy({ name: 'Public' }); if (!effectiveRole) { - // If even the synchronous attempt failed return next( new Error( 'Internal Server Error: Public role missing and cannot be fetched.', @@ -102,11 +81,10 @@ function checkPermissions(permission) { ); } } else { - effectiveRole = publicRoleCache; // Use the cached object + effectiveRole = publicRoleCache; } } - // Check if we got a valid role object if (!effectiveRole) { return next( new Error( @@ -115,15 +93,10 @@ function checkPermissions(permission) { ); } - // 4. Check Permissions on the "effective" role - // Assume the effectiveRole object (from app_role or RolesDBApi) has a getPermissions() method - // or a 'permissions' property (if permissions are eagerly loaded). - let rolePermissions = []; - if (typeof effectiveRole.getPermissions === 'function') { - rolePermissions = await effectiveRole.getPermissions(); // Get permissions asynchronously if the method exists - } else if (Array.isArray(effectiveRole.permissions)) { - rolePermissions = effectiveRole.permissions; // Or take from property if permissions are pre-loaded - } else { + const rolePermissionNames = + await AccessPolicy.getRolePermissionNames(effectiveRole); + + if (!rolePermissionNames) { const log = req.log || logger; log.error( { roleId: effectiveRole.id, roleName: effectiveRole.name }, @@ -134,14 +107,12 @@ function checkPermissions(permission) { ); } - if (rolePermissions.find((p) => p.name === permission)) { - next(); // The "effective" role has the required permission + if (rolePermissionNames.has(permission)) { + next(); } else { - // The "effective" role does not have the required permission const roleName = effectiveRole.name || 'unknown role'; next( - new ValidationError( - 'auth.forbidden', + new ForbiddenError( `Role '${roleName}' denied access to '${permission}'.`, ), ); @@ -176,6 +147,20 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([ 'PROJECT_UI_CONTROL_SETTINGS', ]); +function getRouteId(req) { + if (req.params?.id) return req.params.id; + + const path = req.path || req.url || ''; + const match = path.match(/^\/([^/?#]+)\/?$/); + if (!match) return null; + + try { + return decodeURIComponent(match[1]); + } catch (_error) { + return match[1]; + } +} + /** * Middleware creator to check standard CRUD permissions based on HTTP method and entity name. * @param {string} name - The name of the entity. @@ -183,6 +168,16 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([ */ function checkCrudPermissions(name) { return (req, res, next) => { + const isSelfUserRoute = + name === 'users' && + req.currentUser && + req.currentUser.id === getRouteId(req) && + ['GET', 'PUT', 'PATCH'].includes(req.method); + + if (isSelfUserRoute) { + return next(); + } + const isRuntimePublicRead = req.isRuntimePublicRequest === true && req.method === 'GET' && diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js index 98a8c85..009a028 100644 --- a/backend/src/routes/global_transition_defaults.js +++ b/backend/src/routes/global_transition_defaults.js @@ -2,7 +2,12 @@ const express = require('express'); const passport = require('passport'); const Global_transition_defaultsService = require('../services/global_transition_defaults'); const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults'); -const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { + wrapAsync, + commonErrorHandler, + isUuidV4, + assertRouteIdMatchesBody, +} = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const router = express.Router(); @@ -117,9 +122,10 @@ router.put( '/:id', jwtAuth, wrapAsync(async (req, res) => { + assertRouteIdMatchesBody(req); await Global_transition_defaultsService.update( req.body.data, - req.body.id, + req.params.id, req.currentUser, ); res.status(200).send(true); diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index e188f22..3c3e29e 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -3,7 +3,12 @@ const passport = require('passport'); const db = require('../db/models'); const Project_transition_settingsService = require('../services/project_transition_settings'); const Project_transition_settingsDBApi = require('../db/api/project_transition_settings'); -const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { + wrapAsync, + commonErrorHandler, + isUuidV4, + assertRouteIdMatchesBody, +} = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const RuntimePresentationAccessService = require('../services/runtime-presentation-access'); @@ -409,9 +414,10 @@ router.put( '/:id', jwtAuth, wrapAsync(async (req, res) => { + assertRouteIdMatchesBody(req); await Project_transition_settingsService.update( req.body.data, - req.body.id, + req.params.id, req.currentUser, ); res.status(200).send(true); diff --git a/backend/src/routes/tour_pages.js b/backend/src/routes/tour_pages.js index 4470fc2..902ca34 100644 --- a/backend/src/routes/tour_pages.js +++ b/backend/src/routes/tour_pages.js @@ -1,9 +1,15 @@ const express = require('express'); const Tour_pagesService = require('../services/tour_pages'); const Tour_pagesDBApi = require('../db/api/tour_pages'); -const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); +const { + wrapAsync, + commonErrorHandler, + isUuidV4, + assertRouteIdMatchesBody, +} = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { parse } = require('json2csv'); +const { logger } = require('../utils/logger'); /** * @swagger @@ -220,7 +226,12 @@ router.post( router.put( '/:id', wrapAsync(async (req, res) => { - await Tour_pagesService.update(req.body.data, req.body.id, req.currentUser); + assertRouteIdMatchesBody(req); + await Tour_pagesService.update( + req.body.data, + req.params.id, + req.currentUser, + ); res.status(200).send(true); }), ); @@ -271,7 +282,7 @@ router.get( const csv = parse(payload.rows, opts); res.status(200).attachment('export.csv').send(csv); } catch (err) { - console.error(err); + logger.error({ err }, 'Tour pages CSV export error'); res.status(500).send('CSV export error'); } } else { diff --git a/backend/src/services/access-policy-audit.js b/backend/src/services/access-policy-audit.js new file mode 100644 index 0000000..ef70bcb --- /dev/null +++ b/backend/src/services/access-policy-audit.js @@ -0,0 +1,153 @@ +const db = require('../db/models'); + +class AccessPolicyAuditService { + static async findViolations(options = {}) { + const transaction = options.transaction; + + const publicRoles = await db.roles.findAll({ + where: { name: 'Public' }, + include: [{ association: 'permissions' }], + transaction, + }); + + const publicRolePermissions = publicRoles.flatMap((role) => + (role.permissions || []).map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + permissionName: permission.name, + })), + ); + + const publicUsersWithCustomPermissions = await db.users.findAll({ + attributes: ['id', 'email'], + include: [ + { + association: 'app_role', + attributes: ['id', 'name'], + where: { name: 'Public' }, + required: true, + }, + { + association: 'custom_permissions', + attributes: ['id', 'name'], + required: true, + }, + ], + transaction, + }); + + const productionPresentationAccessForNonPublicUsers = + await db.production_presentation_access.findAll({ + attributes: ['id', 'userId', 'projectId'], + include: [ + { + association: 'user', + attributes: ['id', 'email'], + required: true, + include: [ + { + association: 'app_role', + attributes: ['id', 'name'], + required: false, + }, + ], + }, + { + association: 'project', + attributes: ['id', 'name', 'slug'], + required: false, + }, + ], + transaction, + }); + + const nonPublicGrants = + productionPresentationAccessForNonPublicUsers.filter( + (grant) => grant.user?.app_role?.name !== 'Public', + ); + + return { + publicRolePermissions: publicRolePermissions.map((entry) => ({ + roleId: entry.roleId, + id: entry.permissionId, + name: entry.permissionName, + })), + publicUsersWithCustomPermissions: publicUsersWithCustomPermissions.map( + (user) => ({ + id: user.id, + email: user.email, + customPermissions: (user.custom_permissions || []).map( + (permission) => ({ + id: permission.id, + name: permission.name, + }), + ), + }), + ), + productionPresentationAccessForNonPublicUsers: nonPublicGrants.map( + (grant) => ({ + id: grant.id, + userId: grant.userId, + userEmail: grant.user?.email || null, + userRole: grant.user?.app_role?.name || null, + projectId: grant.projectId, + projectSlug: grant.project?.slug || null, + }), + ), + }; + } + + static hasViolations(report) { + return ( + report.publicRolePermissions.length > 0 || + report.publicUsersWithCustomPermissions.length > 0 || + report.productionPresentationAccessForNonPublicUsers.length > 0 + ); + } + + static async cleanupViolations(options = {}) { + const transaction = options.transaction; + const report = await this.findViolations({ transaction }); + + const publicRoleIds = [ + ...new Set( + report.publicRolePermissions + .map((permission) => permission.roleId) + .filter(Boolean), + ), + ]; + + for (const publicRoleId of publicRoleIds) { + const publicRole = await db.roles.findByPk(publicRoleId, { transaction }); + if (publicRole) { + await publicRole.setPermissions([], { transaction }); + } + } + + for (const userReport of report.publicUsersWithCustomPermissions) { + const user = await db.users.findByPk(userReport.id, { transaction }); + await user.setCustom_permissions([], { transaction }); + } + + const grantIds = report.productionPresentationAccessForNonPublicUsers.map( + (grant) => grant.id, + ); + + if (grantIds.length > 0) { + await db.production_presentation_access.destroy({ + where: { id: { [db.Sequelize.Op.in]: grantIds } }, + transaction, + }); + } + + return { + before: report, + removedPublicRolePermissions: report.publicRolePermissions.length, + clearedPublicUserCustomPermissions: + report.publicUsersWithCustomPermissions.length, + removedNonPublicProductionPresentationGrants: grantIds.length, + }; + } +} + +module.exports = AccessPolicyAuditService; diff --git a/backend/src/services/access-policy.js b/backend/src/services/access-policy.js new file mode 100644 index 0000000..f260ca0 --- /dev/null +++ b/backend/src/services/access-policy.js @@ -0,0 +1,146 @@ +const db = require('../db/models'); + +const PUBLIC_ROLE = 'Public'; +const PLATFORM_WIDE_ROLES = new Set([ + 'Administrator', + 'Platform Owner', + 'Account Manager', +]); + +class AccessPolicy { + static normalizeSlug(slug) { + return String(slug || '') + .trim() + .replace(/^\/+|\/+$/g, '') + .toLowerCase(); + } + + static getRoleName(user) { + return user?.app_role?.name || user?.role?.name || null; + } + + static getStandaloneRoleName(role) { + return role?.name || null; + } + + static getCustomPermissions(user) { + return Array.isArray(user?.custom_permissions) + ? user.custom_permissions + : []; + } + + static getRolePermissions(user) { + const permissions = []; + + if (Array.isArray(user?.app_role?.permissions)) { + permissions.push(...user.app_role.permissions); + } + + if (Array.isArray(user?.app_role_permissions)) { + permissions.push(...user.app_role_permissions); + } + + return permissions; + } + + static getPermissionName(permission) { + return typeof permission === 'string' ? permission : permission?.name; + } + + static getEffectivePermissionNames(user) { + return new Set( + [...this.getRolePermissions(user), ...this.getCustomPermissions(user)] + .map((permission) => this.getPermissionName(permission)) + .filter(Boolean), + ); + } + + static async getRolePermissionNames(role) { + if (!role) return new Set(); + if (this.getStandaloneRoleName(role) === PUBLIC_ROLE) return new Set(); + + if (Array.isArray(role.permissions)) { + return new Set( + role.permissions + .map((permission) => this.getPermissionName(permission)) + .filter(Boolean), + ); + } + + if (typeof role.getPermissions === 'function') { + const permissions = await role.getPermissions(); + return new Set( + permissions + .map((permission) => this.getPermissionName(permission)) + .filter(Boolean), + ); + } + + return null; + } + + static async hasPermission(user, permission) { + if (!user || !permission) return false; + if (this.isPublicUser(user)) return false; + return this.getEffectivePermissionNames(user).has(permission); + } + + static isPublicUser(user) { + return this.getRoleName(user) === PUBLIC_ROLE; + } + + static isInternalUser(user) { + return Boolean(user?.id) && !this.isPublicUser(user); + } + + static isPlatformWideRole(user) { + return PLATFORM_WIDE_ROLES.has(this.getRoleName(user)); + } + + static canUseAdminApi(user) { + return ( + !this.isPublicUser(user) && + this.getEffectivePermissionNames(user).size > 0 + ); + } + + static async getProjectBySlug(slug, options = {}) { + const normalizedSlug = this.normalizeSlug(slug); + if (!normalizedSlug) return null; + + return db.projects.findOne({ + where: { slug: normalizedSlug }, + attributes: ['id', 'name', 'slug', 'production_presentation_visibility'], + transaction: options.transaction, + }); + } + + static async canViewProductionPresentation(user, projectSlug, options = {}) { + const project = await this.getProjectBySlug(projectSlug, options); + if (!project) return false; + + if (project.production_presentation_visibility !== 'private') { + return true; + } + + if (this.canUseAdminApi(user)) { + return true; + } + + if (!this.isPublicUser(user) || !user?.id) { + return false; + } + + const access = await db.production_presentation_access.findOne({ + where: { + projectId: project.id, + userId: user.id, + }, + transaction: options.transaction, + }); + + return Boolean(access); + } +} + +module.exports = AccessPolicy; diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js index e5282bc..56cabd4 100644 --- a/backend/src/services/roles.js +++ b/backend/src/services/roles.js @@ -7,6 +7,7 @@ const axios = require('axios'); const config = require('../config'); const stream = require('stream'); const { validateReadOnlySql } = require('../utils/sqlValidator'); +const { logger } = require('../utils/logger'); const WIDGET_SQL_MAX_LENGTH = 5000; const WIDGET_SQL_MAX_ROWS = 1000; @@ -34,9 +35,24 @@ const runSafeWidgetQuery = async (sql) => { }; module.exports = class RolesService { + static assertPublicRoleHasNoPermissions(data, existingRole) { + const nextName = data?.name || existingRole?.name; + if (nextName !== 'Public') return; + + const permissions = Array.isArray(data?.permissions) + ? data.permissions.filter(Boolean) + : []; + + if (permissions.length > 0) { + throw new ValidationError('Public role cannot receive permissions'); + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { + this.assertPublicRoleHasNoPermissions(data); + const createdRole = await RolesDBApi.create(data, { currentUser, transaction, @@ -65,7 +81,10 @@ module.exports = class RolesService { .pipe(csv()) .on('data', (data) => results.push(data)) .on('end', async () => { - console.log('CSV results', results); + logger.info( + { count: results.length }, + 'Parsed role CSV import rows', + ); resolve(); }) .on('error', (error) => reject(error)); @@ -94,6 +113,8 @@ module.exports = class RolesService { throw new ValidationError('rolesNotFound'); } + this.assertPublicRoleHasNoPermissions(data, roles); + const updatedRoles = await RolesDBApi.update(id, data, { currentUser, transaction, @@ -161,7 +182,10 @@ module.exports = class RolesService { try { customization = JSON.parse(role.role_customization || '{}'); } catch (e) { - console.log(e); + logger.warn( + { err: e, roleId: role.id }, + 'Failed to parse role customization JSON', + ); } if (widgetIdIsUUID && Array.isArray(customization[key])) { @@ -213,7 +237,10 @@ module.exports = class RolesService { try { customization = JSON.parse(role.role_customization || '{}'); } catch (e) { - console.log(e); + logger.warn( + { err: e, roleId: role.id }, + 'Failed to parse role customization JSON', + ); } customization[key] = customization[key].filter((item) => item !== infoId); @@ -269,7 +296,10 @@ module.exports = class RolesService { try { customization = JSON.parse(role.role_customization || '{}'); } catch (e) { - console.error('Failed to parse role customization JSON:', e); + logger.error( + { err: e, roleId: role.id }, + 'Failed to parse role customization JSON', + ); throw e; } diff --git a/backend/src/services/runtime-presentation-access.js b/backend/src/services/runtime-presentation-access.js index 827f99c..76fde4e 100644 --- a/backend/src/services/runtime-presentation-access.js +++ b/backend/src/services/runtime-presentation-access.js @@ -1,22 +1,13 @@ const db = require('../db/models'); +const AccessPolicy = require('./access-policy'); class RuntimePresentationAccessService { static normalizeSlug(slug) { - return String(slug || '') - .trim() - .replace(/^\/+|\/+$/g, '') - .toLowerCase(); + return AccessPolicy.normalizeSlug(slug); } static async getProjectBySlug(slug, options = {}) { - const normalizedSlug = this.normalizeSlug(slug); - if (!normalizedSlug) return null; - - return db.projects.findOne({ - where: { slug: normalizedSlug }, - attributes: ['id', 'name', 'slug', 'production_presentation_visibility'], - transaction: options.transaction, - }); + return AccessPolicy.getProjectBySlug(slug, options); } static async isPrivateProductionPresentation(slug, options = {}) { @@ -24,22 +15,8 @@ class RuntimePresentationAccessService { return project?.production_presentation_visibility === 'private'; } - static userHasAnyPermission(user) { - const customPermissions = Array.isArray(user?.custom_permissions) - ? user.custom_permissions - : []; - const rolePermissions = Array.isArray(user?.app_role?.permissions) - ? user.app_role.permissions - : []; - const mappedRolePermissions = Array.isArray(user?.app_role_permissions) - ? user.app_role_permissions - : []; - - return ( - customPermissions.length > 0 || - rolePermissions.length > 0 || - mappedRolePermissions.length > 0 - ); + static canUseAdminApi(user) { + return AccessPolicy.canUseAdminApi(user); } static async canUserAccessPrivateProductionPresentation( @@ -47,29 +24,11 @@ class RuntimePresentationAccessService { slug, options = {}, ) { - const project = await this.getProjectBySlug(slug, options); - if (!project) return false; - if (project.production_presentation_visibility !== 'private') return true; - - if (this.userHasAnyPermission(user)) { - return true; - } - - if (!user?.id) return false; - - const access = await db.production_presentation_access.findOne({ - where: { - projectId: project.id, - userId: user.id, - }, - transaction: options.transaction, - }); - - return Boolean(access); + return AccessPolicy.canViewProductionPresentation(user, slug, options); } static async getAllowedPrivateProductionSlugs(user, options = {}) { - if (!user?.id || this.userHasAnyPermission(user)) return []; + if (!user?.id || this.canUseAdminApi(user)) return []; const accessRows = await db.production_presentation_access.findAll({ where: { userId: user.id }, diff --git a/backend/src/services/users.js b/backend/src/services/users.js index bc52c0d..141f656 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -5,6 +5,7 @@ const ValidationError = require('./notifications/errors/validation'); const config = require('../config'); const AuthService = require('./auth'); const { logger } = require('../utils/logger'); +const AccessPolicy = require('./access-policy'); // Generate base service from factory const BaseUsersService = createEntityService(UsersDBApi, { @@ -84,6 +85,23 @@ class UsersService extends BaseUsersService { ); } + static async assertPublicUserHasNoAdminPermissions(data, transaction) { + const roleId = this.normalizeRoleId(data.app_role); + if (!roleId) return false; + + const role = await db.roles.findByPk(roleId, { transaction }); + if (role?.name !== 'Public') return false; + + const customPermissions = this.normalizeIdArray(data.custom_permissions); + if (customPermissions.length > 0) { + throw new ValidationError( + 'Public users cannot receive custom permissions', + ); + } + + return true; + } + static async updateProductionPresentationAccessForPublicUser({ user, data, @@ -127,21 +145,32 @@ class UsersService extends BaseUsersService { throw new ValidationError('iam.errors.userAlreadyExists'); } + const isPublicUserData = await this.assertPublicUserHasNoAdminPermissions( + data, + transaction, + ); + const sanitizedData = isPublicUserData + ? { ...data, custom_permissions: [] } + : data; + let user; if (existingUser?.deletedAt) { await existingUser.restore({ transaction }); - user = await UsersDBApi.update(existingUser.id, data, { + user = await UsersDBApi.update(existingUser.id, sanitizedData, { currentUser, transaction, }); } else { - user = await UsersDBApi.create({ data }, { currentUser, transaction }); + user = await UsersDBApi.create( + { data: sanitizedData }, + { currentUser, transaction }, + ); } await this.createProductionPresentationAccessForPublicUser({ user, - data, + data: sanitizedData, currentUser, transaction, }); @@ -174,7 +203,33 @@ class UsersService extends BaseUsersService { throw new ValidationError('UsersNotFound'); } - const user = await UsersDBApi.update(id, data, { + const roleId = this.normalizeRoleId(data.app_role); + const role = roleId + ? await db.roles.findByPk(roleId, { transaction }) + : null; + const nextRoleName = role?.name || existingUser.app_role?.name; + const nextUser = { + ...existingUser, + app_role: { name: nextRoleName }, + custom_permissions: + data.custom_permissions || existingUser.custom_permissions, + }; + + if (AccessPolicy.isPublicUser(nextUser)) { + await this.assertPublicUserHasNoAdminPermissions( + { + ...data, + app_role: roleId || existingUser.app_role?.id, + }, + transaction, + ); + } + + const sanitizedData = AccessPolicy.isPublicUser(nextUser) + ? { ...data, custom_permissions: [] } + : data; + + const user = await UsersDBApi.update(id, sanitizedData, { currentUser, transaction, }); diff --git a/backend/tests/access-policy.test.js b/backend/tests/access-policy.test.js new file mode 100644 index 0000000..8c41ff4 --- /dev/null +++ b/backend/tests/access-policy.test.js @@ -0,0 +1,72 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const AccessPolicy = require('../src/services/access-policy'); + +test('effective permissions combine role permissions and custom permissions', async () => { + const user = { + id: 'user-1', + app_role: { + name: 'Tour Designer', + permissions: [{ name: 'READ_PROJECTS' }], + }, + custom_permissions: [{ name: 'CREATE_SEARCH' }], + }; + + assert.equal(await AccessPolicy.hasPermission(user, 'READ_PROJECTS'), true); + assert.equal(await AccessPolicy.hasPermission(user, 'CREATE_SEARCH'), true); + assert.equal(await AccessPolicy.hasPermission(user, 'DELETE_USERS'), false); +}); + +test('public users cannot use admin api even if stale permissions exist', async () => { + const user = { + id: 'public-1', + app_role: { + name: 'Public', + permissions: [{ name: 'READ_PROJECTS' }], + }, + custom_permissions: [{ name: 'CREATE_SEARCH' }], + }; + + assert.equal(AccessPolicy.isPublicUser(user), true); + assert.equal(await AccessPolicy.hasPermission(user, 'READ_PROJECTS'), false); + assert.equal(AccessPolicy.canUseAdminApi(user), false); +}); + +test('public role permissions are ignored for fallback permission checks', async () => { + const permissions = await AccessPolicy.getRolePermissionNames({ + name: 'Public', + permissions: [{ name: 'READ_PROJECTS' }], + }); + + assert.equal(permissions.size, 0); +}); + +test('internal users with permissions can use admin api', () => { + const user = { + id: 'staff-1', + app_role: { + name: 'Content Reviewer', + permissions: [{ name: 'READ_PROJECTS' }], + }, + custom_permissions: [], + }; + + assert.equal(AccessPolicy.isInternalUser(user), true); + assert.equal(AccessPolicy.canUseAdminApi(user), true); +}); + +test('platform-wide roles are explicit', () => { + assert.equal( + AccessPolicy.isPlatformWideRole({ + app_role: { name: 'Administrator' }, + }), + true, + ); + assert.equal( + AccessPolicy.isPlatformWideRole({ + app_role: { name: 'Tour Designer' }, + }), + false, + ); +}); diff --git a/backend/tests/helpers.test.js b/backend/tests/helpers.test.js new file mode 100644 index 0000000..239f897 --- /dev/null +++ b/backend/tests/helpers.test.js @@ -0,0 +1,33 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { assertRouteIdMatchesBody } = require('../src/helpers'); + +test('assertRouteIdMatchesBody allows matching top-level body id', () => { + assert.doesNotThrow(() => + assertRouteIdMatchesBody({ + params: { id: 'route-id' }, + body: { id: 'route-id' }, + }), + ); +}); + +test('assertRouteIdMatchesBody allows matching data body id', () => { + assert.doesNotThrow(() => + assertRouteIdMatchesBody({ + params: { id: 'route-id' }, + body: { data: { id: 'route-id' } }, + }), + ); +}); + +test('assertRouteIdMatchesBody rejects mismatched body id', () => { + assert.throws( + () => + assertRouteIdMatchesBody({ + params: { id: 'route-id' }, + body: { data: { id: 'body-id' } }, + }), + /Request body id does not match route id/, + ); +}); diff --git a/backend/tests/integration/access-policy.test.js b/backend/tests/integration/access-policy.test.js new file mode 100644 index 0000000..c2e8b6a --- /dev/null +++ b/backend/tests/integration/access-policy.test.js @@ -0,0 +1,267 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const db = require('../../src/db/models'); +const AccessPolicy = require('../../src/services/access-policy'); +const AccessPolicyAuditService = require('../../src/services/access-policy-audit'); + +const suffix = `${Date.now()}-${process.pid}`; + +test.after(async () => { + await db.sequelize.close(); +}); + +async function authenticateWithTimeout(timeoutMs = 1500) { + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Database unavailable after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + try { + await Promise.race([db.sequelize.authenticate(), timeout]); + } finally { + clearTimeout(timeoutId); + } +} + +async function withTransaction(t, callback) { + try { + await authenticateWithTimeout(); + } catch (error) { + t.skip(`Database unavailable: ${error.message}`); + return; + } + + const transaction = await db.sequelize.transaction(); + try { + await callback(transaction); + } finally { + await transaction.rollback(); + } +} + +async function createRole(name, transaction) { + return db.roles.create({ name }, { transaction }); +} + +async function createPermission(name, transaction) { + return db.permissions.create({ name }, { transaction }); +} + +async function createUser({ email, role }, transaction) { + const user = await db.users.create( + { + email, + password: 'not-used-in-test', + emailVerified: true, + }, + { transaction }, + ); + await user.setApp_role(role, { transaction }); + return user; +} + +async function createProject({ slug, visibility }, transaction) { + return db.projects.create( + { + name: `Test ${slug}`, + slug, + production_presentation_visibility: visibility, + }, + { transaction }, + ); +} + +test('guest can view public production presentation only', async (t) => { + await withTransaction(t, async (transaction) => { + const publicProject = await createProject( + { + slug: `test-public-runtime-access-${suffix}`, + visibility: 'public', + }, + transaction, + ); + const privateProject = await createProject( + { + slug: `test-private-runtime-access-${suffix}`, + visibility: 'private', + }, + transaction, + ); + + assert.equal( + await AccessPolicy.canViewProductionPresentation( + null, + publicProject.slug, + { transaction }, + ), + true, + ); + assert.equal( + await AccessPolicy.canViewProductionPresentation( + null, + privateProject.slug, + { transaction }, + ), + false, + ); + }); +}); + +test('public user can view granted private production presentation', async (t) => { + await withTransaction(t, async (transaction) => { + const publicRole = await createRole('Public', transaction); + const publicUser = await createUser( + { + email: `public-granted-${suffix}@example.test`, + role: publicRole, + }, + transaction, + ); + const privateProject = await createProject( + { + slug: `test-private-granted-runtime-access-${suffix}`, + visibility: 'private', + }, + transaction, + ); + + await db.production_presentation_access.create( + { + userId: publicUser.id, + projectId: privateProject.id, + }, + { transaction }, + ); + + const authUser = await db.users.findOne({ + where: { id: publicUser.id }, + include: [ + { association: 'app_role', include: [{ association: 'permissions' }] }, + { association: 'custom_permissions' }, + ], + transaction, + }); + + assert.equal( + await AccessPolicy.canViewProductionPresentation( + authUser.get({ plain: true }), + privateProject.slug, + { transaction }, + ), + true, + ); + }); +}); + +test('internal user with permission can use admin api and view private presentation', async (t) => { + await withTransaction(t, async (transaction) => { + const role = await createRole('Content Reviewer', transaction); + const permission = await createPermission( + `TEST_READ_PROJECTS_${suffix}`, + transaction, + ); + await role.setPermissions([permission], { transaction }); + + const internalUser = await createUser( + { + email: `internal-access-${suffix}@example.test`, + role, + }, + transaction, + ); + const privateProject = await createProject( + { + slug: `test-private-internal-runtime-access-${suffix}`, + visibility: 'private', + }, + transaction, + ); + + const authUser = await db.users.findOne({ + where: { id: internalUser.id }, + include: [ + { association: 'app_role', include: [{ association: 'permissions' }] }, + { association: 'custom_permissions' }, + ], + transaction, + }); + const plainUser = authUser.get({ plain: true }); + + assert.equal(AccessPolicy.canUseAdminApi(plainUser), true); + assert.equal( + await AccessPolicy.canViewProductionPresentation( + plainUser, + privateProject.slug, + { transaction }, + ), + true, + ); + }); +}); + +test('audit finds and cleanup removes stale Public grants', async (t) => { + await withTransaction(t, async (transaction) => { + const publicRole = await createRole('Public', transaction); + const internalRole = await createRole('Tour Designer', transaction); + const permission = await createPermission( + `TEST_READ_USERS_${suffix}`, + transaction, + ); + await publicRole.setPermissions([permission], { transaction }); + + const publicUser = await createUser( + { + email: `public-stale-${suffix}@example.test`, + role: publicRole, + }, + transaction, + ); + await publicUser.setCustom_permissions([permission], { transaction }); + + const internalUser = await createUser( + { + email: `internal-stale-grant-${suffix}@example.test`, + role: internalRole, + }, + transaction, + ); + const privateProject = await createProject( + { + slug: `test-private-stale-grant-${suffix}`, + visibility: 'private', + }, + transaction, + ); + await db.production_presentation_access.create( + { + userId: internalUser.id, + projectId: privateProject.id, + }, + { transaction }, + ); + + const report = await AccessPolicyAuditService.findViolations({ + transaction, + }); + + assert.ok(report.publicRolePermissions.length >= 1); + assert.ok(report.publicUsersWithCustomPermissions.length >= 1); + assert.ok(report.productionPresentationAccessForNonPublicUsers.length >= 1); + + const cleanup = await AccessPolicyAuditService.cleanupViolations({ + transaction, + }); + assert.ok(cleanup.removedPublicRolePermissions >= 1); + assert.ok(cleanup.clearedPublicUserCustomPermissions >= 1); + assert.ok(cleanup.removedNonPublicProductionPresentationGrants >= 1); + + const after = await AccessPolicyAuditService.findViolations({ + transaction, + }); + assert.equal(AccessPolicyAuditService.hasViolations(after), false); + }); +});