Add backend policy tests and API client split
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m8s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m8s
This commit is contained in:
@@ -17,15 +17,17 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Copy backend package.json as main package.json
|
# Copy backend package metadata as runtime package metadata
|
||||||
COPY backend/package.json ./package.json
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy backend source directly into root
|
# Copy backend source directly into root
|
||||||
COPY backend/index.js ./index.js
|
COPY backend/index.js ./index.js
|
||||||
COPY backend/db.js ./db.js
|
COPY backend/db.js ./db.js
|
||||||
|
COPY backend/policies ./policies
|
||||||
|
COPY backend/utils ./utils
|
||||||
|
|
||||||
# Copy built frontend
|
# Copy built frontend
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ const multer = require('multer');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const pool = require('./db');
|
const pool = require('./db');
|
||||||
|
const { stripEnvQuotes, hashSecret, maskSecret } = require('./utils/security');
|
||||||
|
const {
|
||||||
|
canReadUser,
|
||||||
|
canUpdateUser,
|
||||||
|
canManageUserStatus,
|
||||||
|
canChangeUserEmail,
|
||||||
|
canManageUserRoleOrTeam,
|
||||||
|
canReadAttendance,
|
||||||
|
} = require('./policies/accessPolicy');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -20,9 +29,6 @@ if (!JWT_SECRET) {
|
|||||||
throw new Error('JWT_SECRET is required in production.');
|
throw new Error('JWT_SECRET is required in production.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, '');
|
|
||||||
const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
||||||
const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`;
|
|
||||||
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
|
const USER_PUBLIC_FIELDS = 'id, tenant_id, team_id, name, email, slug, role, status, bio, avatar_url, sound_enabled, created_at';
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
@@ -489,18 +495,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
|
|||||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
|
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
|
||||||
|
|
||||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
if (!canReadUser(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role === 'agent' && rows[0].id !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role === 'manager') {
|
|
||||||
const canSeeUser = rows[0].id === req.user.id || (req.user.team_id && rows[0].team_id === req.user.team_id);
|
|
||||||
if (!canSeeUser) return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -580,30 +575,14 @@ apiRouter.put('/users/:id', async (req, res) => {
|
|||||||
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
const [existing] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
const isSelf = req.user.id === req.params.id;
|
if (!canUpdateUser(req.user, existing[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
|
|
||||||
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
|
|
||||||
|
|
||||||
if (!isSelf && !isManagerOrAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
|
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
|
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
|
||||||
const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
|
const finalRole = canManageUserRoleOrTeam(req.user) && role !== undefined ? role : existing[0].role;
|
||||||
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
const finalTeamId = canManageUserRoleOrTeam(req.user) && team_id !== undefined ? team_id : existing[0].team_id;
|
||||||
if (req.user.role === 'manager') {
|
const finalStatus = canManageUserStatus(req.user) && status !== undefined ? status : existing[0].status;
|
||||||
const canEditUser = existing[0].id !== req.user.id && req.user.team_id && existing[0].team_id === req.user.team_id && existing[0].role === 'agent';
|
const finalEmail = canChangeUserEmail(req.user, existing[0]) && email !== undefined ? email : existing[0].email;
|
||||||
if (!isSelf && !canEditUser) return res.status(403).json({ error: 'Acesso negado.' });
|
const finalSoundEnabled = req.user.id === req.params.id && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
||||||
}
|
|
||||||
|
|
||||||
const finalStatus = isAdmin && status !== undefined ? status : existing[0].status;
|
|
||||||
const canChangeEmail = isSelf || isAdmin;
|
|
||||||
const finalEmail = canChangeEmail && email !== undefined ? email : existing[0].email;
|
|
||||||
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
|
||||||
|
|
||||||
if (finalEmail !== existing[0].email) {
|
if (finalEmail !== existing[0].email) {
|
||||||
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
const [emailCheck] = await pool.query('SELECT id FROM users WHERE email = ? AND id != ?', [finalEmail, req.params.id]);
|
||||||
@@ -1126,17 +1105,7 @@ apiRouter.get('/attendances/:id', async (req, res) => {
|
|||||||
);
|
);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
if (!canReadAttendance(req.user, rows[0])) return res.status(403).json({ error: 'Acesso negado.' });
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role === 'agent' && rows[0].user_id !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.user.role === 'manager' && (!req.user.team_id || rows[0].team_id !== req.user.team_id)) {
|
|
||||||
return res.status(403).json({ error: 'Acesso negado.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
68
backend/package-lock.json
generated
68
backend/package-lock.json
generated
@@ -73,9 +73,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"raw-body": "~2.5.3",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
@@ -331,9 +331,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -358,14 +358,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -384,7 +384,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -519,9 +519,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -771,9 +771,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
@@ -849,9 +849,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
|
||||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
"integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -900,9 +900,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
@@ -919,9 +919,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -1080,13 +1080,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel-list": {
|
"node_modules/side-channel-list": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3"
|
"object-inspect": "^1.13.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1233,9 +1233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.0",
|
||||||
"mysql2": "^3.9.1",
|
"mysql2": "^3.9.1",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^13.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
backend/policies/accessPolicy.js
Normal file
42
backend/policies/accessPolicy.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const sameTenant = (actor, resource) => actor.role === 'super_admin' || actor.tenant_id === resource.tenant_id;
|
||||||
|
|
||||||
|
const canReadUser = (actor, targetUser) => {
|
||||||
|
if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false;
|
||||||
|
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'agent') return targetUser.id === actor.id;
|
||||||
|
if (actor.role === 'manager') {
|
||||||
|
return targetUser.id === actor.id || Boolean(actor.team_id && targetUser.team_id === actor.team_id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUpdateUser = (actor, targetUser) => {
|
||||||
|
if (!actor || !targetUser || !sameTenant(actor, targetUser)) return false;
|
||||||
|
if (actor.id === targetUser.id) return true;
|
||||||
|
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'manager') {
|
||||||
|
return Boolean(actor.team_id && targetUser.team_id === actor.team_id && targetUser.role === 'agent');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canManageUserStatus = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
|
||||||
|
const canChangeUserEmail = (actor, targetUser) => actor.id === targetUser.id || actor.role === 'super_admin' || actor.role === 'admin';
|
||||||
|
const canManageUserRoleOrTeam = (actor) => actor.role === 'super_admin' || actor.role === 'admin';
|
||||||
|
|
||||||
|
const canReadAttendance = (actor, attendance) => {
|
||||||
|
if (!actor || !attendance || !sameTenant(actor, attendance)) return false;
|
||||||
|
if (actor.role === 'super_admin' || actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'agent') return attendance.user_id === actor.id;
|
||||||
|
if (actor.role === 'manager') return Boolean(actor.team_id && attendance.team_id === actor.team_id);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
canReadUser,
|
||||||
|
canUpdateUser,
|
||||||
|
canManageUserStatus,
|
||||||
|
canChangeUserEmail,
|
||||||
|
canManageUserRoleOrTeam,
|
||||||
|
canReadAttendance,
|
||||||
|
};
|
||||||
64
backend/test/accessPolicy.test.js
Normal file
64
backend/test/accessPolicy.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
canReadUser,
|
||||||
|
canUpdateUser,
|
||||||
|
canManageUserStatus,
|
||||||
|
canChangeUserEmail,
|
||||||
|
canManageUserRoleOrTeam,
|
||||||
|
canReadAttendance,
|
||||||
|
} = require('../policies/accessPolicy');
|
||||||
|
|
||||||
|
const admin = { id: 'u_admin', tenant_id: 'tenant_a', role: 'admin', team_id: null };
|
||||||
|
const manager = { id: 'u_manager', tenant_id: 'tenant_a', role: 'manager', team_id: 'team_a' };
|
||||||
|
const managerWithoutTeam = { id: 'u_manager_2', tenant_id: 'tenant_a', role: 'manager', team_id: null };
|
||||||
|
const agent = { id: 'u_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_a' };
|
||||||
|
const otherAgent = { id: 'u_other_agent', tenant_id: 'tenant_a', role: 'agent', team_id: 'team_b' };
|
||||||
|
const foreignAgent = { id: 'u_foreign', tenant_id: 'tenant_b', role: 'agent', team_id: 'team_x' };
|
||||||
|
const superAdmin = { id: 'u_super', tenant_id: 'system', role: 'super_admin', team_id: null };
|
||||||
|
|
||||||
|
test('user read policy keeps tenants isolated', () => {
|
||||||
|
assert.equal(canReadUser(admin, agent), true);
|
||||||
|
assert.equal(canReadUser(admin, foreignAgent), false);
|
||||||
|
assert.equal(canReadUser(superAdmin, foreignAgent), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agents can only read and update themselves', () => {
|
||||||
|
assert.equal(canReadUser(agent, agent), true);
|
||||||
|
assert.equal(canReadUser(agent, otherAgent), false);
|
||||||
|
assert.equal(canUpdateUser(agent, agent), true);
|
||||||
|
assert.equal(canUpdateUser(agent, otherAgent), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('managers can read their team and update only team agents', () => {
|
||||||
|
assert.equal(canReadUser(manager, agent), true);
|
||||||
|
assert.equal(canReadUser(manager, otherAgent), false);
|
||||||
|
assert.equal(canReadUser(managerWithoutTeam, agent), false);
|
||||||
|
assert.equal(canUpdateUser(manager, agent), true);
|
||||||
|
assert.equal(canUpdateUser(manager, admin), false);
|
||||||
|
assert.equal(canUpdateUser(manager, otherAgent), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only admins can manage role, team, and status fields', () => {
|
||||||
|
assert.equal(canManageUserRoleOrTeam(admin), true);
|
||||||
|
assert.equal(canManageUserRoleOrTeam(manager), false);
|
||||||
|
assert.equal(canManageUserStatus(admin), true);
|
||||||
|
assert.equal(canManageUserStatus(manager), false);
|
||||||
|
assert.equal(canChangeUserEmail(agent, agent), true);
|
||||||
|
assert.equal(canChangeUserEmail(manager, agent), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attendance detail policy matches role boundaries', () => {
|
||||||
|
const ownAttendance = { id: 'att_1', tenant_id: 'tenant_a', user_id: 'u_agent', team_id: 'team_a' };
|
||||||
|
const teamAttendance = { id: 'att_2', tenant_id: 'tenant_a', user_id: 'u_another', team_id: 'team_a' };
|
||||||
|
const otherTeamAttendance = { id: 'att_3', tenant_id: 'tenant_a', user_id: 'u_other_agent', team_id: 'team_b' };
|
||||||
|
const foreignAttendance = { id: 'att_4', tenant_id: 'tenant_b', user_id: 'u_foreign', team_id: 'team_x' };
|
||||||
|
|
||||||
|
assert.equal(canReadAttendance(agent, ownAttendance), true);
|
||||||
|
assert.equal(canReadAttendance(agent, teamAttendance), false);
|
||||||
|
assert.equal(canReadAttendance(manager, teamAttendance), true);
|
||||||
|
assert.equal(canReadAttendance(manager, otherTeamAttendance), false);
|
||||||
|
assert.equal(canReadAttendance(admin, otherTeamAttendance), true);
|
||||||
|
assert.equal(canReadAttendance(admin, foreignAttendance), false);
|
||||||
|
assert.equal(canReadAttendance(superAdmin, foreignAttendance), true);
|
||||||
|
});
|
||||||
22
backend/test/security.test.js
Normal file
22
backend/test/security.test.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { stripEnvQuotes, hashSecret, maskSecret } = require('../utils/security');
|
||||||
|
|
||||||
|
test('stripEnvQuotes removes leading and trailing double quotes from env values', () => {
|
||||||
|
assert.equal(stripEnvQuotes('"secret"'), 'secret');
|
||||||
|
assert.equal(stripEnvQuotes('secret'), 'secret');
|
||||||
|
assert.equal(stripEnvQuotes('"partly'), 'partly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hashSecret returns a stable sha256 digest without exposing the secret', () => {
|
||||||
|
const first = hashSecret('fasto_sk_example');
|
||||||
|
const second = hashSecret('fasto_sk_example');
|
||||||
|
|
||||||
|
assert.equal(first, second);
|
||||||
|
assert.equal(first.length, 64);
|
||||||
|
assert.notEqual(first, 'fasto_sk_example');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maskSecret stores only an id and secret suffix', () => {
|
||||||
|
assert.equal(maskSecret('rt_123', 'abcdefghijklmnopqrstuvwxyz'), 'masked:rt_123:uvwxyz');
|
||||||
|
});
|
||||||
11
backend/utils/security.js
Normal file
11
backend/utils/security.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const stripEnvQuotes = (value = '') => value.replace(/^"|"$/g, '');
|
||||||
|
const hashSecret = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
const maskSecret = (id, value) => `masked:${id}:${value.slice(-6)}`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
stripEnvQuotes,
|
||||||
|
hashSecret,
|
||||||
|
maskSecret,
|
||||||
|
};
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:backend": "npm --prefix backend test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
93
src/services/apiClient.ts
Normal file
93
src/services/apiClient.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Em produção, usa caminho relativo porque o backend serve o frontend.
|
||||||
|
// Em desenvolvimento, aponta para o backend local.
|
||||||
|
export const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
export const getHeaders = (customToken?: string) => {
|
||||||
|
const token = customToken || localStorage.getItem('ctms_token');
|
||||||
|
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshSubscribers: ((token: string) => void)[] = [];
|
||||||
|
let sessionExpiredHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export const setSessionExpiredHandler = (handler: () => void) => {
|
||||||
|
sessionExpiredHandler = handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefreshed = (token: string) => {
|
||||||
|
refreshSubscribers.forEach(cb => cb(token));
|
||||||
|
refreshSubscribers = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRefreshSubscriber = (cb: (token: string) => void) => {
|
||||||
|
refreshSubscribers.push(cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expireSession = () => {
|
||||||
|
if (sessionExpiredHandler) {
|
||||||
|
sessionExpiredHandler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.removeItem('ctms_token');
|
||||||
|
localStorage.removeItem('ctms_refresh_token');
|
||||||
|
localStorage.removeItem('ctms_user_id');
|
||||||
|
localStorage.removeItem('ctms_tenant_id');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
|
let response = await fetch(url, options);
|
||||||
|
|
||||||
|
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
|
||||||
|
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
expireSession();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
addRefreshSubscriber((newToken) => {
|
||||||
|
options.headers = getHeaders(newToken);
|
||||||
|
resolve(fetch(url, options));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!refreshResponse.ok) {
|
||||||
|
throw new Error('Refresh token invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await refreshResponse.json();
|
||||||
|
localStorage.setItem('ctms_token', data.token);
|
||||||
|
|
||||||
|
options.headers = getHeaders(data.token);
|
||||||
|
response = await fetch(url, options);
|
||||||
|
|
||||||
|
onRefreshed(data.token);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Session expired or refresh failed:", err);
|
||||||
|
expireSession();
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
@@ -1,88 +1,6 @@
|
|||||||
|
|
||||||
import { Attendance, DashboardFilter, User } from '../types';
|
import { Attendance, DashboardFilter, User } from '../types';
|
||||||
|
import { API_URL, apiFetch, getHeaders, setSessionExpiredHandler } from './apiClient';
|
||||||
// URL do Backend
|
|
||||||
// Em produção (import.meta.env.PROD), usa caminho relativo '/api' pois o backend serve o frontend
|
|
||||||
// Em desenvolvimento, aponta para o localhost:3001
|
|
||||||
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
|
|
||||||
|
|
||||||
const getHeaders = (customToken?: string) => {
|
|
||||||
const token = customToken || localStorage.getItem('ctms_token');
|
|
||||||
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
|
|
||||||
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global flag to prevent multiple simultaneous refresh attempts
|
|
||||||
let isRefreshing = false;
|
|
||||||
let refreshSubscribers: ((token: string) => void)[] = [];
|
|
||||||
|
|
||||||
const onRefreshed = (token: string) => {
|
|
||||||
refreshSubscribers.forEach(cb => cb(token));
|
|
||||||
refreshSubscribers = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRefreshSubscriber = (cb: (token: string) => void) => {
|
|
||||||
refreshSubscribers.push(cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
|
||||||
let response = await fetch(url, options);
|
|
||||||
|
|
||||||
// If unauthorized, attempt to refresh the token
|
|
||||||
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
|
|
||||||
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
logout();
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRefreshing) {
|
|
||||||
// If a refresh is already in progress, wait for it to finish and retry
|
|
||||||
return new Promise(resolve => {
|
|
||||||
addRefreshSubscriber((newToken) => {
|
|
||||||
options.headers = getHeaders(newToken);
|
|
||||||
resolve(fetch(url, options));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refreshToken })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!refreshResponse.ok) {
|
|
||||||
throw new Error('Refresh token invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await refreshResponse.json();
|
|
||||||
localStorage.setItem('ctms_token', data.token);
|
|
||||||
|
|
||||||
// Retry the original request
|
|
||||||
options.headers = getHeaders(data.token);
|
|
||||||
response = await fetch(url, options);
|
|
||||||
|
|
||||||
onRefreshed(data.token);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Session expired or refresh failed:", err);
|
|
||||||
logout();
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getNotifications = async (): Promise<any[]> => {
|
export const getNotifications = async (): Promise<any[]> => {
|
||||||
try {
|
try {
|
||||||
@@ -693,6 +611,8 @@ export const logout = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setSessionExpiredHandler(logout);
|
||||||
|
|
||||||
export const login = async (credentials: any): Promise<any> => {
|
export const login = async (credentials: any): Promise<any> => {
|
||||||
const response = await fetch(`${API_URL}/auth/login`, {
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user