(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))) |