diff --git a/config/services.yaml b/config/services.yaml index d6876bfbf..a66c0e174 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -29,7 +29,6 @@ services: - '../src/API/Model/' - '../src/DependencyInjection/' - '../src/Doctrine/Extensions/' - - '../src/Entity/' - '../src/Event/' - '../src/Export/Package/' - '../src/Export/Base/' diff --git a/src/Audit/Loggable.php b/src/Audit/Loggable.php index 2eef39754..4d12b476e 100644 --- a/src/Audit/Loggable.php +++ b/src/Audit/Loggable.php @@ -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', + ) { } } diff --git a/src/Audit/Versioned.php b/src/Audit/SensitiveProperty.php similarity index 73% rename from src/Audit/Versioned.php rename to src/Audit/SensitiveProperty.php index b568d1440..9679b46d1 100644 --- a/src/Audit/Versioned.php +++ b/src/Audit/SensitiveProperty.php @@ -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() - { - } } diff --git a/src/Doctrine/Behavior/ModifiedAt.php b/src/Doctrine/Behavior/ModifiedAt.php index 533af9b21..6db0e6c40 100644 --- a/src/Doctrine/Behavior/ModifiedAt.php +++ b/src/Doctrine/Behavior/ModifiedAt.php @@ -9,6 +9,9 @@ namespace App\Doctrine\Behavior; +use Symfony\Component\DependencyInjection\Attribute\Exclude; + +#[Exclude] interface ModifiedAt { public function getModifiedAt(): ?\DateTimeImmutable; diff --git a/src/Entity/AccessToken.php b/src/Entity/AccessToken.php index eccdb58c5..b6b03fe2f 100644 --- a/src/Entity/AccessToken.php +++ b/src/Entity/AccessToken.php @@ -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; diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 4a227c68d..9383f2386 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -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; diff --git a/src/Entity/Configuration.php b/src/Entity/Configuration.php index 49a9dd607..1922ef006 100644 --- a/src/Entity/Configuration.php +++ b/src/Entity/Configuration.php @@ -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] diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index bb1383b50..d941515b9 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -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'; diff --git a/src/Entity/ExportableItem.php b/src/Entity/ExportableItem.php index d7b8c1781..55d98407d 100644 --- a/src/Entity/ExportableItem.php +++ b/src/Entity/ExportableItem.php @@ -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; diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 3928b258f..58bc5cfe0 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -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'; diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index fafa26685..50dfb4c2d 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -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; diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 4e2cfadbd..5a4269f00 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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; diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index d4fb408df..8fa0d0c7d 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -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 { /** diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index 4480685f8..4e37676ad 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -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'), new Serializer\Groups(['Default'])])] #[Constraints\TimesheetAll] +#[Loggable(ignoredProperties: ['category', 'date'], title: 'timesheet')] class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt { use ModifiedTrait; diff --git a/src/Entity/User.php b/src/Entity/User.php index 427aabaad..0fe854a5e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; } diff --git a/src/Invoice/InvoiceFilename.php b/src/Invoice/InvoiceFilename.php index 362dc7d8f..3108afc2c 100644 --- a/src/Invoice/InvoiceFilename.php +++ b/src/Invoice/InvoiceFilename.php @@ -10,7 +10,9 @@ namespace App\Invoice; use App\Utils\FileHelper; +use Symfony\Component\DependencyInjection\Attribute\Exclude; +#[Exclude] final class InvoiceFilename { private string $filename; diff --git a/src/Invoice/InvoiceItem.php b/src/Invoice/InvoiceItem.php index 61ba40717..0cc4581e2 100644 --- a/src/Invoice/InvoiceItem.php +++ b/src/Invoice/InvoiceItem.php @@ -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; diff --git a/src/Entity/Tax.php b/src/Invoice/Tax.php similarity index 87% rename from src/Entity/Tax.php rename to src/Invoice/Tax.php index 429c87d14..d9865f6e7 100644 --- a/src/Entity/Tax.php +++ b/src/Invoice/Tax.php @@ -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( diff --git a/src/Invoice/TaxRow.php b/src/Invoice/TaxRow.php index 8fca4b2e7..119aa15fa 100644 --- a/src/Invoice/TaxRow.php +++ b/src/Invoice/TaxRow.php @@ -9,8 +9,9 @@ namespace App\Invoice; -use App\Entity\Tax; +use Symfony\Component\DependencyInjection\Attribute\Exclude; +#[Exclude] final class TaxRow { public function __construct( diff --git a/tests/Audit/VersionedTest.php b/tests/Audit/SensitivePropertyTest.php similarity index 58% rename from tests/Audit/VersionedTest.php rename to tests/Audit/SensitivePropertyTest.php index eea08454b..5bc6849d2 100644 --- a/tests/Audit/VersionedTest.php +++ b/tests/Audit/SensitivePropertyTest.php @@ -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]); } diff --git a/tests/Entity/InvoiceTemplateTest.php b/tests/Entity/InvoiceTemplateTest.php index 9fa56fd07..bbaa1fa54 100644 --- a/tests/Entity/InvoiceTemplateTest.php +++ b/tests/Entity/InvoiceTemplateTest.php @@ -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;