Repository: CSCfi/rems
Pull request #2637 (2021-04-16 11:34:55)

EGA 1
For #2466.

# Checklist for author

Remove items that aren't applicable, check items that are done.

## Reviewability
- [x] Link to issue
- [ ] Consider adding screenshots for ease of review (maybe next PR should be the UI?, listed in #2466)

## Backwards compatibility
- [x] API is backwards compatible or completely new
- [x] Config is backwards compatible

## Documentation
- [x] Update changelog if necessary
- [x] Update docs/ (if applicable)
- [ ] Update manual/ (if applicable) (PR once feature complete with UI, listed in #2466)
- [ ] ADR for major architectural decisions or experiments (PR later, listed in #2466)
- [x] New config options in config-defaults.edn

## Testing
- [ ] Complex logic is unit tested
- [ ] Valuable features are integration / browser / acceptance tested automatically

## Follow-up
- [x] New tasks are created for pending or remaining tasks (list in #2466)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f14ddd068..1f52a5314 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Changes since v2.18
 
 ### Additions
 - Pdf applications now contain license text (for inline licenses), url (for link licenses) or attachment name (for attachment licenses), and license acceptance status for applicant and members. (#2688)
+- There is a new v2 push API to sync entitlements outside of REMS. So far the only supported type is `:ega`. See [configuration.md](docs/configuration.md) for more details. (#2466)
 
 ## v2.18 "Särkiniemientie" 2021-05-18
 
diff --git a/dev-config.edn b/dev-config.edn
index 509b47801..2ec710663 100644
--- a/dev-config.edn
+++ b/dev-config.edn
@@ -7,8 +7,8 @@
  :authentication :fake
  :log-authentication-details true
  ;; Open ID Connect settings
- ; These allow login to http://localhost:3000/oidc-callback only
- ;:authentication :oidc
+ ;; These allow login to http://localhost:3000/oidc-callback only
+ ;; :authentication :oidc
  :oidc-metadata-url "https://remsdev.eu.auth0.com/.well-known/openid-configuration"
  :oidc-client-id "65TIN1Joo6EvsHSPCN4zyCaxxPh0WJF1"
  :oidc-client-secret "e1yr9R_8WW3Xg03wm6wJ4UmNVVjiqdVhwFGvHhttyoRS3h04F4MGh0zRaV7vKm30"
diff --git a/docs/architecture/012-layers.md b/docs/architecture/012-layers.md
index 576ca3e12..aea4c1607 100644
--- a/docs/architecture/012-layers.md
+++ b/docs/architecture/012-layers.md
@@ -9,32 +9,34 @@ architecture for the backend.
 
 ```
 
-HTTP               /api/licenses/*      /api/workflows/*  ...
-
-
-                   +----------+         +----------+
-API layer          | license  |         | workflow |      ...
-                   +----------+         +----------+
-                        |                    |
-                        |                    |
-                        v                    v
-                   +----------+         +----------+
-Service layer      | license  |         | workflow |      ...
-                   +----------+         +----------+
-                        |      \             |
-                        |       '--------+   |
-                        |                |   |
-                        v                v   v
-                   +----------+         +----------+
-DB layer           | license  |         | workflow |
-                   +----------+         +----------+
-                             |           |
-                             v           v
-                           +--------------+
-                           | rems.db.core |
-                           +--------------+
-                                  |
-                                  v
+HTTP               /api/licenses/*      /api/workflows/*     /api/user-settings/*                   ...
+
+
+                   +----------+         +----------+        +---------------+
+API layer          | license  |         | workflow |        | user-settings |                       ...
+                   +----------+         +----------+        +---------------+
+                        |                    |                      |        \
+                        |                    |                      |         '------+
+                        |                    |                      |                |
+                        v                    v                      v                v
+                   +----------+         +----------+        +---------------+     +-----+
+Service layer      | license  |         | workflow |        | user-settings |     | ega |           ...
+                   +----------+         +----------+        +---------------+     +-----+
+                        |      \             |                      |                |
+                        |       '--------+   |                      |                |
+                        |                |   |                      |                |
+                        v                v   v                      v                v
+                   +----------+         +----------+        +---------------+     +-----+
+DB/EXT layer       | license  |         | workflow |        | user-settings |     | ega |           ...
+                   +----------+         +----------+        +---------------+     +-----+
+                             |           |                   |
+                             |           | +----------------'
+                             v           v v
+                            +--------------+
+                            | rems.db.core |
+                            +--------------+
+                                   |
+                                   v
                                 .----.
                                /      \
                               |\      /|
@@ -145,3 +147,11 @@ In particular:
 - `rems.schema-base` contains common schemas that can be used everywhere.
 - `rems.schema-base` and `rems.api.schema` shouldn't depend on any api, db or service components.
 - Schemas used for JSON in the DB should be in `rems.schema-base` or the `rems.db.*` namespace for that feature.
+
+## Amendment: External layer for integrations
+
+Date: 2021-06-08
+Authors: @Macroz
+
+Sometimes there are external services other than the database. We can have namespaces like `rems.ext.*` for these, like `rems.ext.ega`.
+These should also be referred to from a service namespace like the `rems.db.*` namespaces are.
diff --git a/docs/configuration.md b/docs/configuration.md
index 8d49a18b0..d7e0f04e0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -52,6 +52,25 @@ This option should not be used in production. Keep also in mind that anyone with
 
 ## Entitlements
 
+REMS can push entitlements to external systems with a few different options. There are currently two distinct versions. The original entitlement post (v1) and a newer refined approach called entitlement push (v2).
+
+### Entitlement push (v2)
+
+The entitlement push can be configured using the `:entitlement-push` key like so in your `config.edn`. Any number of external system targets can be defined.
+
+```edn
+  :entitlement-push [{:id "ega"
+                      :type :ega
+                      :connect-server-url "https://ega.server.url:1234/ega-openid-connect-server"
+                      :permission-server-url "https://ega.server.url:1234/ega-permissions"
+                      :client-id "..."
+                      :client-secret "..."}
+```
+
+So far the only supported type is `:ega`, i.e. entitlements are meant to be pushed to [European Genome-Phoenome Archive](https://ega-archive.org/). Enable the EGA support also by adding `:enable-ega true`.
+
+### Entitlement post (v1)
+
 REMS can submit a HTTP POST request for every entitlement granted or
 revoked.
 
diff --git a/docs/ga4gh-visas.md b/docs/ga4gh-visas.md
index 44e21eb37..33cea29a4 100644
--- a/docs/ga4gh-visas.md
+++ b/docs/ga4gh-visas.md
@@ -34,7 +34,6 @@ configured via the `:ga4gh-visa-public-key` parameter. As the
 specification requires, the Visa headers have a `"jku"` parameter,
 that points to the `/api/jwk` url, where the public key can be fetched
 for verifying the Visa.
-
 ### Reading ResearcherStatus Visas
 
 Upon login, REMS fetches an id token from the OIDC server's userinfo
@@ -51,3 +50,20 @@ If an applicant has `researcher-status-by` with value `"so"` or
 checkbox in the applicant details.
 
 See also Bona Fide bot in [bots.md](bots.md).
+
+### European Genome-Phenome Archive (EGA)
+
+The GA4GH Visas are used also by the entitlement push to [EGA](https://ega-archive.org/).
+
+The normal flow is such that:
+- The entitlement push is configured to the REMS instance configuration. See [configuration.md](configuration.md) for the necessary configuration. NB: Remember to enable the EGA support also by adding `:enable-ega true`. This is done only once.
+- The handlers must each link their ELIXIR user accounts to an EGA user account (an external process).
+- The handlers must each log in to REMS, using their ELIXIR ID, and from the Profile page fetch a new EGA API-Key for themselves.
+- Subsequently, when the handler approves an application, their ELIXIR ID and their EGA API-Key will be used to push the entitlement to the configured EGA server in the background. The EGA will display the rights, and allow downloading of data after some delay.
+- Should you wish to use the approver bot together with EGA, you can configure its user from the command-line.
+
+User configuration from command-line (for e.g. approver bot):
+
+```sh
+java -Drems.config=path/to/config -jar rems.jar ega api-key approver-bot <ega-username> <ega-password> <entitlement-push-config-id>
+```
diff --git a/resources/config-defaults.edn b/resources/config-defaults.edn
index 5deb81d42..9eb4da4b0 100644
--- a/resources/config-defaults.edn
+++ b/resources/config-defaults.edn
@@ -73,10 +73,23 @@
  :mail-from nil
  :email-retry-period "P3D" ;; in ISO 8601 duration format https://en.wikipedia.org/wiki/ISO_8601#Durations
 
+ ;; External systems for pushing entitlements (v1)
+ ;;
  ;; URLs to notify about granted and revoked entitlements.
  :entitlements-target {:add nil
-                       :remove nil
-                       :ga4gh nil} ;; Url where entitlements are pushed in ga4gh format, see https://github.com/ga4gh-duri/ga4gh-duri.github.io/
+                       :remove nil}
+
+ ;; External systems for pushing entitlements (v2)
+ ;; NB: remember feature flag `:enable-ega`
+ ;;
+ ;; Example:
+ ;; :entitlement-push [{:id "ega"
+ ;;                     :type :ega
+ ;;                     :connect-server-url "https://ega.ebi.ac.uk:8053/ega-openid-connect-server"
+ ;;                     :permission-server-url "https://ega.ebi.ac.uk:8053/ega-permissions"
+ ;;                     :client-id "..."
+ ;;                     :client-secret "..."}
+ :entitlement-push nil
 
  ;; Default length for entitlements, in days. This only affects the UI of the approve command.
  ;; The default, nil, means indefinite entitlements.
@@ -287,7 +300,7 @@
  ;; enable /api/permissions/:user
  ;; see also docs/ga4gh-visas.md
  :enable-permissions-api false
- ;; Keys for signing GA4GH Visas produced by permissions api.
+ ;; Keys for signing GA4GH Visas produced by permissions api or sent to EGA.
  ;; Format: path to file containing key in JWK (Json Web Key) format
  :ga4gh-visa-private-key nil
  :ga4gh-visa-public-key nil
diff --git a/resources/migrations/20210317070506-user-secrets.down.sql b/resources/migrations/20210317070506-user-secrets.down.sql
new file mode 100644
index 000000000..b9c3b07e2
--- /dev/null
+++ b/resources/migrations/20210317070506-user-secrets.down.sql
@@ -0,0 +1,1 @@
+DROP TABLE IF EXISTS user_secrets;
diff --git a/resources/migrations/20210317070506-user-secrets.up.sql b/resources/migrations/20210317070506-user-secrets.up.sql
new file mode 100644
index 000000000..fd01490be
--- /dev/null
+++ b/resources/migrations/20210317070506-user-secrets.up.sql
@@ -0,0 +1,5 @@
+CREATE TABLE user_secrets (
+  userId varchar(255) NOT NULL PRIMARY KEY,
+  secrets jsonb,
+  FOREIGN KEY (userId) REFERENCES users
+);
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
index 4a96d489f..e82bcf959 100644
--- a/resources/sql/queries.sql
+++ b/resources/sql/queries.sql
@@ -474,6 +474,21 @@ DO UPDATE SET settings = :settings::jsonb;
 DELETE FROM user_settings
 WHERE userId = :user
 
+-- :name get-user-secrets :? :1
+SELECT secrets::TEXT
+FROM user_secrets
+WHERE userId = :user;
+
+-- :name update-user-secrets! :!
+INSERT INTO user_secrets (userId, secrets)
+VALUES (:user, :secrets::jsonb)
+ON CONFLICT (userId)
+DO UPDATE SET secrets = :secrets::jsonb;
+
+-- :name delete-user-secrets! :!
+DELETE FROM user_secrets
+WHERE userId = :user
+
 -- :name get-users :? :*
 SELECT userId
 FROM users;
diff --git a/resources/translations/en.edn b/resources/translations/en.edn
index 440983224..e088c93fe 100644
--- a/resources/translations/en.edn
+++ b/resources/translations/en.edn
@@ -30,6 +30,7 @@
                      :organization-does-not-exist "Organization \"%1\" does not exist"
                      :validation-errors "Check the following errors:"}
             :export-entitlements "Export entitlements as CSV"
+            :generate-ega-api-key-in-profile "Generate one at the Profile page."
             :handled-applications "Processed applications"
             :invite-member "Invite member"
             :licenses-selection "Add terms of use"
@@ -498,7 +499,15 @@
            :phase-revoked "Phase revoked:"
            :rejected "Rejected"
            :revoked "Revoked"}
-  :profile {:idp-email "Email address from identity provider"
+  :profile {:delete-api-key "Delete API-key"
+            :ega-api-key-expiration-date "Expiration date"
+            :ega-api-key-expired [:p "You have an expired EGA API-key. Please generate a new one."]
+            :ega-api-key-none [:p "Looks like you don't have a valid EGA API-key yet."]
+            :ega-api-key-valid [:p "You have a valid EGA API-key."]
+            :ega-full "The European Genome-phenome Archive"
+            :ega-intro [:p "Applications that you approve may have their entitlements pushed to EGA servers. This requires you to have a valid API-Key. To generate one, make sure you first link your ELIXIR user account to the EGA account."]
+            :generate-api-key "Generate API-key"
+            :idp-email "Email address from identity provider"
             :no-email "(no email address)"
             :notification-email "Use another email address for notifications"
             :save "Save"
diff --git a/resources/translations/fi.edn b/resources/translations/fi.edn
index 413775c81..dbde8b7ae 100644
--- a/resources/translations/fi.edn
+++ b/resources/translations/fi.edn
@@ -28,6 +28,7 @@
                      :organization-does-not-exist "Organisaatiota \"%1\" ei ole olemassa"
                      :validation-errors "Tarkasta seuraavat virheet:"}
             :export-entitlements "Lataa käyttöoikeudet CSV-tiedostona"
+            :generate-ega-api-key-in-profile "Luo avain Profiili-sivulla."
             :handled-applications "Käsitellyt hakemukset"
             :invite-member "Kutsu jäsen"
             :licenses-selection "Lisää käyttöehtoja"
@@ -454,7 +455,15 @@
            :phase-revoked "Vaihe peruttu:"
            :rejected "Hylätty"
            :revoked "Peruttu"}
-  :profile {:idp-email "Sähköpostiosoite tunnistautumispalvelulta"
+  :profile {:delete-api-key "Poista API-avain"
+            :ega-api-key-expiration-date "API-avain vanhenee"
+            :ega-api-key-expired [:p "Sinulla on vanhentunut API-avain. Luo itsellesi uusi avain."]
+            :ega-api-key-none [:p "Näyttää siltä, että sinulla ei ole vielä API-avainta."]
+            :ega-api-key-valid [:p "Sinulla on voimassa oleva API-avain."]
+            :ega-full "The European Genome-phenome Archive"
+            :ega-intro [:p "Hyväksymiesi hakemusten käyttövaltuudet voidaan lähettää EGA-palvelimille. Tämä vaatii, että sinulla on voimassa oleva API-avain. Varmista, että olet ensin yhdistänyt ELIXIR-käyttäjätunnuksesi EGA-tunnukseesi."]
+            :generate-api-key "Luo API-avain"
+            :idp-email "Sähköpostiosoite tunnistautumispalvelulta"
             :no-email "(ei sähköpostiosoitetta)"
             :notification-email "Käytä toista sähköpostiosoitetta ilmoituksille"
             :save "Tallenna"
diff --git a/resources/translations/sv.edn b/resources/translations/sv.edn
index 1ebbf7f49..0726f3639 100644
--- a/resources/translations/sv.edn
+++ b/resources/translations/sv.edn
@@ -28,6 +28,7 @@
                      :organization-does-not-exist "Organisationen \"%1\" hittades inte."
                      :validation-errors "Var vänlig kontrollera följande:"}
             :export-entitlements "Ladda ner rättigheterna som csv-fil"
+            :generate-ega-api-key-in-profile "Hämta den på Profil-sidan."
             :handled-applications "Behandlade ansökningar"
             :invite-member "Bjud in deltagare"
             :licenses-selection "Lägg till licenser"
@@ -454,7 +455,15 @@
            :phase-revoked "Status återkallad:"
            :rejected "Avslag"
            :revoked "Återkallad"}
-  :profile {:idp-email "E-postadress från identifieringstjänst"
+  :profile {:delete-api-key "Radera API-nyckeln"
+            :ega-api-key-expiration-date "API-nyckel utgångsdatum"
+            :ega-api-key-expired [:p "Du har en utgången EGA API-nyckel. Vänligen hämta en ny API-nyckel."]
+            :ega-api-key-none [:p "Du har inte en giltig EGA API-nyckel än."]
+            :ega-api-key-valid [:p "Du har en giltig EGA API-nyckel."]
+            :ega-full "The European Genome-phenome Archive"
+            :ega-intro [:p "Rättigheter för ansökningar som du gödkänner kan överföras till EGA-servrar. Detta kräver att du har en giltig API-nyckel. För att hämta en, se till att du först länkar ditt ELIXIR-konto till EGA-kontot."]
+            :generate-api-key "Hämta en API-nyckel"
+            :idp-email "E-postadress från identifieringstjänst"
             :no-email "(ingen e-postadress)"
             :notification-email "Använd en annan e-postadress för att ta emot meddelanden"
             :save "Spara"
diff --git a/src/clj/rems/api.clj b/src/clj/rems/api.clj
index f40beabae..defce81c1 100644
--- a/src/clj/rems/api.clj
+++ b/src/clj/rems/api.clj
@@ -25,14 +25,14 @@
             [rems.api.workflows :refer [workflows-api]]
             [rems.auth.auth :as auth]
             [rems.db.core :as db]
-            [rems.json :refer [muuntaja]]
+            [rems.json :as json]
             [rems.util :refer [get-user-id]]
             [ring.middleware.cors :refer [wrap-cors]]
             [ring.util.http-response :refer :all]
             [ring.util.response :as response]
-            [schema.core :as s]
-            [rems.json :as json])
-  (:import [rems.auth ForbiddenException UnauthorizedException]
+            [schema.core :as s])
+  (:import clojure.lang.ExceptionInfo
+           [rems.auth ForbiddenException UnauthorizedException]
            rems.DataException
            rems.InvalidRequestException
            rems.TryAgainException))
@@ -80,6 +80,12 @@
   (-> (service-unavailable (json/generate-string (.-data exception)))
       (response/content-type "application/json")))
 
+(defn ex-info-handler
+  [exception ex-data _request]
+  (log/error exception (str (.getMessage exception) " " (pr-str ex-data)))
+  (-> (internal-server-error)
+      (response/content-type "application/json")))
+
 (defn with-logging
   ;; Like in compojure.api.exception, but logs some of the data (with pprint)
   "Wrap compojure-api exception-handler a function which will log the
@@ -137,10 +143,11 @@
   (audit-log-middleware
    (api
     {;; TODO: should this be in rems.middleware?
-     :formats muuntaja
+     :formats json/muuntaja
      :middleware [cors-middleware
                   transaction-middleware]
      :exceptions {:handlers {UnauthorizedException unauthorized-handler
+                             ExceptionInfo ex-info-handler
                              ForbiddenException forbidden-handler
                              InvalidRequestException invalid-handler
                              TryAgainException try-again-handler
diff --git a/src/clj/rems/api/services/ega.clj b/src/clj/rems/api/services/ega.clj
new file mode 100644
index 000000000..9114cfcc0
--- /dev/null
+++ b/src/clj/rems/api/services/ega.clj
@@ -0,0 +1,195 @@
+(ns rems.api.services.ega
+  "Service for interfacing with EGA Permission API."
+  (:require [clj-time.core :as time-core]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest is]]
+            [clojure.tools.logging :as log]
+            [medley.core :refer [find-first]]
+            [rems.config :refer [env]]
+            [rems.db.user-secrets :as user-secrets]
+            [rems.db.user-settings :as user-settings]
+            [rems.ext.ega :as ega]
+            [rems.ga4gh :as ga4gh]
+            [rems.util :refer [getx]]))
+
+(defn get-ega-config
+  "Returns the EGA push configuration.
+
+  NB: This is currently limited to one EGA of configuration at a time."
+  []
+  (let [config (find-first (comp #{:ega} :type) (:entitlement-push env))]
+    (assert (seq config) "EGA entitlement push must be configured!")
+
+    config))
+
+(defn- get-api-key-for [handler-userid]
+  (get-in (user-secrets/get-user-secrets handler-userid) [:ega :api-key])) ; TODO: implement
+
+(defn- get-dac-for [_resid _handler-userid]
+  "EGAC00001000908")
+
+(defn- entitlement->update
+  "Converts an entitlement to a GA4GH visa for EGA.
+
+  `entitlement` – entitlement to convert
+
+  NB: the GA4GH signing keys will be required from the environment"
+  [entitlement]
+  {:format "JWT"
+   :visas (-> [{:resid (:resid entitlement)
+                :start (:start entitlement)
+                :end (:end entitlement)
+                :userid (:userid entitlement)
+                :dac-id (get-dac-for (:resid entitlement) (:approvedby entitlement))}]
+              ga4gh/entitlements->passport
+              :ga4gh_passport_v1)})
+
+(deftest test-entitlement->update
+  (with-redefs [env (rems.config/load-keys {:public-url "https://rems.example/"
+                                            :ga4gh-visa-private-key "test-data/example-private-key.jwk"
+                                            :ga4gh-visa-public-key "test-data/example-public-key.jwk"})
+                time-core/now (fn [] (time-core/date-time 2021 04 17))] ; iat
+    (is (= {:format "JWT"
+            :visas [[{:alg "RS256"
+                      :kid "2011-04-29"
+                      :jku "https://rems.example/api/jwk"
+                      :typ "JWT"}
+                     {:sub "elixir-user"
+                      :iss "https://rems.example/"
+                      :exp 1647475200
+                      :ga4gh_visa_v1
+                      {:value "EGAD00001006673"
+                       :type "ControlledAccessGrants"
+                       :source "EGAC00001000908"
+                       :asserted 1615939200
+                       :by "dac"}
+                      :iat 1618617600}]]}
+           (update-in (entitlement->update {:resid "EGAD00001006673"
+                                            :userid "elixir-user"
+                                            :approvedby "dac-user"
+                                            :start (time-core/date-time 2021 03 17)
+                                            :end (time-core/date-time 2022 03 17)})
+                      [:visas 0]
+                      ga4gh/visa->claims)))))
+
+(defn entitlement-push [action entitlement config]
+  (let [api-key (get-api-key-for (:approvedby entitlement))
+        common-fields {:api-key api-key
+                       :account-id (:userid entitlement)
+                       :config config}]
+
+    (when (str/blank? api-key)
+      (log/warnf "Missing EGA api-key for %s" (:approvedby entitlement)))
+
+    (log/infof "Pushing entitlements to %s %s: %s %s" (getx config :id) (getx config :permission-server-url) entitlement config)
+
+    (case action
+      :add
+      (ega/post-create-or-update-permissions (merge common-fields
+                                                    (entitlement->update entitlement)))
+
+      :remove
+      (ega/delete-permissions (merge common-fields
+                                     {:dataset-ids [(:resid entitlement)]})))))
+
+(defn generate-api-key-with-access-token
+  "Generates an API-Key with `access-token` and saves it to the user's secrets.
+
+  `:userid`               - REMS user to generate the key for
+  `:access-token`         - User's ELIXIR access token
+  `:config`               - configuration of the EGA integration with following keys:
+    `:connect-server-url` - EGA login server url
+    `:permission-server-url` - EGA permission server url
+    `:client-id`          - client id for REMS
+    `:client-secret`      - client secret for REMS"
+  [{:keys [userid access-token config]}]
+  (assert userid "User missing!")
+
+  (try
+    (let [_ (log/info "Generate API-Key...")
+          expiration-date (time-core/plus (time-core/now) (time-core/years 1))
+          api-key (-> {:access-token access-token
+                       :id userid
+                       :expiration-date expiration-date
+                       :reason "rems_ega_push"
+                       :config config}
+                      ega/get-api-key-generate
+                      :body
+                      :token)
+          _ (assert api-key)
+
+          _ (log/info "Save user secret...")
+          secrets-result (user-secrets/update-user-secrets! userid {:ega {:api-key api-key}})
+          _ (assert (:success secrets-result))
+
+          _ (log/info "Save user settings...")
+          settings-result (user-settings/update-user-settings! userid {:ega {:api-key-expiration-date expiration-date}})
+          _ (assert (:success settings-result))]
+
+      (log/info "Success!")
+      {:success true
+       :api-key-expiration-date expiration-date})
+
+    (catch Throwable t
+      (log/error t "Failure!")
+      {:success false})))
+
+(defn generate-api-key-with-account
+  "Logs into EGA with `username` and `password` to fetch an access token, then generates an API-Key and saves it to the user's secrets.
+
+  `:userid`               - REMS user to generate the key for
+  `:username`             - EGA user to use
+  `:password`             - EGA user password
+  `:config`               - configuration of the EGA integration with following keys:
+    `:connect-server-url` - EGA login server url
+    `:permission-server-url` - EGA permission server url
+    `:client-id`          - client id for REMS
+    `:client-secret`      - client secret for REMS"
+  [{:keys [userid username password config]}]
+  (assert userid "User missing!")
+
+  (let [_ (log/info "Get EGA token...")
+        access-token (-> {:username username
+                          :password password
+                          :config config}
+                         ega/post-token
+                         :body
+                         :access_token)
+        _ (assert access-token)]
+    (generate-api-key-with-access-token {:userid userid
+                                         :access-token access-token
+                                         :config config})))
+
+(defn delete-api-key
+  "Deletes the API-Key and removes it from the user's secrets.
+
+  `:userid`               - REMS user to delete the key of
+  `:access-token`         - User's ELIXIR access token
+  `:config`               - configuration of the EGA integration with following keys:
+    `:connect-server-url` - EGA login server url
+    `:permission-server-url` - EGA permission server url
+    `:client-id`          - client id for REMS
+    `:client-secret`      - client secret for REMS"
+  [{:keys [userid access-token config]}]
+  (assert userid "User missing!")
+
+  (try
+    (let [_ (log/info "Deleting API-Key...")
+          _result (-> {:access-token access-token
+                       :id userid
+                       :config config}
+                      ega/delete-api-key-invalidate)
+
+          _ (log/info "Remove user secret...")
+          secrets-result (user-secrets/update-user-secrets! userid {:ega {}})
+          _ (assert (:success secrets-result))
+
+          _ (log/info "Remove user setting...")
+          settings-result (user-settings/update-user-settings! userid {:ega {}})
+          _ (assert (:success settings-result))]
+      (log/info "Success!")
+      {:success true})
+    (catch Throwable t
+      (do
+        (log/error t "Failure!")
+        {:success false}))))
diff --git a/src/clj/rems/api/services/user_settings.clj b/src/clj/rems/api/services/user_settings.clj
new file mode 100644
index 000000000..39b5bae4d
--- /dev/null
+++ b/src/clj/rems/api/services/user_settings.clj
@@ -0,0 +1,10 @@
+(ns rems.api.services.user-settings
+  (:require [rems.db.user-settings :as user-settings]))
+
+(def UserSettings user-settings/UserSettings)
+
+(defn get-user-settings [userid]
+  (user-settings/get-user-settings userid))
+
+(defn update-user-settings! [userid settings]
+  (user-settings/update-user-settings! userid settings))
diff --git a/src/clj/rems/api/user_settings.clj b/src/clj/rems/api/user_settings.clj
index 52f99066d..72fcd3c34 100644
--- a/src/clj/rems/api/user_settings.clj
+++ b/src/clj/rems/api/user_settings.clj
@@ -1,7 +1,9 @@
 (ns rems.api.user-settings
   (:require [compojure.api.sweet :refer :all]
             [rems.api.schema :as schema]
-            [rems.db.user-settings :as user-settings]
+            [rems.config :refer [env]]
+            [rems.api.services.ega :as ega]
+            [rems.api.services.user-settings :as user-settings]
             [rems.util :refer [getx-user-id get-user-id]]
             [ring.util.http-response :refer :all]
             [schema.core :as s])
@@ -13,6 +15,13 @@
   {(s/optional-key :language) s/Keyword
    (s/optional-key :notification-email) (s/maybe s/Str)})
 
+(s/defschema GenerateEGAApiKeyResponse
+  {:success s/Bool
+   (s/optional-key :api-key-expiration-date) DateTime})
+
+(s/defschema DeleteEGAApiKeyResponse
+  {:success s/Bool})
+
 (def user-settings-api
   (context "/user-settings" []
     :tags ["user-settings"]
@@ -28,4 +37,26 @@
       :roles #{:logged-in}
       :body [settings UpdateUserSettings]
       :return schema/SuccessResponse
-      (ok (user-settings/update-user-settings! (getx-user-id) settings)))))
+      (ok (user-settings/update-user-settings! (getx-user-id) settings)))
+
+    (POST "/generate-ega-api-key" [:as request] ; NB: binding syntax
+      :summary "Generates a new EGA API-key for the user."
+      :roles #{:handler}
+      :return GenerateEGAApiKeyResponse
+      (if-not (:enable-ega env)
+        (not-implemented "EGA not enabled")
+        (let [access-token (get-in request [:session :access-token])]
+          (ok (ega/generate-api-key-with-access-token {:userid (get-user-id)
+                                                       :access-token access-token
+                                                       :config (ega/get-ega-config)})))))
+
+    (POST "/delete-ega-api-key" [:as request] ; NB: binding syntax
+      :summary "Deletes the EGA API-key of the user."
+      :roles #{:handler}
+      :return DeleteEGAApiKeyResponse
+      (if-not (:enable-ega env)
+        (not-implemented "EGA not enabled")
+        (let [access-token (get-in request [:session :access-token])]
+          (ok (ega/delete-api-key {:userid (get-user-id)
+                                   :access-token access-token
+                                   :config (ega/get-ega-config)})))))))
diff --git a/src/clj/rems/auth/fake_login.clj b/src/clj/rems/auth/fake_login.clj
index 498f6b676..2d76f4d83 100644
--- a/src/clj/rems/auth/fake_login.clj
+++ b/src/clj/rems/auth/fake_login.clj
@@ -15,7 +15,9 @@
 
 (defn- fake-login [session username]
   (assoc (redirect "/redirect")
-         :session (assoc session :identity (users/get-raw-user-attributes username))))
+         :session (assoc session
+                         :identity (users/get-raw-user-attributes username)
+                         :access-token (str "access-token-" username)))) ; fake access token
 
 (defn- user-selection [username]
   (let [url (url (login-url) {:username username})]
diff --git a/src/clj/rems/db/entitlements.clj b/src/clj/rems/db/entitlements.clj
index de5322269..5a510fd91 100644
--- a/src/clj/rems/db/entitlements.clj
+++ b/src/clj/rems/db/entitlements.clj
@@ -6,7 +6,8 @@
             [clojure.tools.logging :as log]
             [mount.core :as mount]
             [rems.common.application-util :as application-util]
-            [rems.auth.util :refer [throw-forbidden throw-unauthorized]]
+            [rems.api.services.ega :as ega]
+            [rems.auth.util :refer [throw-forbidden]]
             [rems.config :refer [env]]
             [rems.db.applications :as applications]
             [rems.db.core :as db]
@@ -17,8 +18,7 @@
             [rems.json :as json]
             [rems.common.roles :refer [has-roles?]]
             [rems.scheduler :as scheduler]
-            [rems.util :refer [getx-user-id]])
-  (:import [org.joda.time DateTime]))
+            [rems.util :refer [getx-user-id]]))
 
 ;; TODO move Entitlement schema here from rems.api?
 
@@ -63,29 +63,44 @@
        :mail (:mail e)
        :end (:end e)})))
 
-(defn- post-entitlements! [{:keys [entitlements action] :as params}]
-  (when-let [target (get-in env [:entitlements-target action])]
-    (let [payload (get-entitlements-payload entitlements action)
-          json-payload (json/generate-string payload)]
-      (log/infof "Posting entitlements to %s:" target payload)
-      (let [response (try
-                       (http/post target
-                                  {:throw-exceptions false
-                                   :body json-payload
-                                   :content-type :json
-                                   :socket-timeout 2500
-                                   :conn-timeout 2500})
-                       (catch Exception e
-                         (log/error "POST failed" e)
-                         {:status "exception"}))
-            status (:status response)]
-        (when-not (= 200 status)
-          (log/warnf "Entitlement post failed: %s", response)
-          (str "failed: " status))))))
+(defn- post-entitlements! [{:keys [action type entitlements config]}]
+  (case type
+    :ega
+    (when config
+      (try (doseq [entitlement entitlements] ; technically these could be grouped per user & api-key
+             (ega/entitlement-push action entitlement config))
+           (catch Exception e
+             (log/error "POST failed" e)
+             (or (ex-data e)
+                 {:status "exception"}))))
+
+    :basic ; TODO: let's move this :entitlements-target (v1) at some point to :entitlement-post (v2)
+    (when-let [target (get-in env [:entitlements-target action])]
+      (let [payload (get-entitlements-payload entitlements action)
+            json-payload (json/generate-string payload)]
+        (log/infof "Posting entitlements to %s: %s" target payload)
+        (let [response (try
+                         (http/post target
+                                    {:throw-exceptions false
+                                     :body json-payload
+                                     :content-type :json
+                                     :socket-timeout 2500
+                                     :conn-timeout 2500})
+                         (catch Exception e
+                           (log/error "POST failed" e)
+                           {:status "exception"}))
+              status (:status response)]
+          (when-not (= 200 status)
+            (log/warnf "Entitlement post failed: %s", response)
+            (str "failed: " status)))))))
 
 ;; TODO argh adding these everywhere sucks
+;; TODO consider using schema coercions
 (defn- fix-entry-from-db [entry]
-  (update-in entry [:outbox/entitlement-post :action] keyword))
+  (-> entry
+      (update-in [:outbox/entitlement-post :action] keyword)
+      (update-in [:outbox/entitlement-post :type] keyword)
+      (update-in [:outbox/entitlement-post :config :type] keyword)))
 
 (defn process-outbox! []
   (doseq [entry (mapv fix-entry-from-db
@@ -101,11 +116,13 @@
   :start (scheduler/start! process-outbox! (.toStandardDuration (time/seconds 10)))
   :stop (scheduler/stop! entitlement-poller))
 
-(defn- add-to-outbox! [action entitlements]
+(defn- add-to-outbox! [action type entitlements config]
   (outbox/put! {:outbox/type :entitlement-post
                 :outbox/deadline (time/plus (time/now) (time/days 1)) ;; hardcoded for now
                 :outbox/entitlement-post {:action action
-                                          :entitlements entitlements}}))
+                                          :type type
+                                          :entitlements entitlements
+                                          :config config}}))
 
 (defn- grant-entitlements! [application-id user-id resource-ids actor end]
   (log/info "granting entitlements on application" application-id "to" user-id "resources" resource-ids "until" end)
@@ -117,8 +134,10 @@
                           :start (time/now) ; TODO should ideally come from the command time
                           :end end})
     ;; TODO could generate only one outbox entry per application. Currently one per user-resource pair.
-    (add-to-outbox! :add (db/get-entitlements {:application application-id :user user-id :resource resource-id}))
-    (add-to-outbox! :ga4gh (db/get-entitlements {:application application-id :user user-id :resource resource-id}))))
+    (let [entitlements (db/get-entitlements {:application application-id :user user-id :resource resource-id})]
+      (add-to-outbox! :add :basic entitlements nil)
+      (doseq [config (:entitlement-push env)]
+        (add-to-outbox! :add (:type config) entitlements config)))))
 
 (defn- revoke-entitlements! [application-id user-id resource-ids actor end]
   (log/info "revoking entitlements on application" application-id "to" user-id "resources" resource-ids "at" end)
@@ -128,7 +147,10 @@
                            :resource resource-id
                            :revokedby actor
                            :end end})
-    (add-to-outbox! :remove (db/get-entitlements {:application application-id :user user-id :resource resource-id}))))
+    (let [entitlements (db/get-entitlements {:application application-id :user user-id :resource resource-id})]
+      (add-to-outbox! :remove :basic entitlements nil)
+      (doseq [config (:entitlement-push env)]
+        (add-to-outbox! :remove (:type config) entitlements config)))))
 
 (defn- get-entitlements-by-user [application-id]
   (->> (db/get-entitlements {:application application-id :active-at (time/now)})
diff --git a/src/clj/rems/db/outbox.clj b/src/clj/rems/db/outbox.clj
index 3d026f8d6..489f1b384 100644
--- a/src/clj/rems/db/outbox.clj
+++ b/src/clj/rems/db/outbox.clj
@@ -9,6 +9,7 @@
             [schema.core :as s])
   (:import [org.joda.time Duration DateTime]))
 
+;; TODO: the meaning of the fields should be documented
 (def OutboxData
   {(s/optional-key :outbox/id) s/Int
    :outbox/type (s/enum :email :entitlement-post :event-notification)
@@ -17,7 +18,7 @@
    :outbox/deadline DateTime
    :outbox/next-attempt (s/maybe DateTime)
    :outbox/latest-attempt (s/maybe DateTime)
-   :outbox/latest-error (s/maybe s/Str)
+   :outbox/latest-error (s/maybe s/Any)
    (s/optional-key :outbox/email) s/Any
    (s/optional-key :outbox/entitlement-post) s/Any
    (s/optional-key :outbox/event-notification) s/Any})
diff --git a/src/clj/rems/db/user_secrets.clj b/src/clj/rems/db/user_secrets.clj
new file mode 100644
index 000000000..98addbebe
--- /dev/null
+++ b/src/clj/rems/db/user_secrets.clj
@@ -0,0 +1,46 @@
+(ns rems.db.user-secrets
+  (:require [rems.common.util :refer [deep-merge]]
+            [rems.db.core :as db]
+            [rems.json :as json]
+            [schema.coerce :as coerce]
+            [schema.core :as s]))
+
+(s/defschema UserSecrets
+  {(s/optional-key :ega) {(s/optional-key :api-key) s/Str}})
+
+(def ^:private validate-user-secrets
+  (s/validator UserSecrets))
+
+(defn- secrets->json [secrets]
+  (-> secrets
+      validate-user-secrets
+      json/generate-string))
+
+(def ^:private coerce-user-secrets
+  (coerce/coercer! UserSecrets json/coercion-matcher))
+
+(defn- json->secrets [json]
+  (when json
+    (-> json
+        json/parse-string
+        coerce-user-secrets)))
+
+(defn get-user-secrets [user]
+  (when user
+    (json->secrets (:secrets (db/get-user-secrets {:user user})))))
+
+(defn update-user-secrets! [user new-secrets]
+  (assert user "User missing!")
+  (let [old-secrets (get-user-secrets user)
+        validated (try (validate-user-secrets (deep-merge old-secrets new-secrets))
+                       (catch Exception _e nil))]
+    (if validated
+      (do
+        (db/update-user-secrets!
+         {:user user
+          :secrets (secrets->json (merge old-secrets validated))})
+        {:success true})
+      {:success false})))
+
+(defn delete-user-secrets! [user]
+  (db/delete-user-secrets! {:user user}))
diff --git a/src/clj/rems/db/user_settings.clj b/src/clj/rems/db/user_settings.clj
index 4640ec9f5..3ed673ab9 100644
--- a/src/clj/rems/db/user_settings.clj
+++ b/src/clj/rems/db/user_settings.clj
@@ -16,7 +16,7 @@
 (s/defschema UserSettings
   {:language s/Keyword
    :notification-email (s/maybe s/Str)
-   (s/optional-key :ega) {:api-key-expiration-date DateTime}})
+   (s/optional-key :ega) {(s/optional-key :api-key-expiration-date) DateTime}})
 
 (def ^:private validate-user-settings
   (s/validator UserSettings))
diff --git a/src/clj/rems/ext/ega.clj b/src/clj/rems/ext/ega.clj
new file mode 100644
index 000000000..6d3fdde7d
--- /dev/null
+++ b/src/clj/rems/ext/ega.clj
@@ -0,0 +1,163 @@
+(ns rems.ext.ega
+  "Utilities for interfacing with EGA Permission API.
+
+  The function names try to match the remote API method and paths."
+  (:require [clj-http.client :as http]
+            [clojure.string :as str]
+            [clojure.tools.logging :as log]
+            [rems.db.user-secrets :as user-secrets]
+            [rems.json :as json]
+            [rems.util :refer [getx]]))
+
+(def ^:private +common-opts+
+  {:socket-timeout 2500
+   :conn-timeout 2500
+   :as :json})
+
+(defn post-token
+  "Fetches an EGA Token representing an EGA user.
+
+  `:username`             - username (in EGA) e.g. foo@bar.com
+  `:password`             - password for the user
+  `:config`               - configuration of the EGA integration with following keys:
+    `:connect-server-url` - EGA login server url
+    `:client-id`          - client id for REMS
+    `:client-secret`      - client secret for REMS
+
+  NB: It is valid for one hour."
+  [{:keys [username password config]}]
+  (http/post (str (:connect-server-url config) "/token")
+             (merge +common-opts+
+                    {:form-params {"grant_type" "password"
+                                   "client_id" (getx config :client-id)
+                                   "client_secret" (getx config :client-secret)
+                                   "username" username
+                                   "password" password
+                                   "scope" "openid"}})))
+
+(defn get-api-key-generate
+  "Generate an API-Key for a user with a token.
+
+  The API-Key can be used longer (e.g. a year) to represent the
+  user it was generated for.
+
+  `:access-token`            - the access token
+  `:id`                      - identity for the key e.g. user
+  `:expiration-date`         - YYYY-MM-DD of the desired expiration date
+  `:reason`                  - string description of the use of the key
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [access-token id expiration-date reason config]}]
+  (assert access-token)
+  (http/get (str (getx config :permission-server-url) "/api_key/generate")
+            (merge +common-opts+
+                   {:oauth-token access-token
+                    :query-params {"id" id
+                                   "expiration_date" expiration-date ; TODO: pass as date?
+                                   "reason" reason}})))
+
+(defn delete-api-key-invalidate
+  "Invalidates an API-Key of a user with a token.
+
+  `:access-token`            - the access token
+  `:id`                      - identity for the key e.g. user
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [access-token id config]}]
+  (assert access-token)
+  (http/delete (str (getx config :permission-server-url) "/api_key/" id)
+               (merge +common-opts+
+                      {:oauth-token access-token})))
+
+(defn get-api-key-list
+  "List the API-Keys available.
+
+  `:access-token` - the access token
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url
+
+  NB: The actual API-Key is not returned by this call. It is only returned once in `api-key-generate`."
+  [{:keys [access-token config]}]
+  (http/get (str (getx config :permission-server-url) "/api_key")
+            (merge +common-opts+
+                   {:oauth-token access-token})))
+
+(defn get-permissions
+  "Gets the permissions of the specified user.
+
+  `:api-key`                 - valid API-Key of the person acting
+  `:account-id`              - account id (Elixir or EGA)
+  `:format`                  - PLAIN or JWT, defaults to JWT.
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [api-key account-id format config]}]
+  (http/get (str (getx config :permission-server-url) "/permissions")
+            (merge +common-opts+
+                   {:headers {"authorization" (str "api-key " api-key)
+                              "x-account-id" account-id}
+                    :query-params {"format" (or format "JWT")}})))
+
+(defn get-me-permissions
+  "Gets the permissions of the current user.
+
+  `:api-key`    - valid API-Key of the person acting
+  `:format`     - PLAIN or JWT, defaults to JWT.
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [api-key format config]}]
+  (http/get (str (getx config :permission-server-url) "/me/permissions")
+            (merge +common-opts+
+                   {:headers {"authorization" (str "api-key " api-key)}
+                    :query-params {"format" (or format "JWT")}})))
+
+(defn get-dataset-users
+  "Lists the users with permission to the dataset.
+
+  `:api-key`                 - valid API-Key of the person acting
+  `:dataset-id`              - id of the dataset
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [api-key dataset-id config]}]
+  (http/get (str (getx config :permission-server-url) "/datasets/" dataset-id "/users")
+            (merge +common-opts+
+                   {:headers {"authorization" (str "api-key " api-key)}})))
+
+(defn post-create-or-update-permissions
+  "Create or update the permissions of the user.
+
+  `:api-key`                 - valid API-Key of the person acting
+  `:account-id`              - account id (Elixir or EGA) of the user whose permissions are updated
+  `:visas`                   - visas of permissions to create
+  `:format`                  - PLAIN or JWT, defaults to JWT.
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [api-key account-id visas format config] :as params}]
+  (log/infof "%s: %s" #'post-create-or-update-permissions params)
+  (http/post (str (getx config :permission-server-url) "/permissions")
+             (merge +common-opts+
+                    {:headers {"authorization" (str "api-key " api-key)
+                               "x-account-id" account-id}
+                     :content-type :json
+                     :body (json/generate-string (if (= "JWT" format)
+                                                   (map (fn [visa]
+                                                          {:jwt visa
+                                                           :format format})
+                                                        visas)
+                                                   visas))
+                     :query-params {:format (or format "JWT")}})))
+
+(defn delete-permissions
+  "Delete the permissions of the user for a given dataset.
+
+  `:api-key`                 - valid API-Key of the person acting
+  `:account-id`              - account id (Elixir or EGA) of the user whose permissions are deleted
+  `:dataset-ids`             - ids of the datasets to delete
+  `:config`                  - configuration of the EGA integration with following keys:
+    `:permission-server-url` - EGA permission server url"
+  [{:keys [api-key account-id dataset-ids config] :as params}]
+  (log/infof "%s: %s" #'delete-permissions params)
+  (http/delete (str (getx config :permission-server-url) "/permissions")
+               (merge +common-opts+
+                      {:headers {"authorization" (str "api-key " api-key)
+                                 "x-account-id" account-id}
+                       :query-params {:values (str/join "," dataset-ids)}})))
diff --git a/src/clj/rems/ga4gh.clj b/src/clj/rems/ga4gh.clj
index b159130d7..af036f1fa 100644
--- a/src/clj/rems/ga4gh.clj
+++ b/src/clj/rems/ga4gh.clj
@@ -6,12 +6,10 @@
   (:require [buddy.core.keys :as keys]
             [clj-time.coerce]
             [clj-time.core :as time]
-            [clojure.test :refer :all]
             [clojure.tools.logging :as log]
             [rems.common.util :refer [getx]]
             [rems.config :refer [env oidc-configuration]]
             [rems.jwt :as jwt]
-            [rems.testing-util :refer [with-fixed-time]]
             [schema.core :as s])
   (:import java.time.Instant))
 
@@ -57,42 +55,17 @@
 
 (def +default-length+ (time/years 1))
 
-(defn- entitlement->visa-claims [{:keys [resid _catappid start end _mail userid _approvedby]}]
+(defn- entitlement->visa-claims [{:keys [resid _catappid start end _mail userid _approvedby dac-id]}]
   {:iss (:public-url env)
    :sub userid
    :iat (clj-time.coerce/to-epoch (time/now))
    :exp (clj-time.coerce/to-epoch (or end (time/plus (time/now) +default-length+)))
    :ga4gh_visa_v1 {:type "ControlledAccessGrants"
                    :value (str resid)
-                   :source (:public-url env)
+                   :source (or dac-id (:public-url env))
                    :by "dac" ; the Data Access Commitee acts via REMS
                    :asserted (clj-time.coerce/to-epoch start)}})
 
-(deftest test-entitlement->visa-claims
-  (with-redefs [env {:public-url "https://rems.example/"}]
-    (with-fixed-time (time/date-time 2010 01 01)
-      (fn []
-        (is (= {:iss "https://rems.example/"
-                :sub "user@example.com"
-                :iat (clj-time.coerce/to-epoch "2010")
-                :exp (clj-time.coerce/to-epoch "2011")
-                :ga4gh_visa_v1 {:type "ControlledAccessGrants"
-                                :value "urn:1234"
-                                :source "https://rems.example/"
-                                :by "dac"
-                                :asserted (clj-time.coerce/to-epoch "2009")}}
-               (entitlement->visa-claims {:resid "urn:1234" :start (time/date-time 2009) :userid "user@example.com"})))
-        (is (= {:iss "https://rems.example/"
-                :sub "user@example.com"
-                :iat (clj-time.coerce/to-epoch "2010")
-                :exp (clj-time.coerce/to-epoch "2010-06-02")
-                :ga4gh_visa_v1 {:type "ControlledAccessGrants"
-                                :value "urn:1234"
-                                :source "https://rems.example/"
-                                :by "dac"
-                                :asserted (clj-time.coerce/to-epoch "2009")}}
-               (entitlement->visa-claims {:resid "urn:1234" :start (time/date-time 2009) :end (time/date-time 2010 6 2) :userid "user@example.com"})))))))
-
 (defn- entitlement->visa [entitlement]
   (sign-visa (entitlement->visa-claims entitlement)))
 
@@ -127,3 +100,10 @@
     (some identity
           (doall (for [visa visas]
                    (visa->researcher-status-by (jwt/validate visa (:issuer oidc-configuration) nil (Instant/now))))))))
+
+(defn visa->claims
+  "Peek into the contents of the visa / JWT token without verifying it.
+
+  Useful for testing or debugging."
+  [visa]
+  (jwt/peek visa))
diff --git a/src/clj/rems/jwt.clj b/src/clj/rems/jwt.clj
index 8550208e2..360e83613 100644
--- a/src/clj/rems/jwt.clj
+++ b/src/clj/rems/jwt.clj
@@ -1,11 +1,15 @@
 (ns rems.jwt
-  (:require [buddy.core.keys :as buddy-keys]
+  (:require [buddy.core.codecs :as codecs]
+            [buddy.core.codecs.base64 :as b64]
+            [buddy.core.keys :as buddy-keys]
             [buddy.sign.jwe :as buddy-jwe]
             [buddy.sign.jwt :as buddy-jwt]
             [clj-http.client :as http]
-            [mount.core :as mount]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest is]]
             [rems.common.util :refer [getx index-by]]
-            [rems.config :refer [oidc-configuration]]))
+            [rems.config :refer [oidc-configuration]]
+            [rems.json :as json]))
 
 ;; Could consider caching this if it is a performance bottleneck.
 ;; However our OIDC login already has like 3 roundtrips to the OIDC
@@ -33,3 +37,28 @@
                                              :now now}
                                             (when issuer {:iss issuer})
                                             (when audience {:aud audience})))))
+
+(defn peek
+  "Peek into the claims of a JWT token without verifying anything."
+  [jwt]
+  (->> (str/split jwt #"\." 2) ; 2 because we don't care about the signature
+       (mapv (fn [s] (.getBytes s "UTF-8")))
+       (mapv b64/decode)
+       (mapv codecs/bytes->str)
+       (mapv json/parse-string)))
+
+(deftest test-peek
+  (is (= [{:alg "RS256"
+           :kid "2011-04-29"
+           :jku "http://localhost:3000/api/jwk"
+           :typ "JWT"}
+          {:sub "elixir-user"
+           :iss "http://localhost:3000/"
+           :exp 2556144000
+           :ga4gh_visa_v1 {:value "EGAD00001006673"
+                           :type "ControlledAccessGrants"
+                           :source "EGAC00001000908"
+                           :asserted 1034294400
+                           :by "dac"}
+           :iat 2524608000}]
+         (peek "eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMC9hcGkvandrIiwidHlwIjoiSldUIiwia2lkIjoiMjAxMS0wNC0yOSJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAvIiwic3ViIjoiZWxpeGlyLXVzZXIiLCJpYXQiOjI1MjQ2MDgwMDAsImV4cCI6MjU1NjE0NDAwMCwiZ2E0Z2hfdmlzYV92MSI6eyJ0eXBlIjoiQ29udHJvbGxlZEFjY2Vzc0dyYW50cyIsInZhbHVlIjoiRUdBRDAwMDAxMDA2NjczIiwic291cmNlIjoiRUdBQzAwMDAxMDAwOTA4IiwiYnkiOiJkYWMiLCJhc3NlcnRlZCI6MTAzNDI5NDQwMH19.LnfsNxVfM_NfuxYYQtZexp975Hc3hrCxTG0fhMrgTakSLXa6gASc5MPn14seqsTjuyhtmUnu7WrCEVxko8WRvJybGDWmdbrycYafNg4amevtbs7hTPCkqAXD1DcuP53LDeLhSl_YrNgfz4aDE0uaw37I8TAsqdAeDALcZqQ6SIwF5wBG_wRWtKTPmDp-GTpzy9STx-nrIqw3SYeftunlI4wDs5avaktDuOpgMl8TVUGodGFjJsZjN8UOhKgSsGdXDGmu4FeeIjJt9Sa_dsCQPZQ1GpHyg1lFa63FZPPOy2-F9TNZcHJR1vFxKLD9U8Lvr11-EFjIiGuDg6miiWyodw"))))
diff --git a/src/clj/rems/standalone.clj b/src/clj/rems/standalone.clj
index 8842815f7..e961458c8 100644
--- a/src/clj/rems/standalone.clj
+++ b/src/clj/rems/standalone.clj
@@ -6,7 +6,9 @@
             [luminus-migrations.core :as migrations]
             [luminus.http-server :as http]
             [luminus.repl-server :as repl]
+            [medley.core :refer [find-first]]
             [mount.core :as mount]
+            [rems.api.services.ega :as ega]
             [rems.application.search :as search]
             [rems.common.git :as git]
             [rems.config :refer [env]]
@@ -106,7 +108,8 @@
         The regex is a (Java) regular expression that should match the whole path of the request.
         Example regex: /api/applications/[0-9]+/?
      \"api-key allow-all <api-key>\" -- clears the allowed method/path whitelist.
-        An empty list means all methods and paths are allowed."
+        An empty list means all methods and paths are allowed.
+     \"ega api-key <userid> <username> <password> <config-id>\" -- generate a new API-Key for the user using EGA login"
   [& args]
   (exit-on-signals!)
   (log/info "REMS" git/+version+)
@@ -167,6 +170,18 @@
           (prn (api-key/get-api-key api-key))
           (mapv prn (api-key/get-api-keys))))
 
+      "ega"
+      (let [[_ command userid username password config-id & _] args]
+        (mount/start #'rems.config/env #'rems.db.core/*db*)
+        (case command
+          "api-key" (let [ega-config (->> (:entitlement-push env)
+                                          (filter (comp #{:ega} :type))
+                                          (find-first (comp #{config-id} :id)))]
+                      (assert ega-config (str "Could not find :entitlement-push with :type :ega and :id " (pr-str config-id)))
+                      (ega/generate-api-key-with-account {:userid userid :username username :password password :config ega-config}))
+          (do (usage)
+              (System/exit 1))))
+
       "list-users"
       (do
         (mount/start #'rems.config/env #'rems.db.core/*db*)
diff --git a/src/cljs/rems/actions.cljs b/src/cljs/rems/actions.cljs
index e61da3990..e1bbc468d 100644
--- a/src/cljs/rems/actions.cljs
+++ b/src/cljs/rems/actions.cljs
@@ -1,11 +1,13 @@
 (ns rems.actions
   "The /actions page that shows a list of applications you can act on."
-  (:require [re-frame.core :as rf]
+  (:require [cljs-time.core :as time-core]
+            [re-frame.core :as rf]
             [rems.application-list :as application-list]
             [rems.atoms :refer [document-title]]
             [rems.collapsible :as collapsible]
             [rems.fetcher :as fetcher]
             [rems.flash-message :as flash-message]
+            [rems.profile :as profile]
             [rems.search :as search]
             [rems.text :refer [text]]))
 
@@ -14,10 +16,13 @@
  (fn [{:keys [db]} _]
    {:db (dissoc db
                 ::todo-applications
-                ::handled-applications)
+                ::handled-applications
+                ::user-settings)
     :dispatch-n [[::todo-applications]
+                 [::user-settings]
                  [:rems.table/reset]]}))
 
+(fetcher/reg-fetcher ::user-settings "/api/user-settings") ; for the EGA warning
 (fetcher/reg-fetcher ::todo-applications "/api/applications/todo")
 (fetcher/reg-fetcher ::handled-applications "/api/applications/handled")
 
@@ -54,6 +59,7 @@
   [:div
    [document-title (text :t.navigation/actions)]
    [flash-message/component :top]
+   [profile/maybe-ega-api-key-warning]
    [:div.spaced-sections
     [collapsible/component
      {:id "todo-applications"
diff --git a/src/cljs/rems/profile.cljs b/src/cljs/rems/profile.cljs
index a365c951f..bc079990b 100644
--- a/src/cljs/rems/profile.cljs
+++ b/src/cljs/rems/profile.cljs
@@ -1,14 +1,16 @@
 (ns rems.profile
-  (:require [re-frame.core :as rf]
-            [rems.atoms :refer [document-title]]
+  (:require [cljs-time.core :as time-core]
+            [re-frame.core :as rf]
+            [rems.atoms :refer [document-title info-field link]]
             [rems.collapsible :as collapsible]
+            [rems.common.roles :as roles]
             [rems.flash-message :as flash-message]
             [rems.fetcher :as fetcher]
             [rems.guide-util :refer [component-info example]]
             [rems.spinner :as spinner]
-            [rems.text :refer [text]]
+            [rems.text :refer [text localize-utc-date]]
             [rems.user :as user]
-            [rems.util :refer [put!]]))
+            [rems.util :refer [put! post!]]))
 
 (rf/reg-event-fx
  ::enter-page
@@ -44,6 +46,27 @@
             :error-handler (flash-message/default-error-handler :top description)}))
    {}))
 
+(rf/reg-event-fx
+ ::generate-api-key
+ (fn [{:keys [db]} _]
+   (let [description [text :t.profile/generate-api-key]]
+     (post! "/api/user-settings/generate-ega-api-key"
+            {:handler (flash-message/default-success-handler :top description
+                                                             #(rf/dispatch [::user-settings]))
+             :error-handler (flash-message/default-error-handler :top description)}))
+   {}))
+
+(rf/reg-event-fx
+ ::delete-api-key
+ (fn [{:keys [db]} _]
+   (let [description [text :t.profile/delete-api-key]]
+     (post! "/api/user-settings/delete-ega-api-key"
+            {:handler (flash-message/default-success-handler :top description
+                                                             #(rf/dispatch [::user-settings]))
+             :error-handler (flash-message/default-error-handler :top description)}))
+   {}))
+
+
 (rf/reg-sub
  ::missing-email?
  (fn [db _]
@@ -54,6 +77,20 @@
 
 ;;;; UI
 
+(defn maybe-ega-api-key-warning []
+  (let [config @(rf/subscribe [:rems.config/config])]
+    (when (:enable-ega config)
+      (if-let [ega-api-key-expiration-date (get-in @(rf/subscribe [::user-settings])
+                                                   [:ega :api-key-expiration-date])]
+        (when (time-core/after? (time-core/now) ega-api-key-expiration-date)
+          [:div.alert.alert-warning
+           (text :t.profile/ega-api-key-expired)
+           [link nil "/profile" (text :t.actions/generate-ega-api-key-in-profile)]])
+        [:div.alert.alert-warning
+         (text :t.profile/ega-api-key-none)
+         [link nil "/profile" (text :t.actions/generate-ega-api-key-in-profile)]]))))
+
+
 (defn- missing-email-warning-dialog []
   [:div.alert.alert-warning
    (text :t.profile/warning-about-missing-email)])
@@ -62,45 +99,102 @@
   (when @(rf/subscribe [::missing-email?])
     [missing-email-warning-dialog]))
 
-(defn profile-page []
+(defn- user-settings []
   (let [identity @(rf/subscribe [:identity])
         form @(rf/subscribe [::form])]
-    [:<>
-     [document-title (text :t.navigation/profile)]
-     [flash-message/component :top]
-     [collapsible/component
-      {:title (text :t.profile/settings)
-       :always (if @(rf/subscribe [::user-settings :fetching?])
-                 [spinner/big]
-                 [:form
-                  {:on-submit (fn [event]
-                                (.preventDefault event)
-                                (rf/dispatch [::save]))}
-
-                  [:div.form-group
-                   (text :t.profile/idp-email) ": " (or (:email (:user identity))
-                                                        [:span.text-muted (text :t.profile/no-email)])]
-
-                  (let [id "notification-email"]
-                    [:div.form-group
-                     [:label {:for id} (text :t.profile/notification-email) ":"]
-                     [:input.form-control
-                      {:type "email"
-                       :id id
-                       :value (:notification-email form)
-                       :on-change (fn [event]
-                                    (let [value (.. event -target -value)]
-                                      (rf/dispatch [::set-form (assoc form :notification-email value)])))}]])
-
-                  [:button.btn.btn-primary
-                   {:type "submit"}
-                   (text :t.profile/save)]])}]
-     [:div.mt-3
+    [collapsible/component
+     {:title (text :t.profile/settings)
+      :always (if @(rf/subscribe [::user-settings :fetching?])
+                [spinner/big]
+                [:form
+                 {:on-submit (fn [event]
+                               (.preventDefault event)
+                               (rf/dispatch [::save]))}
+
+                 [:div.form-group
+                  (text :t.profile/idp-email) ": " (or (:email (:user identity))
+                                                       [:span.text-muted (text :t.profile/no-email)])]
+
+                 (let [id "notification-email"]
+                   [:div.form-group
+                    [:label {:for id} (text :t.profile/notification-email) ":"]
+                    [:input.form-control
+                     {:type "email"
+                      :id id
+                      :value (:notification-email form)
+                      :on-change (fn [event]
+                                   (let [value (.. event -target -value)]
+                                     (rf/dispatch [::set-form (assoc form :notification-email value)])))}]])
+
+                 [:button.btn.btn-primary
+                  {:type "submit"}
+                  (text :t.profile/save)]])}]))
+
+(defn- user-details []
+  (let [identity @(rf/subscribe [:identity])]
+    [collapsible/component
+     {:title (text :t.profile/your-details)
+      :always [:<>
+               [user/username (:user identity)]
+               [user/attributes (:user identity) false]]}]))
+
+(defn- delete-ega-api-key-button []
+  [:form
+   {:on-submit (fn [event]
+                 (.preventDefault event)
+                 (rf/dispatch [::delete-api-key]))}
+
+   [:button.btn.btn-primary
+    {:type "submit"}
+    (text :t.profile/delete-api-key)]])
+
+(defn- generate-ega-api-key-button []
+  [:form
+   {:on-submit (fn [event]
+                 (.preventDefault event)
+                 (rf/dispatch [::generate-api-key]))}
+
+   [:button.btn.btn-primary
+    {:type "submit"}
+    (text :t.profile/generate-api-key)]])
+
+(defn- existing-ega-key-delete [ega-api-key-expiration-date]
+  [:<>
+   (if (time-core/before? (time-core/now) ega-api-key-expiration-date)
+     (text :t.profile/ega-api-key-valid)
+     (text :t.profile/ega-api-key-expired))
+
+   [info-field (text :t.profile/ega-api-key-expiration-date) (localize-utc-date ega-api-key-expiration-date) {:inline? true}]
+
+   [delete-ega-api-key-button]])
+
+(defn- no-ega-key-generate []
+  [:<>
+   (text :t.profile/ega-api-key-none)
+   [generate-ega-api-key-button]])
+
+(defn- ega-settings []
+  (let [config @(rf/subscribe [:rems.config/config])]
+    (when (and (:enable-ega config) (roles/has-roles? :handler))
       [collapsible/component
-       {:title (text :t.profile/your-details)
-        :always [:<>
-                 [user/username (:user identity)]
-                 [user/attributes attributes false]]}]]]))
+       {:title (text :t.profile/ega-full)
+        :always (if @(rf/subscribe [::user-settings :fetching?])
+                  [spinner/big]
+                  [:<>
+                   (text :t.profile/ega-intro)
+                   (if-let [ega-api-key-expiration-date (get-in @(rf/subscribe [::user-settings])
+                                                                [:ega :api-key-expiration-date])]
+                     [existing-ega-key-delete ega-api-key-expiration-date]
+                     [no-ega-key-generate])])}])))
+
+(defn profile-page []
+  [:<>
+   [document-title (text :t.navigation/profile)]
+   [flash-message/component :top]
+   [:div.spaced-vertically-3
+    [user-settings]
+    [user-details]
+    [ega-settings]]])
 
 (defn guide []
   [:div


Test patch:
diff --git a/src/clj/rems/db/test_data.clj b/src/clj/rems/db/test_data.clj
index c64de3135..c2c93598a 100644
--- a/src/clj/rems/db/test_data.clj
+++ b/src/clj/rems/db/test_data.clj
@@ -531,9 +531,14 @@
                                                                                      :field/title {:fi "Kuvaus"
                                                                                                    :en "Description"
                                                                                                    :sv "Rubrik"}
-                                                                                     :field/optional false}]})}]})]
+                                                                                     :field/optional false}]})}]})
+        ega (create-workflow! {:actor owner
+                               :organization {:organization/id "csc"}
+                               :title "EGA workflow, a variant of default"
+                               :type :workflow/default
+                               :handlers handlers})]
 
-    ;; attach both kinds of licenses to all workflows created by owner
+    ;; attach both kinds of licenses to all workflows created by owner except EGA
     (let [link (create-license! {:actor owner
                                  :license/type :link
                                  :organization {:organization/id "nbn"}
@@ -557,6 +562,7 @@
           (db/create-workflow-license! {:wfid wfid :licid licid}))))
 
     {:default default
+     :ega ega
      :decider decider
      :master master
      :auto-approve auto-approve
@@ -890,6 +896,17 @@
                                                      :license/link {:en "https://www.apache.org/licenses/LICENSE-2.0"
                                                                     :fi "https://www.apache.org/licenses/LICENSE-2.0"
                                                                     :sv "https://www.apache.org/licenses/LICENSE-2.0"}})
+
+        ega-creative-commons-license (create-license! {:actor owner
+                                                       :license/type :link
+                                                       :organization {:organization/id "csc"}
+                                                       :license/title {:en "CC Attribution 4.0"
+                                                                       :fi "CC Nimeä 4.0"
+                                                                       :sv "CC Erkännande 4.0"}
+                                                       :license/link {:en "https://creativecommons.org/licenses/by/4.0/legalcode"
+                                                                      :fi "https://creativecommons.org/licenses/by/4.0/legalcode.fi"
+                                                                      :sv "https://creativecommons.org/licenses/by/4.0/legalcode.sv"}})
+
         _ (create-disabled-license! {:actor owner
                                      :organization {:organization/id "nbn"}})
         attachment-license (create-attachment-license! {:actor owner
@@ -907,6 +924,12 @@
                                 :organization {:organization/id "hus"}
                                 :actor owner
                                 :license-ids [license1 extra-license attachment-license]})
+
+        ega-resource (create-resource! {:resource-ext-id "EGAD00001006673"
+                                        :organization {:organization/id "csc"}
+                                        :actor owner
+                                        :license-ids [ega-creative-commons-license]})
+
         res-organization-owner (create-resource! {:resource-ext-id "Owned by organization owner"
                                                   :organization {:organization/id "organization1"}
                                                   :actor organization-owner1
@@ -915,22 +938,22 @@
                                                   :organization {:organization/id "nbn"}
                                                   :actor owner
                                                   :license-ids [extra-license attachment-license]})
-        res-duplicate-resource-name1 (create-resource! {:resource-ext-id "duplicate resource name"
-                                                        :organization {:organization/id "hus"}
-                                                        :actor owner
-                                                        :license-ids [license1 extra-license attachment-license]})
-        res-duplicate-resource-name2 (create-resource! {:resource-ext-id "duplicate resource name"
-                                                        :organization {:organization/id "hus"}
-                                                        :actor owner
-                                                        :license-ids [license2 extra-license attachment-license]})
-        res-duplicate-resource-name-with-long-name1 (create-resource! {:resource-ext-id "urn:nbn:fi:lb-201403263443773465837568375683683756"
-                                                                       :organization {:organization/id "hus"}
-                                                                       :actor owner
-                                                                       :license-ids [license1 extra-license attachment-license]})
-        res-duplicate-resource-name-with-long-name2 (create-resource! {:resource-ext-id "urn:nbn:fi:lb-201403263443773465837568375683683756"
-                                                                       :organization {:organization/id "hus"}
-                                                                       :actor owner
-                                                                       :license-ids [license2 extra-license attachment-license]})
+        _res-duplicate-resource-name1 (create-resource! {:resource-ext-id "duplicate resource name"
+                                                         :organization {:organization/id "hus"}
+                                                         :actor owner
+                                                         :license-ids [license1 extra-license attachment-license]})
+        _res-duplicate-resource-name2 (create-resource! {:resource-ext-id "duplicate resource name"
+                                                         :organization {:organization/id "hus"}
+                                                         :actor owner
+                                                         :license-ids [license2 extra-license attachment-license]})
+        _res-duplicate-resource-name-with-long-name1 (create-resource! {:resource-ext-id "urn:nbn:fi:lb-201403263443773465837568375683683756"
+                                                                        :organization {:organization/id "hus"}
+                                                                        :actor owner
+                                                                        :license-ids [license1 extra-license attachment-license]})
+        _res-duplicate-resource-name-with-long-name2 (create-resource! {:resource-ext-id "urn:nbn:fi:lb-201403263443773465837568375683683756"
+                                                                        :organization {:organization/id "hus"}
+                                                                        :actor owner
+                                                                        :license-ids [license2 extra-license attachment-license]})
 
         workflows (create-workflows! (merge users +bot-users+))
         _ (db/create-workflow-license! {:wfid (:organization-owner workflows)
@@ -967,7 +990,19 @@
                                                        :field/privacy :private}]})
         form-organization-owner (create-all-field-types-example-form! organization-owner1 {:organization/id "organization1"} "Owned by organization owner" {:en "Owned by organization owner"
                                                                                                                                                             :fi "Omistaja organization owner"
-                                                                                                                                                            :sv "Ägare organization owner"})]
+                                                                                                                                                            :sv "Ägare organization owner"})
+
+        ega-form (create-form! {:actor owner
+                                :organization {:organization/id "csc"}
+                                :form/internal-name "EGA Application Form"
+                                :form/external-title {:en "EGA Form"
+                                                      :fi "EGA Lomake"
+                                                      :sv "EGA Blankett"}
+                                :form/fields [{:field/title {:en "Description"
+                                                             :fi "Kuvaus"
+                                                             :sv "Text"}
+                                               :field/optional false
+                                               :field/type :text}]})]
     (create-archived-form! owner)
 
     ;; Create catalogue items
@@ -1021,6 +1056,14 @@
                              :form-id form-private-hus
                              :organization {:organization/id "hus"}
                              :workflow-id (:default workflows)})
+    (create-catalogue-item! {:actor owner
+                             :title {:en "CINECA synthetic cohort EUROPE UK1 referencing fake samples"
+                                     :fi "CINECA synthetic cohort EUROPE UK1 referencing fake samples"
+                                     :sv "CINECA synthetic cohort EUROPE UK1 referencing fake samples"}
+                             :resource-id ega-resource
+                             :form-id ega-form
+                             :organization {:organization/id "csc"}
+                             :workflow-id (:ega workflows)})
     (create-catalogue-item! {:actor owner
                              :title {:en "Default workflow with extra license"
                                      :fi "Oletustyövuo ylimääräisellä lisenssillä"
diff --git a/test/clj/rems/api/test_over_http.clj b/test/clj/rems/api/test_over_http.clj
index 017322594..8d5248357 100644
--- a/test/clj/rems/api/test_over_http.clj
+++ b/test/clj/rems/api/test_over_http.clj
@@ -61,7 +61,7 @@
         (testing "slow transaction should hit timeout"
           ;; more than the connection idle timeout of 8s
           (reset! sleep-time (* 10 1000))
-          (is (= {:status 500 :body "{\"type\":\"unknown-exception\",\"class\":\"clojure.lang.ExceptionInfo\"}"}
+          (is (= {:status 500 :body ""}
                  (save-draft!))))
         (testing "subsequent transactions should pass"
           (reset! sleep-time nil)
diff --git a/test/clj/rems/api/test_user_settings.clj b/test/clj/rems/api/test_user_settings.clj
index e8de46299..48cbb025a 100644
--- a/test/clj/rems/api/test_user_settings.clj
+++ b/test/clj/rems/api/test_user_settings.clj
@@ -1,10 +1,17 @@
 (ns ^:integration rems.api.test-user-settings
-  (:require [clojure.test :refer :all]
+  (:require [clojure.string :as str]
+            [clojure.test :refer :all]
+            [clj-time.core :as time-core]
             [rems.api.testing :refer :all]
             [rems.db.test-data :as test-data]
             [rems.db.test-data-helpers :as test-helpers]
+            [rems.db.user-secrets :as user-secrets]
+            [rems.db.user-settings :as user-settings]
             [rems.handler :refer [handler]]
-            [ring.mock.request :refer :all])
+            [rems.json :as json]
+            [rems.testing-util :refer [with-fixed-time]]
+            [ring.mock.request :refer :all]
+            [stub-http.core :as stub])
   (:import [java.util UUID]))
 
 (use-fixtures
@@ -43,3 +50,151 @@
                  (authenticate "42" user-id)
                  handler
                  read-ok-body))))))
+
+(defn ega-config [server]
+  {:type :ega
+   :connect-server-url (str (:uri server) "/c")
+   :permission-server-url (str (:uri server) "/p")})
+
+(defn run-with-ega-server
+  [spec callback]
+  (with-open [server (stub/start! spec)]
+    (with-redefs [rems.config/env (assoc rems.config/env
+                                         :entitlement-push [(ega-config server)])]
+      (callback server))))
+
+(comment
+  ;; small helper to test the EGA API / REMS UI
+  ;; configure this to the dev-config.edn or so
+  :entitlement-push [{:id :stub-ega
+                      :type :ega
+                      :permission-server-url "http://localhost:1234/p"}]
+  (def ega-stub-server
+    (stub/start! {:port 1234}
+                 {{:path "/p/api_key/handler" :method "DELETE"} {:status 200 :content-type "application/json"}
+                  "/p/api_key/generate" {:status 200 :content-type "application/json" :body (json/generate-string {:token "access-token-xyz"})}}))
+  (.close ega-stub-server))
+
+(deftest test-generate-api-key
+  (test-data/create-test-api-key!)
+  (let [user-id (str (UUID/randomUUID))]
+    (test-helpers/create-user! {:eppn user-id})
+
+    (testing "without authentication"
+      (let [{:keys [body] :as response} (-> (request :post "/api/user-settings/generate-ega-api-key")
+                                            handler
+                                            read-body-and-status)]
+        (is (response-is-unauthorized? response))
+        (is (str/includes? body "Invalid anti-forgery token"))))
+
+    (testing "not a handler"
+      (is (= {:status 403
+              :body "forbidden"}
+             (-> (request :post "/api/user-settings/generate-ega-api-key")
+                 (authenticate "42" user-id)
+                 handler
+                 read-body-and-status))))
+
+    (test-helpers/create-workflow! {:handlers [user-id]})
+
+    (testing "without entitlement push configured"
+      (is (= {:status 500
+              :body {:type "unknown-exception" :class "java.lang.AssertionError"}}
+             (-> (request :post "/api/user-settings/generate-ega-api-key")
+                 (authenticate "42" user-id)
+                 handler
+                 read-body-and-status))))
+
+    (testing "success"
+      (with-fixed-time (time-core/date-time 2021)
+        (fn []
+          (run-with-ega-server
+           {"/p/api_key/generate" {:status 200 :content-type "application/json" :body (json/generate-string {:token "access-token-xyz"})}}
+           (fn [_server]
+             (let [cookie (login-with-cookies user-id)
+                   csrf (get-csrf-token cookie)]
+               (testing "generate-ega-api-key with session"
+                 (is (= {:status 200
+                         :body {:success true
+                                :api-key-expiration-date "2022-01-01T00:00:00.000Z"}} ; one year expiry
+                        (-> (request :post "/api/user-settings/generate-ega-api-key")
+                            (header "Cookie" cookie)
+                            (header "x-csrf-token" csrf)
+                            handler
+                            assert-response-is-ok
+                            read-body-and-status)))
+                 (is (= (time-core/date-time 2022)
+                        (get-in (user-settings/get-user-settings user-id) [:ega :api-key-expiration-date]))
+                     "one year expiry")
+                 (is (= {:ega {:api-key "access-token-xyz"}}
+                        (user-secrets/get-user-secrets user-id))))))))))))
+
+(deftest test-delete-api-key
+  (test-data/create-test-api-key!)
+  (let [user-id (str (UUID/randomUUID))
+        handler-id (str (UUID/randomUUID))]
+    (test-helpers/create-user! {:eppn user-id})
+    (test-helpers/create-user! {:eppn handler-id})
+    (test-helpers/create-workflow! {:handlers [handler-id]})
+
+    (testing "setup api-key to delete"
+      (with-fixed-time (time-core/date-time 2021)
+        (fn []
+          (run-with-ega-server
+           {"/p/api_key/generate" {:status 200 :content-type "application/json" :body (json/generate-string {:token "access-token-xyz"})}}
+           (fn [_server]
+             (let [cookie (login-with-cookies handler-id)
+                   csrf (get-csrf-token cookie)]
+               (testing "generate-ega-api-key with session"
+                 (is (= {:status 200
+                         :body {:success true
+                                :api-key-expiration-date "2022-01-01T00:00:00.000Z"}}
+                        (-> (request :post "/api/user-settings/generate-ega-api-key")
+                            (header "Cookie" cookie)
+                            (header "x-csrf-token" csrf)
+                            handler
+                            assert-response-is-ok
+                            read-body-and-status))))))))))
+
+    (testing "without authentication"
+      (let [{:keys [body] :as response} (-> (request :post "/api/user-settings/delete-ega-api-key")
+                                            handler
+                                            read-body-and-status)]
+        (is (response-is-unauthorized? response))
+        (is (str/includes? body "Invalid anti-forgery token"))))
+
+    (testing "not a handler"
+      (is (= {:status 403
+              :body "forbidden"}
+             (-> (request :post "/api/user-settings/delete-ega-api-key")
+                 (authenticate "42" user-id)
+                 handler
+                 read-body-and-status))))
+
+    (test-helpers/create-workflow! {:handlers [user-id]})
+
+    (testing "without entitlement push configured"
+      (is (= {:status 500
+              :body {:type "unknown-exception" :class "java.lang.AssertionError"}}
+             (-> (request :post "/api/user-settings/delete-ega-api-key")
+                 (authenticate "42" user-id)
+                 handler
+                 read-body-and-status))))
+
+    (testing "success"
+      (with-fixed-time (time-core/date-time 2021)
+        (fn []
+          (run-with-ega-server
+           {{:path (str "/p/api_key/" handler-id) :method "DELETE"} {:status 200 :content-type "application/json" :body ""}}
+           (fn [server]
+             (let [cookie (login-with-cookies handler-id)
+                   csrf (get-csrf-token cookie)]
+               (testing "delete-ega-api-key with session"
+                 (is (= {:status 200
+                         :body {:success true}}
+                        (-> (request :post "/api/user-settings/delete-ega-api-key")
+                            (header "Cookie" cookie)
+                            (header "x-csrf-token" csrf)
+                            handler
+                            assert-response-is-ok
+                            read-body-and-status))))))))))))
diff --git a/test/clj/rems/db/test_entitlements.clj b/test/clj/rems/db/test_entitlements.clj
index 20ef95e52..c6e624fd5 100644
--- a/test/clj/rems/db/test_entitlements.clj
+++ b/test/clj/rems/db/test_entitlements.clj
@@ -1,6 +1,7 @@
 (ns ^:integration rems.db.test-entitlements
   (:require [clj-time.core :as time]
             [clojure.test :refer :all]
+            [rems.config]
             [rems.db.applications :as applications]
             [rems.db.core :as db]
             [rems.db.entitlements :as entitlements]
@@ -32,12 +33,23 @@
 (defn run-with-server
   [endpoint-spec callback]
   (with-open [server (stub/start! {"/add" endpoint-spec
-                                   "/remove" endpoint-spec
-                                   "/ga4gh" endpoint-spec})]
+                                   "/remove" endpoint-spec})]
     (with-redefs [rems.config/env (assoc rems.config/env
                                          :entitlements-target {:add (str (:uri server) "/add")
-                                                               :remove (str (:uri server) "/remove")
-                                                               :ga4gh (str (:uri server) "/ga4gh")})]
+                                                               :remove (str (:uri server) "/remove")})]
+      (callback server))))
+
+(defn ega-config [server]
+  {:id :test-entitlement-push
+   :type :ega
+   :connect-server-url (str (:uri server) "/c")
+   :permission-server-url (str (:uri server) "/p")})
+
+(defn run-with-ega-server
+  [spec callback]
+  (with-open [server (stub/start! spec)]
+    (with-redefs [rems.config/env (assoc rems.config/env
+                                         :entitlement-push [(ega-config server)])]
       (callback server))))
 
 (deftest test-post-entitlements!
@@ -45,38 +57,26 @@
     (run-with-server
      {:status 200}
      (fn [server]
-       (is (nil? (#'entitlements/post-entitlements! {:action :add :entitlements +entitlements+})))
+       (is (nil? (#'entitlements/post-entitlements! {:action :add :type :basic :entitlements +entitlements+})))
        (is (= [+expected-payload+] (for [r (stub/recorded-requests server)]
                                      (json/parse-string (get-in r [:body "postData"]))))))))
 
-  (testing "ok :ga4gh action"
-    (run-with-server
-     {:status 200}
-     (fn [server]
-       (is (nil? (#'entitlements/post-entitlements! {:action :ga4gh :entitlements +entitlements+})))
-       (let [data (-> (stub/recorded-requests server)
-                      first
-                      (get-in [:body "postData"])
-                      json/parse-string)]
-         (is (= [:ga4gh_passport_v1] (keys data)))
-         (is (= [true true] (map string? (:ga4gh_passport_v1 data))))))))
-
   (testing "not-found"
     (run-with-server
      {:status 404}
      (fn [_]
-       (is (= "failed: 404" (#'entitlements/post-entitlements! {:action :add :entitlements +entitlements+}))))))
+       (is (= "failed: 404" (#'entitlements/post-entitlements! {:action :add :type :basic :entitlements +entitlements+}))))))
   (testing "timeout"
     (run-with-server
      {:status 200 :delay 5000} ;; timeout of 2500 in code
      (fn [_]
-       (is (= "failed: exception" (#'entitlements/post-entitlements! {:action :add :entitlements +entitlements+}))))))
+       (is (= "failed: exception" (#'entitlements/post-entitlements! {:action :add :type :basic :entitlements +entitlements+}))))))
   (testing "invalid url"
     (with-redefs [rems.config/env (assoc rems.config/env
                                          :entitlements-target {:add "http://invalid/entitlements"})]
-      (is (= "failed: exception" (#'entitlements/post-entitlements! {:action :add :entitlements +entitlements+})))))
+      (is (= "failed: exception" (#'entitlements/post-entitlements! {:action :add :type :basic :entitlements +entitlements+})))))
   (testing "no server configured"
-    (is (nil? (#'entitlements/post-entitlements! {:action :add :entitlements +entitlements+})))))
+    (is (nil? (#'entitlements/post-entitlements! {:action :add :type :basic :entitlements +entitlements+})))))
 
 (defn- get-requests [server]
   (doall
@@ -87,9 +87,6 @@
 (defn- requests-for-paths [server ^String path]
   (filter #(= (% :path) path) (set (get-requests server))))
 
-(defn- is-valid-ga4gh? [entry]
-  (string? (first (:ga4gh_passport_v1 (:body entry)))))
-
 (deftest test-entitlement-granting
   (let [applicant "bob"
         member "elsa"
@@ -163,13 +160,10 @@
                (is (= #{[applicant "resource1"] [applicant "resource2"]}
                       (set (map (juxt :userid :resid) (db/get-entitlements {:application app-id}))))))
              (testing "entitlements were POSTed to callbacks"
-               (let [add-paths (requests-for-paths server "/add")
-                     ga4gh-paths (requests-for-paths server "/ga4gh")]
+               (let [add-paths (requests-for-paths server "/add")]
                  (is (= #{{:path "/add" :body [{:application app-id :mail "b@o.b" :resource "resource1" :user "bob" :end nil}]}
                           {:path "/add" :body [{:application app-id :mail "b@o.b" :resource "resource2" :user "bob" :end nil}]}}
-                        (set add-paths)))
-                 (is (= 2 (count ga4gh-paths)))
-                 (is (every? is-valid-ga4gh? ga4gh-paths))))))))
+                        (set add-paths)))))))))
 
       (testing "approved application, more accepted licenses generates more entitlements"
         (run-with-server
@@ -187,13 +181,10 @@
                       [member "resource1"] [member "resource2"]}
                     (set (map (juxt :userid :resid) (db/get-entitlements {:application app-id}))))))
            (testing "new entitlements were POSTed to callbacks"
-             (let [add-paths (requests-for-paths server "/add")
-                   ga4gh-paths (requests-for-paths server "/ga4gh")]
+             (let [add-paths (requests-for-paths server "/add")]
                (is (= #{{:path "/add" :body [{:resource "resource1" :application app-id :user "elsa" :mail "e.l@s.a" :end nil}]}
                         {:path "/add" :body [{:resource "resource2" :application app-id :user "elsa" :mail "e.l@s.a" :end nil}]}}
-                      (set add-paths)))
-               (is (= 2 (count ga4gh-paths)))
-               (is (every? is-valid-ga4gh? ga4gh-paths)))))))
+                      (set add-paths))))))))
 
       (testing "removing a member ends entitlements"
         (run-with-server
@@ -231,14 +222,11 @@
                     (set (map (juxt :userid :resid) (db/get-entitlements {:application app-id :active-at (time/now)}))))))
            (testing "entitlement changes POSTed to callbacks"
              (let [add-paths (requests-for-paths server "/add")
-                   remove-paths (requests-for-paths server "/remove")
-                   ga4gh-paths (requests-for-paths server "/ga4gh")]
+                   remove-paths (requests-for-paths server "/remove")]
                (is (= #{{:path "/add" :body [{:resource "resource3" :application app-id :user "bob" :mail "b@o.b" :end nil}]}}
                       (set add-paths)))
                (is (= #{{:path "/remove" :body [{:resource "resource2" :application app-id :user "bob" :mail "b@o.b" :end +test-time-string+}]}}
-                      (set remove-paths)))
-               (is (= 1 (count ga4gh-paths)))
-               (is (every? is-valid-ga4gh? ga4gh-paths)))))))
+                      (set remove-paths))))))))
 
       (testing "closed application should end entitlements"
         (run-with-server
@@ -315,3 +303,36 @@
            (testing "ended entitlements POSTed to callback"
              (is (= [{:path "/remove" :body [{:resource "resource1" :application app-id :user "bob" :mail "b@o.b" :end +test-time-string+}]}]
                     (get-requests server))))))))))
+
+
+(deftest test-entitlement-push
+  (let [post-data-for (fn [server]
+                        {:action :add
+                         :type :ega
+                         :entitlements [{:resid "EGAD00001006673" :userid "elixir-user" :start (time/date-time 2002 10 11) :mail "elixir-user@elixir.org"}]
+                         :config (ega-config server)})]
+    (testing "ok :add action"
+      (run-with-ega-server
+       {"/p/permissions" {:status 200 :content-type "application/json" :body (json/generate-string {})}}
+       (fn [server]
+         (is (= nil (#'entitlements/post-entitlements! (post-data-for server))))
+         (let [post (update-in (first (stub/recorded-requests server)) [:body "postData"] json/parse-string)]
+           (is (= "elixir-user" (get-in post [:headers :x-account-id]))))))) ; sanity check only, content test is in ega.clj
+
+    (testing "not-found"
+      (run-with-ega-server
+       {"/p/permissions" {:status 404}}
+       (fn [server]
+         (is (= 404 (:status (#'entitlements/post-entitlements! (post-data-for server))))))))
+
+    (testing "timeout"
+      (run-with-ega-server
+       {"/p/permissions" {:status 200 :delay 5000}} ;; timeout of 2500 in code
+       (fn [server]
+         (is (= {:status "exception"} (#'entitlements/post-entitlements! (post-data-for server)))))))
+
+    (testing "invalid url"
+      (is (= {:status "exception"} (#'entitlements/post-entitlements! (post-data-for {:uri "http://invalid/address/altogether"})))))
+
+    (testing "no server configured means no problem"
+      (is (= nil (#'entitlements/post-entitlements! (dissoc (post-data-for {:uri "http://invalid/address/no/problem"}) :config)))))))
diff --git a/test/clj/rems/db/test_user_secrets.clj b/test/clj/rems/db/test_user_secrets.clj
new file mode 100644
index 000000000..e5c6826e6
--- /dev/null
+++ b/test/clj/rems/db/test_user_secrets.clj
@@ -0,0 +1,53 @@
+(ns ^:integration rems.db.test-user-secrets
+  (:require [clojure.test :refer [deftest is testing use-fixtures]]
+            [rems.db.testing :refer [rollback-db-fixture test-db-fixture]]
+            [rems.db.user-secrets :as user-secrets]
+            [rems.db.users :as users]))
+
+(use-fixtures :once test-db-fixture)
+(use-fixtures :each rollback-db-fixture)
+
+(deftest test-user-secrets
+  (users/add-user! {:userid "user"})
+  (users/add-user! {:userid "unrelated"})
+
+  (testing "default secrets for a new user"
+    (is (= nil
+           (user-secrets/get-user-secrets "user"))))
+
+  (testing "add secrets"
+    (is (= {:success true}
+           (user-secrets/update-user-secrets! "user" {:ega {:api-key "123"}})))
+    (is (= {:ega {:api-key "123"}}
+           (user-secrets/get-user-secrets "user"))))
+
+  (testing "modify secrets"
+    (is (= {:success true}
+           (user-secrets/update-user-secrets! "user" {:ega {:api-key "456"}})))
+    (is (= {:ega {:api-key "456"}}
+           (user-secrets/get-user-secrets "user"))))
+
+  (testing "updating with empty secrets does not change secrets"
+    (is (= {:success true}
+           (user-secrets/update-user-secrets! "user" {})))
+    (is (= {:ega {:api-key "456"}}
+           (user-secrets/get-user-secrets "user"))))
+
+  (testing "updating with invalid secrets does not change secrets"
+    (is (= {:success false}
+           (user-secrets/update-user-secrets! "user" {:invalid "value"}))
+        "completely invalid")
+    (is (= {:success false}
+           (user-secrets/update-user-secrets! "user" {:ega {:api-key "789"}
+                                                      :invalid "value"}))
+        "partially invalid")
+    (is (= {:ega {:api-key "456"}}
+           (user-secrets/get-user-secrets "user"))))
+
+  (testing "changing one user's secrets does not change another user's secrets"
+    (is (= {:success true}
+           (user-secrets/update-user-secrets! "unrelated" {:ega {:api-key "789"}})))
+    (is (= {:success true}
+           (user-secrets/update-user-secrets! "user" {:ega {:api-key "123"}})))
+    (is (= {:ega {:api-key "789"}}
+           (user-secrets/get-user-secrets "unrelated")))))
diff --git a/test/clj/rems/test_ga4gh.clj b/test/clj/rems/test_ga4gh.clj
new file mode 100644
index 000000000..dd72a31c7
--- /dev/null
+++ b/test/clj/rems/test_ga4gh.clj
@@ -0,0 +1,32 @@
+(ns rems.test-ga4gh
+  (:require [clj-time.coerce]
+            [clj-time.core :as time]
+            [clojure.test :refer :all]
+            [rems.config :refer [env]]
+            [rems.ga4gh :as ga4gh]
+            [rems.testing-util :refer [with-fixed-time]]))
+
+(deftest test-entitlement->visa-claims
+  (with-redefs [env {:public-url "https://rems.example/"}]
+    (with-fixed-time (time/date-time 2010 01 01)
+      (fn []
+        (is (= {:iss "https://rems.example/"
+                :sub "user@example.com"
+                :iat (clj-time.coerce/to-epoch "2010")
+                :exp (clj-time.coerce/to-epoch "2011")
+                :ga4gh_visa_v1 {:type "ControlledAccessGrants"
+                                :value "urn:1234"
+                                :source "https://rems.example/"
+                                :by "dac"
+                                :asserted (clj-time.coerce/to-epoch "2009")}}
+               (#'ga4gh/entitlement->visa-claims {:resid "urn:1234" :start (time/date-time 2009) :userid "user@example.com"})))
+        (is (= {:iss "https://rems.example/"
+                :sub "user@example.com"
+                :iat (clj-time.coerce/to-epoch "2010")
+                :exp (clj-time.coerce/to-epoch "2010-06-02")
+                :ga4gh_visa_v1 {:type "ControlledAccessGrants"
+                                :value "urn:1234"
+                                :source "https://rems.example/"
+                                :by "dac"
+                                :asserted (clj-time.coerce/to-epoch "2009")}}
+               (#'ga4gh/entitlement->visa-claims {:resid "urn:1234" :start (time/date-time 2009) :end (time/date-time 2010 6 2) :userid "user@example.com"})))))))