@growy/strapi-plugin-encrypted-field 2.1.9 → 2.3.0
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 +2 -45
- package/admin/src/components/Input.jsx +32 -50
- package/admin/src/index.js +8 -37
- package/admin/src/translations/en.json +7 -2
- package/admin/src/translations/es.json +7 -2
- package/package.json +5 -3
- package/server/bootstrap.js +55 -93
- package/server/index.js +3 -5
- package/server/middlewares/decrypt.js +22 -14
- package/server/register.js +4 -0
- package/server/utils/crypto.js +40 -31
- package/admin/src/pages/AuditLogs.jsx +0 -123
- package/server/content-types/audit-log/schema.json +0 -59
- package/server/controllers/audit-log.js +0 -67
- package/server/routes/audit-log.js +0 -20
package/README.md
CHANGED
|
@@ -16,11 +16,10 @@ 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
|
|
20
19
|
- ✅ **Gestión de claves robusta** con validación y mensajes de error claros
|
|
21
20
|
- ✅ **Datos cifrados** en base de datos con IV único y Auth Tag
|
|
22
21
|
- ✅ **Reutilizable** en cualquier colección o componente
|
|
23
|
-
- ✅ **
|
|
22
|
+
- ✅ **Soporte completo** para componentes anidados y estructuras complejas
|
|
24
23
|
|
|
25
24
|
## Instalación
|
|
26
25
|
|
|
@@ -99,12 +98,10 @@ npm run develop
|
|
|
99
98
|
|
|
100
99
|
## Requisitos
|
|
101
100
|
|
|
102
|
-
- **Strapi**: v5.0.0 o superior
|
|
101
|
+
- **Strapi**: v5.0.0 o superior
|
|
103
102
|
- **Node.js**: 18.x - 22.x
|
|
104
103
|
- **npm**: 6.0.0 o superior
|
|
105
104
|
|
|
106
|
-
⚠️ **Nota sobre Strapi 5**: Esta versión utiliza los componentes actualizados del Design System v2. Si tienes problemas con componentes de layout, asegúrate de que tu proyecto esté actualizado a Strapi 5.
|
|
107
|
-
|
|
108
105
|
## Validación de datos
|
|
109
106
|
|
|
110
107
|
El plugin soporta validación antes del cifrado:
|
|
@@ -123,46 +120,6 @@ El plugin soporta validación antes del cifrado:
|
|
|
123
120
|
|
|
124
121
|
Si el valor no cumple con el patrón, se lanzará un error antes de cifrar.
|
|
125
122
|
|
|
126
|
-
## Auditoría de seguridad
|
|
127
|
-
|
|
128
|
-
El plugin incluye un sistema completo de auditoría que registra todas las acciones sensibles realizadas sobre los campos cifrados:
|
|
129
|
-
|
|
130
|
-
### Funcionalidades de auditoría
|
|
131
|
-
|
|
132
|
-
- **Registro automático** de accesos a datos sensibles
|
|
133
|
-
- **Acciones rastreadas**:
|
|
134
|
-
- ✅ **Vista** de valores cifrados (al mostrar contenido oculto)
|
|
135
|
-
- ✅ **Copia** de valores al portapapeles
|
|
136
|
-
- ✅ **Descifrado** de datos (próximamente)
|
|
137
|
-
- **Información registrada**:
|
|
138
|
-
- Usuario que realizó la acción
|
|
139
|
-
- Campo y colección afectados
|
|
140
|
-
- ID de entrada modificada
|
|
141
|
-
- Dirección IP y User-Agent del navegador
|
|
142
|
-
- Timestamp preciso de la acción
|
|
143
|
-
|
|
144
|
-
### Acceso a logs de auditoría
|
|
145
|
-
|
|
146
|
-
1. **En el panel de administración**, ve al menú lateral izquierdo
|
|
147
|
-
2. **Busca el ítem del plugin** "Encrypted Field" (con el ícono 🔒)
|
|
148
|
-
3. **Dentro encontrarás** la opción **"Logs de Auditoría"**
|
|
149
|
-
4. **Haz clic** para ver la tabla con todos los eventos registrados
|
|
150
|
-
5. **Usa la paginación** para navegar por eventos antiguos
|
|
151
|
-
|
|
152
|
-
**Ubicación exacta:** Menú izquierdo → Encrypted Field → Logs de Auditoría
|
|
153
|
-
|
|
154
|
-
⚠️ **Nota:** Si no ves la opción, asegúrate de tener permisos de administrador y reinstalar el plugin después de actualizar.
|
|
155
|
-
|
|
156
|
-
### Configuración de permisos
|
|
157
|
-
|
|
158
|
-
Para acceder a los logs de auditoría, los usuarios necesitan el permiso:
|
|
159
|
-
```json
|
|
160
|
-
{
|
|
161
|
-
"action": "plugin::encrypted-field.read",
|
|
162
|
-
"subject": null
|
|
163
|
-
}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
123
|
## Uso
|
|
167
124
|
|
|
168
125
|
### 1. Agregar campo cifrado a una colección
|
|
@@ -34,63 +34,45 @@ const Input = (props) => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
const handleCopy = async () => {
|
|
37
|
-
if (value)
|
|
38
|
-
try {
|
|
39
|
-
await navigator.clipboard.writeText(value);
|
|
37
|
+
if (!value) return;
|
|
40
38
|
|
|
41
|
-
// Registrar auditoría
|
|
42
|
-
await logAudit('copy', name, value);
|
|
43
|
-
|
|
44
|
-
toggleNotification({
|
|
45
|
-
type: 'success',
|
|
46
|
-
message: 'Copiado al portapapeles',
|
|
47
|
-
});
|
|
48
|
-
} catch (err) {
|
|
49
|
-
toggleNotification({
|
|
50
|
-
type: 'danger',
|
|
51
|
-
message: 'Error al copiar',
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const toggleVisibility = () => {
|
|
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
39
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
40
|
+
await navigator.clipboard.writeText(value);
|
|
41
|
+
toggleNotification({
|
|
42
|
+
type: 'success',
|
|
43
|
+
message: formatMessage({
|
|
44
|
+
id: 'encrypted-field.notification.copied',
|
|
45
|
+
defaultMessage: 'Copiado al portapapeles',
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
toggleNotification({
|
|
50
|
+
type: 'danger',
|
|
51
|
+
message: formatMessage({
|
|
52
|
+
id: 'encrypted-field.notification.copyError',
|
|
53
|
+
defaultMessage: 'Error al copiar',
|
|
80
54
|
}),
|
|
81
55
|
});
|
|
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
56
|
}
|
|
89
57
|
};
|
|
90
58
|
|
|
59
|
+
const toggleVisibility = () => {
|
|
60
|
+
setIsVisible((prev) => !prev);
|
|
61
|
+
};
|
|
62
|
+
|
|
91
63
|
const fieldName = name.includes('.') ? name.split('.').pop() : name;
|
|
92
64
|
const label = intlLabel?.id ? formatMessage(intlLabel) : (intlLabel || fieldName);
|
|
93
65
|
|
|
66
|
+
const visibilityLabel = formatMessage({
|
|
67
|
+
id: isVisible ? 'encrypted-field.action.hide' : 'encrypted-field.action.show',
|
|
68
|
+
defaultMessage: isVisible ? 'Ocultar' : 'Mostrar',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const copyLabel = formatMessage({
|
|
72
|
+
id: 'encrypted-field.action.copy',
|
|
73
|
+
defaultMessage: 'Copiar',
|
|
74
|
+
});
|
|
75
|
+
|
|
94
76
|
return (
|
|
95
77
|
<Field.Root
|
|
96
78
|
name={name}
|
|
@@ -117,11 +99,11 @@ const Input = (props) => {
|
|
|
117
99
|
top: '50%',
|
|
118
100
|
transform: 'translateY(-50%)',
|
|
119
101
|
display: 'flex',
|
|
120
|
-
gap: '4px'
|
|
102
|
+
gap: '4px',
|
|
121
103
|
}}>
|
|
122
104
|
<IconButton
|
|
123
105
|
onClick={toggleVisibility}
|
|
124
|
-
label={
|
|
106
|
+
label={visibilityLabel}
|
|
125
107
|
disabled={disabled}
|
|
126
108
|
variant="ghost"
|
|
127
109
|
>
|
|
@@ -129,7 +111,7 @@ const Input = (props) => {
|
|
|
129
111
|
</IconButton>
|
|
130
112
|
<IconButton
|
|
131
113
|
onClick={handleCopy}
|
|
132
|
-
label=
|
|
114
|
+
label={copyLabel}
|
|
133
115
|
disabled={disabled || !value}
|
|
134
116
|
variant="ghost"
|
|
135
117
|
>
|
package/admin/src/index.js
CHANGED
|
@@ -100,47 +100,18 @@ export default {
|
|
|
100
100
|
],
|
|
101
101
|
},
|
|
102
102
|
});
|
|
103
|
-
|
|
104
|
-
// Agregar página de auditoría al menú - Strapi 5 approach
|
|
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 component = await import('./pages/AuditLogs');
|
|
114
|
-
return component.default;
|
|
115
|
-
},
|
|
116
|
-
permissions: [
|
|
117
|
-
{
|
|
118
|
-
action: 'plugin::encrypted-field.read',
|
|
119
|
-
subject: null,
|
|
120
|
-
},
|
|
121
|
-
],
|
|
122
|
-
});
|
|
123
103
|
},
|
|
124
104
|
|
|
125
105
|
async registerTrads({ locales }) {
|
|
126
|
-
|
|
127
|
-
locales.map((locale) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
})
|
|
135
|
-
.catch(() => {
|
|
136
|
-
return {
|
|
137
|
-
data: {},
|
|
138
|
-
locale,
|
|
139
|
-
};
|
|
140
|
-
});
|
|
106
|
+
return Promise.all(
|
|
107
|
+
locales.map(async (locale) => {
|
|
108
|
+
try {
|
|
109
|
+
const { default: data } = await import(`./translations/${locale}.json`);
|
|
110
|
+
return { data, locale };
|
|
111
|
+
} catch {
|
|
112
|
+
return { data: {}, locale };
|
|
113
|
+
}
|
|
141
114
|
})
|
|
142
115
|
);
|
|
143
|
-
|
|
144
|
-
return Promise.resolve(importedTrads);
|
|
145
116
|
},
|
|
146
117
|
};
|
|
@@ -13,5 +13,10 @@
|
|
|
13
13
|
"encrypted-field.options.minLength.description": "Minimum number of characters required",
|
|
14
14
|
"encrypted-field.options.advanced.regex": "Validation",
|
|
15
15
|
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
16
|
-
"encrypted-field.options.regex.description": "Validation pattern before encryption"
|
|
17
|
-
|
|
16
|
+
"encrypted-field.options.regex.description": "Validation pattern before encryption",
|
|
17
|
+
"encrypted-field.notification.copied": "Copied to clipboard",
|
|
18
|
+
"encrypted-field.notification.copyError": "Error copying to clipboard",
|
|
19
|
+
"encrypted-field.action.show": "Show",
|
|
20
|
+
"encrypted-field.action.hide": "Hide",
|
|
21
|
+
"encrypted-field.action.copy": "Copy"
|
|
22
|
+
}
|
|
@@ -13,5 +13,10 @@
|
|
|
13
13
|
"encrypted-field.options.minLength.description": "Número mínimo de caracteres requeridos",
|
|
14
14
|
"encrypted-field.options.advanced.regex": "Validación",
|
|
15
15
|
"encrypted-field.options.regex.label": "RegEx pattern",
|
|
16
|
-
"encrypted-field.options.regex.description": "Patrón de validación antes de cifrar"
|
|
17
|
-
|
|
16
|
+
"encrypted-field.options.regex.description": "Patrón de validación antes de cifrar",
|
|
17
|
+
"encrypted-field.notification.copied": "Copiado al portapapeles",
|
|
18
|
+
"encrypted-field.notification.copyError": "Error al copiar al portapapeles",
|
|
19
|
+
"encrypted-field.action.show": "Mostrar",
|
|
20
|
+
"encrypted-field.action.hide": "Ocultar",
|
|
21
|
+
"encrypted-field.action.copy": "Copiar"
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growy/strapi-plugin-encrypted-field",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Campo personalizado de texto cifrado para Strapi",
|
|
5
5
|
"strapi": {
|
|
6
6
|
"name": "encrypted-field",
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@strapi/strapi": "^5.0.0"
|
|
13
|
+
"@strapi/strapi": "^5.0.0",
|
|
14
|
+
"@strapi/design-system": "^2.0.0",
|
|
15
|
+
"react": "^18.0.0"
|
|
14
16
|
},
|
|
15
17
|
"main": "server/index.js",
|
|
16
18
|
"exports": {
|
|
@@ -53,4 +55,4 @@
|
|
|
53
55
|
"publishConfig": {
|
|
54
56
|
"access": "public"
|
|
55
57
|
}
|
|
56
|
-
}
|
|
58
|
+
}
|
package/server/bootstrap.js
CHANGED
|
@@ -1,117 +1,79 @@
|
|
|
1
1
|
const { encrypt, decrypt, validateValue, isEncryptedField } = require('./utils/crypto');
|
|
2
2
|
|
|
3
|
+
function processEncryption(event, strapi) {
|
|
4
|
+
if (!event.model?.uid) return;
|
|
5
|
+
|
|
6
|
+
const { data } = event.params;
|
|
7
|
+
const currentModel = strapi.getModel(event.model.uid);
|
|
8
|
+
|
|
9
|
+
if (!currentModel?.attributes) return;
|
|
10
|
+
|
|
11
|
+
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
12
|
+
if (!isEncryptedField(attribute)) continue;
|
|
13
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
|
|
14
|
+
|
|
15
|
+
const value = data[key];
|
|
16
|
+
if (value === null || value === undefined || value === '') continue;
|
|
17
|
+
|
|
18
|
+
const validation = validateValue(value, attribute);
|
|
19
|
+
if (!validation.valid) {
|
|
20
|
+
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
data[key] = encrypt(value, strapi);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processDecryption(result, event, strapi) {
|
|
28
|
+
if (!result) return;
|
|
29
|
+
if (!event.model?.uid) return;
|
|
30
|
+
|
|
31
|
+
const currentModel = strapi.getModel(event.model.uid);
|
|
32
|
+
if (!currentModel?.attributes) return;
|
|
33
|
+
|
|
34
|
+
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
35
|
+
if (!isEncryptedField(attribute)) continue;
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(result, key)) continue;
|
|
37
|
+
|
|
38
|
+
const value = result[key];
|
|
39
|
+
if (typeof value === 'string' && value) {
|
|
40
|
+
result[key] = decrypt(value, strapi);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
3
45
|
module.exports = ({ strapi }) => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
46
|
+
const allModels = [
|
|
47
|
+
...Object.values(strapi.contentTypes),
|
|
48
|
+
...Object.values(strapi.components),
|
|
49
|
+
];
|
|
50
|
+
|
|
10
51
|
allModels.forEach((model) => {
|
|
11
52
|
const attributes = model.attributes || {};
|
|
12
|
-
|
|
13
|
-
const encryptedFields = Object.entries(attributes)
|
|
14
|
-
.filter(([key, attr]) => isEncryptedField(attr))
|
|
15
|
-
.map(([key]) => key);
|
|
16
|
-
|
|
17
|
-
if (encryptedFields.length === 0) return;
|
|
53
|
+
const hasEncryptedFields = Object.values(attributes).some(isEncryptedField);
|
|
18
54
|
|
|
19
|
-
|
|
55
|
+
if (!hasEncryptedFields) return;
|
|
20
56
|
|
|
21
|
-
|
|
22
57
|
strapi.db.lifecycles.subscribe({
|
|
23
|
-
models: [uid],
|
|
24
|
-
|
|
58
|
+
models: [model.uid],
|
|
59
|
+
|
|
25
60
|
async beforeCreate(event) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!event.model?.uid) return;
|
|
29
|
-
|
|
30
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
31
|
-
|
|
32
|
-
if (!currentModel?.attributes) return;
|
|
33
|
-
|
|
34
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
35
|
-
if (!isEncryptedField(attribute)) continue;
|
|
36
|
-
|
|
37
|
-
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
38
|
-
const value = data[key];
|
|
39
|
-
|
|
40
|
-
if (value === null || value === undefined || value === '') continue;
|
|
41
|
-
|
|
42
|
-
const validation = validateValue(value, attribute);
|
|
43
|
-
if (!validation.valid) {
|
|
44
|
-
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
data[key] = encrypt(value, strapi);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
61
|
+
processEncryption(event, strapi);
|
|
50
62
|
},
|
|
51
63
|
|
|
52
64
|
async beforeUpdate(event) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { data } = event.params;
|
|
56
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
57
|
-
|
|
58
|
-
if (!currentModel?.attributes) return;
|
|
59
|
-
|
|
60
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
61
|
-
if (!isEncryptedField(attribute)) continue;
|
|
62
|
-
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
63
|
-
const value = data[key];
|
|
64
|
-
if (value === null || value === undefined || value === '') continue;
|
|
65
|
-
|
|
66
|
-
const validation = validateValue(value, attribute);
|
|
67
|
-
if (!validation.valid) {
|
|
68
|
-
throw new Error(`Validación fallida para el campo "${key}": ${validation.error}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
data[key] = encrypt(value, strapi);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
65
|
+
processEncryption(event, strapi);
|
|
74
66
|
},
|
|
75
67
|
|
|
76
68
|
async afterFindOne(event) {
|
|
77
|
-
|
|
78
|
-
if (!result) return;
|
|
79
|
-
if (!event.model?.uid) return;
|
|
80
|
-
|
|
81
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
82
|
-
|
|
83
|
-
if (!currentModel?.attributes) return;
|
|
84
|
-
|
|
85
|
-
for (const [key, attribute] of Object.entries(currentModel.attributes)) {
|
|
86
|
-
if (!isEncryptedField(attribute)) continue;
|
|
87
|
-
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
88
|
-
const value = result[key];
|
|
89
|
-
if (typeof value === 'string' && value) {
|
|
90
|
-
result[key] = decrypt(value, strapi);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
69
|
+
processDecryption(event.result, event, strapi);
|
|
94
70
|
},
|
|
95
71
|
|
|
96
72
|
async afterFindMany(event) {
|
|
97
73
|
const { result } = event;
|
|
98
74
|
if (!result || !Array.isArray(result)) return;
|
|
99
|
-
if (!event.model?.uid) return;
|
|
100
|
-
|
|
101
|
-
const currentModel = strapi.getModel(event.model.uid);
|
|
102
|
-
|
|
103
|
-
if (!currentModel?.attributes) return;
|
|
104
|
-
|
|
105
75
|
for (const item of result) {
|
|
106
|
-
|
|
107
|
-
if (!isEncryptedField(attribute)) continue;
|
|
108
|
-
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
109
|
-
const value = item[key];
|
|
110
|
-
if (typeof value === 'string' && value) {
|
|
111
|
-
item[key] = decrypt(value, strapi);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
76
|
+
processDecryption(item, event, strapi);
|
|
115
77
|
}
|
|
116
78
|
},
|
|
117
79
|
});
|
package/server/index.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
const register = require('./register');
|
|
2
2
|
const bootstrap = require('./bootstrap');
|
|
3
3
|
const decrypt = require('./middlewares/decrypt');
|
|
4
|
-
const auditLogController = require('./controllers/audit-log');
|
|
5
|
-
const auditLogRoutes = require('./routes/audit-log');
|
|
6
4
|
|
|
7
5
|
module.exports = {
|
|
8
6
|
register,
|
|
9
7
|
bootstrap,
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
config: {
|
|
9
|
+
default: {},
|
|
10
|
+
validator: () => { },
|
|
12
11
|
},
|
|
13
|
-
routes: auditLogRoutes,
|
|
14
12
|
middlewares: {
|
|
15
13
|
decrypt,
|
|
16
14
|
},
|
|
@@ -1,38 +1,49 @@
|
|
|
1
1
|
const { decrypt, isEncryptedField } = require('../utils/crypto');
|
|
2
2
|
|
|
3
|
+
function resolveModelUidFromPath(ctx, strapi) {
|
|
4
|
+
const path = ctx.request?.path || '';
|
|
5
|
+
const match = path.match(/^\/api\/([^/]+)/);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
|
|
8
|
+
const slug = match[1];
|
|
9
|
+
const contentTypes = Object.values(strapi.contentTypes);
|
|
10
|
+
|
|
11
|
+
const ct = contentTypes.find((ct) => {
|
|
12
|
+
const info = ct.info || {};
|
|
13
|
+
return info.pluralName === slug || info.singularName === slug;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return ct?.uid || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
module.exports = (config, { strapi }) => {
|
|
4
20
|
return async (ctx, next) => {
|
|
5
21
|
await next();
|
|
6
22
|
|
|
7
23
|
if (!ctx.body) return;
|
|
8
24
|
|
|
9
|
-
|
|
25
|
+
const rootModelUid = resolveModelUidFromPath(ctx, strapi);
|
|
26
|
+
|
|
10
27
|
const decryptRecursive = (obj, modelUid = null) => {
|
|
11
28
|
if (!obj || typeof obj !== 'object') return;
|
|
12
29
|
|
|
13
|
-
|
|
14
30
|
if (Array.isArray(obj)) {
|
|
15
|
-
obj.forEach(item => decryptRecursive(item, modelUid));
|
|
31
|
+
obj.forEach((item) => decryptRecursive(item, modelUid));
|
|
16
32
|
return;
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
|
|
20
35
|
let currentModelUid = modelUid;
|
|
21
36
|
if (obj.__component) {
|
|
22
37
|
currentModelUid = obj.__component;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
|
|
26
40
|
let model = null;
|
|
27
41
|
if (currentModelUid) {
|
|
28
42
|
try {
|
|
29
43
|
model = strapi.getModel(currentModelUid) || strapi.components[currentModelUid];
|
|
30
|
-
} catch (e) {
|
|
31
|
-
|
|
32
|
-
}
|
|
44
|
+
} catch (e) { }
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
|
|
36
47
|
if (model?.attributes) {
|
|
37
48
|
for (const [key, attribute] of Object.entries(model.attributes)) {
|
|
38
49
|
if (isEncryptedField(attribute) && obj[key] && typeof obj[key] === 'string') {
|
|
@@ -45,7 +56,6 @@ module.exports = (config, { strapi }) => {
|
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
|
|
49
59
|
for (const value of Object.values(obj)) {
|
|
50
60
|
if (value && typeof value === 'object') {
|
|
51
61
|
decryptRecursive(value, currentModelUid);
|
|
@@ -53,12 +63,10 @@ module.exports = (config, { strapi }) => {
|
|
|
53
63
|
}
|
|
54
64
|
};
|
|
55
65
|
|
|
56
|
-
|
|
57
66
|
if (ctx.body.data) {
|
|
58
|
-
decryptRecursive(ctx.body.data);
|
|
67
|
+
decryptRecursive(ctx.body.data, rootModelUid);
|
|
59
68
|
} else {
|
|
60
|
-
|
|
61
|
-
decryptRecursive(ctx.body);
|
|
69
|
+
decryptRecursive(ctx.body, rootModelUid);
|
|
62
70
|
}
|
|
63
71
|
};
|
|
64
72
|
};
|
package/server/register.js
CHANGED
package/server/utils/crypto.js
CHANGED
|
@@ -5,9 +5,12 @@ const IV_LENGTH = 12;
|
|
|
5
5
|
const AUTH_TAG_LENGTH = 16;
|
|
6
6
|
const KEY_LENGTH = 32;
|
|
7
7
|
|
|
8
|
+
let _cachedKey = null;
|
|
9
|
+
let _cachedKeySource = null;
|
|
10
|
+
|
|
8
11
|
function getEncryptionKey(strapi) {
|
|
9
12
|
const key = process.env.ENCRYPTION_KEY || strapi?.config?.get('plugin.encrypted-field.encryptionKey');
|
|
10
|
-
|
|
13
|
+
|
|
11
14
|
if (!key) {
|
|
12
15
|
const errorMsg = '⚠️ ENCRYPTION_KEY no configurada. Debe establecer una clave de 64 caracteres hexadecimales en las variables de entorno o configuración de Strapi.';
|
|
13
16
|
if (strapi?.log?.error) {
|
|
@@ -15,33 +18,39 @@ function getEncryptionKey(strapi) {
|
|
|
15
18
|
}
|
|
16
19
|
throw new Error(errorMsg);
|
|
17
20
|
}
|
|
18
|
-
|
|
21
|
+
|
|
22
|
+
if (_cachedKey && _cachedKeySource === key) {
|
|
23
|
+
return _cachedKey;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
if (typeof key !== 'string' || key.length !== 64) {
|
|
20
27
|
throw new Error(`ENCRYPTION_KEY debe tener exactamente 64 caracteres hexadecimales (32 bytes). Actual: ${key?.length || 0}`);
|
|
21
28
|
}
|
|
22
|
-
|
|
29
|
+
|
|
23
30
|
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
24
31
|
throw new Error('ENCRYPTION_KEY debe contener solo caracteres hexadecimales (0-9, a-f, A-F)');
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
|
|
34
|
+
_cachedKey = Buffer.from(key, 'hex');
|
|
35
|
+
_cachedKeySource = key;
|
|
36
|
+
return _cachedKey;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
function encrypt(text, strapi) {
|
|
31
40
|
if (typeof text !== 'string') return text;
|
|
32
|
-
|
|
41
|
+
|
|
33
42
|
if (text === '') return text;
|
|
34
43
|
|
|
35
44
|
try {
|
|
36
45
|
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
37
46
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
38
47
|
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
39
|
-
|
|
48
|
+
|
|
40
49
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
41
50
|
encrypted += cipher.final('hex');
|
|
42
|
-
|
|
51
|
+
|
|
43
52
|
const authTag = cipher.getAuthTag();
|
|
44
|
-
|
|
53
|
+
|
|
45
54
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
46
55
|
} catch (error) {
|
|
47
56
|
if (strapi?.log?.error) {
|
|
@@ -53,29 +62,29 @@ function encrypt(text, strapi) {
|
|
|
53
62
|
|
|
54
63
|
function decrypt(encryptedText, strapi) {
|
|
55
64
|
if (!encryptedText || typeof encryptedText !== 'string') return encryptedText;
|
|
56
|
-
|
|
65
|
+
|
|
57
66
|
const parts = encryptedText.split(':');
|
|
58
67
|
if (parts.length !== 3) {
|
|
59
68
|
return encryptedText;
|
|
60
69
|
}
|
|
61
|
-
|
|
70
|
+
|
|
62
71
|
try {
|
|
63
72
|
const [ivHex, authTagHex, encrypted] = parts;
|
|
64
|
-
|
|
73
|
+
|
|
65
74
|
if (ivHex.length !== IV_LENGTH * 2 || authTagHex.length !== AUTH_TAG_LENGTH * 2) {
|
|
66
75
|
return encryptedText;
|
|
67
76
|
}
|
|
68
|
-
|
|
77
|
+
|
|
69
78
|
const ENCRYPTION_KEY = getEncryptionKey(strapi);
|
|
70
79
|
const iv = Buffer.from(ivHex, 'hex');
|
|
71
80
|
const authTag = Buffer.from(authTagHex, 'hex');
|
|
72
|
-
|
|
81
|
+
|
|
73
82
|
const decipher = crypto.createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv);
|
|
74
83
|
decipher.setAuthTag(authTag);
|
|
75
|
-
|
|
84
|
+
|
|
76
85
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
77
86
|
decrypted += decipher.final('utf8');
|
|
78
|
-
|
|
87
|
+
|
|
79
88
|
return decrypted;
|
|
80
89
|
} catch (error) {
|
|
81
90
|
if (strapi?.log?.debug) {
|
|
@@ -91,9 +100,9 @@ function validateValue(value, attribute) {
|
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
if (typeof value !== 'string') {
|
|
94
|
-
return {
|
|
95
|
-
valid: false,
|
|
96
|
-
error: 'El valor debe ser una cadena de texto'
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
error: 'El valor debe ser una cadena de texto'
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -101,30 +110,30 @@ function validateValue(value, attribute) {
|
|
|
101
110
|
try {
|
|
102
111
|
const regex = new RegExp(attribute.regex);
|
|
103
112
|
if (!regex.test(value)) {
|
|
104
|
-
return {
|
|
105
|
-
valid: false,
|
|
106
|
-
error: `El valor no cumple con el patrón de validación: ${attribute.regex}`
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: `El valor no cumple con el patrón de validación: ${attribute.regex}`
|
|
107
116
|
};
|
|
108
117
|
}
|
|
109
118
|
} catch (error) {
|
|
110
|
-
return {
|
|
111
|
-
valid: false,
|
|
112
|
-
error: `Patrón regex inválido: ${error.message}`
|
|
119
|
+
return {
|
|
120
|
+
valid: false,
|
|
121
|
+
error: `Patrón regex inválido: ${error.message}`
|
|
113
122
|
};
|
|
114
123
|
}
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
if (attribute.maxLength && value.length > attribute.maxLength) {
|
|
118
|
-
return {
|
|
119
|
-
valid: false,
|
|
120
|
-
error: `El valor excede la longitud máxima de ${attribute.maxLength} caracteres`
|
|
127
|
+
return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: `El valor excede la longitud máxima de ${attribute.maxLength} caracteres`
|
|
121
130
|
};
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
if (attribute.minLength && value.length < attribute.minLength) {
|
|
125
|
-
return {
|
|
126
|
-
valid: false,
|
|
127
|
-
error: `El valor debe tener al menos ${attribute.minLength} caracteres`
|
|
134
|
+
return {
|
|
135
|
+
valid: false,
|
|
136
|
+
error: `El valor debe tener al menos ${attribute.minLength} caracteres`
|
|
128
137
|
};
|
|
129
138
|
}
|
|
130
139
|
|
|
@@ -1,123 +0,0 @@
|
|
|
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(`/encrypted-field/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;
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
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
|
-
}));
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
module.exports = [
|
|
2
|
-
{
|
|
3
|
-
method: 'POST',
|
|
4
|
-
path: '/audit-logs/log',
|
|
5
|
-
handler: 'audit-log.log',
|
|
6
|
-
config: {
|
|
7
|
-
policies: [],
|
|
8
|
-
auth: false,
|
|
9
|
-
}
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
method: 'GET',
|
|
13
|
-
path: '/audit-logs',
|
|
14
|
-
handler: 'audit-log.getLogs',
|
|
15
|
-
config: {
|
|
16
|
-
policies: [],
|
|
17
|
-
auth: false,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
];
|