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.
|
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
|
## Environment Variables
|
||||||
|
|
||||||
Create a `.env` file in the backend directory:
|
Create a `.env` file in the backend directory:
|
||||||
@ -172,13 +188,13 @@ Swagger UI available at: `http://localhost:8080/api-docs`
|
|||||||
|
|
||||||
### Core Endpoints
|
### Core Endpoints
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
| -------------------------------- | -------------------------------- |
|
||||||
| `POST /api/auth/signin/local` | Email/password login |
|
| `POST /api/auth/signin/local` | Email/password login |
|
||||||
| `POST /api/auth/signup` | User registration |
|
| `POST /api/auth/signup` | User registration |
|
||||||
| `GET /api/auth/me` | Current user info (JWT required) |
|
| `GET /api/auth/me` | Current user info (JWT required) |
|
||||||
| `GET /api/auth/signin/google` | Google OAuth login |
|
| `GET /api/auth/signin/google` | Google OAuth login |
|
||||||
| `GET /api/auth/signin/microsoft` | Microsoft OAuth login |
|
| `GET /api/auth/signin/microsoft` | Microsoft OAuth login |
|
||||||
|
|
||||||
### Entity CRUD Pattern
|
### Entity CRUD Pattern
|
||||||
|
|
||||||
@ -194,23 +210,23 @@ DELETE /api/{entity}/:id # Soft delete record
|
|||||||
|
|
||||||
### Main Entities
|
### Main Entities
|
||||||
|
|
||||||
| Entity | Description |
|
| Entity | Description |
|
||||||
|--------|-------------|
|
| -------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `projects` | Virtual tour projects |
|
| `projects` | Virtual tour projects |
|
||||||
| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) |
|
| `tour_pages` | Pages within a tour (elements, navigation, transitions stored in ui_schema_json) |
|
||||||
| `assets` | Uploaded media files |
|
| `assets` | Uploaded media files |
|
||||||
| `asset_variants` | Resized/optimized asset versions |
|
| `asset_variants` | Resized/optimized asset versions |
|
||||||
| `element_type_defaults` | Global element default settings |
|
| `element_type_defaults` | Global element default settings |
|
||||||
| `project_element_defaults` | Project-specific element settings |
|
| `project_element_defaults` | Project-specific element settings |
|
||||||
| `project_audio_tracks` | Background audio for projects |
|
| `project_audio_tracks` | Background audio for projects |
|
||||||
| `publish_events` | Publishing history and status tracking |
|
| `publish_events` | Publishing history and status tracking |
|
||||||
| `pwa_caches` | PWA cache manifests for offline support |
|
| `pwa_caches` | PWA cache manifests for offline support |
|
||||||
| `presigned_url_requests` | S3 presigned URL request tracking |
|
| `presigned_url_requests` | S3 presigned URL request tracking |
|
||||||
| `access_logs` | User access audit trail |
|
| `access_logs` | User access audit trail |
|
||||||
| `users` | User accounts |
|
| `users` | User accounts |
|
||||||
| `roles` | User roles |
|
| `roles` | User roles |
|
||||||
| `permissions` | Granular permissions |
|
| `permissions` | Granular permissions |
|
||||||
| `project_memberships` | Team access per project |
|
| `project_memberships` | Team access per project |
|
||||||
|
|
||||||
### Element Defaults Hierarchy
|
### 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.
|
3. **Instance** (`tour_pages.ui_schema_json`) - Page-specific elements with their settings stored inline. Created in constructor with project defaults applied.
|
||||||
|
|
||||||
**Additional Endpoints:**
|
**Additional Endpoints:**
|
||||||
|
|
||||||
- `POST /api/project-element-defaults/:id/reset` - Reset to current global default
|
- `POST /api/project-element-defaults/:id/reset` - Reset to current global default
|
||||||
- `GET /api/project-element-defaults/:id/diff` - Compare with 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:
|
Pages have an `environment` field (`dev`, `stage`, or `production`) that determines visibility:
|
||||||
|
|
||||||
- **Constructor** (`/constructor?projectId=`) - Always shows `dev` environment
|
- **Constructor** (`/constructor?projectId=`) - Always shows `dev` environment
|
||||||
- **Stage preview** (`/p/[slug]/stage`) - Shows `stage` environment
|
- **Stage preview** (`/p/[slug]/stage`) - Shows `stage` environment
|
||||||
- **Public runtime** (`/p/[slug]`) - Shows `production` environment
|
- **Public runtime** (`/p/[slug]`) - Shows `production` environment
|
||||||
@ -313,15 +331,15 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
|
|||||||
|
|
||||||
### Default Roles
|
### Default Roles
|
||||||
|
|
||||||
| Role | Description |
|
| Role | Description |
|
||||||
|------|-------------|
|
| ---------------- | ------------------------------------------------------------- |
|
||||||
| Administrator | Full access to all features (user/role/permission management) |
|
| Administrator | Full access to all features (user/role/permission management) |
|
||||||
| Platform Owner | Full project access, user management |
|
| Platform Owner | Full project access, user management |
|
||||||
| Account Manager | Project and asset management |
|
| Account Manager | Project and asset management |
|
||||||
| Tour Designer | Create and edit tours, assets, pages |
|
| Tour Designer | Create and edit tours, assets, pages |
|
||||||
| Content Reviewer | Review and update content (read/update access) |
|
| Content Reviewer | Review and update content (read/update access) |
|
||||||
| Analytics Viewer | Read-only access for viewing data |
|
| Analytics Viewer | Read-only access for viewing data |
|
||||||
| Public | Minimal access for public users |
|
| Public | Minimal access for public users |
|
||||||
|
|
||||||
## Environment Detection
|
## Environment Detection
|
||||||
|
|
||||||
@ -329,21 +347,21 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
|
|||||||
|
|
||||||
The backend uses `NODE_ENV` to determine database configuration:
|
The backend uses `NODE_ENV` to determine database configuration:
|
||||||
|
|
||||||
| Value | Database | Description |
|
| Value | Database | Description |
|
||||||
|-------|----------|-------------|
|
| ------------ | ------------------ | ------------------- |
|
||||||
| `production` | Production config | Live environment |
|
| `production` | Production config | Live environment |
|
||||||
| `dev_stage` | Staging config | Staging environment |
|
| `dev_stage` | Staging config | Staging environment |
|
||||||
| (other) | Development config | Local development |
|
| (other) | Development config | Local development |
|
||||||
|
|
||||||
### Content Environment (tour_pages.environment)
|
### Content Environment (tour_pages.environment)
|
||||||
|
|
||||||
Separate from server environment, tour pages have a content environment field:
|
Separate from server environment, tour pages have a content environment field:
|
||||||
|
|
||||||
| Value | Access | Description |
|
| Value | Access | Description |
|
||||||
|-------|--------|-------------|
|
| ------------ | ---------------- | --------------------- |
|
||||||
| `dev` | Constructor only | Editing/draft content |
|
| `dev` | Constructor only | Editing/draft content |
|
||||||
| `stage` | Stage preview | Pre-production review |
|
| `stage` | Stage preview | Pre-production review |
|
||||||
| `production` | Public runtime | Published content |
|
| `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.
|
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": {
|
"scripts": {
|
||||||
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
"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",
|
"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",
|
"lint": "eslint . --ext .js",
|
||||||
"db:migrate": "sequelize-cli db:migrate",
|
"db:migrate": "sequelize-cli db:migrate",
|
||||||
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
"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 db = require('../models');
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
|
const { logger } = require('../../utils/logger');
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
@ -45,6 +46,10 @@ class GenericDBApi {
|
|||||||
return ['id', 'createdAt'];
|
return ['id', 'createdAt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get SORTABLE_FIELDS() {
|
||||||
|
return Object.keys(this.MODEL.rawAttributes || {});
|
||||||
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static get AUTOCOMPLETE_FIELD() {
|
||||||
return 'name';
|
return 'name';
|
||||||
}
|
}
|
||||||
@ -305,7 +310,7 @@ class GenericDBApi {
|
|||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
static async findAll(filter = {}, options = {}) {
|
||||||
filter = filter || {};
|
filter = filter || {};
|
||||||
const limit = filter.limit || 0;
|
const limit = Number(filter.limit) || 0;
|
||||||
const currentPage = Number(filter.page) || 0;
|
const currentPage = Number(filter.page) || 0;
|
||||||
const offset = Math.max(currentPage - 1, 0) * limit;
|
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 {
|
try {
|
||||||
if (options.countOnly) {
|
if (options.countOnly) {
|
||||||
const count = await this.MODEL.count({
|
const count = await this.MODEL.count({
|
||||||
@ -422,10 +433,7 @@ class GenericDBApi {
|
|||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
order:
|
order: [[sortField, sortDirection]],
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
transaction: options.transaction,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
@ -437,7 +445,10 @@ class GenericDBApi {
|
|||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error executing query:', error);
|
logger.error(
|
||||||
|
{ err: error, table: this.TABLE_NAME },
|
||||||
|
'Error executing query',
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,11 +5,20 @@ const Utils = require('../utils');
|
|||||||
|
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const config = require('../../config');
|
const config = require('../../config');
|
||||||
|
const { logger } = require('../../utils/logger');
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
module.exports = class UsersDBApi {
|
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
|
* Default includes for findBy() - minimal set for single user lookup
|
||||||
* Only loads avatar and app_role with permissions (needed for RBAC)
|
* 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 = {
|
const queryOptions = {
|
||||||
attributes: { exclude: this.SENSITIVE_FIELDS },
|
attributes: { exclude: this.SENSITIVE_FIELDS },
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
order:
|
order: [[sortField, sortDirection]],
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options?.transaction,
|
transaction: options?.transaction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -677,7 +689,7 @@ module.exports = class UsersDBApi {
|
|||||||
count: count,
|
count: count,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error executing query:', error);
|
logger.error({ err: error, table: 'users' }, 'Error executing query');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,54 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
const {
|
||||||
|
wrapAsync,
|
||||||
|
commonErrorHandler,
|
||||||
|
isUuidV4,
|
||||||
|
assertRouteIdMatchesBody,
|
||||||
|
} = require('../helpers');
|
||||||
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
const { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const { parse } = require('json2csv');
|
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 = {}) {
|
function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -41,7 +88,8 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
wrapAsync(async (req, res) => {
|
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);
|
res.status(200).send(true);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -68,8 +116,11 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
const filetype = req.query.filetype;
|
const filetype = req.query.filetype;
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const runtimeContext = req.runtimeContext;
|
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,
|
currentUser,
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
});
|
});
|
||||||
@ -82,7 +133,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
const csv = parse(payload.rows, opts);
|
const csv = parse(payload.rows, opts);
|
||||||
res.status(200).attachment('export.csv').send(csv);
|
res.status(200).attachment('export.csv').send(csv);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
logger.error({ err, entityName }, 'CSV export error');
|
||||||
res.status(500).send('CSV export error');
|
res.status(500).send('CSV export error');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -96,7 +147,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const currentUser = req.currentUser;
|
const currentUser = req.currentUser;
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
const payload = await DBApi.findAll(req.query, {
|
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), {
|
||||||
countOnly: true,
|
countOnly: true,
|
||||||
currentUser,
|
currentUser,
|
||||||
runtimeContext,
|
runtimeContext,
|
||||||
@ -108,9 +159,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
router.get(
|
router.get(
|
||||||
'/autocomplete',
|
'/autocomplete',
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
|
const limit = clampLimit(req.query.limit, {
|
||||||
|
defaultLimit: 20,
|
||||||
|
maxLimit: MAX_AUTOCOMPLETE_LIMIT,
|
||||||
|
});
|
||||||
const payload = await DBApi.findAllAutocomplete(
|
const payload = await DBApi.findAllAutocomplete(
|
||||||
req.query.query,
|
req.query.query,
|
||||||
req.query.limit,
|
limit,
|
||||||
req.query.offset,
|
req.query.offset,
|
||||||
);
|
);
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
const ValidationError = require('./services/notifications/errors/validation');
|
||||||
|
const { logger } = require('./utils/logger');
|
||||||
|
|
||||||
module.exports = class Helpers {
|
module.exports = class Helpers {
|
||||||
static wrapAsync(fn) {
|
static wrapAsync(fn) {
|
||||||
@ -15,7 +17,7 @@ module.exports = class Helpers {
|
|||||||
return res.status(statusCode).send(error.message);
|
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');
|
return res.status(500).send('Internal server error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,4 +30,11 @@ module.exports = class Helpers {
|
|||||||
value,
|
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 RolesDBApi = require('../db/api/roles');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
|
const AccessPolicy = require('../services/access-policy');
|
||||||
|
|
||||||
// Cache for the 'Public' role object
|
// Cache for the 'Public' role object
|
||||||
let publicRoleCache = null;
|
let publicRoleCache = null;
|
||||||
@ -52,49 +53,27 @@ function checkPermissions(permission) {
|
|||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
const { currentUser } = req;
|
const { currentUser } = req;
|
||||||
|
|
||||||
// 1. Check self-access bypass (only if the user is authenticated)
|
if (await AccessPolicy.hasPermission(currentUser, permission)) {
|
||||||
if (
|
return next();
|
||||||
currentUser &&
|
|
||||||
(currentUser.id === req.params.id || currentUser.id === req.body.id)
|
|
||||||
) {
|
|
||||||
return next(); // User has access to their own resource
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Custom Permissions (only if the user is authenticated)
|
if (currentUser && AccessPolicy.isPublicUser(currentUser)) {
|
||||||
if (currentUser) {
|
return next(new ForbiddenError());
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Determine the "effective" role for permission check
|
|
||||||
let effectiveRole = null;
|
let effectiveRole = null;
|
||||||
try {
|
try {
|
||||||
if (currentUser && currentUser.app_role) {
|
if (currentUser && currentUser.app_role) {
|
||||||
// User is authenticated and has an assigned role
|
|
||||||
effectiveRole = currentUser.app_role;
|
effectiveRole = currentUser.app_role;
|
||||||
} else {
|
} else {
|
||||||
// User is NOT authenticated OR is authenticated but has no role
|
|
||||||
// Use the cached 'Public' role
|
|
||||||
if (!publicRoleCache) {
|
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;
|
const log = req.log || logger;
|
||||||
log.warn(
|
log.warn(
|
||||||
{ role: 'Public' },
|
{ role: 'Public' },
|
||||||
'Role cache is empty, attempting synchronous fetch',
|
'Role cache is empty, attempting synchronous fetch',
|
||||||
);
|
);
|
||||||
// Less efficient fallback option:
|
effectiveRole = await RolesDBApi.findBy({ name: 'Public' });
|
||||||
effectiveRole = await RolesDBApi.findBy({ name: 'Public' }); // Could be slow
|
|
||||||
if (!effectiveRole) {
|
if (!effectiveRole) {
|
||||||
// If even the synchronous attempt failed
|
|
||||||
return next(
|
return next(
|
||||||
new Error(
|
new Error(
|
||||||
'Internal Server Error: Public role missing and cannot be fetched.',
|
'Internal Server Error: Public role missing and cannot be fetched.',
|
||||||
@ -102,11 +81,10 @@ function checkPermissions(permission) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
effectiveRole = publicRoleCache; // Use the cached object
|
effectiveRole = publicRoleCache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we got a valid role object
|
|
||||||
if (!effectiveRole) {
|
if (!effectiveRole) {
|
||||||
return next(
|
return next(
|
||||||
new Error(
|
new Error(
|
||||||
@ -115,15 +93,10 @@ function checkPermissions(permission) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check Permissions on the "effective" role
|
const rolePermissionNames =
|
||||||
// Assume the effectiveRole object (from app_role or RolesDBApi) has a getPermissions() method
|
await AccessPolicy.getRolePermissionNames(effectiveRole);
|
||||||
// or a 'permissions' property (if permissions are eagerly loaded).
|
|
||||||
let rolePermissions = [];
|
if (!rolePermissionNames) {
|
||||||
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 log = req.log || logger;
|
const log = req.log || logger;
|
||||||
log.error(
|
log.error(
|
||||||
{ roleId: effectiveRole.id, roleName: effectiveRole.name },
|
{ roleId: effectiveRole.id, roleName: effectiveRole.name },
|
||||||
@ -134,14 +107,12 @@ function checkPermissions(permission) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rolePermissions.find((p) => p.name === permission)) {
|
if (rolePermissionNames.has(permission)) {
|
||||||
next(); // The "effective" role has the required permission
|
next();
|
||||||
} else {
|
} else {
|
||||||
// The "effective" role does not have the required permission
|
|
||||||
const roleName = effectiveRole.name || 'unknown role';
|
const roleName = effectiveRole.name || 'unknown role';
|
||||||
next(
|
next(
|
||||||
new ValidationError(
|
new ForbiddenError(
|
||||||
'auth.forbidden',
|
|
||||||
`Role '${roleName}' denied access to '${permission}'.`,
|
`Role '${roleName}' denied access to '${permission}'.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -176,6 +147,20 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
|||||||
'PROJECT_UI_CONTROL_SETTINGS',
|
'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.
|
* Middleware creator to check standard CRUD permissions based on HTTP method and entity name.
|
||||||
* @param {string} name - The name of the entity.
|
* @param {string} name - The name of the entity.
|
||||||
@ -183,6 +168,16 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
|||||||
*/
|
*/
|
||||||
function checkCrudPermissions(name) {
|
function checkCrudPermissions(name) {
|
||||||
return (req, res, next) => {
|
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 =
|
const isRuntimePublicRead =
|
||||||
req.isRuntimePublicRequest === true &&
|
req.isRuntimePublicRequest === true &&
|
||||||
req.method === 'GET' &&
|
req.method === 'GET' &&
|
||||||
|
|||||||
@ -2,7 +2,12 @@ const express = require('express');
|
|||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const Global_transition_defaultsService = require('../services/global_transition_defaults');
|
const Global_transition_defaultsService = require('../services/global_transition_defaults');
|
||||||
const Global_transition_defaultsDBApi = require('../db/api/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 { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -117,9 +122,10 @@ router.put(
|
|||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
|
assertRouteIdMatchesBody(req);
|
||||||
await Global_transition_defaultsService.update(
|
await Global_transition_defaultsService.update(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
req.body.id,
|
req.params.id,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
);
|
);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
|
|||||||
@ -3,7 +3,12 @@ const passport = require('passport');
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const Project_transition_settingsService = require('../services/project_transition_settings');
|
const Project_transition_settingsService = require('../services/project_transition_settings');
|
||||||
const Project_transition_settingsDBApi = require('../db/api/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 { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const RuntimePresentationAccessService = require('../services/runtime-presentation-access');
|
const RuntimePresentationAccessService = require('../services/runtime-presentation-access');
|
||||||
|
|
||||||
@ -409,9 +414,10 @@ router.put(
|
|||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
jwtAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
|
assertRouteIdMatchesBody(req);
|
||||||
await Project_transition_settingsService.update(
|
await Project_transition_settingsService.update(
|
||||||
req.body.data,
|
req.body.data,
|
||||||
req.body.id,
|
req.params.id,
|
||||||
req.currentUser,
|
req.currentUser,
|
||||||
);
|
);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const Tour_pagesService = require('../services/tour_pages');
|
const Tour_pagesService = require('../services/tour_pages');
|
||||||
const Tour_pagesDBApi = require('../db/api/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 { checkCrudPermissions } = require('../middlewares/check-permissions');
|
||||||
const { parse } = require('json2csv');
|
const { parse } = require('json2csv');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@ -220,7 +226,12 @@ router.post(
|
|||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
wrapAsync(async (req, res) => {
|
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);
|
res.status(200).send(true);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -271,7 +282,7 @@ router.get(
|
|||||||
const csv = parse(payload.rows, opts);
|
const csv = parse(payload.rows, opts);
|
||||||
res.status(200).attachment('export.csv').send(csv);
|
res.status(200).attachment('export.csv').send(csv);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
logger.error({ err }, 'Tour pages CSV export error');
|
||||||
res.status(500).send('CSV export error');
|
res.status(500).send('CSV export error');
|
||||||
}
|
}
|
||||||
} else {
|
} 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 config = require('../config');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const { validateReadOnlySql } = require('../utils/sqlValidator');
|
const { validateReadOnlySql } = require('../utils/sqlValidator');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
const WIDGET_SQL_MAX_LENGTH = 5000;
|
const WIDGET_SQL_MAX_LENGTH = 5000;
|
||||||
const WIDGET_SQL_MAX_ROWS = 1000;
|
const WIDGET_SQL_MAX_ROWS = 1000;
|
||||||
@ -34,9 +35,24 @@ const runSafeWidgetQuery = async (sql) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = class RolesService {
|
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) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
|
this.assertPublicRoleHasNoPermissions(data);
|
||||||
|
|
||||||
const createdRole = await RolesDBApi.create(data, {
|
const createdRole = await RolesDBApi.create(data, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -65,7 +81,10 @@ module.exports = class RolesService {
|
|||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (data) => results.push(data))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
logger.info(
|
||||||
|
{ count: results.length },
|
||||||
|
'Parsed role CSV import rows',
|
||||||
|
);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
@ -94,6 +113,8 @@ module.exports = class RolesService {
|
|||||||
throw new ValidationError('rolesNotFound');
|
throw new ValidationError('rolesNotFound');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertPublicRoleHasNoPermissions(data, roles);
|
||||||
|
|
||||||
const updatedRoles = await RolesDBApi.update(id, data, {
|
const updatedRoles = await RolesDBApi.update(id, data, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -161,7 +182,10 @@ module.exports = class RolesService {
|
|||||||
try {
|
try {
|
||||||
customization = JSON.parse(role.role_customization || '{}');
|
customization = JSON.parse(role.role_customization || '{}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
logger.warn(
|
||||||
|
{ err: e, roleId: role.id },
|
||||||
|
'Failed to parse role customization JSON',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widgetIdIsUUID && Array.isArray(customization[key])) {
|
if (widgetIdIsUUID && Array.isArray(customization[key])) {
|
||||||
@ -213,7 +237,10 @@ module.exports = class RolesService {
|
|||||||
try {
|
try {
|
||||||
customization = JSON.parse(role.role_customization || '{}');
|
customization = JSON.parse(role.role_customization || '{}');
|
||||||
} catch (e) {
|
} 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);
|
customization[key] = customization[key].filter((item) => item !== infoId);
|
||||||
@ -269,7 +296,10 @@ module.exports = class RolesService {
|
|||||||
try {
|
try {
|
||||||
customization = JSON.parse(role.role_customization || '{}');
|
customization = JSON.parse(role.role_customization || '{}');
|
||||||
} catch (e) {
|
} 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;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,13 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
|
const AccessPolicy = require('./access-policy');
|
||||||
|
|
||||||
class RuntimePresentationAccessService {
|
class RuntimePresentationAccessService {
|
||||||
static normalizeSlug(slug) {
|
static normalizeSlug(slug) {
|
||||||
return String(slug || '')
|
return AccessPolicy.normalizeSlug(slug);
|
||||||
.trim()
|
|
||||||
.replace(/^\/+|\/+$/g, '')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getProjectBySlug(slug, options = {}) {
|
static async getProjectBySlug(slug, options = {}) {
|
||||||
const normalizedSlug = this.normalizeSlug(slug);
|
return AccessPolicy.getProjectBySlug(slug, options);
|
||||||
if (!normalizedSlug) return null;
|
|
||||||
|
|
||||||
return db.projects.findOne({
|
|
||||||
where: { slug: normalizedSlug },
|
|
||||||
attributes: ['id', 'name', 'slug', 'production_presentation_visibility'],
|
|
||||||
transaction: options.transaction,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async isPrivateProductionPresentation(slug, options = {}) {
|
static async isPrivateProductionPresentation(slug, options = {}) {
|
||||||
@ -24,22 +15,8 @@ class RuntimePresentationAccessService {
|
|||||||
return project?.production_presentation_visibility === 'private';
|
return project?.production_presentation_visibility === 'private';
|
||||||
}
|
}
|
||||||
|
|
||||||
static userHasAnyPermission(user) {
|
static canUseAdminApi(user) {
|
||||||
const customPermissions = Array.isArray(user?.custom_permissions)
|
return AccessPolicy.canUseAdminApi(user);
|
||||||
? 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 async canUserAccessPrivateProductionPresentation(
|
static async canUserAccessPrivateProductionPresentation(
|
||||||
@ -47,29 +24,11 @@ class RuntimePresentationAccessService {
|
|||||||
slug,
|
slug,
|
||||||
options = {},
|
options = {},
|
||||||
) {
|
) {
|
||||||
const project = await this.getProjectBySlug(slug, options);
|
return AccessPolicy.canViewProductionPresentation(user, 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getAllowedPrivateProductionSlugs(user, 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({
|
const accessRows = await db.production_presentation_access.findAll({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const ValidationError = require('./notifications/errors/validation');
|
|||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const AuthService = require('./auth');
|
const AuthService = require('./auth');
|
||||||
const { logger } = require('../utils/logger');
|
const { logger } = require('../utils/logger');
|
||||||
|
const AccessPolicy = require('./access-policy');
|
||||||
|
|
||||||
// Generate base service from factory
|
// Generate base service from factory
|
||||||
const BaseUsersService = createEntityService(UsersDBApi, {
|
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({
|
static async updateProductionPresentationAccessForPublicUser({
|
||||||
user,
|
user,
|
||||||
data,
|
data,
|
||||||
@ -127,21 +145,32 @@ class UsersService extends BaseUsersService {
|
|||||||
throw new ValidationError('iam.errors.userAlreadyExists');
|
throw new ValidationError('iam.errors.userAlreadyExists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPublicUserData = await this.assertPublicUserHasNoAdminPermissions(
|
||||||
|
data,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
const sanitizedData = isPublicUserData
|
||||||
|
? { ...data, custom_permissions: [] }
|
||||||
|
: data;
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
|
|
||||||
if (existingUser?.deletedAt) {
|
if (existingUser?.deletedAt) {
|
||||||
await existingUser.restore({ transaction });
|
await existingUser.restore({ transaction });
|
||||||
user = await UsersDBApi.update(existingUser.id, data, {
|
user = await UsersDBApi.update(existingUser.id, sanitizedData, {
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
user = await UsersDBApi.create({ data }, { currentUser, transaction });
|
user = await UsersDBApi.create(
|
||||||
|
{ data: sanitizedData },
|
||||||
|
{ currentUser, transaction },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.createProductionPresentationAccessForPublicUser({
|
await this.createProductionPresentationAccessForPublicUser({
|
||||||
user,
|
user,
|
||||||
data,
|
data: sanitizedData,
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
@ -174,7 +203,33 @@ class UsersService extends BaseUsersService {
|
|||||||
throw new ValidationError('UsersNotFound');
|
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,
|
currentUser,
|
||||||
transaction,
|
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