[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:
Kevin Papst
2026-01-11 11:41:34 +01:00
committed by GitHub
parent 162acf37a8
commit f295d6e30e
21 changed files with 68 additions and 29 deletions

View File

@@ -29,7 +29,6 @@ services:
- '../src/API/Model/'
- '../src/DependencyInjection/'
- '../src/Doctrine/Extensions/'
- '../src/Entity/'
- '../src/Event/'
- '../src/Export/Package/'
- '../src/Export/Base/'

View File

@@ -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',
)
{
}
}

View File

@@ -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()
{
}
}

View File

@@ -9,6 +9,9 @@
namespace App\Doctrine\Behavior;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
#[Exclude]
interface ModifiedAt
{
public function getModifiedAt(): ?\DateTimeImmutable;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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]

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
{
/**

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -10,7 +10,9 @@
namespace App\Invoice;
use App\Utils\FileHelper;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
#[Exclude]
final class InvoiceFilename
{
private string $filename;

View File

@@ -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;

View File

@@ -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(

View File

@@ -9,8 +9,9 @@
namespace App\Invoice;
use App\Entity\Tax;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
#[Exclude]
final class TaxRow
{
public function __construct(

View File

@@ -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]);
}

View File

@@ -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;