feat(core) : add audit attributes, audit_log table and dedicated dbal connection
This commit is contained in:
@@ -1,12 +1,19 @@
|
|||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
default_connection: default
|
||||||
|
connections:
|
||||||
# IMPORTANT: You MUST configure your server version,
|
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
# service) to write outside the ORM transaction so audit rows survive
|
||||||
#server_version: '16'
|
# an application-side rollback and avoid transactional entanglement.
|
||||||
|
default:
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
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:
|
orm:
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
@@ -33,8 +40,13 @@ doctrine:
|
|||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
# Propagate the _test suffix to BOTH connections: the audit
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
# 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:
|
when@prod:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
Reference in New Issue
Block a user