-
Notifications
You must be signed in to change notification settings - Fork 0
Description
📌 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
EncryptedWithDekcast forfilename_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
- Accept:
-
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
attachFilesmethod: Owner + shared with write permission
7. Validation (Form Requests)
-
StoreAttachmentRequestfilerequired, 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
- Depends on:
- Phase 1: Secret Model + CRUD API (Backend Foundation) #174 (Phase 1: Secret Model) ✅ Must be completed first
- TenantKey encryption infrastructure ✅
- Unblocks:
- enhancement: Add file upload to backend API for Secret attachments frontend#141 (File Upload API)
- [EPIC] Client-Side File Encryption for Zero-Knowledge Architecture frontend#143 (Client-side Encryption)
- Part of: Epic [EPIC] Secret Management System (Password Vault) #173 (Phase 2)
📝 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- Do NOT use
Fixes #173- this is not the last sub-issue - Ensure Phase 1 (Phase 1: Secret Model + CRUD API (Backend Foundation) #174) is merged before starting
- This PR should be ~400-500 LOC (within limits)
- Test with various file types and sizes
- Document storage encryption in CHANGELOG
Type: Sub-Issue (Backend)
Priority: High
Estimated Effort: 1 week
Sprint: Phase 2 - File Attachments
Metadata
Metadata
Assignees
Type
Projects
Status