Evidence Management
Status: Final Version: 1.0
Purpose
Define file handling, storage, retention, chain-of-custody, and immutability controls for evidence files supporting ESG metric submissions.
Evidence Types & File Formats
| Category | Evidence Type Code | Allowed MIME Types | Max Size | Required For |
|---|---|---|---|---|
| Environmental | UTILITY_BILL |
application/pdf, image/jpeg, image/png |
10 MB | Energy, water metrics |
METER_READING |
image/*, application/pdf |
5 MB | Energy, water | |
LAB_REPORT |
application/pdf |
10 MB | Emissions, effluents | |
WASTE_MANIFEST |
application/pdf |
10 MB | Waste disposal | |
| Social | HR_REGISTER |
application/pdf, application/vnd.ms-excel, text/csv |
25 MB | Employment, diversity |
TRAINING_RECORD |
application/pdf, image/* |
10 MB | Training hours | |
INCIDENT_REPORT |
application/pdf |
10 MB | Safety incidents | |
| Governance | BOARD_MINUTES |
application/pdf |
10 MB | Board diversity |
POLICY_DOCUMENT |
application/pdf |
10 MB | Governance disclosures | |
AUDIT_REPORT |
application/pdf |
25 MB | Assurance |
File Upload Workflow
1. Client Upload
POST /api/v1/collector/submissions/{uuid}/evidence
Content-Type: multipart/form-data
file: <binary>
evidence_type: UTILITY_BILL
description: March 2025 electricity bill
2. Backend Processing
class EvidenceService
{
public function upload(MetricSubmission $submission, UploadedFile $file, string $evidenceType)
{
// 1. Validate file
$this->validateFile($file, $evidenceType);
// 2. Virus scan
if (!$this->virusScan($file)) {
throw new SecurityException('File failed virus scan');
}
// 3. Generate content hash
$contentHash = hash_file('sha256', $file->path());
// 4. Store immutably
$path = $file->storeAs(
"evidence/{$submission->tenant_id}/{$submission->id}",
Str::uuid() . '.' . $file->extension(),
'evidence' // Separate S3 bucket/filesystem
);
// 5. Create evidence record
$evidence = Evidence::create([
'tenant_id' => $submission->tenant_id,
'filename' => $file->getClientOriginalName(),
'filepath' => $path,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'content_hash' => $contentHash,
'evidence_type' => $evidenceType,
'description' => request('description'),
'uploaded_by_user_id' => auth()->id(),
'uploaded_from_ip' => request()->ip(),
'uploaded_at' => now(),
]);
// 6. Link to submission
$submission->evidence()->attach($evidence->id);
return $evidence;
}
protected function validateFile(UploadedFile $file, string $evidenceType)
{
$rules = config("evidence.types.{$evidenceType}");
if (!in_array($file->getMimeType(), $rules['allowed_mime_types'])) {
throw new ValidationException("File type not allowed for {$evidenceType}");
}
if ($file->getSize() > $rules['max_size_bytes']) {
throw new ValidationException("File exceeds max size ({$rules['max_size_mb']} MB)");
}
}
}
Virus Scanning
Integration: ClamAV
use Xenolope\Quahog\Client as ClamAV;
protected function virusScan(UploadedFile $file): bool
{
$clam = new ClamAV(config('clamav.socket'));
$result = $clam->scanFile($file->path());
if ($result['status'] === 'FOUND') {
Log::alert('Virus detected', [
'file' => $file->getClientOriginalName(),
'virus' => $result['reason'],
'user_id' => auth()->id(),
]);
return false;
}
return true;
}
Storage Architecture
Laravel Filesystem Configuration
// config/filesystems.php
'disks' => [
'evidence' => [
'driver' => 's3',
'key' => env('AWS_EVIDENCE_KEY'),
'secret' => env('AWS_EVIDENCE_SECRET'),
'region' => env('AWS_EVIDENCE_REGION'),
'bucket' => env('AWS_EVIDENCE_BUCKET'),
'url' => env('AWS_EVIDENCE_URL'),
'visibility' => 'private', // Never public
'options' => [
'ServerSideEncryption' => 'AES256', // Encryption at rest
],
],
],
Signed URLs for Access
public function download(Evidence $evidence)
{
$this->authorize('view', $evidence);
// Log access for audit
AuditLog::log('evidence.accessed', $evidence, auth()->user());
// Generate temporary signed URL (1 hour expiry)
return Storage::disk('evidence')->temporaryUrl($evidence->filepath, now()->addHour());
}
Retention Policy
| Evidence Type | Retention Period | Legal Hold Support | Auto-Delete After |
|---|---|---|---|
| All Evidence | 7 years minimum | ✅ Yes | Never (manual only) |
| PII-Containing | Subject to GDPR Right to Erasure | ✅ Yes | On request (anonymize) |
Laravel Implementation
class Evidence extends Model
{
protected $casts = [
'legal_hold' => 'boolean',
'retention_until' => 'date',
];
public function canDelete(): bool
{
if ($this->legal_hold) {
return false; // Cannot delete under legal hold
}
if ($this->retention_until && $this->retention_until->isFuture()) {
return false; // Retention period not expired
}
return true;
}
}
// Scheduled job: Mark evidence eligible for deletion
class MarkEvidenceForDeletionJob
{
public function handle()
{
Evidence::where('retention_until', '<', now())
->where('legal_hold', false)
->whereNull('marked_for_deletion_at')
->update(['marked_for_deletion_at' => now()]);
// Admin reviews and manually deletes
}
}
Chain of Custody
Tracked Fields:
- uploaded_by_user_id: Who uploaded
- uploaded_from_ip: Source IP address
- uploaded_at: Timestamp
- content_hash: SHA-256 hash (immutability verification)
- Access logs: Every download/view logged to audit_logs
Acceptance Criteria
- File type whitelist enforced (MIME type check)
- Max file size validated (per evidence type)
- Virus scanning blocks infected files
- Files stored with encryption at rest (S3 SSE)
- Content hash generated and stored
- Signed URLs expire after 1 hour
- All evidence access logged to audit trail
- Retention policy prevents premature deletion
Cross-References
Change Log
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-03 | Senior Product Architect | Initial evidence management specification |