Database Permissions

AuditChain's Eloquent boot guards prevent updates and deletes at the application layer, but a compromised application can bypass Eloquent entirely and run raw SQL. A dedicated database user with restricted privileges closes this gap by enforcing immutability at the database level.

Why This Matters

The AuditLog model throws a RuntimeException if you try to update or delete a record through Eloquent. This is effective against accidental modifications and application-level bugs, but it does not protect against:

  • Direct SQL queries (DB::statement('DELETE FROM audit_logs ...'))
  • A compromised application running arbitrary database commands
  • An attacker with access to database credentials used by the main application
  • Database management tools (phpMyAdmin, TablePlus, etc.) connected with the app user

By giving the audit connection a database user that can only INSERT and SELECT, you ensure that audit logs cannot be modified or deleted even if the application is fully compromised.

Setting Up a Dedicated Connection

1. Create a Restricted Database User

MySQL

-- Create the user
CREATE USER 'audit_writer'@'%' IDENTIFIED BY 'a-strong-random-password';

-- Grant only INSERT and SELECT on the audit table
GRANT INSERT, SELECT ON your_database.audit_logs TO 'audit_writer'@'%';

-- Apply changes
FLUSH PRIVILEGES;

PostgreSQL

-- Create the user
CREATE USER audit_writer WITH PASSWORD 'a-strong-random-password';

-- Grant only INSERT and SELECT on the audit table
GRANT INSERT, SELECT ON audit_logs TO audit_writer;

-- Grant USAGE on the schema (required for PostgreSQL)
GRANT USAGE ON SCHEMA public TO audit_writer;

If your audit table uses a sequence for any column (uncommon with ULIDs, but possible), you will also need GRANT USAGE, SELECT ON SEQUENCE audit_logs_id_seq TO audit_writer; in PostgreSQL.

2. Add the Connection to config/database.php

Define a new connection that uses the restricted user:

// config/database.php
'connections' => [
    // Your main application connection
    'mysql' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'database' => env('DB_DATABASE', 'your_database'),
        'username' => env('DB_USERNAME', 'app_user'),
        'password' => env('DB_PASSWORD', ''),
        // ...
    ],

    // Dedicated audit connection (INSERT + SELECT only)
    'audit' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'database' => env('DB_DATABASE', 'your_database'),
        'username' => env('AUDIT_DB_USERNAME', 'audit_writer'),
        'password' => env('AUDIT_DB_PASSWORD', ''),
        // ...remaining options same as your main connection
    ],
],

3. Point AuditChain to the Restricted Connection

Set the connection in config/audit-chain.php:

'connection' => 'audit',

Or via your .env file if you prefer:

AUDIT_DB_USERNAME=audit_writer
AUDIT_DB_PASSWORD=a-strong-random-password

Running Migrations

The restricted audit_writer user cannot create or alter tables. Run migrations using your main application connection (the default). AuditChain's migration does not specify a connection, so it uses the default database connection automatically.

Pruning Audit Logs

The audit:prune command deletes old audit logs. Since the restricted user lacks DELETE privileges, pruning will fail if the audit connection is the only one available. Two approaches:

Option A: Run the prune command with a different connection that has DELETE privileges. You can override the connection temporarily in your scheduled task:

Schedule::command('audit:prune --days=365')
    ->daily()
    ->before(function () {
        config(['audit-chain.connection' => 'mysql']);
    })
    ->after(function () {
        config(['audit-chain.connection' => 'audit']);
    });

Option B: Run the prune query manually using your main database connection outside of AuditChain.

Verifying Permissions

Confirm that the restricted user cannot modify or delete audit records:

-- These should succeed
INSERT INTO audit_logs (id, auditable_type, auditable_id, event, created_at)
VALUES ('test', 'App\\Models\\User', '1', 'test', NOW());

SELECT * FROM audit_logs WHERE id = 'test';

-- These should fail with a permission error
UPDATE audit_logs SET event = 'tampered' WHERE id = 'test';
DELETE FROM audit_logs WHERE id = 'test';

Clean up the test row using your main application user after verifying.

Defense in Depth

Database-level permissions are one layer in AuditChain's immutability strategy:

Layer Protects Against
Eloquent boot guards Application-level updates/deletes via Eloquent
Strict $fillable Mass assignment of unauthorized attributes
DB user permissions Raw SQL, compromised app, direct DB access
Hash chain verification Any modification, regardless of how it occurred

No single layer is sufficient on its own. Together, they provide strong assurance that your audit trail has not been tampered with.