Repository: CSCfi/rems
Pull request #3076 (2022-11-01 14:56:37)

Attachment redaction API
relates #2491 (partial implementation)

# API for attachment redaction
- allow `:handler`, `:decider` and `:reviewer` to redact attachments whenever these roles have the permission to add attachments via another command
- process manager handles redaction, by zeroing file contents of given attachment. filename is masked in application model via enrichment and replaced with keyword `:filename/redacted`, which is returned from the API. localization support has been added so that REMS client can render the redacted filename localized.
- redacted attachments are not available for download via client, but they can still be downloaded using the API.
- `:redacted-attachments` event property describes both redacted attachments and events from where the redacted attachments originate from. this can be used for highlighting events in UI. it is relatively similar to `:request-id` but can contain multiple references.
- highlighting mechanism has been refactored to support different highlighting scenarios. currently there are two: `request-id` and redacted attachment events. adding more scenarios in the future should be relatively straightforward.

```clojure
;; :application.command/redact-attachments
(s/defschema RedactAttachmentsCommand
  (assoc CommandWithComment
         :redacted-attachments [CommandAttachment]
         :public s/Bool))
```

```clojure
;; :application.event/attachments-redacted
(s/defschema AttachmentsRedactedEvent
  (assoc EventWithComment ; includes comment and attachments
         :event/type (s/enum :application.event/attachments-redacted)
         :event/redacted-attachments [(merge EventAttachment {:event/id s/Int})]
         :application/public s/Bool))
``` 

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue
- [x] Consider adding screenshots for ease of review

## Backwards compatibility
- [x] API is completely new
- [x] Feature appears correctly in PDF print

## Documentation
- [ ] Update changelog if necessary (maybe could include with separate PR that implements UI?)
- [x] API is documented and shows up in Swagger UI

## Testing
- [x] Complex logic is unit tested
- [x] Valuable features are integration / browser / acceptance tested automatically

## Follow-up
- [x] New tasks are created for pending or remaining tasks #3081 

---

## Screenshots

application
<img width="770" alt="Screenshot 2022-12-08 at 18 10 11" src="https://user-images.githubusercontent.com/794087/206499667-33288f16-ba36-45d2-8ac6-c99b56079369.png">
<img width="770" alt="Screenshot 2022-12-09 at 10 29 32" src="https://user-images.githubusercontent.com/794087/206658697-4b09f290-91f5-47ef-94f1-ea15f0603d37.png">

pdf
<img width="771" alt="Screenshot 2022-12-09 at 10 28 04" src="https://user-images.githubusercontent.com/794087/206658521-c3f8e7d2-a37c-48f1-8d97-d40a1e76bdf3.png">




Patch:
diff --git a/docs/application-permissions.md b/docs/application-permissions.md
index 876fabdbb..4285527bf 100644
--- a/docs/application-permissions.md
+++ b/docs/application-permissions.md
@@ -1,3 +1,3 @@
 # Application Permissions Reference
 
