Skip to content
Snippets Groups Projects
Unverified Commit 76b943ac authored by Cam Saul's avatar Cam Saul
Browse files

Merge branch 'master' into handle-settings-across-multiple-instances

parents dbfe92c5 18d69213
No related branches found
No related tags found
No related merge requests found
Showing
with 363 additions and 85 deletions
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
"Create a new `Pulse`." "Create a new `Pulse`."
[:as {{:keys [name cards channels skip_if_empty collection_id collection_position]} :body}] [:as {{:keys [name cards channels skip_if_empty collection_id collection_position]} :body}]
{name su/NonBlankString {name su/NonBlankString
cards (su/non-empty [pulse/CardRef]) cards (su/non-empty [pulse/CoercibleToCardRef])
channels (su/non-empty [su/Map]) channels (su/non-empty [su/Map])
skip_if_empty (s/maybe s/Bool) skip_if_empty (s/maybe s/Bool)
collection_id (s/maybe su/IntGreaterThanZero) collection_id (s/maybe su/IntGreaterThanZero)
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
"Update a Pulse with `id`." "Update a Pulse with `id`."
[id :as {{:keys [name cards channels skip_if_empty collection_id], :as pulse-updates} :body}] [id :as {{:keys [name cards channels skip_if_empty collection_id], :as pulse-updates} :body}]
{name (s/maybe su/NonBlankString) {name (s/maybe su/NonBlankString)
cards (s/maybe (su/non-empty [pulse/CardRef])) cards (s/maybe (su/non-empty [pulse/CoercibleToCardRef]))
channels (s/maybe (su/non-empty [su/Map])) channels (s/maybe (su/non-empty [su/Map]))
skip_if_empty (s/maybe s/Bool) skip_if_empty (s/maybe s/Bool)
collection_id (s/maybe su/IntGreaterThanZero)} collection_id (s/maybe su/IntGreaterThanZero)}
......
...@@ -86,6 +86,19 @@ ...@@ -86,6 +86,19 @@
(check-self-or-superuser id) (check-self-or-superuser id)
(api/check-404 (fetch-user :id id, :is_active true))) (api/check-404 (fetch-user :id id, :is_active true)))
(defn- valid-email-update?
"This predicate tests whether or not the user is allowed to update the email address associated with this account."
[{:keys [google_auth ldap_auth email] :as foo } maybe-new-email]
(or
;; Admin users can update
api/*is-superuser?*
;; If the email address didn't change, let it through
(= email maybe-new-email)
;; We should not allow a regular user to change their email address if they are a google/ldap user
(and
(not google_auth)
(not ldap_auth))))
(api/defendpoint PUT "/:id" (api/defendpoint PUT "/:id"
"Update an existing, active `User`." "Update an existing, active `User`."
[id :as {{:keys [email first_name last_name is_superuser login_attributes] :as body} :body}] [id :as {{:keys [email first_name last_name is_superuser login_attributes] :as body} :body}]
...@@ -95,18 +108,20 @@ ...@@ -95,18 +108,20 @@
login_attributes (s/maybe user/LoginAttributes)} login_attributes (s/maybe user/LoginAttributes)}
(check-self-or-superuser id) (check-self-or-superuser id)
;; only allow updates if the specified account is active ;; only allow updates if the specified account is active
(api/check-404 (db/exists? User, :id id, :is_active true)) (api/let-404 [user-before-update (fetch-user :id id, :is_active true)]
;; can't change email if it's already taken BY ANOTHER ACCOUNT ;; Google/LDAP non-admin users can't change their email to prevent account hijacking
(api/checkp (not (db/exists? User, :email email, :id [:not= id])) (api/check-403 (valid-email-update? user-before-update email))
"email" (tru "Email address already associated to another user.")) ;; can't change email if it's already taken BY ANOTHER ACCOUNT
(api/check-500 (api/checkp (not (db/exists? User, :email email, :id [:not= id]))
(db/update! User id "email" (tru "Email address already associated to another user."))
(u/select-keys-when body (api/check-500
:present (when api/*is-superuser?* (db/update! User id
#{:login_attributes}) (u/select-keys-when body
:non-nil (set (concat [:first_name :last_name :email] :present (when api/*is-superuser?*
(when api/*is-superuser?* #{:login_attributes})
[:is_superuser])))))) :non-nil (set (concat [:first_name :last_name :email]
(when api/*is-superuser?*
[:is_superuser])))))))
(fetch-user :id id)) (fetch-user :id id))
(api/defendpoint PUT "/:id/reactivate" (api/defendpoint PUT "/:id/reactivate"
......
...@@ -6,7 +6,8 @@ ...@@ -6,7 +6,8 @@
CREATE TABLE IF NOT EXISTS ... -- Good CREATE TABLE IF NOT EXISTS ... -- Good
CREATE TABLE ... -- Bad" CREATE TABLE ... -- Bad"
(:require [clojure.java.jdbc :as jdbc] (:require [cemerick.friend.credentials :as creds]
[clojure.java.jdbc :as jdbc]
[clojure.string :as str] [clojure.string :as str]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[metabase [metabase
...@@ -33,7 +34,8 @@ ...@@ -33,7 +34,8 @@
[metabase.util.date :as du] [metabase.util.date :as du]
[toucan [toucan
[db :as db] [db :as db]
[models :as models]])) [models :as models]])
(:import java.util.UUID))
;;; # Migration Helpers ;;; # Migration Helpers
...@@ -368,7 +370,6 @@ ...@@ -368,7 +370,6 @@
:active true} :active true}
:has_field_values "list")) :has_field_values "list"))
;; In v0.30.0 we switiched to making standard SQL the default for BigQuery; up until that point we had been using ;; In v0.30.0 we switiched to making standard SQL the default for BigQuery; up until that point we had been using
;; BigQuery legacy SQL. For a while, we've supported standard SQL if you specified the case-insensitive `#standardSQL` ;; BigQuery legacy SQL. For a while, we've supported standard SQL if you specified the case-insensitive `#standardSQL`
;; directive at the beginning of your query, and similarly allowed you to specify legacy SQL with the `#legacySQL` ;; directive at the beginning of your query, and similarly allowed you to specify legacy SQL with the `#legacySQL`
...@@ -391,3 +392,11 @@ ...@@ -391,3 +392,11 @@
;; and save the updated dataset_query map ;; and save the updated dataset_query map
(db/update! Card (u/get-id card-id) (db/update! Card (u/get-id card-id)
:dataset_query (assoc-in query [:native :query] updated-sql))))))))) :dataset_query (assoc-in query [:native :query] updated-sql)))))))))
;; Before 0.30.0, we were storing the LDAP user's password in the `core_user` table (though it wasn't used). This
;; migration clears those passwords and replaces them with a UUID. This is similar to a new account setup, or how we
;; disable passwords for Google authenticated users
(defmigration ^{:author "senior", :added "0.30.0"} clear-ldap-user-local-passwords
(db/transaction
(doseq [user (db/select [User :id :password_salt] :ldap_auth [:= true])]
(db/update! User (u/get-id user) :password (creds/hash-bcrypt (str (:password_salt user) (UUID/randomUUID)))))))
...@@ -211,11 +211,8 @@ ...@@ -211,11 +211,8 @@
(let [user (or (db/select-one [User :id :last_login] :email email) (let [user (or (db/select-one [User :id :last_login] :email email)
(user/create-new-ldap-auth-user! {:first_name first-name (user/create-new-ldap-auth-user! {:first_name first-name
:last_name last-name :last_name last-name
:email email :email email}))]
:password password}))]
(u/prog1 user (u/prog1 user
(when password
(user/set-password! (:id user) password))
(when (ldap-group-sync) (when (ldap-group-sync)
(let [special-ids #{(:id (group/admin)) (:id (group/all-users))} (let [special-ids #{(:id (group/admin)) (:id (group/all-users))}
current-ids (set (map :group_id (db/select ['PermissionsGroupMembership :group_id] :user_id (:id user)))) current-ids (set (map :group_id (db/select ['PermissionsGroupMembership :group_id] :user_id (:id user))))
......
...@@ -68,6 +68,10 @@ ...@@ -68,6 +68,10 @@
:in encrypted-json-in :in encrypted-json-in
:out (comp cached-encrypted-json-out u/jdbc-clob->str)) :out (comp cached-encrypted-json-out u/jdbc-clob->str))
(models/add-type! :encrypted-text
:in encryption/maybe-encrypt
:out (comp encryption/maybe-decrypt u/jdbc-clob->str))
(defn compress (defn compress
"Compress OBJ, returning a byte array." "Compress OBJ, returning a byte array."
[obj] [obj]
......
...@@ -14,7 +14,8 @@ ...@@ -14,7 +14,8 @@
functions for fetching a specific Pulse). At some point in the future, we can clean this namespace up and bring the functions for fetching a specific Pulse). At some point in the future, we can clean this namespace up and bring the
code in line with the rest of the codebase, but for the time being, it probably makes sense to follow the existing code in line with the rest of the codebase, but for the time being, it probably makes sense to follow the existing
patterns in this namespace rather than further confuse things." patterns in this namespace rather than further confuse things."
(:require [clojure.tools.logging :as log] (:require [clojure.string :as str]
[clojure.tools.logging :as log]
[medley.core :as m] [medley.core :as m]
[metabase [metabase
[events :as events] [events :as events]
...@@ -27,6 +28,7 @@ ...@@ -27,6 +28,7 @@
[pulse-channel :as pulse-channel :refer [PulseChannel]] [pulse-channel :as pulse-channel :refer [PulseChannel]]
[pulse-channel-recipient :refer [PulseChannelRecipient]]] [pulse-channel-recipient :refer [PulseChannelRecipient]]]
[metabase.util.schema :as su] [metabase.util.schema :as su]
[puppetlabs.i18n.core :refer [tru]]
[schema.core :as s] [schema.core :as s]
[toucan [toucan
[db :as db] [db :as db]
...@@ -73,6 +75,42 @@ ...@@ -73,6 +75,42 @@
:can-write? (partial i/current-user-has-full-permissions? :write) :can-write? (partial i/current-user-has-full-permissions? :write)
:perms-objects-set perms-objects-set}) :perms-objects-set perms-objects-set})
;;; ---------------------------------------------------- Schemas -----------------------------------------------------
(def AlertConditions
"Schema for valid values of `:alert_condition` for Alerts."
(s/enum "rows" "goal"))
(def CardRef
"Schema for the map we use to internally represent the fact that a Card is in a Notification and the details about its
presence there."
(su/with-api-error-message {:id su/IntGreaterThanZero
:include_csv s/Bool
:include_xls s/Bool}
(tru "value must be a map with the keys `{0}`, `{1}`, and `{2}`." "id" "include_csv" "include_xls")))
(def HybridPulseCard
"This schema represents the cards that are included in a pulse. This is the data from the `PulseCard` and some
additional information used by the UI to display it from `Card`. This is a superset of `CardRef` and is coercible to
a `CardRef`"
(su/with-api-error-message
(merge (:schema CardRef)
{:name (s/maybe s/Str)
:description (s/maybe s/Str)
:display (s/maybe su/KeywordOrString)
:collection_id (s/maybe su/IntGreaterThanZero)})
(tru "value must be a map with the following keys `({0})`"
(str/join ", " ["collection_id" "description" "display" "id" "include_csv" "include_xls" "name"]))))
(def CoercibleToCardRef
"Schema for functions accepting either a `HybridPulseCard` or `CardRef`."
(s/conditional
(fn check-hybrid-pulse-card [maybe-map]
(and (map? maybe-map)
(some #(contains? maybe-map %) [:name :description :display :collection_id])))
HybridPulseCard
:else
CardRef))
;;; --------------------------------------------------- Hydration ---------------------------------------------------- ;;; --------------------------------------------------- Hydration ----------------------------------------------------
...@@ -81,8 +119,7 @@ ...@@ -81,8 +119,7 @@
[notification-or-id] [notification-or-id]
(db/select PulseChannel, :pulse_id (u/get-id notification-or-id))) (db/select PulseChannel, :pulse_id (u/get-id notification-or-id)))
(s/defn ^:hydrate cards :- [HybridPulseCard]
(defn ^:hydrate cards
"Return the Cards associated with this `notification`." "Return the Cards associated with this `notification`."
[notification-or-id] [notification-or-id]
(map (partial models/do-post-select Card) (map (partial models/do-post-select Card)
...@@ -96,22 +133,6 @@ ...@@ -96,22 +133,6 @@
[:= :c.archived false]] [:= :c.archived false]]
:order-by [[:pc.position :asc]]}))) :order-by [[:pc.position :asc]]})))
;;; ---------------------------------------------------- Schemas -----------------------------------------------------
(def AlertConditions
"Schema for valid values of `:alert_condition` for Alerts."
(s/enum "rows" "goal"))
(def CardRef
"Schema for the map we use to internally represent the fact that a Card is in a Notification and the details about its
presence there."
(su/with-api-error-message {:id su/IntGreaterThanZero
:include_csv s/Bool
:include_xls s/Bool}
"value must be a map with the keys `id`, `include_csv`, and `include_xls`."))
;;; ---------------------------------------- Notification Fetching Helper Fns ---------------------------------------- ;;; ---------------------------------------- Notification Fetching Helper Fns ----------------------------------------
(s/defn ^:private hydrate-notification :- PulseInstance (s/defn ^:private hydrate-notification :- PulseInstance
...@@ -342,7 +363,7 @@ ...@@ -342,7 +363,7 @@
(s/optional-key :skip_if_empty) s/Bool (s/optional-key :skip_if_empty) s/Bool
(s/optional-key :collection_id) (s/maybe su/IntGreaterThanZero) (s/optional-key :collection_id) (s/maybe su/IntGreaterThanZero)
(s/optional-key :collection_position) (s/maybe su/IntGreaterThanZero) (s/optional-key :collection_position) (s/maybe su/IntGreaterThanZero)
(s/optional-key :cards) [CardRef] (s/optional-key :cards) [CoercibleToCardRef]
(s/optional-key :channels) [su/Map]}] (s/optional-key :channels) [su/Map]}]
(db/update! Pulse (u/get-id notification) (db/update! Pulse (u/get-id notification)
(u/select-keys-when notification (u/select-keys-when notification
......
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
(u/strict-extend (class Setting) (u/strict-extend (class Setting)
models/IModel models/IModel
(merge models/IModelDefaults (merge models/IModelDefaults
{:types (constantly {:value :clob})})) {:types (constantly {:value :encrypted-text})}))
(def ^:private Type (def ^:private Type
...@@ -301,8 +301,19 @@ ...@@ -301,8 +301,19 @@
(defn- update-setting! (defn- update-setting!
"Update an existing Setting. Used internally by `set-string!` below; do not use directly." "Update an existing Setting. Used internally by `set-string!` below; do not use directly."
[setting-name new-value] [setting-name new-value]
(db/update-where! Setting {:key setting-name} ;; This is indeed a very annoying way of having to do things, but `update-where!` doesn't call `pre-update` (in case
:value new-value)) ;; it updates thousands of objects). So we need to manually trigger `pre-update` behavior by calling `do-pre-update`
;; so that `value` can get encrypted if `MB_ENCRYPTION_SECRET_KEY` is in use. Then take that possibly-encrypted
;; value and pass that into `update-where!`.
(let [maybe-encrypted-new-value (if (= setting-name settings-last-updated-key)
;; one more caveat: do not encrypt the `settings-last-updated` setting,
;; since we use it directly in queries for determining whether the cache
;; is out of date.
new-value
;; all other Settings are subject to encryption
(:value (models/do-pre-update Setting {:value new-value})))]
(db/update-where! Setting {:key setting-name}
:value maybe-encrypted-new-value)))
(defn- set-new-setting! (defn- set-new-setting!
"Insert a new row for a Setting. Used internally by `set-string!` below; do not use directly." "Insert a new row for a Setting. Used internally by `set-string!` below; do not use directly."
......
...@@ -191,7 +191,10 @@ ...@@ -191,7 +191,10 @@
"Convenience for creating a new user via LDAP. This account is considered active immediately; thus all active admins "Convenience for creating a new user via LDAP. This account is considered active immediately; thus all active admins
will recieve an email right away." will recieve an email right away."
[new-user :- NewUser] [new-user :- NewUser]
(insert-new-user! (assoc new-user :ldap_auth true))) (insert-new-user! (-> new-user
;; We should not store LDAP passwords
(dissoc :password)
(assoc :ldap_auth true))))
(defn set-password! (defn set-password!
"Updates the stored password for a specified `User` by hashing the password with a random salt." "Updates the stored password for a specified `User` by hashing the password with a random salt."
......
...@@ -473,7 +473,7 @@ ...@@ -473,7 +473,7 @@
(select-nested-keys v nested-keys))}))) (select-nested-keys v nested-keys))})))
(defn base64-string? (defn base64-string?
"Is S a Base-64 encoded string?" "Is `s` a Base-64 encoded string?"
^Boolean [s] ^Boolean [s]
(boolean (when (string? s) (boolean (when (string? s)
(re-find #"^[0-9A-Za-z/+]+=*$" s)))) (re-find #"^[0-9A-Za-z/+]+=*$" s))))
......
(ns metabase.util.encryption (ns metabase.util.encryption
"Utility functions for encrypting and decrypting strings using AES256 CBC + HMAC SHA512 and the `MB_ENCRYPTION_SECRET_KEY` env var." "Utility functions for encrypting and decrypting strings using AES256 CBC + HMAC SHA512 and the
`MB_ENCRYPTION_SECRET_KEY` env var."
(:require [buddy.core (:require [buddy.core
[codecs :as codecs] [codecs :as codecs]
[crypto :as crypto] [crypto :as crypto]
...@@ -8,54 +9,62 @@ ...@@ -8,54 +9,62 @@
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[environ.core :as env] [environ.core :as env]
[metabase.util :as u] [metabase.util :as u]
[puppetlabs.i18n.core :refer [trs]]
[ring.util.codec :as codec])) [ring.util.codec :as codec]))
(defn secret-key->hash (defn secret-key->hash
"Generate a 64-byte byte array hash of SECRET-KEY using 100,000 iterations of PBKDF2+SHA512." "Generate a 64-byte byte array hash of `secret-key` using 100,000 iterations of PBKDF2+SHA512."
^bytes [^String secret-key] ^bytes [^String secret-key]
(kdf/get-bytes (kdf/engine {:alg :pbkdf2+sha512 (kdf/get-bytes (kdf/engine {:alg :pbkdf2+sha512
:key secret-key :key secret-key
:iterations 100000}) ; 100,000 iterations takes about ~160ms on my laptop :iterations 100000}) ; 100,000 iterations takes about ~160ms on my laptop
64)) 64))
;; apperently if you're not tagging in an arglist, `^bytes` will set the `:tag` metadata to `clojure.core/bytes` (ick) so you have to do `^{:tag 'bytes}` instead ;; apperently if you're not tagging in an arglist, `^bytes` will set the `:tag` metadata to `clojure.core/bytes` (ick)
;; so you have to do `^{:tag 'bytes}` instead
(defonce ^:private ^{:tag 'bytes} default-secret-key (defonce ^:private ^{:tag 'bytes} default-secret-key
(when-let [secret-key (env/env :mb-encryption-secret-key)] (when-let [secret-key (env/env :mb-encryption-secret-key)]
(when (seq secret-key) (when (seq secret-key)
(assert (>= (count secret-key) 16) (assert (>= (count secret-key) 16)
"MB_ENCRYPTION_SECRET_KEY must be at least 16 characters.") (trs "MB_ENCRYPTION_SECRET_KEY must be at least 16 characters."))
(secret-key->hash secret-key)))) (secret-key->hash secret-key))))
;; log a nice message letting people know whether DB details encryption is enabled ;; log a nice message letting people know whether DB details encryption is enabled
(log/info (log/info
(format "DB details encryption is %s for this Metabase instance. %s" (if default-secret-key
(if default-secret-key "ENABLED" "DISABLED") (trs "Saved credentials encryption is ENABLED for this Metabase instance.")
(u/emoji (if default-secret-key "🔐" "🔓"))) (trs "Saved credentials encryption is DISABLED for this Metabase instance."))
"\nSee" (u/emoji (if default-secret-key "🔐" "🔓"))
"http://www.metabase.com/docs/latest/operations-guide/start.html#encrypting-your-database-connection-details-at-rest" (trs "\nFor more information, see")
"for more information.") "https://www.metabase.com/docs/latest/operations-guide/start.html#encrypting-your-database-connection-details-at-rest")
(defn encrypt (defn encrypt
"Encrypt string S as hex bytes using a SECRET-KEY (a 64-byte byte array), by default the hashed value of `MB_ENCRYPTION_SECRET_KEY`." "Encrypt string `s` as hex bytes using a `secret-key` (a 64-byte byte array), by default the hashed value of
`MB_ENCRYPTION_SECRET_KEY`."
(^String [^String s] (^String [^String s]
(encrypt default-secret-key s)) (encrypt default-secret-key s))
(^String [^String secret-key, ^String s] (^String [^String secret-key, ^String s]
(let [initialization-vector (nonce/random-bytes 16)] (let [initialization-vector (nonce/random-bytes 16)]
(codec/base64-encode (byte-array (concat initialization-vector (codec/base64-encode
(crypto/encrypt (codecs/to-bytes s) secret-key initialization-vector {:algorithm :aes256-cbc-hmac-sha512}))))))) (byte-array
(concat initialization-vector
(crypto/encrypt (codecs/to-bytes s) secret-key initialization-vector
{:algorithm :aes256-cbc-hmac-sha512})))))))
(defn decrypt (defn decrypt
"Decrypt string S using a SECRET-KEY (a 64-byte byte array), by default the hashed value of `MB_ENCRYPTION_SECRET_KEY`." "Decrypt string `s` using a `secret-key` (a 64-byte byte array), by default the hashed value of
`MB_ENCRYPTION_SECRET_KEY`."
(^String [^String s] (^String [^String s]
(decrypt default-secret-key s)) (decrypt default-secret-key s))
(^String [secret-key, ^String s] (^String [secret-key, ^String s]
(let [bytes (codec/base64-decode s) (let [bytes (codec/base64-decode s)
[initialization-vector message] (split-at 16 bytes)] [initialization-vector message] (split-at 16 bytes)]
(codecs/bytes->str (crypto/decrypt (byte-array message) secret-key (byte-array initialization-vector) {:algorithm :aes256-cbc-hmac-sha512}))))) (codecs/bytes->str (crypto/decrypt (byte-array message) secret-key (byte-array initialization-vector)
{:algorithm :aes256-cbc-hmac-sha512})))))
(defn maybe-encrypt (defn maybe-encrypt
"If `MB_ENCRYPTION_SECRET_KEY` is set, return an encrypted version of S; otherwise return S as-is." "If `MB_ENCRYPTION_SECRET_KEY` is set, return an encrypted version of `s`; otherwise return `s` as-is."
(^String [^String s] (^String [^String s]
(maybe-encrypt default-secret-key s)) (maybe-encrypt default-secret-key s))
(^String [secret-key, ^String s] (^String [secret-key, ^String s]
...@@ -65,7 +74,7 @@ ...@@ -65,7 +74,7 @@
s))) s)))
(defn maybe-decrypt (defn maybe-decrypt
"If `MB_ENCRYPTION_SECRET_KEY` is set and S is encrypted, decrypt S; otherwise return S as-is." "If `MB_ENCRYPTION_SECRET_KEY` is set and `s` is encrypted, decrypt `s`; otherwise return `s` as-is."
(^String [^String s] (^String [^String s]
(maybe-decrypt default-secret-key s)) (maybe-decrypt default-secret-key s))
(^String [secret-key, ^String s] (^String [secret-key, ^String s]
...@@ -75,7 +84,10 @@ ...@@ -75,7 +84,10 @@
(catch Throwable e (catch Throwable e
(if (u/base64-string? s) (if (u/base64-string? s)
;; if we can't decrypt `s`, but it *is* encrypted, log an error message and return `nil` ;; if we can't decrypt `s`, but it *is* encrypted, log an error message and return `nil`
(log/error "Cannot decrypt encrypted details. Have you changed or forgot to set MB_ENCRYPTION_SECRET_KEY?" (.getMessage e)) (log/error
(trs "Cannot decrypt encrypted credentials. Have you changed or forgot to set MB_ENCRYPTION_SECRET_KEY?")
(.getMessage e)
(u/pprint-to-str (u/filtered-stacktrace e)))
;; otherwise return S without decrypting. It's probably not decrypted in the first place ;; otherwise return S without decrypting. It's probably not decrypted in the first place
s))) s)))
s))) s)))
...@@ -50,6 +50,13 @@ ...@@ -50,6 +50,13 @@
(instance? java.util.regex.Pattern existing-schema) (tru "value must be a string that matches the regex `{0}`." (instance? java.util.regex.Pattern existing-schema) (tru "value must be a string that matches the regex `{0}`."
existing-schema))) existing-schema)))
(declare api-error-message)
(defn- create-cond-schema-message [child-schemas]
(str (tru "value must satisfy one of the following requirements: ")
(str/join " " (for [[i child-schema] (m/indexed child-schemas)]
(format "%d) %s" (inc i) (api-error-message child-schema))))))
(defn api-error-message (defn api-error-message
"Extract the API error messages attached to a schema, if any. "Extract the API error messages attached to a schema, if any.
This functionality is fairly sophisticated: This functionality is fairly sophisticated:
...@@ -74,13 +81,16 @@ ...@@ -74,13 +81,16 @@
;; 1) value must be a boolean. ;; 1) value must be a boolean.
;; 2) value must be a valid boolean string ('true' or 'false'). ;; 2) value must be a valid boolean string ('true' or 'false').
(when (instance? schema.core.CondPre schema) (when (instance? schema.core.CondPre schema)
(str (tru "value must satisfy one of the following requirements: ") (create-cond-schema-message (:schemas schema)))
(str/join " " (for [[i child-schema] (m/indexed (:schemas schema))]
(format "%d) %s" (inc i) (api-error-message child-schema)))))) ;; For conditional schemas we'll generate a string similar to `cond-pre` above
(when (instance? schema.core.ConditionalSchema schema)
(create-cond-schema-message (map second (:preds-and-schemas schema))))
;; do the same for sequences of a schema ;; do the same for sequences of a schema
(when (vector? schema) (when (vector? schema)
(str (tru "value must be an array.") (when (= (count schema) 1) (str (tru "value must be an array.") (when (= (count schema) 1)
(when-let [message (:api-error-message (first schema))] (when-let [message (api-error-message (first schema))]
(str " " (tru "Each {0}" message)))))))) (str " " (tru "Each {0}" message))))))))
......
...@@ -103,24 +103,28 @@ ...@@ -103,24 +103,28 @@
;;; | POST /api/pulse | ;;; | POST /api/pulse |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(def ^:private default-post-card-ref-validation-error
{:errors
{:cards (str "value must be an array. Each value must satisfy one of the following requirements: "
"1) value must be a map with the following keys "
"`(collection_id, description, display, id, include_csv, include_xls, name)` "
"2) value must be a map with the keys `id`, `include_csv`, and `include_xls`. The array cannot be empty.")}})
(expect (expect
{:errors {:name "value must be a non-blank string."}} {:errors {:name "value must be a non-blank string."}}
((user->client :rasta) :post 400 "pulse" {})) ((user->client :rasta) :post 400 "pulse" {}))
(expect (expect
{:errors {:cards (str "value must be an array. Each value must be a map with the keys `id`, `include_csv`, and " default-post-card-ref-validation-error
"`include_xls`. The array cannot be empty.")}}
((user->client :rasta) :post 400 "pulse" {:name "abc"})) ((user->client :rasta) :post 400 "pulse" {:name "abc"}))
(expect (expect
{:errors {:cards (str "value must be an array. Each value must be a map with the keys `id`, `include_csv`, and " default-post-card-ref-validation-error
"`include_xls`. The array cannot be empty.")}}
((user->client :rasta) :post 400 "pulse" {:name "abc" ((user->client :rasta) :post 400 "pulse" {:name "abc"
:cards "foobar"})) :cards "foobar"}))
(expect (expect
{:errors {:cards (str "value must be an array. Each value must be a map with the keys `id`, `include_csv`, and " default-post-card-ref-validation-error
"`include_xls`. The array cannot be empty.")}}
((user->client :rasta) :post 400 "pulse" {:name "abc" ((user->client :rasta) :post 400 "pulse" {:name "abc"
:cards ["abc"]})) :cards ["abc"]}))
...@@ -197,6 +201,42 @@ ...@@ -197,6 +201,42 @@
pulse-response pulse-response
(update :channels remove-extra-channels-fields)))))) (update :channels remove-extra-channels-fields))))))
;; Create a pulse with a HybridPulseCard and a CardRef, PUT accepts this format, we should make sure POST does as well
(tt/expect-with-temp [Card [card-1]
Card [card-2 {:name "The card"
:description "Info"
:display :table}]]
(merge
pulse-defaults
{:name "A Pulse"
:creator_id (user->id :rasta)
:creator (user-details (fetch-user :rasta))
:cards (for [card [card-1 card-2]]
(assoc (pulse-card-details card)
:collection_id true))
:channels [(merge pulse-channel-defaults
{:channel_type "email"
:schedule_type "daily"
:schedule_hour 12
:recipients []})]
:collection_id true})
(card-api-test/with-cards-in-readable-collection [card-1 card-2]
(tt/with-temp Collection [collection]
(perms/grant-collection-readwrite-permissions! (perms-group/all-users) collection)
(tu/with-model-cleanup [Pulse]
(-> ((user->client :rasta) :post 200 "pulse" {:name "A Pulse"
:collection_id (u/get-id collection)
:cards [{:id (u/get-id card-1)
:include_csv false
:include_xls false}
(-> card-2
(select-keys [:id :name :description :display :collection_id])
(assoc :include_csv false, :include_xls false))]
:channels [daily-email-channel]
:skip_if_empty false})
pulse-response
(update :channels remove-extra-channels-fields))))))
;; Create a pulse with a csv and xls ;; Create a pulse with a csv and xls
(tt/expect-with-temp [Card [card-1] (tt/expect-with-temp [Card [card-1]
Card [card-2]] Card [card-2]]
...@@ -273,23 +313,28 @@ ...@@ -273,23 +313,28 @@
;;; | PUT /api/pulse/:id | ;;; | PUT /api/pulse/:id |
;;; +----------------------------------------------------------------------------------------------------------------+ ;;; +----------------------------------------------------------------------------------------------------------------+
(def ^:private default-put-card-ref-validation-error
{:errors
{:cards (str "value may be nil, or if non-nil, value must be an array. "
"Each value must satisfy one of the following requirements: "
"1) value must be a map with the following keys "
"`(collection_id, description, display, id, include_csv, include_xls, name)` "
"2) value must be a map with the keys `id`, `include_csv`, and `include_xls`. The array cannot be empty.")}})
(expect (expect
{:errors {:name "value may be nil, or if non-nil, value must be a non-blank string."}} {:errors {:name "value may be nil, or if non-nil, value must be a non-blank string."}}
((user->client :rasta) :put 400 "pulse/1" {:name 123})) ((user->client :rasta) :put 400 "pulse/1" {:name 123}))
(expect (expect
{:errors {:cards (str "value may be nil, or if non-nil, value must be an array. Each value must be a map with the " default-put-card-ref-validation-error
"keys `id`, `include_csv`, and `include_xls`. The array cannot be empty.")}}
((user->client :rasta) :put 400 "pulse/1" {:cards 123})) ((user->client :rasta) :put 400 "pulse/1" {:cards 123}))
(expect (expect
{:errors {:cards (str "value may be nil, or if non-nil, value must be an array. Each value must be a map with the " default-put-card-ref-validation-error
"keys `id`, `include_csv`, and `include_xls`. The array cannot be empty.")}}
((user->client :rasta) :put 400 "pulse/1" {:cards "foobar"})) ((user->client :rasta) :put 400 "pulse/1" {:cards "foobar"}))
(expect (expect
{:errors {:cards (str "value may be nil, or if non-nil, value must be an array. Each value must be a map with the " default-put-card-ref-validation-error
"keys `id`, `include_csv`, and `include_xls`. The array cannot be empty.")}}
((user->client :rasta) :put 400 "pulse/1" {:cards ["abc"]})) ((user->client :rasta) :put 400 "pulse/1" {:cards ["abc"]}))
(expect (expect
...@@ -342,6 +387,35 @@ ...@@ -342,6 +387,35 @@
pulse-response pulse-response
(update :channels remove-extra-channels-fields))))) (update :channels remove-extra-channels-fields)))))
;; Can we add a card to an existing pulse that has a card? Specifically this will include a HybridPulseCard (the
;; original card associated with the pulse) and a CardRef (the new card)
(tt/expect-with-temp [Pulse [pulse {:name "Original Pulse Name"}]
Card [card-1 {:name "Test"
:description "Just Testing"}]
PulseCard [_ {:card_id (u/get-id card-1)
:pulse_id (u/get-id pulse)}]
Card [card-2 {:name "Test2"
:description "Just Testing2"}]]
(merge
pulse-defaults
{:name "Original Pulse Name"
:creator_id (user->id :rasta)
:creator (user-details (fetch-user :rasta))
:cards (mapv (comp #(assoc % :collection_id true) pulse-card-details) [card-1 card-2])
:channels []
:collection_id true})
(with-pulses-in-writeable-collection [pulse]
(card-api-test/with-cards-in-readable-collection [card-1 card-2]
;; The FE will include the original HybridPulseCard, similar to how the API returns the card via GET
(let [pulse-cards (:cards ((user->client :rasta) :get 200 (format "pulse/%d" (u/get-id pulse))))]
(-> ((user->client :rasta) :put 200 (format "pulse/%d" (u/get-id pulse))
{:cards (concat pulse-cards
[{:id (u/get-id card-2)
:include_csv false
:include_xls false}])})
pulse-response
(update :channels remove-extra-channels-fields))))))
;; Can we update *just* the Collection ID of a Pulse? ;; Can we update *just* the Collection ID of a Pulse?
(expect (expect
(tt/with-temp* [Pulse [pulse] (tt/with-temp* [Pulse [pulse]
......
...@@ -322,6 +322,28 @@ ...@@ -322,6 +322,28 @@
((test-users/user->client :crowberto) :put 404 (str "user/" (test-users/user->id :trashbird)) ((test-users/user->client :crowberto) :put 404 (str "user/" (test-users/user->id :trashbird))
{:email "toucan@metabase.com"})) {:email "toucan@metabase.com"}))
;; Google auth users shouldn't be able to change their own password as we get that from Google
(expect
"You don't have permissions to do that."
(tt/with-temp User [user {:email "anemail@metabase.com"
:password "def123"
:google_auth true}]
(let [creds {:username "anemail@metabase.com"
:password "def123"}]
(http/client creds :put 403 (format "user/%d" (u/get-id user))
{:email "adifferentemail@metabase.com"}))))
;; Similar to Google auth accounts, we should not allow LDAP users to change their own email address as we get that
;; from the LDAP server
(expect
"You don't have permissions to do that."
(tt/with-temp User [user {:email "anemail@metabase.com"
:password "def123"
:ldap_auth true}]
(let [creds {:username "anemail@metabase.com"
:password "def123"}]
(http/client creds :put 403 (format "user/%d" (u/get-id user))
{:email "adifferentemail@metabase.com"}))))
;; ## PUT /api/user/:id/password ;; ## PUT /api/user/:id/password
;; Test that a User can change their password (superuser and non-superuser) ;; Test that a User can change their password (superuser and non-superuser)
......
...@@ -6,8 +6,10 @@ ...@@ -6,8 +6,10 @@
[metabase.db.migrations :as migrations] [metabase.db.migrations :as migrations]
[metabase.models [metabase.models
[card :refer [Card]] [card :refer [Card]]
[database :refer [Database]]] [database :refer [Database]]
[user :refer [User]]]
[metabase.util :as u] [metabase.util :as u]
[metabase.util.password :as upass]
[toucan.db :as db] [toucan.db :as db]
[toucan.util.test :as tt])) [toucan.util.test :as tt]))
...@@ -38,3 +40,23 @@ ...@@ -38,3 +40,23 @@
(#'migrations/add-legacy-sql-directive-to-bigquery-sql-cards) (#'migrations/add-legacy-sql-directive-to-bigquery-sql-cards)
(->> (db/select-field->field :name :dataset_query Card :id [:in (map u/get-id [card-1 card-2])]) (->> (db/select-field->field :name :dataset_query Card :id [:in (map u/get-id [card-1 card-2])])
(m/map-vals #(update % :database integer?))))) (m/map-vals #(update % :database integer?)))))
;; Test clearing of LDAP user local passwords
(expect
[false true]
(do
(tt/with-temp* [User [ldap-user {:email "ldapuser@metabase.com"
:password "something secret"
:ldap_auth true}]
User [user {:email "notanldapuser@metabase.com"
:password "no change"}]]
(#'migrations/clear-ldap-user-local-passwords)
(let [get-pass-and-salt #(db/select-one [User :password :password_salt] :id (u/get-id %))
{ldap-pass :password,
ldap-salt :password_salt} (get-pass-and-salt ldap-user)
{user-pass :password,
user-salt :password_salt} (get-pass-and-salt user)]
;; The LDAP user password should be no good now that it's been cleared and replaced
[(upass/verify-password "something secret" ldap-salt ldap-pass)
;; There should be no change for a non ldap user
(upass/verify-password "no change" user-salt user-pass)]))))
...@@ -4,7 +4,14 @@ ...@@ -4,7 +4,14 @@
[honeysql.core :as hsql] [honeysql.core :as hsql]
[metabase.models.setting :as setting :refer [defsetting Setting]] [metabase.models.setting :as setting :refer [defsetting Setting]]
[metabase.test.util :refer :all] [metabase.test.util :refer :all]
<<<<<<< HEAD
[metabase.util.honeysql-extensions :as hx] [metabase.util.honeysql-extensions :as hx]
=======
[metabase.util :as u]
[metabase.util
[encryption :as encryption]
[encryption-test :as encryption-test]]
>>>>>>> master
[puppetlabs.i18n.core :refer [tru]] [puppetlabs.i18n.core :refer [tru]]
[toucan.db :as db])) [toucan.db :as db]))
...@@ -170,7 +177,12 @@ ...@@ -170,7 +177,12 @@
;; all ;; all
(expect (expect
{:key :test-setting-2, :value "TOUCANS", :description "Test setting - this only shows up in dev (2)", :is_env_setting false, :env_name "MB_TEST_SETTING_2", :default "[Default Value]"} {:key :test-setting-2
:value "TOUCANS"
:description "Test setting - this only shows up in dev (2)"
:is_env_setting false
:env_name "MB_TEST_SETTING_2"
:default "[Default Value]"}
(do (set-settings! nil "TOUCANS") (do (set-settings! nil "TOUCANS")
(some (fn [setting] (some (fn [setting]
(when (re-find #"^test-setting-2$" (name (:key setting))) (when (re-find #"^test-setting-2$" (name (:key setting)))
...@@ -179,8 +191,18 @@ ...@@ -179,8 +191,18 @@
;; all ;; all
(expect (expect
[{:key :test-setting-1, :value nil, :is_env_setting true, :env_name "MB_TEST_SETTING_1", :description "Test setting - this only shows up in dev (1)", :default "Using $MB_TEST_SETTING_1"} [{:key :test-setting-1
{:key :test-setting-2, :value "S2", :is_env_setting false, :env_name "MB_TEST_SETTING_2", :description "Test setting - this only shows up in dev (2)", :default "[Default Value]"}] :value nil
:is_env_setting true
:env_name "MB_TEST_SETTING_1"
:description "Test setting - this only shows up in dev (1)"
:default "Using $MB_TEST_SETTING_1"}
{:key :test-setting-2
:value "S2"
:is_env_setting false,
:env_name "MB_TEST_SETTING_2"
:description "Test setting - this only shows up in dev (2)"
:default "[Default Value]"}]
(do (set-settings! nil "S2") (do (set-settings! nil "S2")
(for [setting (setting/all) (for [setting (setting/all)
:when (re-find #"^test-setting-\d$" (name (:key setting)))] :when (re-find #"^test-setting-\d$" (name (:key setting)))]
...@@ -274,6 +296,35 @@ ...@@ -274,6 +296,35 @@
(toucan-name))) (toucan-name)))
;;; ----------------------------------------------- Encrypted Settings -----------------------------------------------
(defn- actual-value-in-db [setting-key]
(-> (db/query {:select [:value]
:from [:setting]
:where [:= :key (name setting-key)]})
first :value u/jdbc-clob->str))
;; If encryption is *enabled*, make sure Settings get saved as encrypted!
(expect
(encryption-test/with-secret-key "ABCDEFGH12345678"
(toucan-name "Sad Can")
(u/base64-string? (actual-value-in-db :toucan-name))))
;; make sure it can be decrypted as well...
(expect
"Sad Can"
(encryption-test/with-secret-key "12345678ABCDEFGH"
(toucan-name "Sad Can")
(encryption/decrypt (actual-value-in-db :toucan-name))))
;; But if encryption is not enabled, of course Settings shouldn't get saved as encrypted.
(expect
"Sad Can"
(encryption-test/with-secret-key nil
(toucan-name "Sad Can")
(actual-value-in-db :toucan-name)))
;;; --------------------------------------------- Cache Synchronization ---------------------------------------------- ;;; --------------------------------------------- Cache Synchronization ----------------------------------------------
(def ^:private settings-last-updated-key @(resolve 'metabase.models.setting/settings-last-updated-key)) (def ^:private settings-last-updated-key @(resolve 'metabase.models.setting/settings-last-updated-key))
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
[user :as user :refer [User]]] [user :as user :refer [User]]]
[metabase.test.data.users :as test-users :refer [user->id]] [metabase.test.data.users :as test-users :refer [user->id]]
[metabase.test.util :as tu] [metabase.test.util :as tu]
[metabase.util.password :as upass]
[toucan.db :as db] [toucan.db :as db]
[toucan.util.test :as tt])) [toucan.util.test :as tt]))
...@@ -162,3 +163,17 @@ ...@@ -162,3 +163,17 @@
(test-users/delete-temp-users!) (test-users/delete-temp-users!)
(tt/with-temp User [_ {:is_superuser true, :is_active false}] (tt/with-temp User [_ {:is_superuser true, :is_active false}]
(invite-user-accept-and-check-inboxes! :google-auth? true)))) (invite-user-accept-and-check-inboxes! :google-auth? true))))
;; LDAP users should not persist their passwords. Check that if somehow we get passed an LDAP user password, it gets
;; swapped with something random
(expect
false
(try
(user/create-new-ldap-auth-user! {:email "ldaptest@metabase.com"
:first_name "Test"
:last_name "SomeLdapStuff"
:password "should be removed"})
(let [{:keys [password password_salt]} (db/select-one [User :password :password_salt] :email "ldaptest@metabase.com")]
(upass/verify-password "should be removed" password_salt password))
(finally
(db/delete! User :email "ldaptest@metabase.com"))))
...@@ -5,6 +5,18 @@ ...@@ -5,6 +5,18 @@
[metabase.test.util :as tu] [metabase.test.util :as tu]
[metabase.util.encryption :as encryption])) [metabase.util.encryption :as encryption]))
(defn do-with-secret-key [^String secret-key, f]
(with-redefs [encryption/default-secret-key (when (seq secret-key)
(encryption/secret-key->hash secret-key))]
(f)))
(defmacro with-secret-key
"Run `body` with the encryption secret key temporarily bound to `secret-key`. Useful for testing how functions behave
with and without encryption disabled."
{:style/indent 1}
[^String secret-key, & body]
`(do-with-secret-key ~secret-key (fn [] ~@body)))
(def ^:private secret (encryption/secret-key->hash "Orw0AAyzkO/kPTLJRxiyKoBHXa/d6ZcO+p+gpZO/wSQ=")) (def ^:private secret (encryption/secret-key->hash "Orw0AAyzkO/kPTLJRxiyKoBHXa/d6ZcO+p+gpZO/wSQ="))
(def ^:private secret-2 (encryption/secret-key->hash "0B9cD6++AME+A7/oR7Y2xvPRHX3cHA2z7w+LbObd/9Y=")) (def ^:private secret-2 (encryption/secret-key->hash "0B9cD6++AME+A7/oR7Y2xvPRHX3cHA2z7w+LbObd/9Y="))
...@@ -49,7 +61,7 @@ ...@@ -49,7 +61,7 @@
(expect (expect
(some (fn [[_ _ message]] (some (fn [[_ _ message]]
(str/includes? message (str "Cannot decrypt encrypted details. Have you changed or forgot to set " (str/includes? message (str "Cannot decrypt encrypted credentials. Have you changed or forgot to set "
"MB_ENCRYPTION_SECRET_KEY? Message seems corrupt or manipulated."))) "MB_ENCRYPTION_SECRET_KEY? Message seems corrupt or manipulated.")))
(tu/with-log-messages (tu/with-log-messages
(encryption/maybe-decrypt secret-2 (encryption/encrypt secret "WOW"))))) (encryption/maybe-decrypt secret-2 (encryption/encrypt secret "WOW")))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment