improved the app security
This commit is contained in:
parent
2f56d1af05
commit
7778d34925
@ -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.
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
78
backend/scripts/check-public-access-hardening.js
Normal file
78
backend/scripts/check-public-access-hardening.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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' &&
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
153
backend/src/services/access-policy-audit.js
Normal file
153
backend/src/services/access-policy-audit.js
Normal 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;
|
||||
146
backend/src/services/access-policy.js
Normal file
146
backend/src/services/access-policy.js
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
72
backend/tests/access-policy.test.js
Normal file
72
backend/tests/access-policy.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
33
backend/tests/helpers.test.js
Normal file
33
backend/tests/helpers.test.js
Normal 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/,
|
||||
);
|
||||
});
|
||||
267
backend/tests/integration/access-policy.test.js
Normal file
267
backend/tests/integration/access-policy.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user