-<table border="1"><tr><th>State \ Role</th><th>applicant</th><th>decider</th><th>everyone‑else</th><th>expirer</th><th>handler</th><th>member</th><th>past‑decider</th><th>past‑reviewer</th><th>reporter</th><th>reviewer</th></tr><tr><th valign="top">approved</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑member</div><div>change‑resources</div><div>close</div><div>invite‑member</div><div>remark</div><div>remove‑member</div><div>revoke</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">closed</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">draft</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>copy‑as‑new</div><div>delete</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --></td><td valign="top"><!-- role: past-reviewer --></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --></td></tr><tr><th valign="top">rejected</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">returned</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>close</div><div>copy‑as‑new</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>remark</div><div>remove‑member</div><div>request‑review</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">revoked</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">submitted</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>approve</div><div>decide</div><div>reject</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>approve</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>reject</div><div>remark</div><div>remove‑member</div><div>request‑decision</div><div>request‑review</div><div>return</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>remark</div><div>review</div></td></tr></table>
\ No newline at end of file
+<table border="1"><tr><th>State \ Role</th><th>applicant</th><th>decider</th><th>everyone‑else</th><th>expirer</th><th>handler</th><th>member</th><th>past‑decider</th><th>past‑reviewer</th><th>reporter</th><th>reviewer</th></tr><tr><th valign="top">approved</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑member</div><div>change‑resources</div><div>close</div><div>invite‑member</div><div>redact‑attachments</div><div>remark</div><div>remove‑member</div><div>revoke</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">closed</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">draft</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>copy‑as‑new</div><div>delete</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --></td><td valign="top"><!-- role: past-reviewer --></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --></td></tr><tr><th valign="top">rejected</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">returned</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>change‑resources</div><div>close</div><div>copy‑as‑new</div><div>invite‑member</div><div>remove‑member</div><div>save‑draft</div><div>submit</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>decide</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>redact‑attachments</div><div>remark</div><div>remove‑member</div><div>request‑review</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div><div>review</div></td></tr><tr><th valign="top">revoked</th><td valign="top"><!-- role: applicant --><div>copy‑as‑new</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div></td><td valign="top"><!-- role: everyone-else --></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: member --><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div></td></tr><tr><th valign="top">submitted</th><td valign="top"><!-- role: applicant --><div>accept‑licenses</div><div>copy‑as‑new</div><div>remove‑member</div><div>uninvite‑member</div></td><td valign="top"><!-- role: decider --><div>see‑everything</div><div>approve</div><div>decide</div><div>redact‑attachments</div><div>reject</div><div>remark</div></td><td valign="top"><!-- role: everyone-else --><div>accept‑invitation</div></td><td valign="top"><!-- role: expirer --><div>delete</div><div>send‑expiration‑notifications</div></td><td valign="top"><!-- role: handler --><div>see‑everything</div><div>add‑licenses</div><div>add‑member</div><div>approve</div><div>assign‑external‑id</div><div>change‑applicant</div><div>change‑resources</div><div>close</div><div>invite‑decider</div><div>invite‑member</div><div>invite‑reviewer</div><div>redact‑attachments</div><div>reject</div><div>remark</div><div>remove‑member</div><div>request‑decision</div><div>request‑review</div><div>return</div><div>uninvite‑member</div></td><td valign="top"><!-- role: member --><div>accept‑licenses</div><div>copy‑as‑new</div></td><td valign="top"><!-- role: past-decider --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: past-reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div></td><td valign="top"><!-- role: reporter --><div>see‑everything</div></td><td valign="top"><!-- role: reviewer --><div>see‑everything</div><div>redact‑attachments</div><div>remark</div><div>review</div></td></tr></table>
\ No newline at end of file
diff --git a/env/dev/clj/rems.clj b/env/dev/clj/rems.clj
index 137fe9cbb..11a094573 100644
--- a/env/dev/clj/rems.clj
+++ b/env/dev/clj/rems.clj
@@ -2,7 +2,7 @@
   (:require [clojure.pprint :refer [pprint]]
             [clojure.tools.namespace.repl :as repl]
             [kaocha.repl]
-            [rems.standalone]
+            [rems.main]
             [rems.repl-utils]))
 
 (defn repl-help []
@@ -17,18 +17,18 @@
   (println "                 (kaocha 'rems.api.test-catalogue-items/change-form-test)"))
 
 (defn start-app []
-  (rems.standalone/start-app))
+  (rems.main/start-app))
 
 (defn stop-app []
-  (rems.standalone/stop-app))
+  (rems.main/stop-app))
 
 (defn reload []
-  (rems.standalone/stop-app)
-  (repl/refresh-all :after 'rems.standalone/start-app))
+  (rems.main/stop-app)
+  (repl/refresh-all :after 'rems.main/start-app))
 
 (defn refresh []
-  (rems.standalone/stop-app)
-  (repl/refresh :after 'rems.standalone/start-app))
+  (rems.main/stop-app)
+  (repl/refresh :after 'rems.main/start-app))
 
 (defn pptransit []
   (rems.repl-utils/pptransit))
diff --git a/project.clj b/project.clj
index e74d6d040..465b5c5f7 100644
--- a/project.clj
+++ b/project.clj
@@ -70,7 +70,7 @@
   :test-paths ["src/clj" "src/cljc" "test/clj" "test/cljc"] ; also run tests from src files
   :resource-paths ["resources" "target/shadow"]
   :target-path "target/%s/"
-  :main rems.standalone
+  :main rems.main
   :migratus {:store :database :db ~(get (System/getenv) "DATABASE_URL" "postgresql://localhost/rems?user=rems")}
 
   :plugins [[lein-cljfmt "0.6.7"]
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
index bebe2ad2d..e5854f064 100644
--- a/resources/sql/queries.sql
+++ b/resources/sql/queries.sql
@@ -309,6 +309,11 @@ UPDATE attachment
 SET (appId, userId, filename, type) = (:application, :user, :filename, :type)
 WHERE id = :id;
 
+-- :name redact-attachment! :!
+UPDATE attachment
+SET data = decode('', 'hex')
+WHERE id = :id;
+
 -- :name get-attachment :? :1
 SELECT id, appid, filename, userId, type, data FROM attachment
 WHERE id = :id;
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 02e04c469..f1d5b2e66 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -174,6 +174,7 @@
                  :applicant "Applicant"
                  :application "Application"
                  :applications "Applications"
+                 :attachment-filename-redacted "Attachment redacted"
                  :continue-existing-application "Continue application"
                  :copied-from "copied from"
                  :copied-to "copied to"
@@ -189,6 +190,7 @@
                  ;; %1 - name of event actor
                  :events {:applicant-changed "%1 changed the applicant to %2."
                           :approved "%1 approved the application."
+                          :attachments-redacted "%1 redacted attachments. %2"
                           :closed "%1 closed the application."
                           :copied-from "This application is a copy of an old application."
                           :copied-to "%1 copied the application to a new application."
@@ -239,6 +241,7 @@
                  :last-activity "Last activity"
                  :latest-activity "Latest activity"
                  :my-applications "My Applications"
+                 :redacted-attachments-replaced "New attachments replace the redacted attachments."
                  :resource "Resource"
                  :state "State"
                  :states {:approved "Approved"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index d8d59a304..04522707f 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -170,6 +170,7 @@
                  :applicant "Hakija"
                  :application "Hakemus"
                  :applications "Hakemukset"
+                 :attachment-filename-redacted "Liitetiedosto poistettu"
                  :continue-existing-application "Jatka hakemusta"
                  :copied-from "kopioitu hakemuksesta"
                  :copied-to "kopioitu hakemukseksi"
@@ -180,6 +181,7 @@
                  :entitlement-end "Pääsyoikeudet loppuvat %1."
                  :events {:applicant-changed "%1 vaihtoi hakijan henkilöksi %2."
                           :approved "%1 hyväksyi hakemuksen."
+                          :attachments-redacted "%1 poisti liitetiedostoja. %2"
                           :closed "%1 sulki hakemuksen."
                           :copied-from "Tämä hakemus on kopio vanhasta hakemuksesta."
                           :copied-to "%1 kopioi hakemuksen uudeksi hakemukseksi."
@@ -223,6 +225,7 @@
                  :last-activity "Muokattu"
                  :latest-activity "Viimeisin toimenpide"
                  :my-applications "Omat hakemukset"
+                 :redacted-attachments-replaced "Uudet liitetiedostot korvaavat poistetut liitetiedostot."
                  :resource "Resurssi"
                  :state "Tila"
                  :states {:approved "Hyväksytty"
diff --git a/resources/translations/sv.edn b/resources/translations/sv.edn
index ed00afaf9..d0bd9889c 100644
--- a/resources/translations/sv.edn
+++ b/resources/translations/sv.edn
@@ -170,6 +170,7 @@
                  :applicant "Sökande"
                  :application "Ansökan"
                  :applications "Ansökningar"
+                 :attachment-filename-redacted "Bilagan borttagen"
                  :continue-existing-application "Återuppta ansökan"
                  :copied-from "Kopierat från ansökan"
                  :copied-to "Kopierat till ansökan"
@@ -180,6 +181,7 @@
                  :entitlement-end "Upphörandedatum för rättigheterna: %1."
                  :events {:applicant-changed "%1 bytte sökande till %2." ; TODO check
                           :approved "%1 godkände ansökan."
+                          :attachments-redacted "%1 tog bort bilagor. %2"
                           :closed "%1 stängde ansökan."
                           :copied-from "Den här ansökan är en kopia från en tidigare ansökan."
                           :copied-to "%1 kopierade ansökan som ny ansökan."
@@ -223,6 +225,7 @@
                  :last-activity "Ändrat"
                  :latest-activity "Senaste aktivitet"
                  :my-applications "Mina ansökningar"
+                 :redacted-attachments-replaced "Nya bilagor byts ut gamla bilagor."
                  :resource "Resurs"
                  :state "Status"
                  :states {:approved "Godkänd"
diff --git a/src/clj/rems/api/applications.clj b/src/clj/rems/api/applications.clj
index 551b2e4f9..f0b651966 100644
--- a/src/clj/rems/api/applications.clj
+++ b/src/clj/rems/api/applications.clj
@@ -251,6 +251,7 @@
     (command-endpoint :application.command/invite-reviewer commands/InviteReviewerCommand)
     (command-endpoint :application.command/change-applicant commands/ChangeApplicantCommand
                       "Promote member of application to applicant. Previous applicant becomes a member.")
+    (command-endpoint :application.command/redact-attachments commands/RedactAttachmentsCommand)
     (command-endpoint :application.command/reject commands/RejectCommand)
     (command-endpoint :application.command/remark commands/RemarkCommand)
     (command-endpoint :application.command/remove-member commands/RemoveMemberCommand)
diff --git a/src/clj/rems/api/schema.clj b/src/clj/rems/api/schema.clj
index 63c33d9b1..3fa6cf028 100644
--- a/src/clj/rems/api/schema.clj
+++ b/src/clj/rems/api/schema.clj
@@ -197,7 +197,7 @@
 
 (s/defschema ApplicationAttachment
   {:attachment/id s/Int
-   :attachment/filename s/Str
+   :attachment/filename (s/cond-pre s/Str (s/enum :filename/redacted))
    :attachment/type s/Str})
 
 (s/defschema BlacklistEntry
diff --git a/src/clj/rems/api/services/attachment.clj b/src/clj/rems/api/services/attachment.clj
index 6f81f6f76..da9517608 100644
--- a/src/clj/rems/api/services/attachment.clj
+++ b/src/clj/rems/api/services/attachment.clj
@@ -1,6 +1,7 @@
 (ns rems.api.services.attachment
   (:require [clojure.set :as set]
             [clojure.tools.logging :as log]
+            [medley.core :refer [assoc-some find-first]]
             [rems.application.commands :as commands]
             [rems.application.model :as model]
             [rems.auth.util :refer [throw-forbidden]]
@@ -16,10 +17,6 @@
       (header "Content-Disposition" (str "attachment;filename=" (pr-str (:attachment/filename attachment))))
       (content-type (:attachment/type attachment))))
 
-(defn- contains-attachment? [application attachment-id]
-  (some #(= attachment-id (:attachment/id %))
-        (:application/attachments application)))
-
 (defn get-application-attachment [user-id attachment-id]
   (let [attachment (attachments/get-attachment attachment-id)]
     (cond
@@ -29,12 +26,15 @@
       (= user-id (:attachment/user attachment))
       attachment
 
-      (contains-attachment? (applications/get-application-for-user user-id (:application/id attachment))
-                            attachment-id)
-      attachment
-
       :else
-      (throw-forbidden))))
+      (let [application (applications/get-application-for-user user-id (:application/id attachment))
+            application-attachment (->> (:application/attachments application)
+                                        (find-first #(= attachment-id (:attachment/id %))))
+            redacted? (= :filename/redacted (:attachment/filename application-attachment))]
+        (if (some? application-attachment) ; user can see the attachment
+          (assoc-some attachment :attachment/filename (when redacted?
+                                                        "redacted"))
+          (throw-forbidden))))))
 
 (defn add-application-attachment [user-id application-id file]
   (attachments/check-size file)
@@ -55,19 +55,22 @@
   (let [classes (model/classify-attachments application)
         out (ByteArrayOutputStream.)]
     (with-open [zip (ZipOutputStream. out)]
-      (doseq [metadata (getx application :application/attachments)]
-        (let [id (getx metadata :attachment/id)
-              attachment (attachments/get-attachment id)]
-          (when (or all? (contains? (get classes id) :field/value))
-            ;; we deduplicate filenames when uploading, but here's a
-            ;; failsafe in case we have duplicate filenames in old
-            ;; applications
-            (try
-              (.putNextEntry zip (ZipEntry. (getx attachment :attachment/filename)))
-              (.write zip (getx attachment :attachment/data))
-              (.closeEntry zip)
-              (catch ZipException e
-                (log/warn "Ignoring attachment" (pr-str metadata) "when generating zip. Cause:" e)))))))
+      (doseq [metadata (getx application :application/attachments)
+              :let [id (getx metadata :attachment/id)
+                    attachment (attachments/get-attachment id)]
+              :when (or all? (contains? (get classes id) :field/value))]
+        ;; we deduplicate filenames when uploading, but here's a
+        ;; failsafe in case we have duplicate filenames in old
+        ;; applications
+        (try
+          (.putNextEntry zip (ZipEntry. (let [filename (getx metadata :attachment/filename)]
+                                          (if (= :filename/redacted filename)
+                                            "redacted"
+                                            filename))))
+          (.write zip (getx attachment :attachment/data))
+          (.closeEntry zip)
+          (catch ZipException e
+            (log/warn "Ignoring attachment" (pr-str metadata) "when generating zip. Cause:" e)))))
     (-> (ok (ByteArrayInputStream. (.toByteArray out))) ;; extra copy of the data here, could be more efficient
         (header "Content-Disposition" (str "attachment;filename=attachments-" (getx application :application/id) ".zip"))
         (content-type "application/zip"))))
diff --git a/src/clj/rems/api/services/command.clj b/src/clj/rems/api/services/command.clj
index e9583c2d8..5150ffefb 100644
--- a/src/clj/rems/api/services/command.clj
+++ b/src/clj/rems/api/services/command.clj
@@ -30,7 +30,8 @@
    (bona-fide-bot/run-bona-fide-bot new-events)
    (event-notification/queue-notifications! new-events)
    (process-managers/delete-applications new-events)
-   (process-managers/delete-orphan-attachments-on-submit new-events)))
+   (process-managers/delete-orphan-attachments-on-submit new-events)
+   (process-managers/clear-redacted-attachments new-events)))
 
 (def ^:private command-injections
   (merge applications/fetcher-injections
diff --git a/src/clj/rems/api/services/todos.clj b/src/clj/rems/api/services/todos.clj
index 0562c2fde..d74db13cd 100644
--- a/src/clj/rems/api/services/todos.clj
+++ b/src/clj/rems/api/services/todos.clj
@@ -55,7 +55,8 @@
           :application.command/uninvite-member
           ;; remarks can be made without a request, also on handled todos
           :application.command/remark
-          :application.command/send-expiration-notifications}
+          :application.command/send-expiration-notifications
+          :application.command/redact-attachments}
         all-commands (set (keys commands/command-schemas))]
 
     ;; This test is to make sure that as new commands are added,
diff --git a/src/clj/rems/application/commands.clj b/src/clj/rems/application/commands.clj
index 7afa8748f..ef0961c46 100644
--- a/src/clj/rems/application/commands.clj
+++ b/src/clj/rems/application/commands.clj
@@ -1,6 +1,7 @@
 (ns rems.application.commands
   (:require [clojure.test :refer [deftest is testing]]
-            [medley.core :refer [assoc-some distinct-by update-existing]]
+            [clojure.set]
+            [medley.core :refer [assoc-some distinct-by find-first update-existing]]
             [rems.common.application-util :as application-util]
             [rems.common.form :as form]
             [rems.form-validation :as form-validation]
@@ -78,6 +79,10 @@
          :member schema-base/User))
 (s/defschema RejectCommand
   CommandWithComment)
+(s/defschema RedactAttachmentsCommand
+  (assoc CommandWithComment
+         :redacted-attachments [CommandAttachment]
+         :public s/Bool))
 (s/defschema RemarkCommand
   (assoc CommandWithComment
          :public s/Bool))
@@ -132,6 +137,7 @@
    :application.command/invite-decider InviteDeciderCommand
    :application.command/invite-member InviteMemberCommand
    :application.command/invite-reviewer InviteReviewerCommand
+   :application.command/redact-attachments RedactAttachmentsCommand
    :application.command/reject RejectCommand
    :application.command/remark RemarkCommand
    :application.command/remove-member RemoveMemberCommand
@@ -285,19 +291,36 @@
 (defn- ok [& events]
   (ok-with-data nil events))
 
-(defn- invalid-attachments-error [injections cmd]
-  (let [invalid-ids (for [att (:attachments cmd)
-                          :let [id (:attachment/id att)
-                                attachment ((getx injections :get-attachment-metadata) id)]
+(defn- user-can-redact-attachment? [cmd application attachment]
+  (or (application-util/is-handler? application (:actor cmd))
+      (= (:attachment/user attachment) (:actor cmd))))
+
+(defn- invalid-attachments-error
+  "Checks that attachments are not nil, attachment user is command actor,
+   and attachment application id matches command application id. Redacted
+   attachments are checked similarly, but application handlers are allowed
+   to redact all attachments."
+  [cmd application injections]
+  (let [invalid-ids (for [id (set (map :attachment/id (:attachments cmd)))
+                          :let [attachment (-> (getx injections :get-attachment-metadata)
+                                               (apply [id]))]
                           :when (or (nil? attachment)
                                     (not= (:attachment/user attachment) (:actor cmd))
                                     (not= (:application/id attachment) (:application-id cmd)))]
-                      id)]
-    (when (seq invalid-ids)
-      {:errors [{:type :invalid-attachments :attachments invalid-ids}]})))
-
-(defn- add-comment-and-attachments [cmd injections event]
-  (or (invalid-attachments-error injections cmd)
+                      id)
+        invalid-redact-ids (for [id (set (map :attachment/id (:redacted-attachments cmd)))
+                                 :let [attachment (-> (getx injections :get-attachment-metadata)
+                                                      (apply [id]))]
+                                 :when (or (nil? attachment)
+                                           (not (user-can-redact-attachment? cmd application attachment))
+                                           (not= (:application/id attachment) (:application-id cmd)))]
+                             id)]
+    (when-some [ids (seq (concat invalid-ids invalid-redact-ids))]
+      {:errors [{:type :invalid-attachments
+                 :attachments (sort (set ids))}]})))
+
+(defn- add-comment-and-attachments [cmd application injections event]
+  (or (invalid-attachments-error cmd application injections)
       (ok (assoc-some event
                       :application/comment (:comment cmd)
                       :event/attachments (when-let [att (:attachments cmd)]
@@ -408,36 +431,61 @@
 (defmethod command-handler :application.command/approve
   [cmd application injections]
   (or (entitlement-end-not-in-future-error (:entitlement-end cmd))
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    (merge {:event/type :application.event/approved}
                                           (when-let [end (:entitlement-end cmd)]
                                             {:entitlement/end end})))))
 
+(defn- empty-redacted-attachments-error [cmd]
+  (when (empty? (:redacted-attachments cmd))
+    {:errors [{:type :empty-redacted-attachments}]}))
+
+(defn- find-redacted-attachments
+  "Finds events from which the redacted attachments originate from,
+   and returns those events id with the redacted attachment id."
+  [cmd application]
+  (vec (for [event (:application/events application)
+             :let [event-attachment-ids (set (map :attachment/id (:event/attachments event)))]
+             redacted-id (map :attachment/id (:redacted-attachments cmd))
+             :when (contains? event-attachment-ids redacted-id)]
+         (merge (select-keys event [:event/id])
+                {:attachment/id redacted-id}))))
+
+(defmethod command-handler :application.command/redact-attachments
+  [cmd application injections]
+  (or (empty-redacted-attachments-error cmd)
+      (add-comment-and-attachments cmd application injections
+                                   {:event/type :application.event/attachments-redacted
+                                    :application/redacted-attachments (find-redacted-attachments
+                                                                       cmd
+                                                                       application)
+                                    :application/public (:public cmd)})))
+
 (defmethod command-handler :application.command/reject
   [cmd application injections]
-  (add-comment-and-attachments cmd injections
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/rejected}))
 
 (defmethod command-handler :application.command/return
   [cmd application injections]
-  (add-comment-and-attachments cmd injections
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/returned}))
 
 (defmethod command-handler :application.command/close
   [cmd application injections]
-  (add-comment-and-attachments cmd injections
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/closed}))
 
 (defmethod command-handler :application.command/revoke
   [cmd application injections]
-  (add-comment-and-attachments cmd injections
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/revoked}))
 
 (defmethod command-handler :application.command/request-decision
   [cmd application injections]
   (or (must-not-be-empty cmd :deciders)
       (invalid-users-errors (:deciders cmd) injections)
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/decision-requested
                                     :application/request-id (UUID/randomUUID)
                                     :application/deciders (:deciders cmd)})))
@@ -453,7 +501,7 @@
       (when-not (contains? #{:approved :rejected} (:decision cmd))
         {:errors [{:type :invalid-decision :decision (:decision cmd)}]})
       (let [last-request-for-actor (get-in application [:rems.application.model/latest-decision-request-by-user (:actor cmd)])]
-        (add-comment-and-attachments cmd injections
+        (add-comment-and-attachments cmd application injections
                                      {:event/type :application.event/decided
                                       :application/request-id last-request-for-actor
                                       :application/decision (:decision cmd)}))))
@@ -462,7 +510,7 @@
   [cmd application injections]
   (or (must-not-be-empty cmd :reviewers)
       (invalid-users-errors (:reviewers cmd) injections)
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/review-requested
                                     :application/request-id (UUID/randomUUID)
                                     :application/reviewers (:reviewers cmd)})))
@@ -476,7 +524,7 @@
   [cmd application injections]
   (or (actor-is-not-reviewer-error application cmd)
       (let [last-request-for-actor (get-in application [:rems.application.model/latest-review-request-by-user (:actor cmd)])]
-        (add-comment-and-attachments cmd injections
+        (add-comment-and-attachments cmd application injections
                                      {:event/type :application.event/reviewed
                                       ;; Currently we want to tie all comments to the latest request.
                                       ;; In the future this might change so that commenters can freely continue to comment
@@ -485,14 +533,14 @@
 
 (defmethod command-handler :application.command/remark
   [cmd application injections]
-  (add-comment-and-attachments cmd injections
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/remarked
                                 :application/public (:public cmd)}))
 
 (defmethod command-handler :application.command/add-licenses
   [cmd application injections]
   (or (must-not-be-empty cmd :licenses)
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/licenses-added
                                     :application/licenses (mapv (fn [id] {:license/id id}) (:licenses cmd))})))
 
@@ -504,7 +552,7 @@
         (invalid-catalogue-items cat-ids injections)
         (unbundlable-catalogue-items-for-actor application cat-ids (:actor cmd) injections)
         (changes-original-workflow application cat-ids (:actor cmd) injections)
-        (add-comment-and-attachments cmd injections
+        (add-comment-and-attachments cmd application injections
                                      {:event/type :application.event/resources-changed
                                       :application/forms (build-forms-list workflow cat-ids injections)
                                       :application/resources (build-resources-list cat-ids injections)
@@ -524,15 +572,15 @@
        :invitation/token (secure-token)}))
 
 (defmethod command-handler :application.command/invite-decider
-  [cmd _application injections]
-  (add-comment-and-attachments cmd injections
+  [cmd application injections]
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/decider-invited
                                 :application/decider (:decider cmd)
                                 :invitation/token ((getx injections :secure-token))}))
 
 (defmethod command-handler :application.command/invite-reviewer
-  [cmd _application injections]
-  (add-comment-and-attachments cmd injections
+  [cmd application injections]
+  (add-comment-and-attachments cmd application injections
                                {:event/type :application.event/reviewer-invited
                                 :application/reviewer (:reviewer cmd)
                                 :invitation/token ((getx injections :secure-token))}))
@@ -572,7 +620,7 @@
         {:errors [{:type :cannot-remove-applicant}]})
       (when-not (member? (:userid (:member cmd)) application)
         {:errors [{:type :user-not-member :user (:member cmd)}]})
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/member-removed
                                     :application/member (:member cmd)})))
 
@@ -582,7 +630,7 @@
                                      (vals (:application/invitation-tokens application))))
                            (:member cmd))
         {:errors [{:type :user-not-member :user (:member cmd)}]})
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/member-uninvited
                                     :application/member (:member cmd)})))
 
