Skip to content

Phase 2: File Attachments API (Upload/Download/Encryption) #175

@kevalyq

Description

@kevalyq

📌 Parent Epic

#173 (Secret Management System)

🎯 Goal

Implement file attachment support for Secrets with encrypted storage, upload/download endpoints, and metadata management. This enables users to attach files (images, PDFs, documents) to their secrets.


📋 Implementation Tasks

1. Database Schema

Migration: create_secret_attachments_table

Schema::create('secret_attachments', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->foreignUuid('secret_id')->constrained('secrets')->cascadeOnDelete();
    
    // File metadata (encrypted)
    $table->text('filename_enc'); // Original filename
    $table->unsignedBigInteger('file_size'); // Original size in bytes
    $table->string('mime_type', 255); // MIME type
    $table->text('storage_path'); // Path to encrypted blob
    $table->string('checksum_sha256', 64)->nullable(); // File integrity
    
    // Audit
    $table->foreignUuid('uploaded_by')->constrained('users');
    $table->timestamps();
    
    $table->index('secret_id');
    $table->index(['secret_id', 'created_at']);
});

Storage Structure:

storage/app/
  attachments/
    {tenant_id}/
      {secret_id}/
        {attachment_id}.enc  # Encrypted file blob

2. Models

File: app/Models/SecretAttachment.php

  • SecretAttachment model
  • Use EncryptedWithDek cast for filename_enc
  • Transient property: filename_plain
  • Hidden fields: filename_enc, storage_path
  • Relationships:
    • belongsTo(Secret)
    • belongsTo(User, 'uploaded_by')
  • Accessor for download URL
  • Method: encryptedStoragePath() - returns storage path
  • Method: delete() - removes file from storage

Update: app/Models/Secret.php

  • Add relationship: hasMany(SecretAttachment, 'secret_id')
  • Add accessor: attachment_count

3. File Storage Service

File: app/Services/AttachmentStorageService.php

  • store(UploadedFile $file, Secret $secret, User $user): SecretAttachment

    • Validate file size/type
    • Encrypt file content (server-side)
    • Generate checksum (SHA-256 of original file)
    • Store encrypted blob
    • Create attachment record
  • retrieve(SecretAttachment $attachment): string

    • Read encrypted blob
    • Decrypt content
    • Return plaintext file content
  • delete(SecretAttachment $attachment): bool

    • Remove file from storage
    • Delete attachment record

Encryption Method:

// Use tenant DEK to encrypt file
$tenant = $secret->tenantKey;
$encrypted = $tenant->encrypt(file_get_contents($file));

// Store encrypted blob
Storage::put($storagePath, base64_encode(json_encode($encrypted)));

4. API Endpoints

File: app/Http/Controllers/Api/V1/SecretAttachmentController.php

  • POST /api/v1/secrets/{secret}/attachments - Upload file

    • Accept: multipart/form-data
    • Validate: file type (images, PDFs, docs), size (max 10MB)
    • Encrypt and store file
    • Return attachment metadata
  • GET /api/v1/secrets/{secret}/attachments - List attachments

    • Return attachment metadata (no file content)
    • Include download URLs
  • GET /api/v1/attachments/{attachment}/download - Download file

    • Authorization: Owner or shared user with read permission
    • Decrypt file content
    • Return file with proper headers (Content-Type, Content-Disposition)
  • DELETE /api/v1/attachments/{attachment} - Delete attachment

    • Authorization: Owner only
    • Remove file from storage
    • Delete record

5. Configuration

File: config/attachments.php

return [
    'max_file_size' => env('ATTACHMENT_MAX_SIZE', 10 * 1024 * 1024), // 10MB
    'allowed_mime_types' => [
        'image/jpeg', 'image/png', 'image/gif', 'image/webp',
        'application/pdf',
        'application/msword',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'text/plain',
    ],
    'storage_disk' => env('ATTACHMENT_STORAGE_DISK', 'local'),
];

6. Authorization (Policies)

File: app/Policies/SecretAttachmentPolicy.php

  • viewAny: Can view secret (owner + shared users)
  • view: Can view secret
  • create: Can update secret (owner + shared with write)
  • delete: Owner only (or shared with admin permission)

Update: app/Policies/SecretPolicy.php

  • Add attachFiles method: Owner + shared with write permission

7. Validation (Form Requests)

  • StoreAttachmentRequest

    • file required, file, max:10240 (10MB), mimes:jpg,png,pdf,doc,docx,txt
  • Custom validation rule: AllowedMimeType

    • Validates MIME type against config whitelist

8. Tests (Pest)

Feature Tests: tests/Feature/SecretAttachmentTest.php

  • Upload attachment to secret
  • List secret attachments
  • Download attachment (decrypts correctly)
  • Delete attachment (removes file from storage)
  • Cannot upload file exceeding size limit
  • Cannot upload disallowed file type
  • Cannot upload to other user's secret
  • Cannot download other user's attachment
  • Attachment count updates correctly
  • Cascade delete when secret deleted

