feat(admin): add option to restrict document creation to admins only

Add new configuration option ACKIFY_ONLY_ADMIN_CAN_CREATE (default: false) to control who can create documents.

Backend changes:
- Add OnlyAdminCanCreate config field to AppConfig
- Implement authorization checks in document handlers
- Protect POST /documents and GET /documents/find-or-create endpoints
- Add unit tests for admin-only document creation (4 tests)

Frontend changes:
- Inject ACKIFY_ONLY_ADMIN_CAN_CREATE to window object
- Hide DocumentForm component for non-admin users when enabled
- Add i18n translations (en, fr, es, de, it)
- Display warning message for non-admin users

Documentation:
- Update .env.example files with new variable
- Update configuration docs (en/fr)
- Update install script to prompt for restriction option
- Update install/README.md

When enabled, only users listed in ACKIFY_ADMIN_EMAILS can create new documents. Both direct creation and find-or-create endpoints are protected.
This commit is contained in:
Benjamin
2025-11-06 16:08:03 +01:00
parent a5c376bae5
commit aa5fee90f6
19 changed files with 297 additions and 16 deletions

View File

@@ -65,5 +65,8 @@ ACKIFY_ED25519_PRIVATE_KEY=your_base64_encoded_ed25519_private_key
# Admin Configuration
# ACKIFY_ADMIN_EMAILS=admin@your-domain.com,admin2@your-domain.com
# Document Creation Restriction
# ACKIFY_ONLY_ADMIN_CAN_CREATE=false
# Server Configuration
ACKIFY_LISTEN_ADDR=:8080

View File