@@ -591,7 +639,7 @@
   (or (when-not (contains? (set (map :userid (:application/members application)))
                            (:userid (:member cmd)))
         {:errors [{:type :user-not-member :user (:member cmd)}]})
-      (add-comment-and-attachments cmd injections
+      (add-comment-and-attachments cmd application injections
                                    {:event/type :application.event/applicant-changed
                                     :application/applicant (:member cmd)})))
 
diff --git a/src/clj/rems/application/events.clj b/src/clj/rems/application/events.clj
index 6c1ea47a4..9b853ce7b 100644
--- a/src/clj/rems/application/events.clj
+++ b/src/clj/rems/application/events.clj
@@ -117,6 +117,12 @@
          :event/type (s/enum :application.event/member-uninvited)
          :application/member {:name s/Str
                               :email s/Str}))
+(s/defschema AttachmentsRedactedEvent
+  (assoc EventWithComment
+         :event/type (s/enum :application.event/attachments-redacted)
+         :application/redacted-attachments [(merge EventAttachment
+                                                   {:event/id schema-base/EventId})]
+         :application/public s/Bool))
 (s/defschema ApplicantChangedEvent
   (assoc EventWithComment
          :event/type (s/enum :application.event/applicant-changed)
@@ -170,7 +176,8 @@
          :event/type (s/enum :application.event/submitted)))
 
 (def event-schemas
-  {:application.event/applicant-changed ApplicantChangedEvent
+  {:application.event/attachments-redacted AttachmentsRedactedEvent
+   :application.event/applicant-changed ApplicantChangedEvent
    :application.event/approved ApprovedEvent
    :application.event/closed ClosedEvent
    :application.event/review-requested ReviewRequestedEvent
diff --git a/src/clj/rems/application/master_workflow.clj b/src/clj/rems/application/master_workflow.clj
index 555e735c7..5d1c9dc06 100644
--- a/src/clj/rems/application/master_workflow.clj
+++ b/src/clj/rems/application/master_workflow.clj
@@ -41,6 +41,7 @@
     :application.command/add-member
     :application.command/assign-external-id
     :application.command/change-resources
+    :application.command/redact-attachments
     :application.command/remove-member
     :application.command/invite-decider
     :application.command/invite-member
@@ -76,16 +77,20 @@
   {:applicant non-submittable-application-commands
    :handler (conj handler-all-commands :see-everything)
    :reviewer #{:see-everything
+               :application.command/redact-attachments
                :application.command/remark
                :application.command/review}
    :past-reviewer #{:see-everything
+                    :application.command/redact-attachments
                     :application.command/remark}
    :decider #{:see-everything
+              :application.command/redact-attachments
               :application.command/remark
               :application.command/decide
               :application.command/approve
               :application.command/reject}
    :past-decider #{:see-everything
+                   :application.command/redact-attachments
                    :application.command/remark}})
 
 (def ^:private returned-permissions
@@ -93,12 +98,14 @@
                     :application.command/close)
    :handler (conj handler-returned-commands :see-everything)
    :decider #{:see-everything
