Skip to content

Commit

Permalink
[11.x] Adds command to re-encrypt table columns
Browse files Browse the repository at this point in the history
  • Loading branch information
DarkGhostHunter committed Mar 1, 2024
1 parent 7f84e6e commit f0a90f2
Show file tree
Hide file tree
Showing 3 changed files with 373 additions and 0 deletions.
187 changes: 187 additions & 0 deletions src/Illuminate/Foundation/Console/CryptRefreshCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

namespace Illuminate\Foundation\Console;

use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Contracts\Encryption\Encrypter as EncrypterContract;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder as SchemaContract;
use Illuminate\Support\DateFactory;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;

#[AsCommand(name: 'crypt:refresh')]
class CryptRefreshCommand extends Command
{
use ConfirmableTrait;

/**
* The console command signature.
*
* @var string
*/
protected $signature = 'crypt:refresh
{targets : The table name and columns to refresh, like "table:column,column..."}
{--connection : The database connection to use.}
{--flag-column=laravel_refreshed_at : The temporary column name to flag successfully refreshed columns. Setting it to "false" or "null" will disable it.}
{--id=id : The column ID to use for lazy chunking.}
{--chunk=1000 : The amount of items per chunk to process.}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Refreshes encrypted table columns with the current app encryption key';

/**
* Execute the console command.
*
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @return void
*/
public function handle(EncrypterContract $encrypter, SchemaContract $schema, DateFactory $date)
{
$this->confirmToProceed();

[$table, $columns, $id, $flagColumn] = $this->parseOptions();

$query = $this->createQueryFor($table);

$this->ensureFlagColumnShouldExist($schema, $table, $flagColumn);

$this->withProgressBar(
$this->getRowsLazily($query, $columns, $id, $flagColumn),
function ($row) use ($date, $encrypter, $id, $columns, $query, $flagColumn) {
$data = [];

foreach ($columns as $column) {
if (is_string($row->{$column}) && $row->{$column}) {
$data[$column] = $encrypter->encrypt($encrypter->decrypt($row->{$column}, false), false);
}
}

if ($flagColumn) {
$data[$flagColumn] = $date->now();
}

$query->clone()->where($id, $row->{$id})->update($data);
}
);

$this->removeFlagColumnIfExists($schema, $table, $flagColumn);
}

/**
* Return the flag column name.
*
* @return string|false
*/
protected function flagColumn()
{
$name = (string) $this->option('flag-column');

if (in_array(strtolower($name), ['none', 'null', 'false', '0', ''], true)) {
return false;
}

return $name;
}

/**
* Return a configuration value from the app by its key.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
protected function config($key, $default = null)
{
return $this->laravel->make('config')->get($key, $default);
}

/**
* Parse the options of the command.
*
* @return array{table: string, columns: string[], id: string, flagColumn: string|false }
*/
protected function parseOptions()
{
[$table, $columns] = array_pad(explode(':', $this->argument('targets'), 2), 2, null);

if (! $table) {
throw new InvalidArgumentException('No table name was issued.');
}

$columns = array_filter(array_map('trim', explode(',', $columns)));

if (! $columns) {
throw new InvalidArgumentException('No columns were issued.');
}

return [$table, $columns, $this->option('id'), $this->flagColumn()];
}

/**
* Create a lazy query for the target model.
*
* @param \Illuminate\Database\Query\Builder $query
* @param string[] $columns
* @param string $id
* @param string|false $flagColumn
* @return \Illuminate\Support\LazyCollection<object>
*/
protected function getRowsLazily($query, $columns, $id, $flagColumn)
{
return $query
->clone()
->select([$id, ...$columns])
->when($flagColumn)->whereNull($flagColumn)
->lazyById($this->option('chunk'), $id);
}

/**
* Create a new Query Builder instance for the given table.
*
* @param string $table
*/
protected function createQueryFor($table)
{
return $this->laravel->make('db')->connection($this->option('connection'))->table($table);
}

/**
* Ensure the target table has a column to skip successfully refreshed rows.
*
* @param \Illuminate\Database\Schema\Builder $schema
* @param string $name
* @param string $column
* @return void
*/
protected function ensureFlagColumnShouldExist($schema, $name, $column)
{
if (! $column) {
$this->warn("No flag column was issued to skip already refreshed rows.");

return;
}

$this->info("Using <fg=blue>$column</> as flag column in <fg=blue>$name</> to refresh rows.");

$schema->whenTableDoesntHaveColumn($name, $column, fn ($table) => $table->timestamp($column)->nullable());
}

/**
* Removes the Flag Column if it exists on the target table.
*
* @param \Illuminate\Database\Schema\Builder $schema
* @param string $name
* @param string $column
* @return void
*/
protected function removeFlagColumnIfExists(SchemaContract $schema, mixed $name, mixed $column)
{
$schema->whenTableHasColumn($name, $column, fn (Blueprint $builder) => $builder->dropColumn($column));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
use Illuminate\Foundation\Console\PackageDiscoverCommand;
use Illuminate\Foundation\Console\PolicyMakeCommand;
use Illuminate\Foundation\Console\ProviderMakeCommand;
use Illuminate\Foundation\Console\CryptRefreshCommand;
use Illuminate\Foundation\Console\RequestMakeCommand;
use Illuminate\Foundation\Console\ResourceMakeCommand;
use Illuminate\Foundation\Console\RouteCacheCommand;
Expand Down Expand Up @@ -189,6 +190,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid
'ConfigPublish' => ConfigPublishCommand::class,
'ConsoleMake' => ConsoleMakeCommand::class,
'ControllerMake' => ControllerMakeCommand::class,
'CryptRefresh' => CryptRefreshCommand::class,
'Docs' => DocsCommand::class,
'EnumMake' => EnumMakeCommand::class,
'EventGenerate' => EventGenerateCommand::class,
Expand Down
184 changes: 184 additions & 0 deletions tests/Integration/Console/CryptRefreshCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

namespace Illuminate\Tests\Integration\Console;

use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Schema\Blueprint;
use InvalidArgumentException;
use Orchestra\Testbench\TestCase;

class CryptRefreshCommandTest extends TestCase
{
protected $appKey = '/JEsDQCLbuXaUjd/nz/cDcsoczyLX929uYxGuwIzEYs=';
protected $oldKey = 'A/XpDmqaahaIw7mmsJSg33NMVzsb1Bnj+7MYT4KmxhI=';
protected $cipher = 'AES-256-CBC';

protected function defineEnvironment($app)
{
$app['config']['database.default'] = 'testing';
$app['config']['app.key'] = 'base64:' . $this->appKey;
$app['config']['app.previous_keys'] = ['base64:' . $this->oldKey];
$app['config']['app.cipher'] = $this->cipher;
}

protected function setUp(): void
{
$this->afterApplicationCreated(function () {
$this->app->make('db.schema')->create('test_models', function (Blueprint $table) {
$table->id();
$table->text('foo');
$table->text('bar');
$table->text('baz')->nullable();
});

$this->app->make('db')->table('test_models')->insert([
'foo' => 'eyJpdiI6ImZJQnhqVGc5QmRDL1VIOHU3R1Z0S2c9PSIsInZhbHVlIjoiNDYvRk9CWW1hOGFDaGZyMklaOWxidz09IiwibWFjIjoiZjcwMTE0MDVmY2E4MmQ4YjU3MDYwZWIzYTdhZjJlNWE5YjM2YTZjODRkOWM2MmVmZDNmNDlkZDAzMWViNmMwOCIsInRhZyI6IiJ9',
'bar' => 'bar',
]);
});

parent::setUp();
}

public function testRefreshesEncryption()
{
$this->artisan('crypt:refresh', ['targets' => 'test_models:foo,baz',])
->expectsOutput('Using laravel_refreshed_at as flag column in test_models to refresh rows.')
->assertExitCode(0);

$row = $this->app->make('db')->table('test_models')->where('id', 1)->first();

$encrypter = $this->app->make('encrypter');

$this->assertSame('foo', $encrypter->decrypt($row->foo, false));
$this->assertSame('bar', $row->bar);
$this->assertNull($row->baz);
$this->assertObjectNotHasProperty('refreshed_at', $row);

$this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at'));
}

public function testRefreshesEncryptionWithCustomFlagColumn()
{
$this->artisan('crypt:refresh', [
'targets' => 'test_models:foo,baz',
'--flag-column' => 'custom_refreshed_at'
])
->expectsOutput('Using custom_refreshed_at as flag column in test_models to refresh rows.')
->assertExitCode(0);

$row = $this->app->make('db')->table('test_models')->where('id', 1)->first();

$encrypter = $this->app->make('encrypter');

$this->assertSame('foo', $encrypter->decrypt($row->foo, false));
$this->assertSame('bar', $row->bar);
$this->assertNull($row->baz);
$this->assertObjectNotHasProperty('custom_refreshed_at', $row);

$this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'custom_refreshed_at'));
$this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at'));
}

public function testDoesntRefreshesAlreadyRefreshedRow()
{
$this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('laravel_refreshed_at')->nullable());
$this->app->make('db')->table('test_models')
->where('id', 1)
->update([
'foo' => 'untouched',
'bar' => 'untouched',
'baz' => null,
'laravel_refreshed_at' => now(),
]);

$this->artisan('crypt:refresh', ['targets' => 'test_models:foo,baz'])->assertExitCode(0);

$row = $this->app->make('db')->table('test_models')->where('id', 1)->first();

$this->assertSame('untouched', $row->foo);
$this->assertSame('untouched', $row->bar);
$this->assertNull($row->baz);
}

