Skip to content

Commit

Permalink
Implement forget() builder for user data anonymization and/or deletion
Browse files Browse the repository at this point in the history
  • Loading branch information
foglcz authored and OndraM committed Jun 1, 2018
1 parent 082a520 commit a1cd6f3
Show file tree
Hide file tree
Showing 9 changed files with 357 additions and 0 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<!-- There is always Unreleased section on the top. Subsections (Added, Changed, Fixed, Removed) should be added as needed. -->

## Unreleased
### Added
- `UserForget` command to anonymize or completely delete all data belonging to specific user(s) (`$matej->request()->forget()`).

### Changed
- Declare missing direct dependencies (to `php-http/message`, `php-http/promise` and `psr/http-message`) in composer.json.

Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,42 @@ $response = $matej->request()->recommendation($recommendation)->send();

Model names will be provided to you by LMC.

## Forgetting user data (GDPR)
Matej can "forget" user data, either by anonymizing or by deleting them. The right to erasure ("right to be forgotten") is part of
[General Data Protection Regulation in the European Union](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679#d1e2606-1-1)
and can be implemented on your end using the `forget()` builder.

There are two ways how to remove user data, but both of them aren't reversible and you will not be able to identify
the user ever again:

* Preferred way is to `anonymize` the user, which will randomly generate unique identifiers for all personal data,
and change that identifier across all databases and logfiles. This way the users behaviour will stay in Matej database,
and therefore **will continue to contribute to the recommendation model**, but you won't be able to identify the user.
Thus his profile will be effectively frozen (as no new interactions can come in.) **New user id is generated server-side**,
so there is no going back after issuing the request.
* An alternate way is to `delete` the user, which will wipe their data from all databases in accordance
with the Data Protection laws. This may affect the quality of recommendations, as the users behavior will be completely
removed from all databases, and therefore their profile will not contribute to the recommendation model anymore.

Usually, though, the user will identify whether they want their data anonymized or deleted, and you have to adhere to their request.

To call the endpoint, use the `forget()` builder and append the users:

```php
$matej = new Matej('accountId', 'apikey');

$matej->request()
->forget()
->addUser(UserForget::anonymize('anonymize-this-user-id'))
->addUser(UserForget::anonymize('delete-this-user-id'))
->addUsers([
UserForget::anonymize('anonymize-this-user-id-as-well'),
UserForget::delete('delete-this-user-id-as-well'),
])
->send()
;
```

### Exceptions and error handling

Exceptions are thrown only if the whole Request to Matej failed (when sending, decoding, authenticating etc.) or if
Expand Down
80 changes: 80 additions & 0 deletions src/Model/Command/UserForget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\Model\Command;

use Lmc\Matej\Model\Assertion;

/**
* UserForget any user in Matej, either by anonymizing or by deleting their entries.
* Anonymization and deletion is done server-side, and is GDPR-compliant. When anonymizing the data, new user-id is
* generated server-side and client library won't ever know it.
*/
class UserForget extends AbstractCommand implements UserAwareInterface
{
public const ANONYMIZE = 'anonymize';
public const DELETE = 'delete';

/** @var string */
private $userId;
/** @var string */
private $method;

private function __construct(string $userId, string $method)
{
$this->setUserId($userId);
$this->setForgetMethod($method);
}

/**
* Anonymize all user data in Matej.
*/
public static function anonymize(string $userId): self
{
return new static($userId, self::ANONYMIZE);
}

/**
* Completely wipe all user data from Matej.
*/
public static function delete(string $userId): self
{
return new static($userId, self::DELETE);
}

public function getUserId(): string
{
return $this->userId;
}

public function getForgetMethod(): string
{
return $this->method;
}

protected function setUserId(string $userId): void
{
Assertion::typeIdentifier($userId);

$this->userId = $userId;
}

protected function setForgetMethod(string $method): void
{
Assertion::choice($method, [self::ANONYMIZE, self::DELETE]);

$this->method = $method;
}

protected function getCommandType(): string
{
return 'user-forget';
}

protected function getCommandParameters(): array
{
return [
'user_id' => $this->userId,
'method' => $this->method,
];
}
}
50 changes: 50 additions & 0 deletions src/RequestBuilder/ForgetRequestBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\RequestBuilder;

use Fig\Http\Message\RequestMethodInterface;
use Lmc\Matej\Exception\LogicException;
use Lmc\Matej\Model\Assertion;
use Lmc\Matej\Model\Command\UserForget;
use Lmc\Matej\Model\Request;

class ForgetRequestBuilder extends AbstractRequestBuilder
{
protected const ENDPOINT_PATH = '/forget';

/** @var UserForget[] */
protected $users = [];

/** @return $this */
public function addUser(UserForget $user): self
{
$this->users[] = $user;

return $this;
}

/**
* @param UserForget[] $users
* @return $this
*/
public function addUsers(array $users): self
{
foreach ($users as $user) {
$this->addUser($user);
}

return $this;
}

public function build(): Request
{
if (empty($this->users)) {
throw new LogicException(
'At least one UserForget command must be added to the builder before sending the request'
);
}
Assertion::batchSize($this->users);

return new Request(static::ENDPOINT_PATH, RequestMethodInterface::METHOD_POST, $this->users, $this->requestId);
}
}
5 changes: 5 additions & 0 deletions src/RequestBuilder/RequestBuilderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public function recommendation(UserRecommendation $recommendation): Recommendati
return $this->createConfiguredBuilder(RecommendationRequestBuilder::class, $recommendation);
}

public function forget(): ForgetRequestBuilder
{
return $this->createConfiguredBuilder(ForgetRequestBuilder::class);
}