+              :application.command/redact-attachments
               :application.command/remark
               :application.command/decide}})
 
 (def ^:private approved-permissions
   {:applicant non-submittable-application-commands
    :handler #{:see-everything
+              :application.command/redact-attachments
               :application.command/remark
               :application.command/add-member
               :application.command/change-resources
@@ -108,6 +115,7 @@
               :application.command/close
               :application.command/revoke}
    :decider #{:see-everything
+              :application.command/redact-attachments
               :application.command/remark
               :application.command/decide}})
 
@@ -115,6 +123,7 @@
   {:applicant #{:application.command/copy-as-new}
    :member #{:application.command/copy-as-new}
    :handler #{:see-everything
+              :application.command/redact-attachments
               :application.command/remark}
    :reviewer #{:see-everything}
    :past-reviewer #{:see-everything}
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index 442d0902d..b35e6ab48 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -198,6 +198,10 @@
       (update ::latest-decision-request-by-user dissoc (:event/actor event))
       (update-todo-for-requests)))
 
+(defmethod application-base-view :application.event/attachments-redacted
+  [application _event]
+  application)
+
 (defmethod application-base-view :application.event/remarked
   [application _event]
   application)
@@ -296,6 +300,7 @@
     {:permission :application.command/invite-decider}
     {:permission :application.command/invite-member}
     {:permission :application.command/invite-reviewer}
+    {:permission :application.command/redact-attachments}
     {:permission :application.command/remark}
     {:permission :application.command/remove-member}
     {:permission :application.command/request-decision}
@@ -327,6 +332,7 @@
     {:permission :application.command/delete}
     {:permission :application.command/invite-member}
     {:permission :application.command/invite-reviewer}
+    {:permission :application.command/redact-attachments}
     {:permission :application.command/remark}
     {:permission :application.command/remove-member}
     {:permission :application.command/request-decision}