public function testRefreshesAllColumnsWhenFlagColumnDisabled()
{
$this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('laravel_refreshed_at')->nullable());
$this->app->make('db')->table('test_models')
->where('id', 1)
->update([
'laravel_refreshed_at' => now(),
]);

$this->artisan('crypt:refresh', [
'targets' => 'test_models:foo,baz',
'--flag-column' => null
])
->expectsOutput('No flag column was issued to skip already refreshed rows.')
->assertExitCode(0);

$row = $this->app->make('db')->table('test_models')->where('id', 1)->first();

$this->assertNotSame('untouched', $row->foo);
$this->assertNotSame('untouched', $row->bar);
$this->assertNull($row->baz);
}

public function testDoesntRefreshesAlreadyRefreshedRowWithCustomFlagColumn()
{
$this->app->make('db.schema')->table('test_models', fn($table) => $table->timestamp('custom_at')->nullable());
$this->app->make('db')->table('test_models')
->where('id', 1)
->update([
'foo' => 'untouched',
'bar' => 'untouched',
'baz' => null,
'custom_at' => now(),
]);

$this->artisan('crypt:refresh', [
'targets' => 'test_models:foo,baz',
'--flag-column' => 'custom_at'
])->assertExitCode(0);

$row = $this->app->make('db')->table('test_models')->where('id', 1)->first();

$this->assertSame('untouched', $row->foo);
$this->assertSame('untouched', $row->bar);
$this->assertNull($row->baz);
$this->assertObjectNotHasProperty('custom_at', $row);

$this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'custom_at'));
$this->assertFalse($this->app->make('db.schema')->hasColumn('test_models', 'laravel_refreshed_at'));
}

public function testFailsWhenEmptyTable()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('No table name was issued.');

$this->artisan('crypt:refresh', [
'targets' => '',
]);
}

public function testFailsWhenEmptyColumns()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('No columns were issued.');

$this->artisan('crypt:refresh', [
'targets' => 'table: ',
]);
}

public function testFailsWhenColumnIsNotEncrypted()
{
$this->expectException(DecryptException::class);
$this->expectExceptionMessage('The payload is invalid.');

$this->artisan('crypt:refresh', [
'targets' => 'test_models:bar',
]);
}
}

0 comments on commit f0a90f2

Please sign in to comment.