Skip to content

Sub-Issue 182.2: SecretShareController + SecretSharePolicy (Sharing API) #188

@kevalyq

Description

@kevalyq

📌 Parent Issue

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

🎯 Goal

Implement secret sharing API to grant/revoke access between users and roles with fine-grained permissions (read/write/admin).


📋 Implementation Tasks

1. SecretShareController

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

Endpoints:

  • POST /api/v1/secrets/{secret}/shares - Grant access to user or role

    • Request body:
      {
        "user_id": "uuid",  // XOR with role_id
        "role_id": 123,     // XOR with user_id
        "permission": "read",  // read|write|admin
        "expires_at": "2025-12-31T23:59:59Z"  // optional
      }
    • Validation: Exactly one of user_id OR role_id (XOR constraint)
    • Authorization: SecretSharePolicy@create
    • Response: Created share with 201 status
  • GET /api/v1/secrets/{secret}/shares - List all shares for secret

    • Include: User details, role details, permission level, expiration
    • Authorization: SecretSharePolicy@viewAny (owner or admin permission)
    • Response: Array of shares
  • DELETE /api/v1/secrets/{secret}/shares/{share} - Revoke access

    • Check: Owner OR granter OR has admin permission
    • Authorization: SecretSharePolicy@delete
    • Response: 204 No Content

2. SecretSharePolicy

Location: app/Policies/SecretSharePolicy.php

Methods:

  • viewAny(User $user, Secret $secret) - Can list shares (owner or admin)
  • create(User $user, Secret $secret) - Can grant access (owner or admin)
  • delete(User $user, SecretShare $share) - Can revoke (owner, granter, or admin)

Implementation:

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

public function delete(User $user, SecretShare $share): bool
{
    // Owner always can
    if ($share->secret->owner_id === $user->id) {
        return true;
    }
    
    // Granter can revoke their own grants
    if ($share->granted_by === $user->id) {
        return true;
    }
    
    // Admin permission on secret
    return $share->secret->userHasPermission($user, 'admin');
}

3. Form Request

ShareSecretRequest:

  • user_id - nullable, exists:users,id, uuid
  • role_id - nullable, exists:roles,id
  • permission - required, in:read,write,admin
  • expires_at - nullable, date, after:now
  • Custom rule: Exactly one of user_id OR role_id (XOR validation)

XOR Validation:

public function rules(): array
{
    return [
        'user_id' => ['nullable', 'uuid', 'exists:users,id'],
        'role_id' => ['nullable', 'exists:roles,id'],
        'permission' => ['required', 'in:read,write,admin'],
        'expires_at' => ['nullable', 'date', 'after:now'],
    ];
}

public function withValidator($validator): void
{
    $validator->after(function ($validator) {
        $hasUser = !is_null($this->user_id);
        $hasRole = !is_null($this->role_id);
        
        if ($hasUser === $hasRole) {
            $validator->errors()->add('user_id', 
                'Exactly one of user_id or role_id must be provided.'
            );
        }
    });
}

4. Routes

Location: routes/api.php

Route::middleware(['auth:sanctum', 'tenant.enforce'])->prefix('v1')->group(function () {
    Route::post('secrets/{secret}/shares', [SecretShareController::class, 'store']);
    Route::get('secrets/{secret}/shares', [SecretShareController::class, 'index']);
    Route::delete('secrets/{secret}/shares/{share}', [SecretShareController::class, 'destroy']);
});

5. Tests (Pest)

Feature Tests - SecretShareController:

  • Grant access to user (owner)
  • Grant access to role (owner)
  • Grant access with expiration
  • Grant access (unauthorized - not owner/admin)
  • List shares (owner)
  • List shares (user with admin permission)
  • List shares (unauthorized user)
  • Revoke access (owner)
  • Revoke access (granter)
  • Revoke access (user with admin permission)
  • Revoke access (unauthorized user)
  • Validation: XOR constraint (both user_id + role_id fails)
  • Validation: XOR constraint (neither user_id nor role_id fails)

Feature Tests - SecretSharePolicy:

  • Owner can create shares
  • User with admin permission can create shares
  • User with write permission cannot create shares
  • Granter can delete their own shares
  • Owner can delete any share
  • User with admin permission can delete shares
  • User without permission cannot delete shares

Feature Tests - Integration:

  • User with read share can view secret (via SecretController)
  • User with write share can update secret
  • User with admin share can delete secret
  • Expired share does not grant access

Coverage Target: ≥80% for new code


✅ Acceptance Criteria

  • SecretShareController with 3 endpoints
  • SecretSharePolicy with 3 authorization methods
  • ShareSecretRequest with XOR validation
  • Routes registered in api.php
  • All tests passing (≥21 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

Response Formats

// POST /v1/secrets/{secret}/shares
{
  "data": {
    "id": "uuid",
    "secret_id": "uuid",
    "user": {
      "id": "uuid",
      "name": "John Doe",
      "email": "john@example.com"
    },
    "role": null,
    "permission": "read",
    "granted_by": {
      "id": "uuid",
      "name": "Jane Smith"
    },
    "granted_at": "2025-11-17T10:00:00Z",
    "expires_at": "2025-12-31T23:59:59Z"
  }
}

// GET /v1/secrets/{secret}/shares
{
  "data": [
    {
      "id": "uuid",
      "user": { "id": "uuid", "name": "John Doe" },
      "role": null,
      "permission": "read",
      "granted_by": { "id": "uuid", "name": "Owner" },
      "granted_at": "2025-11-17T10:00:00Z",
      "expires_at": null,
      "is_expired": false
    },
    {
      "id": "uuid",
      "user": null,
      "role": { "id": 5, "name": "Manager" },
      "permission": "write",
      "granted_by": { "id": "uuid", "name": "Owner" },
      "granted_at": "2025-11-16T15:30:00Z",
      "expires_at": "2026-01-01T00:00:00Z",
      "is_expired": false
    }
  ]
}

Error Handling

// XOR Validation Error
{
  "message": "The given data was invalid.",
  "errors": {
    "user_id": [
      "Exactly one of user_id or role_id must be provided."
    ]
  }
}

// Permission Denied
{
  "message": "This action is unauthorized."
}

📝 PR Linking Instructions

When creating the PR:

Fixes #185
Part of: #182
Depends on: #184

⚠️ Important:


Type: Sub-Issue (Backend)
Priority: High
Estimated Effort: 3-4 days
Status: ⏸️ Blocked by #184

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