@@ -691,6 +697,7 @@
       (update :application/events (partial mapv #(enrich-event % get-user get-catalogue-item)))
       (assoc :application/applicant (get-user (get-in application [:application/applicant :userid])))
       (assoc :application/attachments (get-attachments-for-application (getx application :application/id)))
+      ;(update :application/attachments mask-redacted-attachments (get-redacted-attachment-ids application))
       (enrich-user-attributes get-user)
       (enrich-blacklist blacklisted?) ;; uses enriched users
       (enrich-workflow-handlers get-workflow)
@@ -714,7 +721,8 @@
                                   :application.event/decider-joined
                                   :application.event/decision-requested})
 (deftest test-sensitive-events
-  (let [public-events #{:application.event/applicant-changed
+  (let [public-events #{:application.event/attachments-redacted
+                        :application.event/applicant-changed
                         :application.event/approved
                         :application.event/closed
                         :application.event/copied-from
@@ -746,11 +754,8 @@
 
 (defn- hide-sensitive-events [events]
   (->> events
-       (remove (comp sensitive-events
-                     :event/type))
-       (remove #(and (= :application.event/remarked
-                        (:event/type %))
-                     (not (:application/public %))))))
+       (remove (comp sensitive-events :event/type))
+       (remove (comp false? :application/public)))) ; :application/public might not be set
 
 (defn- censor-user [user]
   (select-keys user [:userid :name :email :organizations :notification-email]))
@@ -860,6 +865,20 @@
       (user-is-applicant-or-member permissions)
       (not= #{:everyone-else} permissions))))
 
+(defn- get-redacted-attachment-ids [application]
+  (->> (:application/events application)
+       (filter (comp #{:application.event/attachments-redacted} :event/type))
+       (mapcat :application/redacted-attachments)
+       (map :attachment/id)
+       set))
+
+(defn- mask-redacted-attachments [application]
+  (let [redacted-ids (get-redacted-attachment-ids application)
+        redacted? #(contains? redacted-ids (:attachment/id %))]
+    (->> application
+         (transform [:application/attachments ALL redacted?]
+                    #(assoc % :attachment/filename :filename/redacted)))))
+
 (defn apply-user-permissions [application userid]
   (let [see-application? (see-application? application userid)
         roles (permissions/user-roles application userid)
@@ -873,6 +892,7 @@
           (hide-non-public-information)
           (apply-privacy roles)
           (hide-attachments)
+          (mask-redacted-attachments)
           (assoc :application/permissions permissions)
           (assoc :application/roles roles)
           (permissions/cleanup)))))
diff --git a/src/clj/rems/application/process_managers.clj b/src/clj/rems/application/process_managers.clj
index de592d054..c40dbb194 100644
--- a/src/clj/rems/application/process_managers.clj
+++ b/src/clj/rems/application/process_managers.clj
@@ -42,3 +42,12 @@
   (doseq [event new-events]
     (when (= :application.event/submitted (:event/type event))
       (delete-orphan-attachments (:application/id event)))))
+
+(defn clear-redacted-attachments
+  "`:application.event/attachments-redacted` causes a side-effect that clears
+   contents of the redacted attachments in database."
+  [new-events]
+  (doseq [event new-events
+          :when (= :application.event/attachments-redacted (:event/type event))
+          attachment (:application/redacted-attachments event)]
+    (attachments/redact-attachment! (:attachment/id attachment))))
diff --git a/src/clj/rems/db/attachments.clj b/src/clj/rems/db/attachments.clj
index 19521b9ce..d5ab7aab4 100644
--- a/src/clj/rems/db/attachments.clj
+++ b/src/clj/rems/db/attachments.clj
@@ -1,9 +1,7 @@
 (ns rems.db.attachments
   (:require [clojure.string :as str]
             [clojure.test :refer :all]
-            [rems.common.application-util :refer [form-fields-editable?]]
             [rems.common.attachment-types :as attachment-types]
-            [rems.auth.util :refer [throw-forbidden]]
             [rems.db.core :as db]
             [rems.util :refer [file-to-bytes]])
   (:import [rems PayloadTooLargeException UnsupportedMediaTypeException]))
@@ -110,6 +108,13 @@
   {:id (:attachment/id attachment)
    :success true})
 
+(defn redact-attachment!
+  "Updates the attachment by zeroing the file data."
+  [attachment-id]
+  (db/redact-attachment! {:id attachment-id})
+  {:id attachment-id
+   :success true})
+
 (defn copy-attachment! [new-application-id attachment-id]
   (let [attachment (db/get-attachment {:id attachment-id})]
     (:id (db/save-attachment! {:application new-application-id
@@ -120,3 +125,4 @@
 
 (defn delete-attachment! [attachment-id]
   (db/delete-attachment! {:id attachment-id}))
+
diff --git a/src/clj/rems/standalone.clj b/src/clj/rems/main.clj
similarity index 98%
rename from src/clj/rems/standalone.clj
rename to src/clj/rems/main.clj
index 3821c147b..083a9c57e 100644
--- a/src/clj/rems/standalone.clj
+++ b/src/clj/rems/main.clj
@@ -1,5 +1,6 @@
-(ns rems.standalone
-  "Run the REMS app in an embedded http server."
+(ns rems.main
+  "Run REMS CLI functions including the embedded HTTP server. Available CLI functions
+   are described in more detail by rems.main/-main docstring."
   (:require [clojure.string :as str]
             [clojure.tools.cli :refer [parse-opts]]
             [clojure.tools.logging :as log]
diff --git a/src/clj/rems/pdf.clj b/src/clj/rems/pdf.clj
index 119389e91..225410881 100644
--- a/src/clj/rems/pdf.clj
+++ b/src/clj/rems/pdf.clj
@@ -8,7 +8,7 @@
             [rems.common.util :refer [build-index]]
             [rems.config :refer [env]]
             [rems.context :as context]
-            [rems.text :refer [localized localize-decision localize-event localize-state localize-time text text-format with-language]]
+            [rems.text :refer [localized localize-decision localize-event localize-attachment localize-state localize-time text text-format with-language]]
             [rems.util :refer [getx]])
   (:import [java.io ByteArrayOutputStream]))
 
@@ -125,7 +125,7 @@
 (defn- attachment-filenames [application]
   (->> (:application/attachments application)
        (build-index {:keys [:attachment/id]
-                     :value-fn :attachment/filename})))
+                     :value-fn localize-attachment})))
 
 (defn- field-value [filenames field]
   (let [value (:field/value field)]
diff --git a/src/clj/rems/schema_base.clj b/src/clj/rems/schema_base.clj
index cc27cb639..ed4130864 100644
--- a/src/clj/rems/schema_base.clj
+++ b/src/clj/rems/schema_base.clj
@@ -16,6 +16,8 @@
 
 (def FormId s/Int)
 
+(def EventId s/Int) ; used both optionally and as required
+
 (s/defschema OrganizationId {:organization/id s/Str})
 
 (s/defschema Language
@@ -49,7 +51,7 @@
     :description "A string for most fields, or [[{\"column\": string, \"value\": string}]] for table fields"}))
 
 (s/defschema EventBase
-  {(s/optional-key :event/id) s/Int
+  {(s/optional-key :event/id) EventId
    :event/type s/Keyword
    :event/time DateTime
    :event/actor UserId
diff --git a/src/cljc/rems/text.cljc b/src/cljc/rems/text.cljc
index e2eef52d9..eecd77d39 100644
--- a/src/cljc/rems/text.cljc
+++ b/src/cljc/rems/text.cljc
@@ -138,6 +138,7 @@
 (def ^:private event-types
   {:application.event/applicant-changed :t.applications.events/applicant-changed
    :application.event/approved :t.applications.events/approved
+   :application.event/attachments-redacted :t.applications.events/attachments-redacted
    :application.event/closed :t.applications.events/closed
    :application.event/review-requested :t.applications.events/review-requested
    :application.event/reviewed :t.applications.events/reviewed
@@ -222,6 +223,10 @@
         (str/join ", " (mapv #(localized (:catalogue-item/title %))
                              (:application/resources event)))
 
+        :application.event/attachments-redacted
+        (when (seq (:event/attachments event))
+          (text :t.applications/redacted-attachments-replaced))
+
         nil))
      (case event-type
        :application.event/approved
@@ -229,3 +234,11 @@
          (str " " (text-format :t.applications/entitlement-end (localize-utc-date end))))
 
        nil))))
+
+(defn localize-attachment
+  "If attachment is redacted, return localized text for redacted attachment.
+   Otherwise return value of :attachment/filename."
+  [attachment]
+  (if (= :filename/redacted (:attachment/filename attachment))
+    (text :t.applications/attachment-filename-redacted)
+    (:attachment/filename attachment)))
diff --git a/src/cljs/rems/application.cljs b/src/cljs/rems/application.cljs
index b624ed976..183a2090c 100644
--- a/src/cljs/rems/application.cljs
+++ b/src/cljs/rems/application.cljs
@@ -3,7 +3,7 @@
             [clojure.set :refer [union]]
             [goog.string]
             [re-frame.core :as rf]
-            [medley.core :refer [find-first update-existing]]
+            [medley.core :refer [find-first filter-vals update-existing]]
             [cljs-time.core :as time-core]
             [rems.actions.accept-licenses :refer [accept-licenses-action-button]]
             [rems.actions.components :refer [button-wrapper]]
@@ -171,12 +171,53 @@
                                   :validation nil
                                   :attachment-status {}})))
 
+(defn- set-highlightables-by-request-id
+  "Given set of events, for each event find all events that share the
+   same :application/request-id and add them into event :highlight-event-ids."
+  [events]
+  (let [related-events (->> events
+                            (filter :application/request-id)
+                            (group-by :application/request-id)
+                            (filter-vals (comp #(> % 1) count)))]
+    (for [event events
+          :let [request-id (:application/request-id event)
+                highlightable-event-ids (->> (get related-events request-id)
+                                             (map :event/id)
+                                             (set))]]
+      (update event :highlight-event-ids into highlightable-event-ids))))
+
+(defn- set-highlightables-by-redacted-attachments
+  "Given set of events, for each event of type :application.event/attachments-redacted
+   find all related events whose attachments were redacted and add them into event
+   :highlight-event-ids. For each related event, add :application.event/attachments-redacted
+   event into :highlight-event-ids."
+  [events]
+  (let [related-events (for [event events
+                             :when (= :application.event/attachments-redacted
+                                      (:event/type event))
+                             redacted-attachment (:application/redacted-attachments event)]
+                         #{(:event/id redacted-attachment) (:event/id event)})]
+    (for [event events
+          :let [event-id (:event/id event)
+                highlightable-event-ids (->> related-events
+                                             (filter #(contains? % event-id))
+                                             (reduce into #{}))]]
+      (update event :highlight-event-ids into highlightable-event-ids))))
+
+(defn- update-events-highlightables
+  "Updates events with event ids that are related to the event so that they can
+   be highlighted within the UI."
+  [db]
+  (update-in db [::application :data :application/events]
+             (comp set-highlightables-by-request-id
+                   set-highlightables-by-redacted-attachments)))
 
 (rf/reg-event-fx
  ::fetch-application-result
  (fn [{:keys [db]} [_ application full-reload?]]
    (let [initial-fetch? (not (:initialized? (::application db)))]
      {:db (cond-> (update db ::application fetcher/finished application)
+            true (update-events-highlightables)
             (or initial-fetch? full-reload?) (initialize-edit-application))})))
 
 (rf/reg-event-db
@@ -452,14 +493,14 @@
      nil (filterv #(not= application-id (:application/id %)) data))))
 
 (rf/reg-event-db
- ::highlight-request-id
- (fn [db [_ request-id]]
-   (assoc db ::highlight-request-id request-id)))
+ ::highlight-event-ids
+ (fn [db [_ event-ids]]
+   (assoc db ::highlight-event-ids event-ids)))
 
 (rf/reg-sub
- ::highlight-request-id
+ ::highlight-event-ids
  (fn [db _]
-   (::highlight-request-id db)))
+   (::highlight-event-ids db)))
 
 ;;;; UI components
 
@@ -637,9 +678,7 @@
                   :application.event/copied-to
                   [application-link (:application/copied-to event) (text :t.applications/application)]
 
-                  (when (not (empty? (:application/comment event)))
-                    (:application/comment event)))
-        request-id (:application/request-id event)
+                  (not-empty (:application/comment event)))
         attachments (:event/attachments event)
         time (localize-time (:event/time event))]
     [:div.row.event
@@ -648,11 +687,11 @@
      [:label.col-sm-2.col-form-label time]
      [:div.col-sm-10
       [:div.col-form-label.event-description [:b event-text]
-       (when request-id
+       (when-let [highlight-event-ids (seq (:highlight-event-ids event))]
          [:div.float-right
           [:a {:href "#"
                :on-click (fn [e]
-                           (rf/dispatch [::highlight-request-id request-id])
+                           (rf/dispatch [::highlight-event-ids highlight-event-ids])
                            false)}
            " " (text :t.applications/highlight-related-events)]])]
       (when decision
@@ -751,14 +790,12 @@
             [:h3 (text :t.form/events)]]
            (render-events events)))])
 
-(defn- application-state [application config highlight-request-id userid]
+(defn- application-state [{:keys [application config highlight-event-ids userid]}]
   (let [state (:application/state application)
         events (->> (events-with-attachments application)
                     (sort-by :event/time)
-                    (map #(assoc % :highlight
-                                 (and highlight-request-id
-                                      (= (:application/request-id %) highlight-request-id))))
-                    reverse)
+                    (reverse)
+                    (map #(assoc % :highlight (contains? (set highlight-event-ids) (:event/id %)))))
         [events-show-always events-collapse] (split-at 3 events)]
     [collapsible/component
      {:id "header"
@@ -1149,14 +1186,17 @@
                   (mapcat #(get-in % [:resource/duo :duo/codes])))]
     duos))
 
-(defn- render-application [{:keys [application config userid highlight-request-id language]}]
+(defn- render-application [{:keys [application config userid highlight-event-ids language]}]
   [:<>
    [disabled-items-warning application]
    [blacklist-warning application]
    (text :t.applications/intro)
    [:div.row
     [:div.col-lg-8
-     [application-state application config highlight-request-id userid]
+     [application-state {:application application
+                         :config config
+                         :highlight-event-ids highlight-event-ids
+                         :userid userid}]
      [:div.mt-3 [applicants-info application userid]]
      (when (:show-resources-section config)
        [:div.mt-3 [applied-resources application userid language]])
@@ -1187,7 +1227,7 @@
         application @(rf/subscribe [::application])
         loading? @(rf/subscribe [::application :loading?])
         reloading? @(rf/subscribe [::application :reloading?])
-        highlight-request-id @(rf/subscribe [::highlight-request-id])
+        highlight-event-ids @(rf/subscribe [::highlight-event-ids])
         userid (:userid @(rf/subscribe [:user]))
         language @(rf/subscribe [:language])]
     [:div.container-fluid
@@ -1206,7 +1246,7 @@
        [render-application {:application application
                             :config config
                             :userid userid
-                            :highlight-request-id highlight-request-id
+                            :highlight-event-ids highlight-event-ids
                             :language language}])
      ;; Located after the application to avoid re-rendering the application
      ;; when this element is added or removed from virtual DOM.
diff --git a/src/cljs/rems/atoms.cljs b/src/cljs/rems/atoms.cljs
index 56b7a36b3..ab1e1925c 100644
--- a/src/cljs/rems/atoms.cljs
+++ b/src/cljs/rems/atoms.cljs
@@ -4,7 +4,7 @@
             [reagent.core :as reagent]
             [rems.common.util :refer [escape-element-id]]
             [rems.guide-util :refer [component-info example]]
-            [rems.text :refer [text localized]]
+            [rems.text :refer [text localized localize-attachment]]
             [rems.util :refer [focus-when-collapse-opened]]))
 
 (defn external-link []
@@ -121,26 +121,30 @@
        [:label title]
        (into [:div style] values)])))
 
-(defn download-button
-  [title url]
-  [:a.attachment-link.btn.btn-outline-secondary.mr-2.text-truncate
-   {:href url
-    :target :_blank
-    :title title
-    :style {:max-width "25em"}}
+(defn download-button [{:keys [disabled? title url]}]
+  [:a (cond-> {:class [:attachment-link :btn :btn-outline-secondary :mr-2 :text-truncate]
+               :href url
+               :target :_blank
+               :title title
+               :style {:max-width "25em"}}
+        disabled? (-> (dissoc :href :target)
+                      (update :class conj :disabled)))
    [file-download] " " title])
 
 (defn license-attachment-link
   "Renders link to the attachment with `id` and name `title`."
   [id title]
-  [download-button title (str "/api/licenses/attachments/" id)])
+  [download-button {:title title
+                    :url (str "/api/licenses/attachments/" id)}])
 
 (defn attachment-link
   "Renders a link to attachment (should have keys :attachment/id and :attachment/filename)"
   [attachment]
   (when attachment
     [:div.field
-     [download-button (:attachment/filename attachment) (str "/applications/attachment/" (:attachment/id attachment))]]))
+     [download-button {:disabled? (= :filename/redacted (:attachment/filename attachment))
+                       :title (localize-attachment attachment)
+                       :url (str "/applications/attachment/" (:attachment/id attachment))}]]))
 
 (defn enrich-user [user]
   (assoc user :display (str (or (:name user)


Test patch:
diff --git a/test/clj/rems/api/test_applications.clj b/test/clj/rems/api/test_applications.clj
index 350f8d270..108195ee9 100644
--- a/test/clj/rems/api/test_applications.clj
+++ b/test/clj/rems/api/test_applications.clj
@@ -8,6 +8,7 @@
             [rems.api.testing :refer :all]
             [rems.config]
             [rems.db.applications]
+            [rems.db.attachments]
             [rems.db.blacklist :as blacklist]
             [rems.db.core :as db]
             [rems.db.test-data :as test-data]
@@ -239,7 +240,8 @@
     (testing "getting application as handler"
       (let [application (get-application-for-user application-id handler-id)]
         (is (= "workflow/master" (get-in application [:application/workflow :workflow/type])))
-        (is (= #{"application.command/request-review"
+        (is (= #{"application.command/redact-attachments"
+                 "application.command/request-review"
                  "application.command/request-decision"
                  "application.command/remark"
                  "application.command/reject"
@@ -264,7 +266,8 @@
         (testing "handler doesn't see hidden command"
           (let [application (get-application-for-user application-id handler-id)]
             (is (= "workflow/master" (get-in application [:application/workflow :workflow/type])))
-            (is (= #{"application.command/request-review"
+            (is (= #{"application.command/redact-attachments"
+                     "application.command/request-review"
                      "application.command/request-decision"
                      "application.command/reject"
                      "application.command/approve"
@@ -885,6 +888,7 @@
                          api-key reviewer)))
         (testing ", reviewer is able to fetch application and can submit a review"
           (is (= ["see-everything"
+                  "application.command/redact-attachments"
                   "application.command/review"
                   "application.command/remark"]
                  (:application/permissions (get-application-for-user app-id reviewer)))))))
@@ -906,6 +910,7 @@
                          api-key decider)))
         (testing ", decider is able to fetch application and can submit a review"
           (is (= ["see-everything"
+                  "application.command/redact-attachments"
                   "application.command/reject"
                   "application.command/decide"
                   "application.command/remark"
@@ -1280,6 +1285,7 @@
                "application.command/close"
                "application.command/invite-reviewer"
                "application.command/invite-member"
+               "application.command/redact-attachments"
                "application.command/remark"
                "application.command/remove-member"
                "application.command/request-decision"
@@ -1297,6 +1303,7 @@
                                     :comment ""}))))
     (testing "decider's commands"
       (is (= #{"application.command/approve"
+               "application.command/redact-attachments"
                "application.command/reject"
                "application.command/remark"
                "see-everything"}
@@ -1614,6 +1621,17 @@
                          handler)]
         (is (response-is-forbidden? response))))))
 
+(defn- get-attachments [application-id user-id]
+  (->> (get-application-for-user application-id user-id)
+       :application/attachments
+       (sort-by :attachment/id)
+       (mapv :attachment/filename)))
+
+(defn- get-last-event [application-id user-id]
+  (-> (get-application-for-user application-id user-id)
+      :application/events
+      last))
+
 (deftest test-application-comment-attachments
   (let [api-key "42"
         applicant-id "alice"
@@ -1650,7 +1668,7 @@
     (testing "handler uploads an attachment"
       (let [attachment-id (add-attachment handler-id (file "handler-public-remark.txt"))]
         (is (number? attachment-id))
-        (testing "and attaches it to a public remark"
+        (testing ", and attaches it to a public remark"
           (is (= {:success true} (send-command handler-id
                                                {:type :application.command/remark
                                                 :application-id application-id
@@ -1659,8 +1677,7 @@
                                                 :attachments [{:attachment/id attachment-id}]}))))))
 
     (testing "applicant can see attachment"
-      (let [app (get-application-for-user application-id applicant-id)
-            remark-event (last (:application/events app))
+      (let [remark-event (get-last-event application-id applicant-id)
             attachment-id (:attachment/id (first (:event/attachments remark-event)))]
         (is (number? attachment-id))
         (testing "and fetch it"
@@ -1764,7 +1781,7 @@
                 "handler-approve.txt"
                 "handler-close.txt"
                 "handler-close (1).txt"]
-               (mapv :attachment/filename (:application/attachments (get-application-for-user application-id applicant-id))))))
+               (get-attachments application-id applicant-id))))
       (testing "handler"
         (is (= ["handler-public-remark.txt"
                 "reviewer-review.txt"
@@ -1772,7 +1789,233 @@
                 "handler-approve.txt"
                 "handler-close.txt"
                 "handler-close (1).txt"]
-               (mapv :attachment/filename (:application/attachments (get-application-for-user application-id handler-id)))))))))
+               (get-attachments application-id handler-id)))))))
+
+(deftest test-application-redact-attachments
+  (let [api-key "42"
+        applicant-id "alice"
+        handler-id "handler"
+        reviewer-id "developer"
+        decider-id "carl"
+        file #(assoc filecontent :filename %)
+        workflow-id (test-helpers/create-workflow! {:type :workflow/decider
+                                                    :handlers [handler-id]})
+        cat-item-id (test-helpers/create-catalogue-item! {:workflow-id workflow-id})
+        application-id (test-helpers/create-application! {:catalogue-item-ids [cat-item-id]
+                                                          :actor applicant-id})
+        add-attachment #(-> (request :post (str "/api/applications/add-attachment?application-id=" application-id))
+                            (authenticate api-key %1)
+                            (assoc :multipart-params {"file" %2})
+                            handler
+                            read-ok-body
+                            :id)]
+    (testing "submit"
+      (is (= {:success true} (send-command applicant-id
+                                           {:type :application.command/submit
+                                            :application-id application-id}))))
+    (testing "unrelated user can't upload attachment"
+      (is (response-is-forbidden? (-> (request :post (str "/api/applications/add-attachment?application-id=" application-id))
+                                      (authenticate api-key reviewer-id)
+                                      (assoc :multipart-params {"file" filecontent})
+                                      handler))))
+    (testing "invite reviewer"
+      (is (= {:success true} (send-command handler-id
+                                           {:type :application.command/request-review
+                                            :application-id application-id
+                                            :reviewers [reviewer-id]
+                                            :comment "please"}))))
+    (testing "invite decider"
+      (is (= {:success true} (send-command handler-id
+                                           {:type :application.command/request-decision
+                                            :application-id application-id
+                                            :deciders [decider-id]
+                                            :comment "please"}))))
+    (testing "reviewer attaches file to public remark"
+      (let [attachment-id (add-attachment reviewer-id (file "reviewer-public-remark.txt"))]
+        (is (= {:success true} (send-command reviewer-id
+                                             {:type :application.command/remark
+                                              :public true
+                                              :application-id application-id
+                                              :comment "looks interesting"
+                                              :attachments [{:attachment/id attachment-id}]})))
+        (is (= ["reviewer-public-remark.txt"]
+               (get-attachments application-id reviewer-id)))
+        (testing ", decider attempts to redact reviewers attachment but fails"
+          (is (= {:success false
+                  :errors [{:type "invalid-attachments" :attachments [attachment-id]}]}
+                 (send-command decider-id
+                               {:type :application.command/redact-attachments
+                                :application-id application-id
+                                :comment "redacting for reviewer"
+                                :public false
+                                :redacted-attachments [{:attachment/id attachment-id}]}))))
+        (testing ", reviewer redacts and replaces the earlier attachment"
+          (let [remark-event (get-last-event application-id applicant-id)
+                replace-id (add-attachment reviewer-id (file "reviewer-public-remark.txt"))]
+            (is (= {:success true} (send-command reviewer-id
+                                                 {:type :application.command/redact-attachments
+                                                  :application-id application-id
+                                                  :comment "accidental upload, redacting"
+                                                  :public true
+                                                  :redacted-attachments [{:attachment/id attachment-id}]
+                                                  :attachments [{:attachment/id replace-id}]})))
+            (is (= {:application/id application-id
+                    :attachment/filename "reviewer-public-remark.txt" ; filename is preserved in db
+                    :attachment/type "text/plain"
+                    :attachment/data ""}
+                   (-> (rems.db.attachments/get-attachment attachment-id)
+                       (select-keys [:application/id :attachment/filename :attachment/type :attachment/data])
+                       (update :attachment/data slurp))))
+            (is (= ["filename/redacted"
+                    "reviewer-public-remark (1).txt"]
+                   (get-attachments application-id reviewer-id)))
+            (is (= {:event/type "application.event/attachments-redacted"
+                    :application/redacted-attachments [{:attachment/id attachment-id
+                                                        :event/id (:event/id remark-event)}]}
+                   (-> (get-last-event application-id applicant-id)
+                       (select-keys [:application/redacted-attachments :event/type])))
+                "redacted event contains event id from which redacted attachment originates")
+            (testing ", handler redacts reviewers attachment"
+              (is (= {:success true} (send-command reviewer-id
+                                                   {:type :application.command/redact-attachments
+                                                    :application-id application-id
+                                                    :comment "accidental upload, redacting"
+                                                    :public true
+                                                    :redacted-attachments [{:attachment/id replace-id}]})))
+              (is (= {:application/id application-id
+                      :attachment/filename "reviewer-public-remark (1).txt" ; filename is preserved in db
+                      :attachment/type "text/plain"
+                      :attachment/data ""}
+                     (-> (rems.db.attachments/get-attachment replace-id)
+                         (select-keys [:application/id :attachment/filename :attachment/type :attachment/data])
+                         (update :attachment/data slurp))))
+              (is (= ["filename/redacted"
+                      "filename/redacted"]
+                     (get-attachments application-id reviewer-id))))))))
+    (testing "decider attaches file to public remark"
+      (let [attachment-id (add-attachment decider-id (file "decider-public-remark.txt"))]
+        (is (= {:success true} (send-command decider-id
+                                             {:type :application.command/remark
+                                              :public true
+                                              :application-id application-id
+                                              :comment "looks interesting"
+                                              :attachments [{:attachment/id attachment-id}]})))
+        (is (= ["filename/redacted"
+                "filename/redacted"
+                "decider-public-remark.txt"]
+               (get-attachments application-id decider-id)))
+        (testing ", reviewer attempts to redact deciders attachment but fails"
+          (is (= {:success false
+                  :errors [{:type "invalid-attachments" :attachments [attachment-id]}]}
+                 (send-command reviewer-id
+                               {:type :application.command/redact-attachments
+                                :application-id application-id
+                                :comment "redacting for handler"
+                                :public false
+                                :redacted-attachments [{:attachment/id attachment-id}]}))))
+        (testing ", decider uploads attachment and redacts and replaces the earlier attachment"
+          (let [remark-event (get-last-event application-id applicant-id)
+                replace-id (add-attachment decider-id (file "decider-public-remark.txt"))]
+            (is (= {:success true} (send-command decider-id
+                                                 {:type :application.command/redact-attachments
+                                                  :application-id application-id
+                                                  :comment "accidental upload, redacting"
+                                                  :public true
+                                                  :redacted-attachments [{:attachment/id attachment-id}]
+                                                  :attachments [{:attachment/id replace-id}]})))
+            (is (= {:application/id application-id
+                    :attachment/filename "decider-public-remark.txt" ; filename is preserved in db
+                    :attachment/type "text/plain"
+                    :attachment/data ""}
+                   (-> (rems.db.attachments/get-attachment attachment-id)
+                       (select-keys [:application/id :attachment/filename :attachment/type :attachment/data])
+                       (update :attachment/data slurp))))
+            (is (= ["filename/redacted"
+                    "filename/redacted"
+                    "filename/redacted"
+                    "decider-public-remark (1).txt"]
+                   (get-attachments application-id decider-id)))
+            (is (= {:event/type "application.event/attachments-redacted"
+                    :application/redacted-attachments [{:attachment/id attachment-id
+                                                        :event/id (:event/id remark-event)}]}
+                   (-> (get-last-event application-id applicant-id)
+                       (select-keys [:application/redacted-attachments :event/type])))
+                "redacted event contains event id from which redacted attachment originates")
+            (testing ", handler redacts deciders attachment"
+              (is (= {:success true} (send-command handler-id
+                                                   {:type :application.command/redact-attachments
+                                                    :application-id application-id
+                                                    :comment "accidental upload, redacting"
+                                                    :public true
+                                                    :redacted-attachments [{:attachment/id replace-id}]
+                                                    :attachments []})))
+              (is (= {:application/id application-id
+                      :attachment/filename "decider-public-remark (1).txt" ; filename is preserved in db
+                      :attachment/type "text/plain"
+                      :attachment/data ""}
+                     (-> (rems.db.attachments/get-attachment replace-id)
+                         (select-keys [:application/id :attachment/filename :attachment/type :attachment/data])
+                         (update :attachment/data slurp))))
+              (is (= ["filename/redacted"
+                      "filename/redacted"
+                      "filename/redacted"
+                      "filename/redacted"]
+                     (get-attachments application-id reviewer-id))))))))
+    (testing "handler attaches file to private remark"
+      (let [attachment-id (add-attachment handler-id (file "handler-secret-notes.txt"))]
+        (is (= {:success true} (send-command handler-id
+                                             {:type :application.command/remark
+                                              :public false
+                                              :application-id application-id
+                                              :comment "my public notes"
+                                              :attachments [{:attachment/id attachment-id}]})))
+        (testing ", reviewer attempts to redact handlers attachment but fails"
+          (is (= {:success false
+                  :errors [{:type "invalid-attachments" :attachments [attachment-id]}]}
+                 (send-command reviewer-id
+                               {:type :application.command/redact-attachments
+                                :application-id application-id
+                                :comment "redacting for handler"
+                                :public false
+                                :redacted-attachments [{:attachment/id attachment-id}]}))))
+        (testing ", decider attempts to redact handlers attachment but fails"
+          (is (= {:success false
+                  :errors [{:type "invalid-attachments" :attachments [attachment-id]}]}
+                 (send-command decider-id
+                               {:type :application.command/redact-attachments
+                                :application-id application-id
+                                :comment "redacting for handler"
+                                :public false
+                                :redacted-attachments [{:attachment/id attachment-id}]}))))
+        (testing ", handler finally redacts the private attachment"
+          (let [remarked-event (get-last-event application-id handler-id)]
+            (is (= {:success true}
+                   (send-command handler-id
+                                 {:type :application.command/redact-attachments
+                                  :application-id application-id
+                                  :comment "redacting private attachment"
+                                  :public false
+                                  :redacted-attachments [{:attachment/id attachment-id}]})))
+            (is (= {:event/type "application.event/attachments-redacted"
+                    :application/redacted-attachments [{:attachment/id attachment-id
+                                                        :event/id (:event/id remarked-event)}]}
+                   (-> (get-last-event application-id handler-id)
+                       (select-keys [:application/redacted-attachments :event/type])))
+                "redacted event contains event id from which redacted attachment originates")))))
+    (testing ":application/attachments"
+      (testing "applicant"
+        (is (= ["filename/redacted"
+                "filename/redacted"
+                "filename/redacted"
+                "filename/redacted"]
+               (get-attachments application-id applicant-id))))
+      (testing "handler"
+        (is (= ["filename/redacted"
+                "filename/redacted"
+                "filename/redacted"
+                "filename/redacted"
+                "filename/redacted"]
+               (get-attachments application-id handler-id)))))))
 
 (deftest test-application-attachment-zip
   (let [api-key "42"
@@ -1873,7 +2116,40 @@
                                                   api-key "malice"))))
       (testing "fetch zip for nonexisting application"
         (is (response-is-not-found? (api-response :get "/api/applications/99999999/attachments" nil
-                                                  api-key "malice")))))))
+                                                  api-key "malice"))))
+      (testing "remark with attachments and redact attachment"
+        (let [black-comment-id (add-attachment handler-id (file "black.txt"))]
+          (is (= {:success true} (send-command handler-id
+                                               {:type :application.command/remark
+                                                :public true
+                                                :application-id app-id
+                                                :comment "see attachment"
+                                                :attachments [{:attachment/id black-comment-id}]})))
+          (is (= {:success true} (send-command handler-id
+                                               {:type :application.command/redact-attachments
+                                                :public true
+                                                :application-id app-id
+                                                :comment "redacting black comment"
+                                                :redacted-attachments [{:attachment/id black-comment-id}]
+                                                :attachments []})))
+          (testing ", then fetch zip as applicant, handler and reporter"
+            (is (= {"blue.txt" (slurp testfile)
+                    "red.txt" (slurp testfile)
+                    "green.txt" (slurp testfile)
+                    "blue (1).txt" (slurp testfile)
+                    "yellow.txt" (slurp testfile)
+                    "redacted" ""} ; filename is masked and file contents are emptied
+                   (fetch-zip applicant-id "?all=true")
+                   (fetch-zip applicant-id)
+                   (fetch-zip handler-id)
+                   (fetch-zip reporter-id))))
+          (testing ", then fetch zip with all=false as applicant, handler and reporter"
+            (is (= {"blue.txt" (slurp testfile)
+                    "red.txt" (slurp testfile)
+                    "green.txt" (slurp testfile)}
+                   (fetch-zip applicant-id "?all=false")
+                   (fetch-zip handler-id "?all=false")
+                   (fetch-zip reporter-id "?all=false")))))))))
 
 
 (deftest test-application-api-license-attachments
diff --git a/test/clj/rems/api/testing.clj b/test/clj/rems/api/testing.clj
index fc5b11ea1..60d879fd8 100644
--- a/test/clj/rems/api/testing.clj
+++ b/test/clj/rems/api/testing.clj
@@ -9,7 +9,7 @@
             [rems.handler :refer :all]
             [rems.locales]
             [rems.middleware]
-            [rems.standalone]
+            [rems.main]
             [ring.mock.request :refer :all]
             [rems.json :as json]))
 