@@ -27,10 +27,11 @@ type AuthConfig struct {
}
type AppConfig struct {
BaseURL string
Organisation string
SecureCookies bool
AdminEmails []string
BaseURL string
Organisation string
SecureCookies bool
AdminEmails []string
OnlyAdminCanCreate bool
}
type DatabaseConfig struct {
@@ -167,6 +168,9 @@ func Load() (*Config, error) {
}
}
// Parse admin-only document creation flag
config.App.OnlyAdminCanCreate = getEnvBool("ACKIFY_ONLY_ADMIN_CAN_CREATE", false)
// Parse mail config (optional, service disabled if MAIL_HOST not set)
mailHost := getEnv("ACKIFY_MAIL_HOST", "")
if mailHost != "" {

View File

@@ -30,20 +30,33 @@ type webhookPublisher interface {
// Handler handles document API requests
type Handler struct {
signatureService *services.SignatureService
documentService documentService
webhookPublisher webhookPublisher
signatureService *services.SignatureService
documentService documentService
webhookPublisher webhookPublisher
adminEmails []string
onlyAdminCanCreate bool
}
// NewHandler creates a new documents handler
// Backward-compatible constructor used by tests and existing code
func NewHandler(signatureService *services.SignatureService, documentService documentService) *Handler {
return &Handler{signatureService: signatureService, documentService: documentService}
return &Handler{
signatureService: signatureService,
documentService: documentService,
adminEmails: []string{},
onlyAdminCanCreate: false,
}
}
// Extended constructor with webhook publisher
func NewHandlerWithPublisher(signatureService *services.SignatureService, documentService documentService, publisher webhookPublisher) *Handler {
return &Handler{signatureService: signatureService, documentService: documentService, webhookPublisher: publisher}
func NewHandlerWithPublisher(signatureService *services.SignatureService, documentService documentService, publisher webhookPublisher, adminEmails []string, onlyAdminCanCreate bool) *Handler {
return &Handler{
signatureService: signatureService,
documentService: documentService,
webhookPublisher: publisher,
adminEmails: adminEmails,
onlyAdminCanCreate: onlyAdminCanCreate,
}
}
// DocumentDTO represents a document data transfer object
@@ -89,6 +102,34 @@ type CreateDocumentResponse struct {
func (h *Handler) HandleCreateDocument(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check if only admins can create documents
if h.onlyAdminCanCreate {
user, authenticated := shared.GetUserFromContext(ctx)
if !authenticated {
logger.Logger.Warn("Unauthenticated user attempted to create document",
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusUnauthorized, shared.ErrCodeUnauthorized, "Authentication required to create document", nil)
return
}
// Check if user is admin
isAdmin := false
for _, adminEmail := range h.adminEmails {
if strings.ToLower(user.Email) == strings.ToLower(adminEmail) {
isAdmin = true
break
}
}
if !isAdmin {
logger.Logger.Warn("Non-admin user attempted to create document",
"user_email", user.Email,
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Only administrators can create documents", nil)
return
}
}
// Parse request body
var req CreateDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -347,6 +388,26 @@ func (h *Handler) HandleFindOrCreateDocument(w http.ResponseWriter, r *http.Requ
return
}
// Check if only admins can create documents
if h.onlyAdminCanCreate {
isAdmin := false
for _, adminEmail := range h.adminEmails {
if strings.ToLower(user.Email) == strings.ToLower(adminEmail) {
isAdmin = true
break
}
}
if !isAdmin {
logger.Logger.Warn("Non-admin user attempted to create document via find-or-create",
"user_email", user.Email,
"reference", ref,
"remote_addr", r.RemoteAddr)
shared.WriteError(w, http.StatusForbidden, shared.ErrCodeForbidden, "Only administrators can create documents", nil)
return
}
}
// User is authenticated, create the document
doc, isNew, err := h.documentService.FindOrCreateDocument(ctx, ref)
if err != nil {

View File

@@ -702,6 +702,138 @@ func TestHandler_HandleCreateDocument_Concurrent(t *testing.T) {
assert.Equal(t, 0, errCount, "All concurrent requests should succeed")
}
// ============================================================================
// ADMIN-ONLY DOCUMENT CREATION TESTS
// ============================================================================
func TestHandler_HandleCreateDocument_AdminOnlyEnabled_AdminUser(t *testing.T) {
t.Parallel()
handler := &Handler{
signatureService: &services.SignatureService{},
documentService: &mockDocumentService{},
adminEmails: []string{"admin@example.com"},
onlyAdminCanCreate: true,
}
reqBody := CreateDocumentRequest{
Reference: "https://example.com/doc.pdf",
Title: "Admin Document",
}
body, err := json.Marshal(reqBody)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Add admin user to context
adminUser := &models.User{
Sub: "oauth2|admin",
Email: "admin@example.com",
Name: "Admin User",
}
ctx := context.WithValue(req.Context(), shared.ContextKeyUser, adminUser)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleCreateDocument(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), "test-doc-123")
}
func TestHandler_HandleCreateDocument_AdminOnlyEnabled_NonAdminUser(t *testing.T) {
t.Parallel()
handler := &Handler{
signatureService: &services.SignatureService{},
documentService: &mockDocumentService{},
adminEmails: []string{"admin@example.com"},
onlyAdminCanCreate: true,
}
reqBody := CreateDocumentRequest{
Reference: "https://example.com/doc.pdf",
Title: "User Document",
}
body, err := json.Marshal(reqBody)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Add non-admin user to context
regularUser := &models.User{
Sub: "oauth2|user",
Email: "user@example.com",
Name: "Regular User",
}
ctx := context.WithValue(req.Context(), shared.ContextKeyUser, regularUser)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleCreateDocument(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Contains(t, rec.Body.String(), "Only administrators can create documents")
}
func TestHandler_HandleCreateDocument_AdminOnlyEnabled_Unauthenticated(t *testing.T) {
t.Parallel()
handler := &Handler{
signatureService: &services.SignatureService{},
documentService: &mockDocumentService{},
adminEmails: []string{"admin@example.com"},
onlyAdminCanCreate: true,
}
reqBody := CreateDocumentRequest{
Reference: "https://example.com/doc.pdf",
Title: "Unauthenticated Document",
}
body, err := json.Marshal(reqBody)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// No user in context (unauthenticated)
rec := httptest.NewRecorder()
handler.HandleCreateDocument(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Body.String(), "Authentication required")
}
func TestHandler_HandleCreateDocument_AdminOnlyDisabled_AnyUser(t *testing.T) {
t.Parallel()
handler := &Handler{
signatureService: &services.SignatureService{},
documentService: &mockDocumentService{},
adminEmails: []string{"admin@example.com"},
onlyAdminCanCreate: false, // Disabled
}
reqBody := CreateDocumentRequest{
Reference: "https://example.com/doc.pdf",
Title: "Public Document",
}
body, err := json.Marshal(reqBody)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// No authentication needed when admin-only is disabled
rec := httptest.NewRecorder()
handler.HandleCreateDocument(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), "test-doc-123")
}
// ============================================================================
// BENCHMARKS
// ============================================================================

View File

@@ -40,6 +40,7 @@ type RouterConfig struct {
AutoLogin bool
OAuthEnabled bool
MagicLinkEnabled bool
OnlyAdminCanCreate bool
}
// NewRouter creates and configures the API v1 router
@@ -68,7 +69,7 @@ func NewRouter(cfg RouterConfig) *chi.Mux {
healthHandler := health.NewHandler()
authHandler := apiAuth.NewHandler(cfg.AuthService, cfg.MagicLinkService, apiMiddleware, cfg.BaseURL, cfg.OAuthEnabled, cfg.MagicLinkEnabled)
usersHandler := users.NewHandler(cfg.AdminEmails)
documentsHandler := documents.NewHandlerWithPublisher(cfg.SignatureService, cfg.DocumentService, cfg.WebhookPublisher)
documentsHandler := documents.NewHandlerWithPublisher(cfg.SignatureService, cfg.DocumentService, cfg.WebhookPublisher, cfg.AdminEmails, cfg.OnlyAdminCanCreate)
signaturesHandler := signatures.NewHandlerWithDeps(cfg.SignatureService, cfg.ExpectedSignerRepository, cfg.WebhookPublisher)
// Public routes

View File

@@ -184,13 +184,14 @@ func NewServer(ctx context.Context, cfg *config.Config, frontend embed.FS, versi
AutoLogin: cfg.OAuth.AutoLogin,
OAuthEnabled: cfg.Auth.OAuthEnabled,
MagicLinkEnabled: cfg.Auth.MagicLinkEnabled,
OnlyAdminCanCreate: cfg.App.OnlyAdminCanCreate,
}
apiRouter := api.NewRouter(apiConfig)
router.Mount("/api/v1", apiRouter)
router.Get("/oembed", handlers.HandleOEmbed(cfg.App.BaseURL))
router.NotFound(EmbedFolder(frontend, "web/dist", cfg.App.BaseURL, version, cfg.Auth.OAuthEnabled, cfg.Auth.MagicLinkEnabled, signatureRepo))
router.NotFound(EmbedFolder(frontend, "web/dist", cfg.App.BaseURL, version, cfg.Auth.OAuthEnabled, cfg.Auth.MagicLinkEnabled, cfg.App.OnlyAdminCanCreate, signatureRepo))
httpServer := &http.Server{
Addr: cfg.Server.ListenAddr,

View File

@@ -22,8 +22,9 @@ import (
// For index.html, it replaces __ACKIFY_BASE_URL__ placeholder with the actual base URL,
// __ACKIFY_VERSION__ with the application version,
// __ACKIFY_OAUTH_ENABLED__ and __ACKIFY_MAGICLINK_ENABLED__ with auth method flags,
// __ACKIFY_ONLY_ADMIN_CAN_CREATE__ with document creation restriction flag,
// and __META_TAGS__ with dynamic meta tags based on query parameters
func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, signatureRepo *database.SignatureRepository) http.HandlerFunc {
func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fsys, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
@@ -62,7 +63,7 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version st
defer file.Close()
if shouldServeIndex || strings.HasSuffix(cleanPath, "index.html") {
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, signatureRepo)
serveIndexTemplate(w, r, file, baseURL, version, oauthEnabled, magicLinkEnabled, onlyAdminCanCreate, signatureRepo)
return
}
@@ -71,7 +72,7 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string, baseURL string, version st
}
}
func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, signatureRepo *database.SignatureRepository) {
func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, baseURL string, version string, oauthEnabled bool, magicLinkEnabled bool, onlyAdminCanCreate bool, signatureRepo *database.SignatureRepository) {
content, err := io.ReadAll(file)
if err != nil {
logger.Logger.Error("Failed to read index.html", "error", err.Error())
@@ -91,9 +92,14 @@ func serveIndexTemplate(w http.ResponseWriter, r *http.Request, file fs.File, ba
if magicLinkEnabled {
magicLinkEnabledStr = "true"
}
onlyAdminCanCreateStr := "false"
if onlyAdminCanCreate {
onlyAdminCanCreateStr = "true"
}
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_OAUTH_ENABLED__", oauthEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_MAGICLINK_ENABLED__", magicLinkEnabledStr)
processedContent = strings.ReplaceAll(processedContent, "__ACKIFY_ONLY_ADMIN_CAN_CREATE__", onlyAdminCanCreateStr)
metaTags := generateMetaTags(r, baseURL, signatureRepo)
processedContent = strings.ReplaceAll(processedContent, "__META_TAGS__", metaTags)

View File

@@ -61,6 +61,9 @@ ACKIFY_OAUTH_SCOPES=openid,email,profile
```bash
# Admin email list (comma-separated)
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
# Restrict document creation to admins only (default: false)
ACKIFY_ONLY_ADMIN_CAN_CREATE=false
```
Admins have access to:
@@ -70,6 +73,11 @@ Admins have access to:
- Email reminders sending
- Document deletion
When `ACKIFY_ONLY_ADMIN_CAN_CREATE` is enabled:
- ✅ Only admin users can create new documents
- ✅ Non-admin users will see an error message when attempting to create documents
- ✅ Both API endpoints (`POST /documents` and `GET /documents/find-or-create`) are protected
### Document Checksum (Optional)
Configuration for automatic checksum computation when creating documents from URLs:

View File

@@ -61,6 +61,9 @@ ACKIFY_OAUTH_SCOPES=openid,email,profile
```bash
# Liste d'emails admin (séparés par virgules)
ACKIFY_ADMIN_EMAILS=admin@company.com,admin2@company.com
# Restreindre la création de documents aux admins uniquement (défaut: false)
ACKIFY_ONLY_ADMIN_CAN_CREATE=false
```
Les admins ont accès à :
@@ -70,6 +73,11 @@ Les admins ont accès à :
- Envoi de rappels email
- Suppression de documents
Quand `ACKIFY_ONLY_ADMIN_CAN_CREATE` est activé :
- ✅ Seuls les utilisateurs admin peuvent créer de nouveaux documents
- ✅ Les utilisateurs non-admin verront un message d'erreur lors d'une tentative de création
- ✅ Les deux endpoints API (`POST /documents` et `GET /documents/find-or-create`) sont protégés
### Checksums Documents (Optionnel)
Configuration pour le calcul automatique de checksum lors de la création de documents depuis des URLs :

View File

@@ -107,6 +107,10 @@ ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
# Admins have access to document management and reminder features
# ACKIFY_ADMIN_EMAILS=admin@your-domain.com,admin2@your-domain.com
# Document Creation Restriction
# When enabled, only admins can create new documents
# ACKIFY_ONLY_ADMIN_CAN_CREATE=false
# ==========================================
# CONFIGURATION INSTRUCTIONS
# ==========================================

View File

@@ -192,6 +192,7 @@ ADMIN_EMAILS=admin@your-domain.com
- `OAUTH_AUTO_LOGIN` - Automatically log in if OAuth session exists
- `MAIL_*` - SMTP configuration for email features
- `AUTH_MAGICLINK_ENABLED` - Force enable/disable MagicLink
- `ONLY_ADMIN_CAN_CREATE` - Restrict document creation to admins only (default: false)
## Troubleshooting

View File

@@ -328,6 +328,21 @@ done
print_success "Admin users configured: $ADMIN_EMAILS"
echo ""
# Document Creation Restriction
echo ""
print_info "By default, any authenticated user can create documents."
print_info "You can restrict document creation to admins only."
echo ""
ONLY_ADMIN_CAN_CREATE=false
if prompt_yes_no "Restrict document creation to admins only?" "n"; then
ONLY_ADMIN_CAN_CREATE=true
print_success "Document creation restricted to admins"
else
print_success "All authenticated users can create documents"
fi
echo ""
# ==========================================
# Generate Secrets
# ==========================================
@@ -462,6 +477,9 @@ if [ -n "$ADMIN_EMAILS" ]; then
# ==========================================
ACKIFY_ADMIN_EMAILS=${ADMIN_EMAILS}
# Restrict document creation to admins only (default: false)
ACKIFY_ONLY_ADMIN_CAN_CREATE=${ONLY_ADMIN_CAN_CREATE}
EOF
fi
@@ -514,6 +532,11 @@ fi
echo ""
print_success "Admin Users: ${ADMIN_EMAILS}"
if [ "$ONLY_ADMIN_CAN_CREATE" = true ]; then
print_info "Document Creation: Restricted to admins only"
else
print_info "Document Creation: All authenticated users"
fi
echo ""
if [ "$ENABLE_TRAEFIK" = true ]; then

View File

@@ -11,6 +11,7 @@
window.ACKIFY_VERSION = '__ACKIFY_VERSION__';
window.ACKIFY_OAUTH_ENABLED = '__ACKIFY_OAUTH_ENABLED__' === 'true';
window.ACKIFY_MAGICLINK_ENABLED = '__ACKIFY_MAGICLINK_ENABLED__' === 'true';
window.ACKIFY_ONLY_ADMIN_CAN_CREATE = '__ACKIFY_ONLY_ADMIN_CAN_CREATE__' === 'true';
</script>
</head>
<body>

View File

@@ -75,6 +75,9 @@
"loadFailed": "Fehler beim Laden des Dokuments",
"loginButton": "Anmelden"
},
"documentCreation": {
"restrictedToAdmins": "Nur Administratoren können Dokumente erstellen."
},
"document": {
"title": "Zu bestätigendes Dokument",
"id": "ID"

View File

@@ -75,6 +75,9 @@
"loadFailed": "Failed to load document",
"loginButton": "Sign in"
},
"documentCreation": {
"restrictedToAdmins": "Only administrators can create documents."
},
"document": {
"title": "Document to confirm",
"id": "ID"

View File

@@ -75,6 +75,9 @@
"loadFailed": "Error al cargar el documento",
"loginButton": "Iniciar sesión"
},
"documentCreation": {
"restrictedToAdmins": "Solo los administradores pueden crear documentos."
},
"document": {
"title": "Documento a confirmar",
"id": "ID"

View File

@@ -75,6 +75,9 @@
"loadFailed": "Échec du chargement du document",
"loginButton": "Se connecter"
},
"documentCreation": {
"restrictedToAdmins": "Seuls les administrateurs peuvent créer des documents."
},
"document": {
"title": "Document à confirmer",
"id": "ID"

View File

@@ -75,6 +75,9 @@
"loadFailed": "Caricamento del documento fallito",
"loginButton": "Accedi"
},
"documentCreation": {
"restrictedToAdmins": "Solo gli amministratori possono creare documenti."
},
"document": {
"title": "Documento da confermare",
"id": "ID"

View File

@@ -35,6 +35,11 @@ const signatureStore = useSignatureStore()
const docId = ref<string | undefined>(undefined)
const user = computed(() => authStore.user)
const isAdmin = computed(() => authStore.isAdmin)
// Check if document creation is restricted to admins
const onlyAdminCanCreate = (window as any).ACKIFY_ONLY_ADMIN_CAN_CREATE || false
const canCreateDocument = computed(() => !onlyAdminCanCreate || isAdmin.value)
const currentDocument = ref<FindOrCreateDocumentResponse | null>(null)
const documentSignatures = ref<any[]>([])
@@ -295,7 +300,15 @@ onMounted(async () => {
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=https://example.com/policy.pdf</code>
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=/path/to/document</code>
<code class="block px-3 py-2 bg-muted rounded text-xs">/?doc=my-unique-ref</code>
<DocumentForm />
<DocumentForm v-if="canCreateDocument" />
<Alert v-else variant="warning" class="mt-4">
<div class="flex items-start">
<AlertTriangle :size="18" class="mr-3 mt-0.5"/>
<div class="flex-1 text-sm">
<p>{{ t('sign.documentCreation.restrictedToAdmins') }}</p>
</div>
</div>
</Alert>
</div>
</CardContent>
</Card>