(ns metabase.models.secret (:require [buddy.core.codecs :as codecs] [clojure.core.memoize :as memoize] [clojure.java.io :as io] [clojure.string :as str] [medley.core :as m] [metabase.api.common :as api] [metabase.driver :as driver] [metabase.driver.util :as driver.u] [metabase.models.interface :as mi] [metabase.premium-features.core :as premium-features] [metabase.util :as u] [metabase.util.i18n :refer [tru]] [metabase.util.log :as log] [methodical.core :as methodical] [toucan2.core :as t2]) (:import (java.io File))) | |
(set! *warn-on-reflection* true) | |
----------------------------------------------- Entity & Lifecycle ----------------------------------------------- | |
(methodical/defmethod t2/table-name :model/Secret [_model] :secret) | |
(doto :model/Secret (derive :metabase/model) (derive :hook/timestamped?) (derive ::mi/read-policy.superuser) (derive ::mi/write-policy.superuser)) | |
(t2/deftransforms :model/Secret
{:value mi/transform-secret-value
:kind mi/transform-keyword
:source mi/transform-keyword}) | |
(methodical/defmethod mi/to-json :model/Secret "Never include the secret value in JSON." [secret json-generator] (next-method (dissoc secret :value) json-generator)) | |
Returns the latest Secret instance for the given | (defn latest-for-id
{:added "0.42.0"}
[id]
(t2/select-one :model/Secret :id id {:order-by [[:version :desc]]})) |
Inserts a new secret value, or updates an existing one, for the given parameters. * if there is no existing Secret instance, inserts with the given field values * if there is an existing latest Secret instance, then inserts a new version with the given parameters. | (defn upsert-secret-value!
{:added "0.42.0"}
[existing-id nm kind src value]
(let [insert-new (fn [id v]
(let [inserted (first (t2/insert-returning-instances! :model/Secret (cond-> {:version v
:name nm
:kind kind
:source src
:value value
:creator_id api/*current-user-id*}
id
(assoc :id id))))]
;; Toucan doesn't support composite primary keys, so adding a new record with incremented
;; version for an existing ID won't return a result from t2/insert!, hence we may need to
;; manually select it here
(t2/select-one :model/Secret :id (or id (u/the-id inserted)) :version v)))
latest-version (when existing-id (latest-for-id existing-id))]
(if latest-version
(insert-new (u/the-id latest-version) (inc (:version latest-version)))
(insert-new nil 1)))) |
---------------------------------------------- Hydration / Util Fns ---------------------------------------------- | |
Return a map of secret subproperties for the property | (defn- ->possible-secret-property-names
[connection-property-name]
;; created-at :creator-id :source :kind are legacy keys
(let [sub-prop-types [:path :value :options :created-at :creator-id :source :kind]
sub-prop #(keyword (str connection-property-name "-" (name %)))]
(zipmap sub-prop-types (map sub-prop sub-prop-types)))) |
(defn- ->id-kw [conn-prop-nm] (keyword (str conn-prop-nm "-id"))) | |
Regex for parsing base64 encoded file uploads. | (def uploaded-base-64-prefix-pattern #"^data:application/([^;]*);base64,") |
The string to replace passwords with when serializing Databases. | (def ^:const protected-password "**MetabasePass**") |
For the driver return a map of all | (defn secret-conn-props-by-name
[driver]
(let [conn-props-fn (get-method driver/connection-properties driver)]
(when (fn? conn-props-fn)
(->> (filter #(= :secret (keyword (:type %))) (conn-props-fn driver))
(reduce (fn [acc prop] (assoc acc (:name prop) prop)) {})
not-empty)))) |
Reduces over the given
In essence, this is a utility function to provide a generic mechanism for transforming db-details containing secret values. | (defn- reduce-over-details-secret-values
{:added "0.42.0"}
[driver db-details reduce-fn]
(if (map? db-details)
(reduce-kv reduce-fn db-details (secret-conn-props-by-name driver))
db-details)) |
(defn- bytes-without-uri-encoding
[value conn-prop]
(let [is-bytes? (bytes? value)
is-string? (string? value)
treatment (get conn-prop :treatment "base64")
str-value (cond
is-bytes? (u/bytes-to-string value)
is-string? value)]
(cond
(and str-value
(= "base64" treatment)
(re-find uploaded-base-64-prefix-pattern str-value))
(-> str-value
(str/replace-first uploaded-base-64-prefix-pattern )
u/decode-base64-to-bytes)
is-string?
(u/string-to-bytes value)
:else
value))) | |
Returns a canonical secret-map containing This is the case: - before database insert and update - during connection testing. The The | (defn- secret-map-from-details
[details conn-prop]
(let [kws (->possible-secret-property-names (:name conn-prop))
value (when-let [^String value (get details (:value kws))]
(bytes-without-uri-encoding value conn-prop))
has-path? (contains? details (:path kws))
has-value? (contains? details (:value kws))
options (get details (:options kws))
path (get details (:path kws))
path-map (when has-path?
{:source :file-path :value path})
value-map (when has-value?
{:source :uploaded :value value})
secret-map (case (keyword options)
:local
path-map
:uploaded
value-map
;; fallback
(cond
has-value? value-map
has-path? path-map))]
(when (and path (premium-features/is-hosted?))
(throw (ex-info
(tru "{0} (a local file path) cannot be used in Metabase hosted environment" (:path kws))
{:invalid-db-details-entry (select-keys details [(:path kws)])})))
;; If the client sent us back protected-password then it should be ignored and value loaded from Secret.
(when (and secret-map (not= (:value secret-map) protected-password))
(update secret-map :value #(some-> % codecs/to-bytes))))) |
Returns a canonical map containing If | (defn- resolve-secret-map
[driver details secret-property]
(let [conn-prop (get (secret-conn-props-by-name driver) secret-property)
detail-map (secret-map-from-details details conn-prop)
secret-id (get details (->id-kw secret-property))
result (cond
detail-map detail-map
secret-id (latest-for-id secret-id))
result-source (:source result)]
(when (:value result)
(cond-> result
;; Fix legacy double encoding stored in secret
secret-id (update :value bytes-without-uri-encoding conn-prop)
;; Normalizes legacy
(not result-source) (assoc :source :uploaded))))) |
Reads the secret-value, which is probably bytes into a string. Private because getting the file-path is an implementation detail of Secret. | (defn- unresolved-value-string
[secret-value]
(cond (string? secret-value)
secret-value
(bytes? secret-value)
(u/bytes-to-string secret-value))) |
---------------------------------------------- Fetching secrets ---------------------------------------------- | |
Retrieves a secret as a string.
If the secret source is | (defn value-as-string
[driver details secret-property]
(when-let [{source :source secret-value :value} (resolve-secret-map driver details secret-property)]
(let [s (unresolved-value-string secret-value)]
(if (= :file-path source)
(slurp s)
s)))) |
(defn- value-as-file*
[driver details secret-property & [ext]]
(when-let [{source :source secret-value :value secret-id :id} (resolve-secret-map driver details secret-property)]
(if (= :file-path source)
(let [secret-value (unresolved-value-string secret-value)
^File existing-file (File. ^String secret-value)]
(if (.exists existing-file)
existing-file
(let [file-path (if secret-id protected-password secret-value)
error-source (let [secret-props (secret-conn-props-by-name driver)]
(tru "File path for {0}" (-> (get secret-props secret-property)
:display-name)))]
(throw (ex-info (tru "{0} points to non-existent file: {1}" error-source file-path)
{:file-path file-path
:secret secret-id})))))
(let [^File tmp-file (doto (File/createTempFile "metabase-secret_" ext)
;; make the file only readable by owner
(.setReadable false false)
(.setReadable true true)
(.deleteOnExit))]
(log/tracef "Creating temp file for secret %s value at %s" (or secret-id ) (.getAbsolutePath tmp-file))
(with-open [out (io/output-stream tmp-file)]
(let [^bytes v (codecs/to-bytes secret-value)]
(.write out v)))
tmp-file)))) | |
Returns the value of the given
| (def
^java.io.File
^{:arglists '([driver details secret-property & [ext]])}
value-as-file!
(memoize/memo value-as-file*)) |
---------------------------------------------- Database Details ---------------------------------------------- | |
Delete Secret instances from the app DB, that will become orphaned when In the future, if/when we allow arbitrary association of secret instances to database instances, this will need to change and become more complicated (likely by consulting a many-to-many join table). | (defn delete-orphaned-secrets!
[{:keys [id details] :as database}]
(when-let [possible-secret-prop-names (seq (keys (secret-conn-props-by-name (driver.u/database->driver database))))]
(doseq [secret-id (reduce (fn [acc prop-name]
(if-let [secret-id (get details (->id-kw prop-name))]
(conj acc secret-id)
acc))
[]
possible-secret-prop-names)]
(log/infof "Deleting secret ID %s from app DB because the owning database (%s) is being deleted" secret-id id)
(t2/delete! :model/Secret :id secret-id)))) |
(defn- hydrate-redacted-secret
[db-details conn-prop-nm _conn-prop]
(let [kws (->possible-secret-property-names conn-prop-nm)
secret-id (get db-details (->id-kw conn-prop-nm))]
;; If db-details contains secret properties, we must be in a PUT and we want to return to client as is.
;; This is true because otherwise [[handle-incoming-client-secrets!]] and
;; [[clean-secret-properties-from-database]] would have removed them.
(cond
(not-empty (select-keys db-details (vals kws)))
db-details
;; Otherwise we want to return options and path from the Secret but redacted value
secret-id
(let [{:keys [source] :as secret} (latest-for-id secret-id)]
(cond-> db-details
(= source :file-path)
(->
(assoc (:path kws) (unresolved-value-string (:value secret))
(:options kws) "local"))
(not= source :file-path)
(->
(assoc (:value kws) protected-password
(:options kws) "uploaded"))))
:else
db-details))) | |
To satisfy clients we need to return the keys they send us in details.
This is a transformation on Fetches the stored secret and fills in | (defn to-json-hydrate-redacted-secrets
[database]
(let [driver (driver.u/database->driver database)]
(m/update-existing
database
:details
(fn [details]
(reduce-over-details-secret-values
driver
details
hydrate-redacted-secret))))) |
Ensures that all possible secret property values are removed from This can be used to cleanup | (defn clean-secret-properties-from-details
[details driver]
(reduce-over-details-secret-values
driver
details
(fn [db-details conn-prop-nm _conn-prop]
(apply dissoc db-details (vals (->possible-secret-property-names conn-prop-nm)))))) |
Ensures that all possible secret property values are removed from | (defn clean-secret-properties-from-database
[database]
(m/update-existing
database
:details
#(clean-secret-properties-from-details
%
(driver.u/database->driver database)))) |
Converts incoming secret values in Only the Secret id should be stored in A secret prop like ```
[:private-key-value ;; IMPORTANT if In this case, we want to look for a | (defn handle-incoming-client-secrets!
[{:keys [details] :as database}]
(let [{original-details :details} (t2/original database)
updated-details (reduce-over-details-secret-values
(driver.u/database->driver database)
details
(fn [db-details conn-prop-nm conn-prop]
(let [kws (->possible-secret-property-names conn-prop-nm)
id-kw (keyword (str conn-prop-nm "-id"))
secret-id (get original-details id-kw)
secret (secret-map-from-details db-details conn-prop)
cleared-details (apply dissoc db-details (vals kws))]
(if secret
(if (:value secret)
(let [{:keys [id]} (upsert-secret-value!
secret-id
(format "%s for %s" (:display-name conn-prop) (:name database))
(:secret-kind conn-prop)
(:source secret)
(:value secret))]
(assoc cleared-details id-kw id))
(do
(t2/delete! :model/Secret :id secret-id)
(dissoc cleared-details id-kw)))
;; Don't throw out a secret even if the client didn't sent it back
(m/assoc-some cleared-details id-kw secret-id)))))]
(assoc database :details updated-details))) |