[3.0] Attributes for change logging (#5763)
* remove Entity directory from excludes, so autoregistration works * do not register services for interfaces * move tax class to invoice namespace * exclude a few classes from service container * flag SensitiveParameter to prevent logging * flag SensitiveProperty to prevent logging * flag Loggable entities for auditing
This commit is contained in:
@@ -29,7 +29,6 @@ services:
|
||||
- '../src/API/Model/'
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Doctrine/Extensions/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Event/'
|
||||
- '../src/Export/Package/'
|
||||
- '../src/Export/Base/'
|
||||
|
||||
@@ -9,13 +9,22 @@
|
||||
|
||||
namespace App\Audit;
|
||||
|
||||
/**
|
||||
* For entity classes, whose changes could be logged.
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class Loggable
|
||||
{
|
||||
/**
|
||||
* @param class-string|null $customFieldClass
|
||||
* @param string[] $ignoredProperties
|
||||
*/
|
||||
public function __construct(public ?string $customFieldClass = null)
|
||||
public function __construct(
|
||||
public ?string $customFieldClass = null,
|
||||
public array $ignoredProperties = [],
|
||||
public ?string $title = null,
|
||||
public string $translationDomain = 'messages',
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
namespace App\Audit;
|
||||
|
||||
/**
|
||||
* Marks a property as sensitive, so it will not be logged.
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class Versioned
|
||||
final class SensitiveProperty
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Doctrine\Behavior;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
interface ModifiedAt
|
||||
{
|
||||
public function getModifiedAt(): ?\DateTimeImmutable;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\SensitiveProperty;
|
||||
use App\Repository\AccessTokenRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -33,6 +34,7 @@ class AccessToken
|
||||
#[ORM\Column(name: 'token', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 2, max: 100)]
|
||||
#[SensitiveProperty]
|
||||
private string $token;
|
||||
#[ORM\Column(name: 'name', type: Types::STRING, length: 50, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
@@ -43,7 +45,7 @@ class AccessToken
|
||||
#[ORM\Column(name: 'expires_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $expiresAt = null;
|
||||
|
||||
public function __construct(User $user, string $token)
|
||||
public function __construct(User $user, #[\SensitiveParameter] string $token)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->token = $token;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
@@ -34,6 +35,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Order(['id', 'name', 'project', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
|
||||
#[Exporter\Expose(name: 'project', label: 'project', exp: 'object.getProject() === null ? null : object.getProject().getName()')]
|
||||
#[Constraints\Activity]
|
||||
#[Loggable(title: 'activity')]
|
||||
class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
use BudgetTrait;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Repository\ConfigurationRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -20,6 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
#[Loggable(title: 'menu.system_configuration')]
|
||||
class Configuration
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
@@ -29,6 +30,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'addressLine1', 'addressLine2', 'addressLine3', 'postCode', 'city', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
#[Constraints\Customer]
|
||||
#[Loggable(title: 'customer')]
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
public const DEFAULT_CURRENCY = 'EUR';
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
interface ExportableItem
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Repository\InvoiceRepository;
|
||||
@@ -35,6 +36,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Expose(name: 'dueDate', label: 'invoice.due_days', type: 'datetime', exp: 'object.getDueDate() === null ? null : object.getDueDate()')]
|
||||
#[Exporter\Expose(name: 'user', label: 'username', type: 'string', exp: 'object.getUser() === null ? null : object.getUser().getDisplayName()')]
|
||||
#[Exporter\Expose(name: 'paymentDate', label: 'invoice.payment_date', type: 'date', exp: 'object.getPaymentDate() === null ? null : object.getPaymentDate()')]
|
||||
#[Loggable(title: 'invoices')]
|
||||
class Invoice implements EntityWithMetaFields
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Invoice\Tax;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
@@ -33,6 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
|
||||
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
|
||||
#[Constraints\Project]
|
||||
#[Loggable(title: 'project')]
|
||||
class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
|
||||
{
|
||||
use BudgetTrait;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Repository\TagRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -23,6 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['visible'])]
|
||||
#[UniqueEntity('name')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Loggable(title: 'tag')]
|
||||
class Tag
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Doctrine\Behavior\ModifiedAt;
|
||||
use App\Doctrine\Behavior\ModifiedTrait;
|
||||
use App\Repository\TimesheetRepository;
|
||||
@@ -48,6 +49,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\VirtualProperty('UserAsId', exp: 'object.getUser().getId()', options: [new Serializer\SerializedName('user'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Not_Expanded'])])]
|
||||
#[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])]
|
||||
#[Constraints\TimesheetAll]
|
||||
#[Loggable(ignoredProperties: ['category', 'date'], title: 'timesheet')]
|
||||
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
{
|
||||
use ModifiedTrait;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Audit\Loggable;
|
||||
use App\Audit\SensitiveProperty;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\StringHelper;
|
||||
@@ -49,6 +51,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Exporter\Expose(name: 'roles', label: 'roles', type: 'array', exp: 'object.getRoles()')]
|
||||
#[Exporter\Expose(name: 'active', label: 'active', type: 'boolean', exp: 'object.isEnabled()')]
|
||||
#[Constraints\User(groups: ['UserCreate', 'Registration', 'Default', 'Profile'])]
|
||||
#[Loggable(ignoredProperties: ['lastLogin'], title: 'user')]
|
||||
class User implements UserInterface, EquatableInterface, ThemeUserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
|
||||
{
|
||||
public const string ROLE_USER = 'ROLE_USER';
|
||||
@@ -142,9 +145,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[Assert\Length(max: 20)]
|
||||
private ?string $auth = self::AUTH_INTERNAL;
|
||||
/**
|
||||
* This flag will be initialized in UserEnvironmentSubscriber.
|
||||
*
|
||||
* @internal has no database mapping as the value is calculated from a permission
|
||||
* @internal no database mapping: the value is calculated from a permission in UserEnvironmentSubscriber
|
||||
*/
|
||||
private ?bool $isAllowedToSeeAllData = null;
|
||||
#[ORM\Column(name: 'username', type: Types::STRING, length: 180, nullable: false)]
|
||||
@@ -175,12 +176,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* Encrypted password. Must be persisted.
|
||||
*/
|
||||
#[ORM\Column(name: 'password', type: Types::STRING, nullable: false)]
|
||||
#[SensitiveProperty]
|
||||
private ?string $password = null;
|
||||
/**
|
||||
* Plain password. Used for model validation, not persisted.
|
||||
*/
|
||||
#[Assert\NotBlank(groups: ['Registration', 'PasswordUpdate', 'UserCreate'])]
|
||||
#[Assert\Length(min: 8, max: 60, groups: ['Registration', 'PasswordUpdate', 'UserCreate', 'ResetPassword', 'ChangePassword'])]
|
||||
#[SensitiveProperty]
|
||||
private ?string $plainPassword = null;
|
||||
#[ORM\Column(name: 'last_login', type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?DateTime $lastLogin = null;
|
||||
@@ -189,6 +192,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
*/
|
||||
#[ORM\Column(name: 'confirmation_token', type: Types::STRING, length: 180, unique: true, nullable: true)]
|
||||
#[Assert\Length(max: 180)]
|
||||
#[SensitiveProperty]
|
||||
private ?string $confirmationToken = null;
|
||||
#[ORM\Column(name: 'password_requested_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $passwordRequestedAt = null;
|
||||
@@ -206,6 +210,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* TODO reduce the length, which was initially forgotten and set to 255, as this is the default for MySQL with Doctrine (see migration Version20230126002049)
|
||||
*/
|
||||
#[ORM\Column(name: 'totp_secret', type: Types::STRING, length: 255, nullable: true)]
|
||||
#[SensitiveProperty]
|
||||
private ?string $totpSecret = null;
|
||||
#[ORM\Column(name: 'totp_enabled', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
private bool $totpEnabled = false;
|
||||
@@ -901,14 +906,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setPassword(?string $password): User
|
||||
public function setPassword(#[\SensitiveParameter] ?string $password): User
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $password): User
|
||||
public function setPlainPassword(#[\SensitiveParameter] ?string $password): User
|
||||
{
|
||||
$this->plainPassword = $password;
|
||||
|
||||
@@ -922,7 +927,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setConfirmationToken(?string $confirmationToken): void
|
||||
public function setConfirmationToken(#[\SensitiveParameter] ?string $confirmationToken): void
|
||||
{
|
||||
$this->confirmationToken = $confirmationToken;
|
||||
}
|
||||
@@ -1123,7 +1128,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
// --------------- 2 Factor Authentication ---------------
|
||||
|
||||
public function setTotpSecret(?string $secret): void
|
||||
public function setTotpSecret(#[\SensitiveParameter] ?string $secret): void
|
||||
{
|
||||
$this->totpSecret = $secret;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Utils\FileHelper;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class InvoiceFilename
|
||||
{
|
||||
private string $filename;
|
||||
|
||||
@@ -13,7 +13,9 @@ use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use DateTime;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class InvoiceItem
|
||||
{
|
||||
private ?float $fixedRate = null;
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Entity\TaxType;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class Tax
|
||||
{
|
||||
public function __construct(
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Entity\Tax;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class TaxRow
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -9,25 +9,20 @@
|
||||
|
||||
namespace App\Tests\Audit;
|
||||
|
||||
use App\Audit\Versioned;
|
||||
use App\Audit\SensitiveProperty;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
|
||||
#[CoversClass(Versioned::class)]
|
||||
class VersionedTest extends TypeTestCase
|
||||
#[CoversClass(SensitiveProperty::class)]
|
||||
class SensitivePropertyTest extends TypeTestCase
|
||||
{
|
||||
public function testConstruct(): void
|
||||
public function testHasCorrectAttribute(): void
|
||||
{
|
||||
$sut = new Versioned();
|
||||
self::assertInstanceOf(Versioned::class, $sut);
|
||||
}
|
||||
|
||||
public function testHasAttributeAttributeOnLoggable(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass(Versioned::class);
|
||||
$sut = new SensitiveProperty();
|
||||
$reflection = new \ReflectionClass($sut);
|
||||
/** @var array<\ReflectionAttribute<\Attribute>> $attributes */
|
||||
$attributes = array_filter($reflection->getAttributes(), fn ($attr) => $attr->getName() === \Attribute::class);
|
||||
self::assertCount(1, $attributes, 'Versioned class should have the Attribute attribute');
|
||||
self::assertCount(1, $attributes, 'SensitiveProperty class should have the Attribute attribute');
|
||||
$attribute = $attributes[0];
|
||||
self::assertEquals(\Attribute::TARGET_PROPERTY, $attribute->getArguments()[0]);
|
||||
}
|
||||
@@ -13,8 +13,8 @@ use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\InvoiceTemplateMeta;
|
||||
use App\Entity\Tax;
|
||||
use App\Entity\TaxType;
|
||||
use App\Invoice\Tax;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
Reference in New Issue
Block a user