Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,31 @@ 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
- **Secret Sharing & Access Control (Phase 3)** (#182)
- **Secret CRUD API**: Full REST API for password manager functionality
- Create secrets with encrypted title, username, password, URL, notes (POST `/v1/secrets`)
- List user's secrets with pagination (GET `/v1/secrets`)
- View secret details (GET `/v1/secrets/{secret}`)
- Update secrets with automatic version incrementing (PATCH `/v1/secrets/{secret}`)
- Soft delete secrets (DELETE `/v1/secrets/{secret}`)
- Owner-based authorization via `SecretPolicy`
- Validation via `StoreSecretRequest` and `UpdateSecretRequest`
- 17 comprehensive Controller tests
- **Secret Sharing API**: Grant/revoke access to secrets
- Grant read/write/admin access to users OR roles (POST `/v1/secrets/{secret}/shares`)
- List all shares for a secret (GET `/v1/secrets/{secret}/shares`)
- Revoke share access (DELETE `/v1/secrets/{secret}/shares/{share}`)
- XOR constraint validation: cannot grant to both user AND role
- Optional expiration dates for time-limited access
- Permission hierarchy: admin > write > read
- Authorization via `SecretSharePolicy` (owner-only for now)
- 18 comprehensive Controller tests covering all scenarios
- **Database Foundation** (already merged):
- `secret_shares` table with XOR constraint
- `SecretShare` model with relationships and scopes
- Migration tests and model tests (13 total)
- **Total Test Coverage**: 35 Controller tests, 13 Model tests, all passing
- **Note**: Tenant resolution uses temporary `TenantKey::first()` pattern (TODO: TenantMiddleware)

- **File Attachments API (Phase 2)** (#175)
- Upload encrypted file attachments to secrets (POST `/v1/secrets/{secret}/attachments`)
Expand Down
213 changes: 213 additions & 0 deletions app/Http/Controllers/Api/V1/SecretController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreSecretRequest;
use App\Http\Requests\UpdateSecretRequest;
use App\Models\Secret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

/**
* SecretController handles CRUD operations for Secrets.
*
* All secrets are scoped to the authenticated user's tenant.
* Users can access secrets they own (owner-based) only.
* Shared secret access via SecretShare is checked at Policy level.
*/
class SecretController extends Controller
{
/**
* Transform Secret model to API response format with plaintext fields.
*
* @return array<string, mixed>
*/
private function transformSecret(Secret $secret): array
{
return [
'id' => $secret->id,
'title' => $secret->title_plain,
'username' => $secret->username_plain,
'password' => $secret->password_plain,
'url' => $secret->url_plain,
'notes' => $secret->notes_plain,
'tags' => $secret->tags,
'expires_at' => $secret->expires_at?->toIso8601String(),
'version' => $secret->version,
'created_at' => $secret->created_at->toIso8601String(),
'updated_at' => $secret->updated_at->toIso8601String(),
];
}

/**
* Assign request fields to secret model.
*
* @return bool Whether any field was modified
*/
private function assignFields(Secret $secret, Request $request): bool
{
$modified = false;

if ($request->has('title')) {
/** @var string $value */
$value = $request->input('title');
$secret->title_plain = $value;
$modified = true;
}
if ($request->has('username')) {
/** @var string|null $value */
$value = $request->input('username');
$secret->username_plain = $value;
$modified = true;
}
if ($request->has('password')) {
/** @var string|null $value */
$value = $request->input('password');
$secret->password_plain = $value;
$modified = true;
}
if ($request->has('url')) {
/** @var string|null $value */
$value = $request->input('url');
$secret->url_plain = $value;
$modified = true;
}
if ($request->has('notes')) {
/** @var string|null $value */
$value = $request->input('notes');
$secret->notes_plain = $value;
$modified = true;
}
if ($request->has('tags')) {
/** @var array<string>|null $value */
$value = $request->input('tags');
$secret->tags = $value;
$modified = true;
}
if ($request->has('expires_at')) {
/** @var \Illuminate\Support\Carbon|null $value */
$value = $request->input('expires_at');
$secret->expires_at = $value;
$modified = true;
}

return $modified;
}

/**
* Display a listing of secrets accessible to the authenticated user.
*/
public function index(Request $request): JsonResponse
{
$this->authorize('viewAny', Secret::class);

/** @var \App\Models\User $user */
$user = $request->user();

// Query secrets owned by user
$query = Secret::where('owner_id', $user->id);

// Pagination
/** @var int $perPageInput */
$perPageInput = $request->input('per_page', 15);
$perPage = min((int) $perPageInput, 100);
$secrets = $query->paginate($perPage);

// Transform secrets to include plaintext fields
$transformedSecrets = $secrets->getCollection()->map(fn (Secret $secret) => $this->transformSecret($secret));

return response()->json([
'data' => $transformedSecrets,
'meta' => [
'current_page' => $secrets->currentPage(),
'per_page' => $secrets->perPage(),
'total' => $secrets->total(),
],
]);
}

/**
* Store a newly created secret.
*/
public function store(StoreSecretRequest $request): JsonResponse
{
$this->authorize('create', Secret::class);

/** @var \App\Models\User $user */
$user = $request->user();

// TODO: Replace with TenantMiddleware that injects tenant_id into request
// For now, use first available tenant (testing only - NOT production-ready)
$tenantId = \App\Models\TenantKey::first()?->id;
if (! $tenantId) {
return response()->json([
'error' => 'Tenant resolution not yet implemented. Please contact system administrator.',
], Response::HTTP_SERVICE_UNAVAILABLE);
}

$secret = new Secret;
$secret->tenant_id = $tenantId;
$secret->owner_id = $user->id;
$secret->version = 1;

$this->assignFields($secret, $request);
$secret->save();

return response()->json([
'data' => $this->transformSecret($secret),
], Response::HTTP_CREATED);
}

/**
* Display the specified secret.
*/
public function show(Secret $secret): JsonResponse
{
// Authorization handled by SecretPolicy
$this->authorize('view', $secret);

return response()->json([
'data' => $this->transformSecret($secret),
]);
}

/**
* Update the specified secret.
*/
public function update(UpdateSecretRequest $request, Secret $secret): JsonResponse
{
// Authorization handled by SecretPolicy
$this->authorize('update', $secret);

$modified = $this->assignFields($secret, $request);

if ($modified) {
// Increment version on update
$secret->version++;
$secret->save();
}

return response()->json([
'data' => $this->transformSecret($secret),
]);
}

/**
* Remove the specified secret (soft delete).
*/
public function destroy(Secret $secret): Response
{
// Authorization handled by SecretPolicy
$this->authorize('delete', $secret);

$secret->delete();

return response()->noContent();
}
}
106 changes: 106 additions & 0 deletions app/Http/Controllers/Api/V1/SecretShareController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\GrantShareRequest;
use App\Models\Secret;
use App\Models\SecretShare;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

/**
* SecretShareController handles sharing secrets with users and roles.
*
* Implements access control via SecretShare model:
* - Grant read/write/admin to users OR roles (XOR)
* - Optional expiration dates
* - Only secret owner can grant/revoke
*/
class SecretShareController extends Controller
{
/**
* List all shares for a secret.
*/
public function index(Secret $secret): JsonResponse
{
// Authorization
$this->authorize('viewAny', [SecretShare::class, $secret]);

// Query active (non-expired) shares
$shares = SecretShare::where('secret_id', $secret->id)
->active()
->get();

// Transform to API response
$data = $shares->map(fn (SecretShare $share) => $this->transformShare($share));

return response()->json([
'data' => $data,
]);
}

/**
* Grant access to a secret.
*/
public function store(GrantShareRequest $request, Secret $secret): JsonResponse
{
// Authorization
$this->authorize('create', [SecretShare::class, $secret]);

/** @var \App\Models\User $user */
$user = $request->user();

// Create share
$share = SecretShare::create([
'secret_id' => $secret->id,
'user_id' => $request->input('user_id'),
'role_id' => $request->input('role_id'),
'permission' => $request->input('permission'),
'granted_by' => $user->id,
'granted_at' => now(),
'expires_at' => $request->input('expires_at'),
]);

return response()->json([
'data' => $this->transformShare($share),
], Response::HTTP_CREATED);
}

/**
* Revoke a share.
*/
public function destroy(Secret $secret, SecretShare $share): Response
{
// Authorization
$this->authorize('delete', [SecretShare::class, $secret, $share]);

// Delete share
$share->delete();

return response()->noContent();
}

/**
* Transform SecretShare to API response format.
*
* @return array<string, mixed>
*/
private function transformShare(SecretShare $share): array
{
return [
'id' => $share->id,
'secret_id' => $share->secret_id,
'user_id' => $share->user_id,
'role_id' => $share->role_id,
'permission' => $share->permission,
'granted_by' => $share->granted_by,
'granted_at' => $share->granted_at->toIso8601String(),
'expires_at' => $share->expires_at?->toIso8601String(),
];
}
}
4 changes: 3 additions & 1 deletion app/Http/Controllers/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

namespace App\Http\Controllers;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

abstract class Controller
{
//
use AuthorizesRequests;
}
Loading