diff --git a/test/clj/rems/application/test_commands.clj b/test/clj/rems/application/test_commands.clj
index 1d659d4cf..2b75868bf 100644
--- a/test/clj/rems/application/test_commands.clj
+++ b/test/clj/rems/application/test_commands.clj
@@ -151,11 +151,17 @@
    :valid-user? dummy-valid-user?
    :find-userid identity})
 
-;; could rework tests to use model/build-application-view instead of this
+(defn- patch-event-ids [events]
+  (->> events
+       (map-indexed (fn [idx event] (assoc event :event/id idx)))))
+
+;; XXX: could rework tests to use model/build-application-view instead of this
 (defn apply-events [application events]
   (events/validate-events events)
   (-> (reduce model/application-view application events)
-      (model/enrich-with-injections injections)))
+      (model/enrich-with-injections injections)
+      ; event ids are normally generated by db, but not available here
+      (update :application/events (comp vec patch-event-ids)))) ; vector preserves correct order for (conj) of events
 
 (defn- set-command-defaults [cmd]
   (cond-> cmd
@@ -2122,3 +2128,74 @@
       (let [application (permissions/remove-role-from-user application :applicant "applicant")
             result (commands/handle-command command application injections)]
         (is (= {:errors [{:type :forbidden}]} result))))))
