feat(core) : add audit attributes, audit_log table and dedicated dbal connection

This commit is contained in:
Matthieu
2026-06-19 20:56:32 +02:00
parent fda03bd1f5
commit 934cf0835f
4 changed files with 111 additions and 9 deletions
+21 -9
View File
@@ -1,12 +1,19 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -33,8 +40,13 @@ doctrine:
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log (LST-61) : append-only `audit_log` table.
*
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
* flushes from the Doctrine listener. Columns are lowercase snake_case.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619185448 extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log: create append-only audit_log table + indexes (additive)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore {}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable {}