Skip to content

Sub-Issue 182.1: SecretController + SecretPolicy (CRUD API) #187

@kevalyq

Description

@kevalyq

📌 Parent Issue

#182 (Phase 3: Secret Sharing & Access Control)

🎯 Goal

Implement CRUD API for Secrets with authorization policies, enabling users to create, read, update, and delete secrets with permission checks.


📋 Implementation Tasks

1. SecretController

Location: app/Http/Controllers/Api/V1/SecretController.php

Endpoints:

  • GET /api/v1/secrets - List user's secrets (owned + shared)

    • Query params: filter (owned/shared/all), page, per_page
    • Response: Paginated list with minimal fields
    • Authorization: Authenticated users
  • POST /api/v1/secrets - Create new secret

    • Validate: title required, tenant_id auto-set from user
    • Use title_plain for encryption (auto-encrypts to title_enc)
    • Set owner_id to authenticated user
    • Response: Created secret with 201 status
  • GET /api/v1/secrets/{id} - View secret details

    • Check: owner OR has share with read+ permission
    • Response: Full secret details (decrypted fields)
    • Authorization: SecretPolicy@view
  • PATCH /api/v1/secrets/{id} - Update secret

    • Check: owner OR has share with write+ permission
    • Validation: same as create
    • Authorization: SecretPolicy@update
  • DELETE /api/v1/secrets/{id} - Soft delete secret

    • Check: owner OR has share with admin permission
    • Cascade: Shares and attachments deleted automatically (FK constraint)
    • Authorization: SecretPolicy@delete

2. SecretPolicy

Location: app/Policies/SecretPolicy.php

Methods:

  • viewAny(User $user) - Can list secrets (always true for authenticated)
  • view(User $user, Secret $secret) - Owner OR shared with read+
  • create(User $user) - All authenticated users
  • update(User $user, Secret $secret) - Owner OR shared with write+
  • delete(User $user, Secret $secret) - Owner OR shared with admin
  • share(User $user, Secret $secret) - Owner OR shared with admin
  • viewShares(User $user, Secret $secret) - Owner OR shared with admin

Implementation:

public function view(User $user, Secret $secret): bool
{
    return $secret->userHasPermission($user, 'read');
}

public function update(User $user, Secret $secret): bool
{
    return $secret->userHasPermission($user, 'write');
}

public function delete(User $user, Secret $secret): bool
{
    return $secret->userHasPermission($user, 'admin');
}

3. Form Requests

StoreSecretRequest:

  • title_plain - required, string, max:255
  • username_plain - nullable, string, max:255
  • password_plain - nullable, string
  • url_plain - nullable, url, max:2048
  • notes_plain - nullable, string, max:10000
  • tags - nullable, array
  • expires_at - nullable, date, after:now

UpdateSecretRequest:

  • Same rules as StoreSecretRequest (all fields optional)

4. Routes

Location: routes/api.php

Route::middleware(['auth:sanctum', 'tenant.enforce'])->prefix('v1')->group(function () {
    Route::apiResource('secrets', SecretController::class);
});

5. Tests (Pest)

Feature Tests - SecretController:

  • List secrets (owned only)
  • List secrets (shared only)
  • List secrets (owned + shared)
  • List secrets with pagination
  • Create secret (valid data)
  • Create secret (validation errors)
  • View secret (owner)
  • View secret (shared with read permission)
  • View secret (unauthorized user)
  • Update secret (owner)
  • Update secret (shared with write permission)
  • Update secret (unauthorized user)
  • Delete secret (owner)
  • Delete secret (shared with admin permission)
  • Delete secret (unauthorized user)

Feature Tests - SecretPolicy:

  • Owner can do everything
  • Read permission allows view, not update/delete
  • Write permission allows view/update, not delete
  • Admin permission allows everything
  • No permission denies all actions
  • Expired shares don't grant access

Coverage Target: ≥80% for new code


✅ Acceptance Criteria

  • SecretController with 5 CRUD endpoints
  • SecretPolicy with 7 authorization methods
  • StoreSecretRequest + UpdateSecretRequest validation
  • Routes registered in api.php
  • All tests passing (≥16 tests)
  • PHPStan level max passing
  • Laravel Pint passing
  • REUSE 3.3 compliant (SPDX headers)
  • CHANGELOG.md updated (Added section)
  • No breaking changes to existing endpoints

🔗 Dependencies


📝 Technical Notes

Controller Response Format

// GET /v1/secrets
{
  "data": [
    {
      "id": "uuid",
      "title": "Decrypted Title",
      "expires_at": "2025-12-31T23:59:59Z",
      "created_at": "2025-11-17T10:00:00Z",
      "is_owner": true
    }
  ],
  "meta": {
    "current_page": 1,
    "total": 42
  }
}

// GET /v1/secrets/{id}
{
  "data": {
    "id": "uuid",
    "title": "Decrypted",
    "username": "Decrypted",
    "password": "Decrypted",
    "url": "Decrypted",
    "notes": "Decrypted",
    "tags": ["tag1", "tag2"],
    "expires_at": null,
    "attachments_count": 2,
    "shares_count": 1,
    "is_owner": true,
    "created_at": "2025-11-17T10:00:00Z"
  }
}

Filter Logic

// Controller logic
$query = Secret::query();

match ($request->input('filter')) {
    'owned' => $query->where('owner_id', $user->id),
    'shared' => $query->whereHas('shares', fn($q) => 
        $q->where('user_id', $user->id)
          ->orWhereIn('role_id', $user->roles->pluck('id'))
    ),
    default => $query->where(fn($q) =>
        $q->where('owner_id', $user->id)
          ->orWhereHas('shares', fn($sq) => ...)
    ),
};

📝 PR Linking Instructions

When creating the PR:

Fixes #184
Part of: #182

⚠️ Important:

  • Do NOT use Fixes #182 - this is not the last sub-issue
  • Estimated LOC: ~450-500

Type: Sub-Issue (Backend)
Priority: High
Estimated Effort: 3-4 days
Status: Ready to start 🚀

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