Search by

asdrubalp9 / laravel-kms-encryption

Drup9

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

Issues

pkg:composer/asdrubalp9/laravel-kms-encryption

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

v0.2.0 2026-10-08 18:27 UTC

This package is auto-updated.

Last update: 2026-10-08 21:30:00 UTC


README

Cifra columnas de Eloquent en reposo con una llave de datos (DEK) por tenant. Google Cloud KMS envuelve cada DEK.

pipeline status

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

ClaveVariable de entornoPor defecto
driverKMS_ENCRYPTION_DRIVERfake
gcp.key_nameKMS_ENCRYPTION_GCP_KEY_NAMEnull
gcp.key_fileKMS_ENCRYPTION_GCP_KEY_FILEnull
gcp.timeoutKMS_ENCRYPTION_GCP_TIMEOUT5
format_prefixKMS_ENCRYPTION_FORMAT_PREFIXkms1
key_typeKMS_ENCRYPTION_KEY_TYPEint
tenant_modelnull
tenant_columntenant_id
tenant_route_keynull (usa la PK del tenant)
tablekms_data_keys
foreign_keytrue
index_tablekms_index_keys
tokens_tablekms_blind_tokens
morph_key_typeKMS_ENCRYPTION_MORPH_KEY_TYPEint
cache.storeKMS_ENCRYPTION_CACHE_STOREarray
cache.ttlKMS_ENCRYPTION_CACHE_TTL300
cache.fallback_ttlKMS_ENCRYPTION_CACHE_FALLBACK_TTL86400
cache.allowed_stores['apc', 'array', 'octane']
degraded_notice_window900
purge_guard_daysKMS_ENCRYPTION_PURGE_GUARD_DAYS90
legacy_laravel_encryptedtrue
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.

TipoSe guarda comoSe lee como
stringtextostring
arrayJSONarray
dateY-m-dCarbonImmutable
datetimeISO 8601 con microsegundos y desplazamientoCarbonImmutable
intentero en textoint
floatJSON, sin pérdida de dígitosfloat
bool1 o 0bool

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:

  1. La clave primaria, si el modelo es instancia de tenant_model.
  2. El atributo tenant_column del modelo, si no está vacío.
  3. TenantContext::current().
  4. 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 MissingTenantException y 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 es DEFERRABLE INITIALLY IMMEDIATE y el trait difiere el chequeo al COMMIT con SET CONSTRAINTS durante el alta. Si tu modelo sobrescribe performInsert(), 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([...]) y DB::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 tabla kms_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, lanza MissingTenantException: nunca se busca en todos los tenants.
  • Un índice desconocido, o usado con el scope del otro tipo, lanza UnknownBlindIndexException.
  • whereBlindTokens exige 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 --rotate recalcula.
  • 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:

NombreEfecto
lowercaseMinúsculas
trimQuita espacios al inicio y al final
asciiQuita tildes (Str::ascii)
digitsConserva solo los dígitos
last:NConserva los últimos N caracteres. Útil en teléfonos: ['digits', 'last:8']
rutQuita puntos, guion y espacios, y deja la K en mayúscula. No valida el dígito verificador
emailtrim y lowercase
wordsSolo 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:

  1. 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.
  2. Frecuencia. Se ve cuántas filas comparten cada huella.
  3. 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():

  1. En una transacción, retira la versión activa N y crea la N+1. Desde ese instante las escrituras usan la N+1.
  2. Reescribe las filas que todavía llevan una versión retirada. Incluye las filas con borrado lógico.
  3. 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-run cuenta y no escribe.
  • --tenant=ID filtra por tenant_column, o por la clave primaria cuando el modelo es el tenant. Corre dentro de TenantContext::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():

  1. Con --rotate, crea la versión N+1 y retira la N. Sin llave, crea la 1.
  2. 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.
  3. Escribe sin eventos del modelo, no cambia el valor cifrado y conserva updated_at.
  4. 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

EventoCuándoDatos
KmsDegradedEl KMS cayó y una lectura usó la DEK de respaldo. Una vez por degraded_notice_window.tenant, versión
DataKeyCreatedSe creó una DEK.tenant, versión
DataKeyRotatedSe rotó la DEK de un tenant.tenant, versión, versión anterior, valores reescritos
DataKeyPurgedSe borró una DEK retirada.tenant, versión, si fue forzado
IndexKeyCreatedSe creó una llave raíz de índices.tenant, versión
IndexKeyRotatedkms: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ónCódigoCuándo
KmsUnavailableExceptionkms_unavailableEl KMS falla y no hay respaldo
MissingTenantExceptionkms_missing_tenantNo se resuelve tenant al escribir
UnencryptedColumnExceptionkms_unencrypted_columnLa base devuelve en claro una columna del mapa
InvalidEnvelopeExceptionkms_invalid_envelopeSobre mal formado, que no autentica o de otro tenant
InvalidKeyExceptionkms_invalid_keyDEK de largo incorrecto, id de tenant inválido o versión inexistente
InvalidColumnTypeExceptionkms_invalid_column_typeTipo desconocido, columna con el cast fuera del mapa o valor que no convierte
InvalidConfigurationExceptionkms_invalid_configurationFallan las guardas de arranque, incluida la validación de blind_indexes
UnknownBlindIndexExceptionkms_unknown_blind_indexEl índice no existe en blind_indexes o es de otro tipo
InvalidTransformExceptionkms_invalid_transformUna transformación no existe o su argumento es inválido

Guardas de arranque

El service provider lanza InvalidConfigurationException al arrancar si:

  1. cache.store no está en cache.allowed_stores.
  2. driver = gcp y gcp.key_file está vacío o no se puede leer, o gcp.key_name está vacío.
  3. driver = fake fuera de los entornos local y testing.
  4. driver tiene un valor desconocido.
  5. format_prefix o key_type tienen un valor inválido.

Modos de fallo

Qué tiene que ser ciertoQué pasa si no lo es
La cuenta de servicio tiene roles/cloudkms.cryptoKeyEncrypterDecrypterToda lectura y escritura cifrada sin caché falla con KmsUnavailableException
APCu está instalado en producción, con apc.enable_cli=1 para workersEl 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 tenantMissingTenantException, nunca un guardado en claro
La DEK retirada sobrevive al plazo de respaldosUn respaldo dentro del plazo queda ilegible en las columnas cifradas
La app migró la columna de cada índice columnEl 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 builderHuellas y tokens desactualizados: la búsqueda no encuentra esa fila hasta ejecutar kms:reindex
La app excluye las columnas cifradas de la bitácoraLa bitácora guarda el valor en claro
Nadie escribe las columnas con saveQuietly() ni con el query builderEl valor queda en claro hasta que alguien lo lee y obtiene UnencryptedColumnException
Borrar un tenant significa perder sus datosCon 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 WHERE o ILIKE sobre 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.