-
Notifications
You must be signed in to change notification settings - Fork 11.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[11.x] Adds command to re-encrypt table columns
- Loading branch information
1 parent
7f84e6e
commit f0a90f2
Showing
3 changed files
with
373 additions
and
0 deletions.
There are no files selected for viewing
187 changes: 187 additions & 0 deletions
187
src/Illuminate/Foundation/Console/CryptRefreshCommand.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
]); | ||
} | ||
} |