fix: restrict signature list visibility to document owner/admin

- Signatures endpoint now returns only user's own signature for non-owner/admin
- Owner/admin can see all signatures, others see only their own (if signed)
- Added signatureCount to FindOrCreateDocument response for public count display
- Frontend shows signature count header even when detailed list is restricted

Closes #20
This commit is contained in:
Benjamin
2026-02-05 20:37:00 +01:00
parent 2449e7ccee
commit 635f8c7021
6 changed files with 102 additions and 16 deletions

View File

@@ -324,6 +324,9 @@ func (h *Handler) HandleGetDocument(w http.ResponseWriter, r *http.Request) {
}
// HandleGetDocumentSignatures handles GET /api/v1/documents/{docId}/signatures
// Returns the detailed signature list only for document owner or admin.
// For authenticated users who are not owner/admin, returns only their own signature (if they signed).
// Non-authenticated users receive an empty list (the count remains available via DocumentDTO).
func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "docId")
if docID == "" {
@@ -333,6 +336,24 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req
ctx := r.Context()
// Check if user can view the detailed list
user, authenticated := shared.GetUserFromContext(ctx)
// Retrieve document to get CreatedBy
doc, err := h.documentService.GetByDocID(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
shared.WriteInternalError(w)
return
}
if doc == nil {
shared.WriteError(w, http.StatusNotFound, shared.ErrCodeNotFound, "Document not found", nil)
return
}
// Owner/Admin can see all signatures
canViewAll := authenticated && user != nil && h.authorizer.CanManageDocument(ctx, user.Email, doc.CreatedBy)
signatures, err := h.signatureService.GetDocumentSignatures(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get signatures",
@@ -342,13 +363,28 @@ func (h *Handler) HandleGetDocumentSignatures(w http.ResponseWriter, r *http.Req
return
}
// Convert to DTOs
dtos := make([]SignatureDTO, len(signatures))
for i := range signatures {
dtos[i] = signatureToDTO(signatures[i])
// If owner/admin, return all signatures
if canViewAll {
dtos := make([]SignatureDTO, len(signatures))
for i := range signatures {
dtos[i] = signatureToDTO(signatures[i])
}
shared.WriteJSON(w, http.StatusOK, dtos)
return
}
shared.WriteJSON(w, http.StatusOK, dtos)
// For authenticated users (not owner/admin), return only their own signature if they signed
if authenticated && user != nil {
for _, sig := range signatures {
if sig.UserEmail == user.Email {
shared.WriteJSON(w, http.StatusOK, []SignatureDTO{signatureToDTO(sig)})
return
}
}
}
// Non-authenticated or user hasn't signed → return empty list
shared.WriteJSON(w, http.StatusOK, []SignatureDTO{})
}
// PublicExpectedSigner represents an expected signer in public API (minimal info)
@@ -358,6 +394,8 @@ type PublicExpectedSigner struct {
}
// HandleGetExpectedSigners handles GET /api/v1/documents/{docId}/expected-signers
// Returns the expected signers list only for document owner or admin.
// Non-authorized users receive an empty list.
func (h *Handler) HandleGetExpectedSigners(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
docID := chi.URLParam(r, "docId")
@@ -367,6 +405,29 @@ func (h *Handler) HandleGetExpectedSigners(w http.ResponseWriter, r *http.Reques
return
}
// Check if user can view the detailed list
user, authenticated := shared.GetUserFromContext(ctx)
// Retrieve document to get CreatedBy
doc, err := h.documentService.GetByDocID(ctx, docID)
if err != nil {
logger.Logger.Error("Failed to get document", "doc_id", docID, "error", err.Error())
shared.WriteInternalError(w)
return
}
if doc == nil {
shared.WriteError(w, http.StatusNotFound, shared.ErrCodeNotFound, "Document not found", nil)
return
}
// If not authenticated or not authorized → return empty list
canViewDetails := authenticated && user != nil && h.authorizer.CanManageDocument(ctx, user.Email, doc.CreatedBy)
if !canViewDetails {
shared.WriteJSON(w, http.StatusOK, []PublicExpectedSigner{})
return
}
// Get expected signers (public version - without internal notes/metadata)
signers, err := h.documentService.ListExpectedSigners(ctx, docID)
if err != nil {
@@ -421,6 +482,7 @@ type FindOrCreateDocumentResponse struct {
VerifyChecksum bool `json:"verifyChecksum"`
CreatedAt string `json:"createdAt"`
IsNew bool `json:"isNew"`
SignatureCount int `json:"signatureCount"`
// Storage fields for uploaded documents
StorageKey string `json:"storageKey,omitempty"`
MimeType string `json:"mimeType,omitempty"`
@@ -463,6 +525,12 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
"doc_id", existingDoc.DocID,
"reference", ref)
// Get signature count
signatureCount := 0
if sigs, err := h.signatureService.GetDocumentSignatures(ctx, existingDoc.DocID); err == nil {
signatureCount = len(sigs)
}
response := FindOrCreateDocumentResponse{
DocID: existingDoc.DocID,
URL: existingDoc.URL,
@@ -476,6 +544,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
VerifyChecksum: existingDoc.VerifyChecksum,
CreatedAt: existingDoc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: false,
SignatureCount: signatureCount,
StorageKey: existingDoc.StorageKey,
MimeType: existingDoc.MimeType,
}
@@ -518,7 +587,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
"reference", ref,
"user_email", user.Email)
// Build response
// Build response (new document has 0 signatures)
response := FindOrCreateDocumentResponse{
DocID: doc.DocID,
URL: doc.URL,
@@ -532,6 +601,7 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
VerifyChecksum: doc.VerifyChecksum,
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
IsNew: isNew,
SignatureCount: 0,
StorageKey: doc.StorageKey,
MimeType: doc.MimeType,
}

View File

@@ -501,9 +501,16 @@ func TestHandler_HandleFindOrCreateDocument_FindExisting(t *testing.T) {
},
}
mockSigService := &mockSignatureService{
getDocumentSignaturesFunc: func(ctx context.Context, docID string) ([]*models.Signature, error) {
return []*models.Signature{testSignature}, nil
},
}
handler := &Handler{
documentService: mockDocService,
authorizer: newMockAuthorizer([]string{}, false),
documentService: mockDocService,
signatureService: mockSigService,
authorizer: newMockAuthorizer([]string{}, false),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/find-or-create?ref=https://example.com/doc.pdf", nil)
@@ -521,6 +528,7 @@ func TestHandler_HandleFindOrCreateDocument_FindExisting(t *testing.T) {
assert.Equal(t, testDoc.DocID, wrapper.Data.DocID)
assert.False(t, wrapper.Data.IsNew, "Should not be new since document was found")
assert.Equal(t, 1, wrapper.Data.SignatureCount, "Should have 1 signature")
}
func TestHandler_HandleFindOrCreateDocument_CreateNew(t *testing.T) {

View File

@@ -251,8 +251,13 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
// Read-only document endpoints
r.Get("/", documentsHandler.HandleListDocuments)
r.Get("/{docId}", documentsHandler.HandleGetDocument)
r.Get("/{docId}/signatures", documentsHandler.HandleGetDocumentSignatures)
r.Get("/{docId}/expected-signers", documentsHandler.HandleGetExpectedSigners)
// Signatures and expected-signers: detailed list restricted to owner/admin
r.Group(func(r chi.Router) {
r.Use(apiMiddleware.OptionalAuth)
r.Get("/{docId}/signatures", documentsHandler.HandleGetDocumentSignatures)
r.Get("/{docId}/expected-signers", documentsHandler.HandleGetExpectedSigners)
})
// Find or create document by reference (public for embed support, but with optional auth)
r.Group(func(r chi.Router) {

View File

@@ -25,8 +25,8 @@
<!-- Document info and signatures -->
<div v-else-if="documentData" class="max-w-2xl mx-auto">
<!-- Document header with signatures -->
<div v-if="documentData.signatures.length > 0">
<!-- Document header with signatures (only shown if user has access to view signatures) -->
<div v-if="documentData.signatures && documentData.signatures.length > 0">
<!-- Header Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 sm:p-5 mb-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">

View File

@@ -604,8 +604,9 @@ onMounted(async () => {
</div>
<!-- Existing Confirmations (Below document on mobile, here on desktop) -->
<div v-if="documentSignatures.length > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-4 sm:p-6 border-b border-slate-100 dark:border-slate-700">
<!-- Show section if there are signatures (count from document, even if user can't see details) -->
<div v-if="currentDocument.signatureCount > 0" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="p-4 sm:p-6" :class="{ 'border-b border-slate-100 dark:border-slate-700': documentSignatures.length > 0 }">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<Users :size="20" class="text-blue-600 dark:text-blue-400" />
@@ -613,12 +614,13 @@ onMounted(async () => {
<div>
<h3 class="font-semibold text-slate-900 dark:text-slate-100">{{ t('sign.confirmations.title') }}</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ t('sign.confirmations.count', { count: documentSignatures.length }, documentSignatures.length) }}
{{ t('sign.confirmations.count', { count: currentDocument.signatureCount }, currentDocument.signatureCount) }}
</p>
</div>
</div>
</div>
<div class="p-4 sm:p-6">
<!-- Only show detailed list if user has access (owner/admin) -->
<div v-if="documentSignatures.length > 0" class="p-4 sm:p-6">
<SignatureList
:signatures="documentSignatures"
:loading="loadingSignatures"

View File

@@ -54,6 +54,7 @@ export interface FindOrCreateDocumentResponse {
verifyChecksum: boolean
createdAt: string
isNew: boolean // true if created, false if found
signatureCount: number
// Storage fields for uploaded documents
storageKey?: string
mimeType?: string