@growy/strapi-plugin-encrypted-field 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/admin/src/components/Input.jsx +39 -5
- package/admin/src/index.js +20 -0
- package/admin/src/pages/AuditLogs.jsx +123 -0
- package/package.json +1 -1
- package/server/content-types/audit-log/schema.json +59 -0
- package/server/controllers/audit-log.js +67 -0
- package/server/register.js +5 -2
- package/server/routes/audit-log.js +30 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Plugin oficial de **Growy AI** para Strapi que proporciona un campo personalizad
|
|
|
16
16
|
- ✅ **UI mejorada** con controles de visibilidad y copiar al portapapeles
|
|
17
17
|
- ✅ **Valores ocultos** por defecto con opción de mostrar/ocultar
|
|
18
18
|
- ✅ **Notificaciones** de confirmación al copiar valores
|
|
19
|
+
- ✅ **Sistema de auditoría** que registra quién accede a datos sensibles
|
|
19
20
|
- ✅ **Gestión de claves robusta** con validación y mensajes de error claros
|
|
20
21
|
- ✅ **Datos cifrados** en base de datos con IV único y Auth Tag
|
|
21
22
|
- ✅ **Reutilizable** en cualquier colección o componente
|
|
@@ -120,6 +121,41 @@ El plugin soporta validación antes del cifrado:
|
|
|
120
121
|
|
|
121
122
|
Si el valor no cumple con el patrón, se lanzará un error antes de cifrar.
|
|
122
123
|
|
|
124
|
+
## Auditoría de seguridad
|
|
125
|
+
|
|
126
|
+
El plugin incluye un sistema completo de auditoría que registra todas las acciones sensibles realizadas sobre los campos cifrados:
|
|
127
|
+
|
|
128
|
+
### Funcionalidades de auditoría
|
|
129
|
+
|
|
130
|
+
- **Registro automático** de accesos a datos sensibles
|
|
131
|
+
- **Acciones rastreadas**:
|
|
132
|
+
- ✅ **Vista** de valores cifrados (al mostrar contenido oculto)
|
|
133
|
+
- ✅ **Copia** de valores al portapapeles
|
|
134
|
+
- ✅ **Descifrado** de datos (próximamente)
|
|
135
|
+
- **Información registrada**:
|
|
136
|
+
- Usuario que realizó la acción
|
|
137
|
+
- Campo y colección afectados
|
|
138
|
+
- ID de entrada modificada
|
|
139
|
+
- Dirección IP y User-Agent del navegador
|
|
140
|
+
- Timestamp preciso de la acción
|
|
141
|
+
|
|
142
|
+
### Acceso a logs de auditoría
|
|
143
|
+
|
|
144
|
+
1. En el panel de administración, ve al menú lateral
|
|
145
|
+
2. Busca **"Logs de Auditoría"** en la sección del plugin
|
|
146
|
+
3. Revisa la tabla con todos los eventos registrados
|
|
147
|
+
4. Usa paginación para navegar por eventos antiguos
|
|
148
|
+
|
|
149
|
+
### Configuración de permisos
|
|
150
|
+
|
|
151
|
+
Para acceder a los logs de auditoría, los usuarios necesitan el permiso:
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"action": "plugin::encrypted-field.read",
|
|
155
|
+
"subject": null
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
123
159
|
## Uso
|
|
124
160
|
|
|
125
161
|
### 1. Agregar campo cifrado a una colección
|
|
@@ -37,6 +37,10 @@ const Input = (props) => {
|
|
|
37
37
|
if (value) {
|
|
38
38
|
try {
|
|
39
39
|
await navigator.clipboard.writeText(value);
|
|
40
|
+
|
|
41
|
+
// Registrar auditoría
|
|
42
|
+
await logAudit('copy', name, value);
|
|
43
|
+
|
|
40
44
|
toggleNotification({
|
|
41
45
|
type: 'success',
|
|
42
46
|
message: 'Copiado al portapapeles',
|
|
@@ -51,7 +55,37 @@ const Input = (props) => {
|
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
const toggleVisibility = () => {
|
|
54
|
-
|
|
58
|
+
const newVisibility = !isVisible;
|
|
59
|
+
setIsVisible(newVisibility);
|
|
60
|
+
|
|
61
|
+
// Registrar auditoría cuando se muestra el valor
|
|
62
|
+
if (newVisibility && value) {
|
|
63
|
+
logAudit('view', name, value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const logAudit = async (action, fieldName, fieldValue) => {
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch('/api/audit-logs/log', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
action,
|
|
76
|
+
field: fieldName,
|
|
77
|
+
collection: 'unknown', // Se puede mejorar obteniendo el contexto real
|
|
78
|
+
entry_id: 'unknown', // Se puede mejorar obteniendo el ID real
|
|
79
|
+
value: fieldValue.substring(0, 50) + (fieldValue.length > 50 ? '...' : ''), // Solo los primeros 50 caracteres para el log
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
console.warn('Failed to log audit event');
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.warn('Error logging audit event:', error);
|
|
88
|
+
}
|
|
55
89
|
};
|
|
56
90
|
|
|
57
91
|
const fieldName = name.includes('.') ? name.split('.').pop() : name;
|
|
@@ -77,10 +111,10 @@ const Input = (props) => {
|
|
|
77
111
|
disabled={disabled}
|
|
78
112
|
style={{ paddingRight: '80px' }}
|
|
79
113
|
/>
|
|
80
|
-
<div style={{
|
|
81
|
-
position: 'absolute',
|
|
82
|
-
right: '8px',
|
|
83
|
-
top: '50%',
|
|
114
|
+
<div style={{
|
|
115
|
+
position: 'absolute',
|
|
116
|
+
right: '8px',
|
|
117
|
+
top: '50%',
|
|
84
118
|
transform: 'translateY(-50%)',
|
|
85
119
|
display: 'flex',
|
|
86
120
|
gap: '4px'
|
package/admin/src/index.js
CHANGED
|
@@ -100,6 +100,26 @@ export default {
|
|
|
100
100
|
],
|
|
101
101
|
},
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
// Agregar página de auditoría al menú
|
|
105
|
+
app.addMenuLink({
|
|
106
|
+
to: '/plugins/encrypted-field/audit-logs',
|
|
107
|
+
icon: 'chartLine',
|
|
108
|
+
intlLabel: {
|
|
109
|
+
id: 'encrypted-field.menu.audit-logs',
|
|
110
|
+
defaultMessage: 'Logs de Auditoría',
|
|
111
|
+
},
|
|
112
|
+
Component: async () => {
|
|
113
|
+
const { AuditLogs } = await import('./pages/AuditLogs');
|
|
114
|
+
return AuditLogs;
|
|
115
|
+
},
|
|
116
|
+
permissions: [
|
|
117
|
+
{
|
|
118
|
+
action: 'plugin::encrypted-field.read',
|
|
119
|
+
subject: null,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
});
|
|
103
123
|
},
|
|
104
124
|
|
|
105
125
|
async registerTrads({ locales }) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Typography, Table, Pagination, Badge } from '@strapi/design-system';
|
|
3
|
+
import { useNotification } from '@strapi/strapi/admin';
|
|
4
|
+
|
|
5
|
+
const AuditLogs = () => {
|
|
6
|
+
const [logs, setLogs] = useState([]);
|
|
7
|
+
const [loading, setLoading] = useState(true);
|
|
8
|
+
const [pagination, setPagination] = useState({ page: 1, pageSize: 25, total: 0 });
|
|
9
|
+
const { toggleNotification } = useNotification();
|
|
10
|
+
|
|
11
|
+
const fetchLogs = async (page = 1) => {
|
|
12
|
+
try {
|
|
13
|
+
setLoading(true);
|
|
14
|
+
const response = await fetch(`/api/audit-logs?page=${page}&pageSize=${pagination.pageSize}`);
|
|
15
|
+
|
|
16
|
+
if (response.ok) {
|
|
17
|
+
const data = await response.json();
|
|
18
|
+
setLogs(data.data || []);
|
|
19
|
+
setPagination(data.meta.pagination);
|
|
20
|
+
} else {
|
|
21
|
+
toggleNotification({
|
|
22
|
+
type: 'danger',
|
|
23
|
+
message: 'Error al cargar logs de auditoría',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error fetching audit logs:', error);
|
|
28
|
+
toggleNotification({
|
|
29
|
+
type: 'danger',
|
|
30
|
+
message: 'Error al cargar logs de auditoría',
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
fetchLogs(pagination.page);
|
|
39
|
+
}, [pagination.page]);
|
|
40
|
+
|
|
41
|
+
const handlePageChange = (page) => {
|
|
42
|
+
setPagination(prev => ({ ...prev, page }));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getBadgeColor = (action) => {
|
|
46
|
+
switch (action) {
|
|
47
|
+
case 'view':
|
|
48
|
+
return 'secondary';
|
|
49
|
+
case 'copy':
|
|
50
|
+
return 'warning';
|
|
51
|
+
case 'decrypt':
|
|
52
|
+
return 'danger';
|
|
53
|
+
default:
|
|
54
|
+
return 'neutral';
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const formatTimestamp = (timestamp) => {
|
|
59
|
+
return new Date(timestamp).toLocaleString('es-ES', {
|
|
60
|
+
year: 'numeric',
|
|
61
|
+
month: 'short',
|
|
62
|
+
day: 'numeric',
|
|
63
|
+
hour: '2-digit',
|
|
64
|
+
minute: '2-digit',
|
|
65
|
+
second: '2-digit',
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const tableHeaders = [
|
|
70
|
+
{ name: 'timestamp', label: 'Fecha/Hora' },
|
|
71
|
+
{ name: 'user', label: 'Usuario' },
|
|
72
|
+
{ name: 'action', label: 'Acción' },
|
|
73
|
+
{ name: 'field', label: 'Campo' },
|
|
74
|
+
{ name: 'collection', label: 'Colección' },
|
|
75
|
+
{ name: 'entry_id', label: 'ID de entrada' },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box padding={8}>
|
|
80
|
+
<Box paddingBottom={4}>
|
|
81
|
+
<Typography variant="alpha">Logs de Auditoría - Campos Cifrados</Typography>
|
|
82
|
+
<Typography variant="epsilon" textColor="neutral600">
|
|
83
|
+
Registro de acciones realizadas sobre campos cifrados
|
|
84
|
+
</Typography>
|
|
85
|
+
</Box>
|
|
86
|
+
|
|
87
|
+
{loading ? (
|
|
88
|
+
<Box>Cargando...</Box>
|
|
89
|
+
) : (
|
|
90
|
+
<>
|
|
91
|
+
<Table
|
|
92
|
+
headers={tableHeaders}
|
|
93
|
+
rows={logs.map(log => ({
|
|
94
|
+
timestamp: formatTimestamp(log.timestamp),
|
|
95
|
+
user: log.user,
|
|
96
|
+
action: (
|
|
97
|
+
<Badge color={getBadgeColor(log.action)}>
|
|
98
|
+
{log.action === 'view' && 'Vista'}
|
|
99
|
+
{log.action === 'copy' && 'Copiado'}
|
|
100
|
+
{log.action === 'decrypt' && 'Descifrado'}
|
|
101
|
+
</Badge>
|
|
102
|
+
),
|
|
103
|
+
field: log.field,
|
|
104
|
+
collection: log.collection,
|
|
105
|
+
entry_id: log.entry_id,
|
|
106
|
+
}))}
|
|
107
|
+
/>
|
|
108
|
+
{pagination.total > pagination.pageSize && (
|
|
109
|
+
<Box paddingTop={4}>
|
|
110
|
+
<Pagination
|
|
111
|
+
activePage={pagination.page}
|
|
112
|
+
pageCount={pagination.pageCount}
|
|
113
|
+
onPageChange={handlePageChange}
|
|
114
|
+
/>
|
|
115
|
+
</Box>
|
|
116
|
+
)}
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export default AuditLogs;
|
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "collectionType",
|
|
3
|
+
"collectionName": "audit_logs",
|
|
4
|
+
"info": {
|
|
5
|
+
"singularName": "audit-log",
|
|
6
|
+
"pluralName": "audit-logs",
|
|
7
|
+
"displayName": "Audit Log",
|
|
8
|
+
"description": "Registros de auditoría para el plugin de campos cifrados"
|
|
9
|
+
},
|
|
10
|
+
"options": {
|
|
11
|
+
"draftAndPublish": false,
|
|
12
|
+
"timestamps": true
|
|
13
|
+
},
|
|
14
|
+
"pluginOptions": {
|
|
15
|
+
"content-manager": {
|
|
16
|
+
"visible": true
|
|
17
|
+
},
|
|
18
|
+
"content-type-builder": {
|
|
19
|
+
"visible": false
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"attributes": {
|
|
23
|
+
"action": {
|
|
24
|
+
"type": "enumeration",
|
|
25
|
+
"enum": [
|
|
26
|
+
"decrypt",
|
|
27
|
+
"copy",
|
|
28
|
+
"view"
|
|
29
|
+
],
|
|
30
|
+
"required": true
|
|
31
|
+
},
|
|
32
|
+
"user": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"required": true
|
|
35
|
+
},
|
|
36
|
+
"field": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"required": true
|
|
39
|
+
},
|
|
40
|
+
"collection": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"required": true
|
|
43
|
+
},
|
|
44
|
+
"entry_id": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"required": true
|
|
47
|
+
},
|
|
48
|
+
"ip_address": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
},
|
|
51
|
+
"user_agent": {
|
|
52
|
+
"type": "text"
|
|
53
|
+
},
|
|
54
|
+
"timestamp": {
|
|
55
|
+
"type": "datetime",
|
|
56
|
+
"default": "now"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { createCoreController } = require('@strapi/strapi').factories;
|
|
2
|
+
|
|
3
|
+
module.exports = createCoreController('plugin::encrypted-field.audit-log', ({ strapi }) => ({
|
|
4
|
+
async log(ctx) {
|
|
5
|
+
try {
|
|
6
|
+
const { action, field, collection, entry_id, ip_address, user_agent } = ctx.request.body;
|
|
7
|
+
|
|
8
|
+
// Obtener información del usuario actual
|
|
9
|
+
const user = ctx.state.user;
|
|
10
|
+
const userInfo = user ? `${user.firstname || ''} ${user.lastname || ''}`.trim() || user.username || user.email : 'Usuario desconocido';
|
|
11
|
+
|
|
12
|
+
// Crear el registro de auditoría
|
|
13
|
+
const auditLog = await strapi.entityService.create('plugin::encrypted-field.audit-log', {
|
|
14
|
+
data: {
|
|
15
|
+
action,
|
|
16
|
+
user: userInfo,
|
|
17
|
+
field,
|
|
18
|
+
collection,
|
|
19
|
+
entry_id,
|
|
20
|
+
ip_address: ip_address || ctx.request.ip,
|
|
21
|
+
user_agent: user_agent || ctx.request.header['user-agent'],
|
|
22
|
+
timestamp: new Date()
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
ctx.send({
|
|
27
|
+
success: true,
|
|
28
|
+
data: auditLog
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
} catch (error) {
|
|
32
|
+
strapi.log.error('Error creating audit log:', error);
|
|
33
|
+
ctx.badRequest('Error al registrar auditoría', { error: error.message });
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async getLogs(ctx) {
|
|
38
|
+
try {
|
|
39
|
+
const { page = 1, pageSize = 25, sort = 'timestamp:desc' } = ctx.query;
|
|
40
|
+
|
|
41
|
+
const logs = await strapi.entityService.findMany('plugin::encrypted-field.audit-log', {
|
|
42
|
+
start: (page - 1) * pageSize,
|
|
43
|
+
limit: pageSize,
|
|
44
|
+
sort: sort,
|
|
45
|
+
populate: '*'
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const total = await strapi.entityService.count('plugin::encrypted-field.audit-log');
|
|
49
|
+
|
|
50
|
+
ctx.send({
|
|
51
|
+
data: logs,
|
|
52
|
+
meta: {
|
|
53
|
+
pagination: {
|
|
54
|
+
page: parseInt(page),
|
|
55
|
+
pageSize: parseInt(pageSize),
|
|
56
|
+
pageCount: Math.ceil(total / pageSize),
|
|
57
|
+
total
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
} catch (error) {
|
|
63
|
+
strapi.log.error('Error fetching audit logs:', error);
|
|
64
|
+
ctx.badRequest('Error al obtener logs de auditoría', { error: error.message });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}));
|
package/server/register.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
module.exports = ({ strapi }) => {
|
|
2
2
|
const decryptMiddleware = require('./middlewares/decrypt');
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
strapi.server.use(decryptMiddleware({}, { strapi }));
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
strapi.customFields.register({
|
|
7
7
|
name: 'encrypted-text',
|
|
8
8
|
plugin: 'encrypted-field',
|
|
9
9
|
type: 'string',
|
|
10
10
|
});
|
|
11
|
+
|
|
12
|
+
// Registrar rutas de auditoría
|
|
13
|
+
strapi.server.routes(require('./routes/audit-log'));
|
|
11
14
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
'log': {
|
|
3
|
+
type: 'content-api',
|
|
4
|
+
routes: [
|
|
5
|
+
{
|
|
6
|
+
method: 'POST',
|
|
7
|
+
path: '/audit-logs/log',
|
|
8
|
+
handler: 'audit-log.log',
|
|
9
|
+
config: {
|
|
10
|
+
policies: [],
|
|
11
|
+
middlewares: []
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
'getLogs': {
|
|
17
|
+
type: 'content-api',
|
|
18
|
+
routes: [
|
|
19
|
+
{
|
|
20
|
+
method: 'GET',
|
|
21
|
+
path: '/audit-logs',
|
|
22
|
+
handler: 'audit-log.getLogs',
|
|
23
|
+
config: {
|
|
24
|
+
policies: [],
|
|
25
|
+
middlewares: []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
};
|