+
+(deftest test-redact-attachments-command
+  (let [application (apply-events nil [dummy-created-event
+                                       {:event/type :application.event/submitted
+                                        :event/time test-time
+                                        :event/actor applicant-user-id
+                                        :application/id app-id}])
+        attachment-id-1 1234
+        attachment-id-2 2345
+        injections {:valid-user? #{applicant-user-id handler-user-id "reviewer"}
+                    :find-userid identity
+                    :get-attachment-metadata
+                    {attachment-id-1 {:application/id (:application/id application)
+                                      :attachment/id attachment-id-1
+                                      :attachment/user handler-user-id}
+                     attachment-id-2 {:application/id (:application/id application)
+                                      :attachment/id attachment-id-2
+                                      :attachment/user handler-user-id}}}]
+    (is (= {:errors [{:type :empty-redacted-attachments}]}
+           (fail-command application
+                         {:type :application.command/redact-attachments
+                          :actor handler-user-id
+                          :comment "should fail"
+                          :public false
+                          :redacted-attachments []
+                          :attachments [{:attachment/id attachment-id-2}]}
+                         injections))
+        "cannot redact if redacted-attachments is empty")
+    (testing "handler can redact attachment"
+      (let [remarked (ok-command application
+                                 {:type :application.command/remark
+                                  :actor handler-user-id
+                                  :comment "handler's remark"
+                                  :attachments [{:attachment/id attachment-id-1}]
+                                  :public false}
+                                 injections)
+            application (apply-events application [remarked])
+            redacted (ok-command application
+                                 {:type :application.command/redact-attachments
+                                  :actor handler-user-id
+                                  :comment "accidental upload, redacting"
+                                  :public false
+                                  :redacted-attachments [{:attachment/id attachment-id-1}]
+                                  :attachments [{:attachment/id attachment-id-2}]}
+                                 injections)
+            application (apply-events application [redacted])
+            [remarked-event redacted-event] (->> (:application/events application)
+                                                 (take-last 2))]
+        (is (= {:event/id 2
+                :event/type :application.event/remarked
+                :event/time test-time
+                :event/actor handler-user-id
+                :application/id app-id
+                :application/comment "handler's remark"
+                :application/public false
+                :event/attachments [{:attachment/id attachment-id-1}]}
+               (-> remarked-event
+                   (dissoc :event/actor-attributes))))
+        (is (= {:event/id 3
+                :event/type :application.event/attachments-redacted
+                :event/time test-time
+                :event/actor handler-user-id
+                :event/attachments [{:attachment/id attachment-id-2}]
+                :application/redacted-attachments [{:attachment/id attachment-id-1
+                                                    :event/id 2}]
+                :application/id app-id
+                :application/comment "accidental upload, redacting"
+                :application/public false}
+               (-> redacted-event
+                   (dissoc :event/actor-attributes))))))))
+
diff --git a/test/clj/rems/browser_test_util.clj b/test/clj/rems/browser_test_util.clj
index 172c01e09..5e64e2657 100644
--- a/test/clj/rems/browser_test_util.clj
+++ b/test/clj/rems/browser_test_util.clj
@@ -17,7 +17,7 @@
             [rems.db.test-data-users :as test-users]
             [rems.db.test-data :as test-data]
             [rems.json :as json]
-            [rems.standalone]
+            [rems.main]
             [rems.util :refer [ensure-empty-directory!]]
             [slingshot.slingshot :refer [try+]])
   (:import [java.net SocketException]))
diff --git a/test/clj/rems/test_browser.clj b/test/clj/rems/test_browser.clj
index f29585ea2..7e46b3743 100644
--- a/test/clj/rems/test_browser.clj
+++ b/test/clj/rems/test_browser.clj
@@ -27,7 +27,7 @@
             [rems.db.test-data-helpers :as test-helpers]
             [rems.db.users :as users]
             [rems.db.user-settings :as user-settings]
-            [rems.standalone]
+            [rems.main]
             [rems.testing-util :refer [with-user with-fake-login-users]]
             [rems.text :as text]))