diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff240e..a64b90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Secret Sharing Foundation (Phase 3)** (#182) + - New `secret_shares` table for fine-grained access control + - XOR constraint enforces sharing with either user OR role (not both) + - Permission hierarchy: `admin` > `write` > `read` + - Optional expiration support via `expires_at` timestamp + - `SecretShare` model with UUID primary key, relationships, and scopes + - `Secret.shares()` relationship for access control queries + - `Secret.userHasPermission()` method validates user permissions (owner OR share) + - `active()` scope filters non-expired shares + - Migration tests verify schema integrity (3 tests) + - Model tests cover relationships, scopes, and expiration logic (10 tests) + - Foundation for upcoming Controllers and Policies in separate PR + - **File Attachments API (Phase 2)** (#175) - Upload encrypted file attachments to secrets (POST `/v1/secrets/{secret}/attachments`) - List attachments for a secret (GET `/v1/secrets/{secret}/attachments`) diff --git a/app/Models/Secret.php b/app/Models/Secret.php index c235610..2ba006c 100644 --- a/app/Models/Secret.php +++ b/app/Models/Secret.php @@ -262,6 +262,16 @@ public function attachments(): \Illuminate\Database\Eloquent\Relations\HasMany return $this->hasMany(SecretAttachment::class); } + /** + * Relation to SecretShare (access control). + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function shares(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(SecretShare::class); + } + /** * Get count of attachments for this secret. * @@ -320,4 +330,43 @@ public function scopeActive($query) ->orWhere('expires_at', '>', now()); }); } + + /** + * Check if user has specific permission on this secret. + * + * Permission hierarchy: admin > write > read + * + * @param string $permission One of: read, write, admin + */ + public function userHasPermission(User $user, string $permission): bool + { + // Owner always has full access + if ($this->owner_id === $user->id) { + return true; + } + + // Check active shares + $share = $this->shares() + ->where(function ($q) use ($user) { + $q->where('user_id', $user->id) + ->orWhereIn('role_id', $user->roles->pluck('id')); + }) + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->first(); + + if (! $share) { + return false; + } + + // Permission hierarchy: admin > write > read + return match ($permission) { + 'read' => in_array($share->permission, ['read', 'write', 'admin']), + 'write' => in_array($share->permission, ['write', 'admin']), + 'admin' => $share->permission === 'admin', + default => false, + }; + } } diff --git a/app/Models/SecretShare.php b/app/Models/SecretShare.php new file mode 100644 index 0000000..4d59cbf --- /dev/null +++ b/app/Models/SecretShare.php @@ -0,0 +1,137 @@ + + */ + protected $fillable = [ + 'secret_id', + 'user_id', + 'role_id', + 'permission', + 'granted_by', + 'granted_at', + 'expires_at', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'granted_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * Get the secret that this share grants access to. + * + * @return BelongsTo + */ + public function secret(): BelongsTo + { + return $this->belongsTo(Secret::class); + } + + /** + * Get the user that has access (if user-based share). + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the role that has access (if role-based share). + * + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * Get the user who granted this share. + * + * @return BelongsTo + */ + public function granter(): BelongsTo + { + return $this->belongsTo(User::class, 'granted_by'); + } + + /** + * Scope to only active (non-expired) shares. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive(\Illuminate\Database\Eloquent\Builder $query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Check if this share has expired. + */ + public function getIsExpiredAttribute(): bool + { + if ($this->expires_at === null) { + return false; + } + + return $this->expires_at->isPast(); + } +} diff --git a/database/migrations/2025_11_16_164313_create_secret_shares_table.php b/database/migrations/2025_11_16_164313_create_secret_shares_table.php new file mode 100644 index 0000000..22c83ca --- /dev/null +++ b/database/migrations/2025_11_16_164313_create_secret_shares_table.php @@ -0,0 +1,55 @@ +uuid('id')->primary(); + $table->foreignUuid('secret_id')->constrained('secrets')->onDelete('cascade'); + $table->foreignUuid('user_id')->nullable()->constrained('users')->onDelete('cascade'); + $table->foreignId('role_id')->nullable()->constrained('roles')->onDelete('cascade'); + $table->enum('permission', ['read', 'write', 'admin']); + $table->foreignUuid('granted_by')->constrained('users'); + $table->timestamp('granted_at'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + // Indexes + $table->index('secret_id'); + $table->index('user_id'); + $table->index('role_id'); + $table->index('expires_at'); + + // Unique constraints: One share per secret+user or secret+role + $table->unique(['secret_id', 'user_id']); + $table->unique(['secret_id', 'role_id']); + }); + + // Add CHECK constraint: Either user_id OR role_id must be set, not both + DB::statement('ALTER TABLE secret_shares ADD CONSTRAINT secret_shares_user_xor_role CHECK ((user_id IS NOT NULL AND role_id IS NULL) OR (user_id IS NULL AND role_id IS NOT NULL))'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('secret_shares'); + } +}; diff --git a/tests/Feature/Migrations/CreateSecretSharesTableTest.php b/tests/Feature/Migrations/CreateSecretSharesTableTest.php new file mode 100644 index 0000000..4434fd8 --- /dev/null +++ b/tests/Feature/Migrations/CreateSecretSharesTableTest.php @@ -0,0 +1,58 @@ +toBeTrue(); + + expect(Schema::hasColumn('secret_shares', 'id'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'secret_id'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'user_id'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'role_id'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'permission'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'granted_by'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'granted_at'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'expires_at'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'created_at'))->toBeTrue(); + expect(Schema::hasColumn('secret_shares', 'updated_at'))->toBeTrue(); + }); + + test('has indexes on key columns', function (): void { + $indexes = Schema::getIndexes('secret_shares'); + $indexColumns = collect($indexes)->pluck('columns')->flatten()->toArray(); + + expect($indexColumns)->toContain('secret_id'); + expect($indexColumns)->toContain('user_id'); + expect($indexColumns)->toContain('role_id'); + expect($indexColumns)->toContain('expires_at'); + }); + + test('has unique constraints for secret_id with user_id and role_id', function (): void { + $indexes = Schema::getIndexes('secret_shares'); + + // Check for unique constraint on secret_id + user_id + $hasUserUnique = collect($indexes)->contains(function ($index) { + return $index['unique'] + && in_array('secret_id', $index['columns']) + && in_array('user_id', $index['columns']); + }); + + // Check for unique constraint on secret_id + role_id + $hasRoleUnique = collect($indexes)->contains(function ($index) { + return $index['unique'] + && in_array('secret_id', $index['columns']) + && in_array('role_id', $index['columns']); + }); + + expect($hasUserUnique)->toBeTrue(); + expect($hasRoleUnique)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Models/SecretShareTest.php b/tests/Feature/Models/SecretShareTest.php new file mode 100644 index 0000000..027181a --- /dev/null +++ b/tests/Feature/Models/SecretShareTest.php @@ -0,0 +1,201 @@ +tenant = TenantKey::create($keys); + + // Seed roles and permissions + $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class); + + $this->owner = User::factory()->create(); + $this->sharedUser = User::factory()->create(); + + $this->secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->owner->id, + 'title_plain' => 'Shared Secret', + ]); +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +test('secret share uses UUID primary key', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($share->id)->toBeString(); + expect(strlen($share->id))->toBe(36); // UUID format +}); + +test('secret share belongs to secret', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($share->secret)->toBeInstanceOf(Secret::class); + expect($share->secret->id)->toBe($this->secret->id); +}); + +test('secret share belongs to user when user-based', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'write', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($share->user)->toBeInstanceOf(User::class); + expect($share->user->id)->toBe($this->sharedUser->id); +}); + +test('secret share belongs to role when role-based', function (): void { + $role = Role::firstWhere('name', 'Manager'); + + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'role_id' => $role->id, + 'permission' => 'admin', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($share->role)->toBeInstanceOf(Role::class); +}); + +test('secret share belongs to granter', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($share->granter)->toBeInstanceOf(User::class); + expect($share->granter->id)->toBe($this->owner->id); +}); + +test('secret has many shares', function (): void { + $user2 = User::factory()->create(); + + SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $user2->id, + 'permission' => 'write', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + ]); + + expect($this->secret->shares)->toHaveCount(2); +}); + +test('active scope filters non-expired shares', function (): void { + // Active share (no expiration) + SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + 'expires_at' => null, + ]); + + // Active share (expires in future) + SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => User::factory()->create()->id, + 'permission' => 'write', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + 'expires_at' => now()->addDays(7), + ]); + + // Expired share + SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => User::factory()->create()->id, + 'permission' => 'admin', + 'granted_by' => $this->owner->id, + 'granted_at' => now()->subDays(8), + 'expires_at' => now()->subDay(), + ]); + + $activeShares = SecretShare::active()->get(); + expect($activeShares)->toHaveCount(2); +}); + +test('is_expired accessor returns false for permanent share', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + 'expires_at' => null, + ]); + + expect($share->is_expired)->toBeFalse(); +}); + +test('is_expired accessor returns false for future expiration', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now(), + 'expires_at' => now()->addDays(7), + ]); + + expect($share->is_expired)->toBeFalse(); +}); + +test('is_expired accessor returns true for past expiration', function (): void { + $share = SecretShare::create([ + 'secret_id' => $this->secret->id, + 'user_id' => $this->sharedUser->id, + 'permission' => 'read', + 'granted_by' => $this->owner->id, + 'granted_at' => now()->subDays(8), + 'expires_at' => now()->subDay(), + ]); + + expect($share->is_expired)->toBeTrue(); +});