asdrubalp9 / laravel-kms-encryption
Cifrado de columnas de Eloquent con una llave de datos por tenant, envuelta por Google Cloud KMS.
Package info
gitlab.com/asdrubalp9/laravel-kms-encryption
pkg:composer/asdrubalp9/laravel-kms-encryption
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- google/auth: ^1.53
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^11.0|^12.0
- illuminate/console: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/encryption: ^11.0|^12.0
- illuminate/events: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Cifra columnas de Eloquent en reposo con una llave de datos (DEK) por tenant. Google Cloud KMS envuelve cada DEK.
Estado: versión 0.2.0 sin publicar. La 0.1.0 ya está publicada.
Qué hace
- Cifra cada columna con AES-256-GCM. Cada valor guardado es un sobre autocontenido:
<prefijo>:<tenant>:<versión>:<nonce>:<texto cifrado>. - Usa una DEK por tenant. Cloud KMS guarda la DEK envuelta en una tabla. La DEK en claro vive solo en una caché en memoria.
- Descifra sin tenant resuelto, porque el sobre lleva su tenant. Los workers de cola funcionan sin contexto.
- Rota la DEK por tenant, reescribe lo pendiente y purga las versiones viejas con un plazo de guarda.
- Cifra lo que ya existe en la base con
kms:encrypt-existing. - Amortigua una caída del KMS con una caché de respaldo y emite el evento
KmsDegraded. - Busca por igualdad y por palabras sobre columnas cifradas con índices ciegos (desde la 0.2.0).
El proyecto no cifra el payload de las colas (jobs, failed_jobs). Solo cifra columnas. No busca por prefijo, rango ni subcadena.
Requisitos
- PHP 8.2 o superior, con
ext-openssl. - Laravel 11 o 12.
- PostgreSQL en producción. SQLite en las pruebas del paquete. No se soporta MySQL.
- En producción, APCu (
ext-apcu) o Octane, para la caché de la DEK.
Instalación
composer require asdrubalp9/laravel-kms-encryption
php artisan vendor:publish --tag=kms-encryption-config
php artisan vendor:publish --tag=kms-encryption-migrations
php artisan migrate
El tag kms-encryption-migrations publica tres migraciones: kms_data_keys (las DEK), kms_index_keys (la llave raíz de los índices ciegos) y kms_blind_tokens (los tokens de los índices de palabras). Si no usas índices ciegos, puedes borrar las dos últimas.
Antes de migrar, define tenant_model en config/kms-encryption.php (o pon foreign_key en false). La migración lee esa clase para declarar la FK.
Configuración
| Clave | Variable de entorno | Por defecto |
|---|---|---|
driver | KMS_ENCRYPTION_DRIVER | fake |
gcp.key_name | KMS_ENCRYPTION_GCP_KEY_NAME | null |
gcp.key_file | KMS_ENCRYPTION_GCP_KEY_FILE | null |
gcp.timeout | KMS_ENCRYPTION_GCP_TIMEOUT | 5 |
format_prefix | KMS_ENCRYPTION_FORMAT_PREFIX | kms1 |
key_type | KMS_ENCRYPTION_KEY_TYPE | int |
tenant_model | null | |
tenant_column | tenant_id | |
tenant_route_key | null (usa la PK del tenant) | |
table | kms_data_keys | |
foreign_key | true | |
index_table | kms_index_keys | |
tokens_table | kms_blind_tokens | |
morph_key_type | KMS_ENCRYPTION_MORPH_KEY_TYPE | int |
cache.store | KMS_ENCRYPTION_CACHE_STORE | array |
cache.ttl | KMS_ENCRYPTION_CACHE_TTL | 300 |
cache.fallback_ttl | KMS_ENCRYPTION_CACHE_FALLBACK_TTL | 86400 |
cache.allowed_stores | ['apc', 'array', 'octane'] | |
degraded_notice_window | 900 | |
purge_guard_days | KMS_ENCRYPTION_PURGE_GUARD_DAYS | 90 |
legacy_laravel_encrypted | true | |
columns | [] | |
blind_indexes | [] |
Producción con Google Cloud KMS:
KMS_ENCRYPTION_DRIVER=gcp
KMS_ENCRYPTION_GCP_KEY_NAME=projects/mi-proyecto/locations/global/keyRings/app/cryptoKeys/datos
KMS_ENCRYPTION_GCP_KEY_FILE=/etc/app/kms-cuenta-de-servicio.json
KMS_ENCRYPTION_CACHE_STORE=apc
KMS_ENCRYPTION_KEY_TYPE=int
La cuenta de servicio necesita el rol roles/cloudkms.cryptoKeyEncrypterDecrypter.
Tipo del id del tenant
key_type vale int, uuid o string. Define el tipo de la columna del tenant en la tabla de llaves y valida el id antes de cifrar.
int: entero positivo sin ceros a la izquierda.uuid: UUID. El paquete lo escribe en minúsculas.string:[A-Za-z0-9-], de 1 a 64 caracteres.
Los tres tipos rechazan :, % y _. El sobre se parte por : y la rotación filtra con LIKE.
Caché de la DEK
cache.store solo admite los almacenes de cache.allowed_stores. Todos viven en memoria. El paquete se niega a arrancar con otro almacén, y el resolutor lo vuelve a comprobar en cada acceso por si la config cambia en ejecución. Si amplías la lista, asumes que ese almacén no persiste fuera de memoria.
El almacén apc necesita apc.enable_cli=1 para que los workers y los comandos de Artisan lo vean. Sin esa opción, APCu no guarda nada en CLI y cada lectura llama al KMS.
apc.enabled=1
apc.enable_cli=1
Con array, la caché dura lo que dura el proceso. Sirve para local y pruebas.
Uso
1. Modelo
use Asdrubalp9\KmsEncryption\Casts\Encrypted;
use Asdrubalp9\KmsEncryption\Concerns\HasEncryptedColumns;
class Orden extends Model
{
use HasEncryptedColumns;
protected function casts(): array
{
return [
'destinatario_telefono' => Encrypted::class,
'metadata' => Encrypted::class,
];
}
}
2. Mapa de columnas
El tipo de cada columna vive en la config, no en el cast:
'columns' => [
App\Models\Orden::class => [
'destinatario_telefono' => 'string',
'metadata' => ['type' => 'array', 'empty_to_null' => false],
],
],
Una entrada corta es el tipo. Una entrada larga es un arreglo con type y empty_to_null. empty_to_null vale true por defecto: una cadena vacía se guarda como null.
| Tipo | Se guarda como | Se lee como |
|---|---|---|
string | texto | string |
array | JSON | array |
date | Y-m-d | CarbonImmutable |
datetime | ISO 8601 con microsegundos y desplazamiento | CarbonImmutable |
int | entero en texto | int |
float | JSON, sin pérdida de dígitos | float |
bool | 1 o 0 | bool |
Una subclase de un modelo del mapa hereda su mapa.
La columna de la base debe ser de tipo text. Un sobre mide más que el valor original.
3. Quién es el tenant de una fila
encryptionTenantKey() resuelve el tenant al escribir, en este orden:
- La clave primaria, si el modelo es instancia de
tenant_model. - El atributo
tenant_columndel modelo, si no está vacío. TenantContext::current().- Si nada resuelve, lanza
MissingTenantException. El paquete nunca guarda en claro.
El modelo puede sobrescribir encryptionTenantKey(string $column): int|string. Al leer, el cast compara el tenant del sobre con el de la fila (decisiones 36 a 38 en docs/superpowers/decisiones-de-implementacion.md).
Para el paso 3, implementa Asdrubalp9\KmsEncryption\Contracts\TenantContext y regístralo en el contenedor:
$this->app->bind(TenantContext::class, MiTenantContext::class);
El valor por defecto es NullTenantContext, que no tiene tenant activo.
El tenant que cifra sus propias columnas
- Con UUID funciona al crear el tenant. El trait asigna el id antes del INSERT.
- Con id autoincremental el id no existe al crear. El paquete lanza
MissingTenantExceptiony no guarda nada. Crea el tenant sin esa columna y asígnala después:
$empresa = Empresa::create(['nombre' => 'Acme']);
$empresa->update(['token' => 'secreto']);
- En PostgreSQL con
foreign_key = true, la DEK de un tenant que cifra sus columnas nace antes que su fila. La FK esDEFERRABLE INITIALLY IMMEDIATEy el trait difiere el chequeo al COMMIT conSET CONSTRAINTSdurante el alta. Si tu modelo sobrescribeperformInsert(), llama al del trait. Si el alta ya está dentro de una transacción tuya, la FK queda diferida el resto de esa transacción.
Cuándo se cifra
El cifrado ocurre en los eventos creating y updating, no al asignar el atributo. fill() y las factorías asignan columnas antes que la columna del tenant, y en creating el modelo ya está completo.
Estas escrituras no disparan los eventos y dejan el valor en claro:
saveQuietly(),withoutEvents().Model::query()->update([...])yDB::table(...)->update([...]).insert()masivo.
El cast detecta esa fila al leerla y lanza UnencryptedColumnException. Ejecuta kms:encrypt-existing para repararla. Una escritura que nadie vuelve a leer no da alarma.
Sobres asignados a mano
Un sobre kms1:... o un payload eyJpdiI6... que asignas en memoria se trata como texto en claro y se cifra de nuevo. Solo cuenta como ya cifrado lo que viene de la base. Por eso fill($otro->getAttributes()) doble-cifra los valores. Asigna los valores descifrados. replicate() ya lo hace por ti.
Un texto que solo se parece a un sobre, como kms1:5:1:x:y asignado por un usuario, queda cifrado como texto y se lee igual que lo escribió.
Respaldo del cast encrypted de Laravel
Con legacy_laravel_encrypted = true, el cast lee los payloads del cast encrypted de Laravel (eyJpdiI6...) que vienen de la base. Al guardar el modelo, los convierte al sobre nuevo. kms:encrypt-existing convierte el resto. Apaga la opción cuando ya no queden payloads viejos.
Búsqueda con índices ciegos
Un índice ciego guarda una huella HMAC del valor normalizado en una columna aparte. La consulta calcula la huella del valor buscado y compara huellas. La base nunca ve el valor.
Hay dos tipos:
column: la huella va en una columna de la misma tabla. Busca por igualdad.tokens: una huella por palabra, en la tablakms_blind_tokens. Busca filas que tengan todas las palabras del texto.
1. Configurar
Cada columna indexada tiene que estar también en columns:
'columns' => [
App\Models\Contacto::class => ['rut' => 'string', 'email' => 'string', 'nombre' => 'string'],
],
'blind_indexes' => [
App\Models\Contacto::class => [
'rut' => [
'rut_idx' => ['transforms' => ['rut']], // type: column por defecto
],
'email' => [
'email_idx' => ['transforms' => ['email']],
],
'nombre' => [
'nombre_palabras' => ['transforms' => ['words'], 'type' => 'tokens'],
],
],
],
El paquete valida esta configuración al arrancar y lanza InvalidConfigurationException si una columna indexada no está en columns, si dos índices del modelo comparten nombre, o si un tipo o una transformación no existe. Los nombres de índice usan minúsculas, dígitos y guion bajo.
2. Crear la columna de cada índice column
La migración de tu app agrega la columna con BlindIndexColumn. Mide 40 caracteres, admite null y lleva un índice normal:
use Asdrubalp9\KmsEncryption\Support\Schema\BlindIndexColumn;
Schema::table('contactos', function (Blueprint $table) {
BlindIndexColumn::add($table, 'rut_idx');
BlindIndexColumn::add($table, 'email_idx');
});
Los índices tokens no necesitan columna: usan la tabla kms_blind_tokens. Después de migrar, ejecuta kms:reindex para indexar lo que ya existe.
3. Buscar
Contacto::whereBlindIndex('rut_idx', '12.345.678-5', $tenantId)->first();
Contacto::whereBlindIndex('rut_idx', $rut, $tenantId)->orWhereBlindIndex('email_idx', $correo, $tenantId);
Contacto::whereBlindTokens('nombre_palabras', 'maria lopez', $tenantId)->get();
- El tenant explícito manda. Sin él se usa
TenantContext::current(). Sin ninguno, lanzaMissingTenantException: nunca se busca en todos los tenants. - Un índice desconocido, o usado con el scope del otro tipo, lanza
UnknownBlindIndexException. whereBlindTokensexige que estén todas las palabras, en cualquier orden y sin importar mayúsculas ni tildes. No busca por prefijo ni por subcadena.- Un valor nulo o vacío, o un texto sin palabras, no devuelve filas. Nunca devuelve todas.
- La búsqueda considera todas las versiones vivas de la llave del tenant, así que funciona mientras
kms:reindex --rotaterecalcula. - La búsqueda no crea llaves. Un tenant sin llave de índices no encuentra nada.
4. Transformaciones
Se aplican en el orden de la lista y definen qué cuenta como igual:
| Nombre | Efecto |
|---|---|
lowercase | Minúsculas |
trim | Quita espacios al inicio y al final |
ascii | Quita tildes (Str::ascii) |
digits | Conserva solo los dígitos |
last:N | Conserva los últimos N caracteres. Útil en teléfonos: ['digits', 'last:8'] |
rut | Quita puntos, guion y espacios, y deja la K en mayúscula. No valida el dígito verificador |
email | trim y lowercase |
words | Solo en tokens, al final. Minúsculas, ASCII, separa por todo carácter no alfanumérico, descarta tokens de menos de 2 caracteres y quita duplicados |
Para una transformación propia, implementa Asdrubalp9\KmsEncryption\Contracts\Transform y escribe su nombre de clase en la lista. Debe ser pura y determinista: si cambia, las huellas guardadas dejan de coincidir.
5. Cómo se guardan
El trait calcula las huellas en creating y updating, antes de cifrar, solo si la columna cambió. Un valor que llega desde la base sin cambios no se recalcula. Un valor que pasa a null deja la huella en null. Las huellas de palabras se escriben en saved. Con índices de palabras, el trait envuelve save() en una transacción: si falla un token, no queda la fila. Si tu modelo sobrescribe save(), llama al del trait.
Un modelo con SoftDeletes conserva sus tokens hasta el borrado forzado. Sin SoftDeletes, delete() los borra.
saveQuietly(), el query builder y los insert() masivos no actualizan huellas ni tokens, igual que no cifran. kms:reindex los repara.
6. Llaves y rotación
La llave raíz de los índices es distinta de la DEK. Vive en kms_index_keys, envuelta por el mismo KMS, con una versión activa por tenant. Cada índice usa una llave derivada con HKDF de la raíz, la tabla, la columna y el nombre del índice.
- Rotar la DEK (
kms:rotate) no toca las huellas ni los tokens. - La llave de índices rota solo con
kms:reindex --rotate.
La huella es <versión>:<32 hex>: un HMAC-SHA256 truncado a 16 bytes.
7. Lo que filtra
Un índice ciego revela a quien lee la base:
- Igualdad dentro de un tenant. Dos filas del mismo tenant con el mismo valor normalizado tienen la misma huella. Se ve qué filas comparten valor, sin saber cuál es.
- Frecuencia. Se ve cuántas filas comparten cada huella.
- Con un índice de palabras: cuántas palabras distintas tiene cada fila, y qué filas comparten una palabra.
No revela nada entre tenants: las llaves son distintas. Tampoco permite probar valores a quien tiene solo la base, porque la llave raíz está envuelta por Cloud KMS. Quien tiene la base y acceso al KMS puede calcular huellas de cualquier valor que pruebe. Con un valor de pocos posibles (un RUT) un atacante así recorre todos. Indexa solo lo que necesitas buscar.
Bitácora de actividad
El paquete no depende de spatie/laravel-activitylog. Si la usas, excluye las columnas cifradas. De lo contrario la bitácora guarda el valor en claro:
use Asdrubalp9\KmsEncryption\Support\EncryptedColumns;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()->logAll()->logExcept(EncryptedColumns::for(self::class));
}
Comandos
kms:rotate {tenant?} {--purge} {--force}
Sin argumentos rota todos los tenants de tenant_model. Con {tenant} rota uno, identificado por tenant_route_key o por la clave primaria.
Para cada tenant, dentro de TenantContext::runAs():
- En una transacción, retira la versión activa N y crea la N+1. Desde ese instante las escrituras usan la N+1.
- Reescribe las filas que todavía llevan una versión retirada. Incluye las filas con borrado lógico.
- Emite
DataKeyRotated.
El comando es reanudable. La versión vive en cada fila, y cada corrida reescribe todas las versiones retiradas. La rotación conserva updated_at.
--purge borra las DEK retiradas que ya no tienen filas pendientes y cuyo purgeable_after ya pasó. --force ignora el plazo y lo advierte. Ni con --force se purga una versión con filas pendientes.
La purga es irreversible. Borrar una DEK deja ilegibles los respaldos de la base anteriores a la rotación. El plazo (purge_guard_days, 90 por defecto) debe cubrir el tiempo que conservas los respaldos.
Rotar la llave maestra de Cloud KMS no requiere rotar las DEK. Cloud KMS descifra con las versiones anteriores de la llave.
kms:encrypt-existing {--tenant=} {--dry-run}
Cifra los valores no nulos que no tienen la forma de un sobre. Convierte también los payloads del cast encrypted de Laravel. Es idempotente y reanudable.
--dry-runcuenta y no escribe.--tenant=IDfiltra portenant_column, o por la clave primaria cuando el modelo es el tenant. Corre dentro deTenantContext::runAs(). Omite con aviso los modelos que no se pueden acotar.- Sin
--tenant, una fila sin tenant resoluble detiene el comando y queda como estaba. - Una app con RLS por tenant debe correr el comando una vez por tenant con
--tenant.
El comando guarda cada fila con save() para disparar el cifrado. Si usas una bitácora, desactívala mientras corre. El paquete no lo hace por ti.
kms:reindex {tenant?} {--model=} {--rotate}
Rellena las huellas y los tokens que faltan o que están en una versión retirada de la llave de índices. Sin argumentos recorre todos los tenants. --model= limita el trabajo a un modelo.
Con --rotate, retira la llave de índices activa, crea la siguiente y recalcula todo. Por cada tenant, dentro de TenantContext::runAs():
- Con
--rotate, crea la versión N+1 y retira la N. Sin llave, crea la 1. - Recorre los modelos con
chunkById()e incluye las filas con borrado lógico. Recalcula las filas cuya columna cifrada pertenece al tenant y tiene un índice pendiente. - Escribe sin eventos del modelo, no cambia el valor cifrado y conserva
updated_at. - Borra las versiones retiradas de la llave sin huellas ni tokens pendientes.
Es reanudable: la versión vive en cada huella. La búsqueda funciona mientras corre, porque usa todas las versiones vivas.
No hay plazo de guarda como en kms:rotate --purge. Una huella no descifra nada, y un respaldo con huellas de una versión purgada solo pierde la búsqueda hasta el siguiente kms:reindex.
Una columna con valor cuyo texto transformado queda vacío (un teléfono sin dígitos) tiene huella nula y se cuenta como pendiente en cada corrida. Es inofensivo.
kms:encrypt-existing ya calcula las huellas de lo que cifra. Lo que estaba cifrado antes de agregar el índice lo rellena kms:reindex.
Eventos
| Evento | Cuándo | Datos |
|---|---|---|
KmsDegraded | El KMS cayó y una lectura usó la DEK de respaldo. Una vez por degraded_notice_window. | tenant, versión |
DataKeyCreated | Se creó una DEK. | tenant, versión |
DataKeyRotated | Se rotó la DEK de un tenant. | tenant, versión, versión anterior, valores reescritos |
DataKeyPurged | Se borró una DEK retirada. | tenant, versión, si fue forzado |
IndexKeyCreated | Se creó una llave raíz de índices. | tenant, versión |
IndexKeyRotated | kms:reindex --rotate rotó la llave de índices. | tenant, versión, versión anterior, valores reindexados |
Ningún evento lleva la DEK. Conecta KmsDegraded al canal de avisos de tu app, preferiblemente con un listener ShouldQueue. Un listener síncrono que falle no interrumpe la lectura. El resolutor entrega su excepción a report().
Errores
Todas las excepciones implementan Asdrubalp9\KmsEncryption\Exceptions\KmsEncryptionException y exponen errorCode(). Ningún mensaje incluye el valor en claro ni la DEK.
| Excepción | Código | Cuándo |
|---|---|---|
KmsUnavailableException | kms_unavailable | El KMS falla y no hay respaldo |
MissingTenantException | kms_missing_tenant | No se resuelve tenant al escribir |
UnencryptedColumnException | kms_unencrypted_column | La base devuelve en claro una columna del mapa |
InvalidEnvelopeException | kms_invalid_envelope | Sobre mal formado, que no autentica o de otro tenant |
InvalidKeyException | kms_invalid_key | DEK de largo incorrecto, id de tenant inválido o versión inexistente |
InvalidColumnTypeException | kms_invalid_column_type | Tipo desconocido, columna con el cast fuera del mapa o valor que no convierte |
InvalidConfigurationException | kms_invalid_configuration | Fallan las guardas de arranque, incluida la validación de blind_indexes |
UnknownBlindIndexException | kms_unknown_blind_index | El índice no existe en blind_indexes o es de otro tipo |
InvalidTransformException | kms_invalid_transform | Una transformación no existe o su argumento es inválido |
Guardas de arranque
El service provider lanza InvalidConfigurationException al arrancar si:
cache.storeno está encache.allowed_stores.driver = gcpygcp.key_fileestá vacío o no se puede leer, ogcp.key_nameestá vacío.driver = fakefuera de los entornoslocalytesting.drivertiene un valor desconocido.format_prefixokey_typetienen un valor inválido.
Modos de fallo
| Qué tiene que ser cierto | Qué pasa si no lo es |
|---|---|
La cuenta de servicio tiene roles/cloudkms.cryptoKeyEncrypterDecrypter | Toda lectura y escritura cifrada sin caché falla con KmsUnavailableException |
APCu está instalado en producción, con apc.enable_cli=1 para workers | El arranque falla con el almacén inválido en el mensaje, o cada lectura llama al KMS |
El modelo trae tenant_column o es el tenant | MissingTenantException, nunca un guardado en claro |
| La DEK retirada sobrevive al plazo de respaldos | Un respaldo dentro del plazo queda ilegible en las columnas cifradas |
La app migró la columna de cada índice column | El guardado falla por columna inexistente. AssertsEncryptedColumns lo detecta en la suite |
Se ejecutó la migración de kms_index_keys (y la de kms_blind_tokens con índices de palabras) | La primera escritura de una columna indexada falla |
Nadie escribe con saveQuietly() ni con el query builder | Huellas y tokens desactualizados: la búsqueda no encuentra esa fila hasta ejecutar kms:reindex |
| La app excluye las columnas cifradas de la bitácora | La bitácora guarda el valor en claro |
Nadie escribe las columnas con saveQuietly() ni con el query builder | El valor queda en claro hasta que alguien lo lee y obtiene UnencryptedColumnException |
| Borrar un tenant significa perder sus datos | Con foreign_key = true, borrar el tenant borra sus DEK y deja ilegibles sus datos cifrados, también en los respaldos |
Si el KMS cae, una lectura usa la DEK de la caché de respaldo (cache.fallback_ttl, 24 horas por defecto) y emite KmsDegraded. Sin DEK en la caché, la lectura lanza KmsUnavailableException. Una escritura de un tenant sin DEK en caché también falla.
Límites conocidos
- El AAD no incluye la fila. El sobre queda atado a tabla, columna y tenant. Copiar un valor a otra columna, tabla o tenant no autentica. Copiarlo a otra fila del mismo tenant y la misma columna sí autentica. Es la misma limitación de gimVirtual, y hace falta para abrir sus sobres.
- Búsqueda solo por igualdad y por palabras. No hay prefijo, rango, subcadena ni similitud, y
WHEREoILIKEsobre la columna cifrada no funcionan. Ver "Lo que filtra" en la sección de índices ciegos. - Una búsqueda por palabras arma una subconsulta por palabra. El paquete no limita cuántas palabras aceptas. Limítalas en tu aplicación.
- Un select parcial sin la columna del tenant abre el sobre con su propio tenant, sin compararlo con la fila.
- Fuera de alcance: índices sobre varias columnas a la vez, otros proveedores de KMS, reenvolver las DEK al rotar la llave maestra, cifrar el payload de las colas.
Pruebas en tu aplicación
El trait AssertsEncryptedColumns comprueba que el mapa y los modelos digan lo mismo:
use Asdrubalp9\KmsEncryption\Testing\AssertsEncryptedColumns;
class CifradoTest extends TestCase
{
use AssertsEncryptedColumns;
public function test_el_mapa_de_columnas_es_coherente(): void
{
$this->assertEncryptedColumnsAreCoherent();
}
}
Comprueba cuatro cosas. Cada modelo del mapa existe y usa HasEncryptedColumns. Cada columna del mapa tiene el cast Encrypted. Cada atributo con ese cast está en el mapa. Cada tipo del mapa es uno de los siete. Pasa [Modelo::class] para revisar también modelos que no están en el mapa.
Con driver = fake el paquete usa FakeKms, una envoltura reversible que no sale a la red. FakeKms::failNextCall() simula una caída.
Adopción desde gimVirtual
El paquete abre los sobres de gimVirtual sin recifrar. Configura:
'format_prefix' => 'gv1',
'key_type' => 'uuid',
'table' => 'organization_data_keys',
'tenant_column' => 'organization_id',
La tabla de llaves de gimVirtual usa estado con los valores activa y retirada. El paquete usa status con active y retired. La adopción necesita una migración que renombre la columna y los valores. Esa migración pertenece al ciclo de adopción de gimVirtual.
Desarrollo
composer install
vendor/bin/phpunit
vendor/bin/pint --test
Para correr la suite contra PostgreSQL, define KMS_TEST_DB_DRIVER=pgsql, KMS_TEST_DB_HOST, KMS_TEST_DB_DATABASE, KMS_TEST_DB_USERNAME y KMS_TEST_DB_PASSWORD (y KMS_TEST_DB_PORT si no es 5432). Cada prueba parte de una base vacía: usa una base desechable. El CI corre ambos motores.
tests/Integration/KmsRealTest.php llama a Google Cloud KMS de verdad. Se salta si faltan KMS_ENCRYPTION_GCP_KEY_FILE y KMS_ENCRYPTION_GCP_KEY_NAME.
Las decisiones de implementación están en docs/superpowers/decisiones-de-implementacion.md. El diseño está en docs/superpowers/specs/.
Licencia
MIT.