improved the app security

This commit is contained in:
Dmitri 2026-06-28 21:29:29 +02:00
parent 2f56d1af05
commit 7778d34925
19 changed files with 1088 additions and 168 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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);
}
});

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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');
}
}
};

View File

@ -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' &&

View File

@ -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);

View File

@ -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);

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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 },

View File

@ -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,
});

View File

@ -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,
);
});

View File

@ -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/,
);
});

View File

@ -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);
});
});