public function resetDatabase(): ResetDatabaseRequestBuilder
{
return $this->createConfiguredBuilder(ResetDatabaseRequestBuilder::class);
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/RequestBuilder/ForgetRequestBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\IntegrationTests\RequestBuilder;

use Lmc\Matej\IntegrationTests\IntegrationTestCase;
use Lmc\Matej\Model\Command\UserForget;

/**
* @covers \Lmc\Matej\RequestBuilder\ForgetRequestBuilder
*/
class ForgetRequestBuilderTest extends IntegrationTestCase
{
/** @test */
public function shouldExecuteForgetRequest(): void
{
$response = static::createMatejInstance()
->request()
->forget()
->addUser(UserForget::delete('user-a'))
->addUser(UserForget::anonymize('user-b'))
->addUsers([
UserForget::delete('user-c'),
UserForget::anonymize('user-d'),
])
->send();

$this->assertResponseCommandStatuses($response, ...$this->generateOkStatuses(4));
}
}
42 changes: 42 additions & 0 deletions tests/unit/Model/Command/UserForgetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\Model\Command;

use Lmc\Matej\UnitTestCase;

class UserForgetTest extends UnitTestCase
{
/** @test */
public function shouldBeInstantiableViaNamedConstructor(): void
{
$userId = 'user-id';

$command = UserForget::anonymize($userId);
$this->assertForgetCommand($command, $userId, UserForget::ANONYMIZE);

$command = UserForget::delete($userId);
$this->assertForgetCommand($command, $userId, UserForget::DELETE);
}

/**
* Execute asserts against UserForget command
*
* @param UserForget $command
*/
private function assertForgetCommand($command, string $userId, string $method): void
{
$this->assertInstanceOf(UserForget::class, $command);
$this->assertSame(
[
'type' => 'user-forget',
'parameters' => [
'user_id' => $userId,
'method' => $method,
],
],
$command->jsonSerialize()
);
$this->assertSame($userId, $command->getUserId());
$this->assertSame($method, $command->getForgetMethod());
}
}
106 changes: 106 additions & 0 deletions tests/unit/RequestBuilder/ForgetRequestBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\RequestBuilder;

use Fig\Http\Message\RequestMethodInterface;
use Lmc\Matej\Exception\DomainException;
use Lmc\Matej\Exception\LogicException;
use Lmc\Matej\Http\RequestManager;
use Lmc\Matej\Model\Command\UserForget;
use Lmc\Matej\Model\Request;
use Lmc\Matej\Model\Response;
use PHPUnit\Framework\TestCase;

/**
* @covers \Lmc\Matej\RequestBuilder\ForgetRequestBuilder
* @covers \Lmc\Matej\RequestBuilder\AbstractRequestBuilder
*/
class ForgetRequestBuilderTest extends TestCase
{
/** @test */
public function shouldBuildRequestWithCommands(): void
{
$builder = new ForgetRequestBuilder();

$anonymizeUserA = UserForget::anonymize('user-anonymize-a');
$anonymizeUserB = UserForget::anonymize('user-anonymize-b');
$deleteUserA = UserForget::delete('user-delete-a');
$deleteUserB = UserForget::delete('user-delete-b');

$builder->addUser($anonymizeUserA);
$builder->addUser($deleteUserA);

$builder->addUsers([$anonymizeUserB, $deleteUserB]);

$builder->setRequestId('custom-request-id-foo');

$request = $builder->build();

$this->assertInstanceOf(Request::class, $request);
$this->assertSame(RequestMethodInterface::METHOD_POST, $request->getMethod());
$this->assertSame('/forget', $request->getPath());

$requestData = $request->getData();
$this->assertCount(4, $requestData);
$this->assertContains($anonymizeUserA, $requestData);
$this->assertContains($anonymizeUserB, $requestData);
$this->assertContains($deleteUserA, $requestData);
$this->assertContains($deleteUserB, $requestData);

$this->assertSame('custom-request-id-foo', $request->getRequestId());
}

/** @test */
public function shouldThrowExceptionWhenBuildingEmptyCommands(): void
{
$builder = new ForgetRequestBuilder();

$this->expectException(LogicException::class);
$this->expectExceptionMessage('At least one UserForget command must be added to the builder before sending the request');
$builder->build();
}

/** @test */
public function shouldThrowExceptionWhenBatchSizeIsTooBig(): void
{
$builder = new ForgetRequestBuilder();

for ($i = 0; $i < 501; $i++) {
$builder->addUser(UserForget::delete('userid-delete-' . $i));
$builder->addUser(UserForget::anonymize('userid-anonymize-' . $i));
}

$this->expectException(DomainException::class);
$this->expectExceptionMessage('Request contains 1002 commands, but at most 1000 is allowed in one request.');
$builder->build();
}

/** @test */
public function shouldThrowExceptionWhenSendingCommandsWithoutRequestManager(): void
{
$builder = new ForgetRequestBuilder();

$builder->addUser(UserForget::delete('user-delete-a'));

$this->expectException(LogicException::class);
$this->expectExceptionMessage('Instance of RequestManager must be set to request builder');
$builder->send();
}

/** @test */
public function shouldSendRequestViaRequestManager(): void
{
$requestManagerMock = $this->createMock(RequestManager::class);
$requestManagerMock->expects($this->once())
->method('sendRequest')
->with($this->isInstanceOf(Request::class))
->willReturn(new Response(0, 0, 0, 0));

$builder = new ForgetRequestBuilder();
$builder->setRequestManager($requestManagerMock);

$builder->addUser(UserForget::delete('user-delete-a'));

$builder->send();
}
}
Loading

0 comments on commit a1cd6f3

Please sign in to comment.