Security Tests: tests/Feature/AttachmentSecurityTest.php

  • File content encrypted in storage
  • Filename encrypted in database
  • Checksum matches original file
  • Cannot access files directly (outside API)
  • Storage path not exposed in API responses

Coverage Target: ≥80% for new code


✅ Acceptance Criteria

  • Migration created and tested (up + down)
  • SecretAttachment model with encryption
  • AttachmentStorageService handles encryption/decryption
  • All endpoints functional (4 routes)
  • Authorization policies enforced
  • File validation (size, type) working
  • Configuration file created
  • All tests passing (≥80% coverage)
  • PHPStan level max passing
  • Laravel Pint passing
  • REUSE 3.3 compliant (SPDX headers)
  • CHANGELOG.md updated (Added section)
  • API documented in OpenAPI spec (SecPal/contracts)
  • No plaintext files in storage

🔗 Dependencies


📝 Technical Notes

File Encryption Flow

// AttachmentStorageService
public function store(UploadedFile $file, Secret $secret, User $user): SecretAttachment
{
    // 1. Read file content
    $content = file_get_contents($file->getRealPath());
    
    // 2. Calculate checksum (before encryption)
    $checksum = hash('sha256', $content);
    
    // 3. Encrypt with tenant DEK
    $tenant = $secret->tenantKey;
    $encrypted = $tenant->encrypt($content);
    
    // 4. Generate storage path
    $attachmentId = Str::uuid();
    $storagePath = "attachments/{$tenant->id}/{$secret->id}/{$attachmentId}.enc";
    
    // 5. Store encrypted blob
    Storage::put($storagePath, json_encode([
        'ciphertext' => base64_encode($encrypted['ciphertext']),
        'nonce' => base64_encode($encrypted['nonce']),
    ]));
    
    // 6. Create attachment record
    return SecretAttachment::create([
        'id' => $attachmentId,
        'secret_id' => $secret->id,
        'filename_plain' => $file->getClientOriginalName(),
        'file_size' => $file->getSize(),
        'mime_type' => $file->getMimeType(),
        'storage_path' => $storagePath,
        'checksum_sha256' => $checksum,
        'uploaded_by' => $user->id,
    ]);
}

Download with Proper Headers

// SecretAttachmentController@download
public function download(SecretAttachment $attachment)
{
    $this->authorize('view', $attachment);
    
    $content = $this->storage->retrieve($attachment);
    
    return response($content)
        ->header('Content-Type', $attachment->mime_type)
        ->header('Content-Disposition', 'attachment; filename="' . $attachment->filename_plain . '"')
        ->header('Content-Length', strlen($content));
}

Frontend Integration (Upload)

// Frontend example (for reference)
const formData = new FormData();
formData.append('file', file);

const response = await fetch(`/api/v1/secrets/${secretId}/attachments`, {
  method: 'POST',
  headers: getAuthHeaders(),
  body: formData,
});

const attachment = await response.json();
console.log('Uploaded:', attachment.filename, attachment.size);

🧪 Testing Strategy

// Example: Upload and download file
test('user can upload and download attachment', function () {
    $user = User::factory()->create();
    $tenant = TenantKey::factory()->create();
    $secret = Secret::factory()->create([
        'tenant_id' => $tenant->id,
        'owner_id' => $user->id,
    ]);
    
    $file = UploadedFile::fake()->image('test.jpg', 100, 100);
    
    // Upload
    $response = $this->actingAs($user)
        ->postJson("/api/v1/secrets/{$secret->id}/attachments", [
            'file' => $file,
        ]);
    
    $response->assertCreated();
    $attachmentId = $response->json('data.id');
    
    // Download
    $downloadResponse = $this->actingAs($user)
        ->get("/api/v1/attachments/{$attachmentId}/download");
    
    $downloadResponse->assertOk();
    $downloadResponse->assertHeader('Content-Type', 'image/jpeg');
    
    // Verify file content matches original
    expect($downloadResponse->content())->toBe(file_get_contents($file->getRealPath()));
});

test('file is encrypted in storage', function () {
    $user = User::factory()->create();
    $tenant = TenantKey::factory()->create();
    $secret = Secret::factory()->create([
        'tenant_id' => $tenant->id,
        'owner_id' => $user->id,
    ]);
    
    $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
    
    $response = $this->actingAs($user)
        ->postJson("/api/v1/secrets/{$secret->id}/attachments", [
            'file' => $file,
        ]);
    
    $attachment = SecretAttachment::first();
    $storedContent = Storage::get($attachment->storage_path);
    
    // Verify stored content is encrypted (not plaintext)
    expect($storedContent)->not->toContain('PDF'); // PDFs start with %PDF
    expect($storedContent)->toContain('ciphertext');
    expect($storedContent)->toContain('nonce');
});

📝 PR Linking Instructions

When creating the PR for this sub-issue, use this in your PR description:

Fixes #TBD (this issue number)
Part of: #173
Depends on: #174

⚠️ Important:


Type: Sub-Issue (Backend)
Priority: High
Estimated Effort: 1 week
Sprint: Phase 2 - File Attachments

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    ✅ Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions