Compare commits

...

6 Commits

Author SHA1 Message Date
Andrey Antukh
9f513c1d10 🔧 Disable HSTS on devenv 2026-01-27 11:32:48 +01:00
Andrey Antukh
cafc4567b3 Make nitrate module loading conditional to flag
This removes the flag checking on each rpc method
2026-01-27 10:42:44 +01:00
Andrey Antukh
9ea7d0e243 Reuse basic team and profile schemas on nitrate 2026-01-27 10:42:44 +01:00
Andrey Antukh
da029de7e4 🔥 Remove subscriptions related management module 2026-01-27 10:42:44 +01:00
Andrey Antukh
10c08c88c7 📎 Adapt nitrate module to auth changes 2026-01-27 10:42:44 +01:00
Andrey Antukh
877b6630f3 ♻️ Make several improvements to management API authentication 2026-01-27 10:06:19 +01:00
17 changed files with 179 additions and 332 deletions

View File

@@ -1,7 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key export PENPOT_NITRATE_SHARED_KEY=super-secret-nitrate-api-key
export PENPOT_EXPORTER_SHARED_KEY=super-secret-exporter-api-key
export PENPOT_SECRET_KEY=super-secret-devenv-key export PENPOT_SECRET_KEY=super-secret-devenv-key
# DEPRECATED: only used for subscriptions
export PENPOT_MANAGEMENT_API_KEY=super-secret-management-api-key
export PENPOT_HOST=devenv export PENPOT_HOST=devenv
export PENPOT_PUBLIC_URI=https://localhost:3449 export PENPOT_PUBLIC_URI=https://localhost:3449

View File

@@ -102,6 +102,8 @@
[:http-server-io-threads {:optional true} ::sm/int] [:http-server-io-threads {:optional true} ::sm/int]
[:http-server-max-worker-threads {:optional true} ::sm/int] [:http-server-max-worker-threads {:optional true} ::sm/int]
[:exporter-shared-key {:optional true} :string]
[:nitrate-shared-key {:optional true} :string]
[:management-api-key {:optional true} :string] [:management-api-key {:optional true} :string]
[:telemetry-uri {:optional true} :string] [:telemetry-uri {:optional true} :string]

View File

@@ -13,13 +13,13 @@
[app.common.time :as ct] [app.common.time :as ct]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
[app.http.middleware :as mw]
[app.main :as-alias main] [app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile] [app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup] [app.setup :as-alias setup]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.worker :as-alias wrk] [app.worker :as-alias wrk]
[integrant.core :as ig] [integrant.core :as ig]
[yetti.request :as yreq]
[yetti.response :as-alias yres])) [yetti.response :as-alias yres]))
;; ---- ROUTES ;; ---- ROUTES
@@ -49,28 +49,40 @@
(fn [cfg request] (fn [cfg request]
(db/tx-run! cfg handler request)))))}) (db/tx-run! cfg handler request)))))})
(def ^:private shared-key-auth
{:name ::shared-key-auth
:compile
(fn [_ _]
(fn [handler key]
(if key
(fn [request]
(if-let [key' (yreq/get-header request "x-shared-key")]
(if (= key key')
(handler request)
{::yres/status 403})
{::yres/status 403}))
(fn [_ _]
{::yres/status 403}))))})
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(let [management-key (or (cf/get :management-api-key) ["" {:middleware [[shared-key-auth (cf/get :management-api-key)]
(get props :management-key))] [default-system cfg]
[transaction]]}
["/authenticate"
{:handler authenticate
:allowed-methods #{:post}}]
["" {:middleware [[mw/shared-key-auth management-key] ["/get-customer"
[default-system cfg] {:handler get-customer
[transaction]]} :transaction true
["/authenticate" :allowed-methods #{:post}}]
{:handler authenticate
:allowed-methods #{:post}}]
["/get-customer" ["/update-customer"
{:handler get-customer {:handler update-customer
:transaction true :allowed-methods #{:post}
:allowed-methods #{:post}}] :transaction true}]])
["/update-customer"
{:handler update-customer
:allowed-methods #{:post}
:transaction true}]]))
;; ---- HELPERS ;; ---- HELPERS

View File

@@ -16,7 +16,6 @@
[app.http.errors :as errors] [app.http.errors :as errors]
[app.tokens :as tokens] [app.tokens :as tokens]
[app.util.pointer-map :as pmap] [app.util.pointer-map :as pmap]
[buddy.core.codecs :as bc]
[cuerdas.core :as str] [cuerdas.core :as str]
[yetti.adapter :as yt] [yetti.adapter :as yt]
[yetti.middleware :as ymw] [yetti.middleware :as ymw]
@@ -301,16 +300,20 @@
:compile (constantly wrap-auth)}) :compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth (defn- wrap-shared-key-auth
[handler shared-key] [handler keys]
(if shared-key (if (seq keys)
(let [shared-key (if (string? shared-key) (fn [request]
shared-key (if-let [[key-id key] (some-> (yreq/get-header request "x-shared-key")
(bc/bytes->b64-str shared-key true))] (str/split #"\s+" 2))]
(fn [request] (let [key-id (str/lower key-id)]
(let [key (yreq/get-header request "x-shared-key")] (if (and (string? key)
(if (= key shared-key) (contains? keys key-id)
(handler (assoc request ::http/auth-with-shared-key true)) (= key (get keys key-id)))
{::yres/status 403})))) (-> request
(assoc ::http/auth-key-id key-id)
(handler))
{::yres/status 403}))
{::yres/status 403}))
(fn [_ _] (fn [_ _]
{::yres/status 403}))) {::yres/status 403})))

View File

@@ -140,10 +140,14 @@
client-version (get-client-version request) client-version (get-client-version request)
client-user-agent (get-client-user-agent request) client-user-agent (get-client-user-agent request)
session-id (get-external-session-id request) session-id (get-external-session-id request)
token-id (::actoken/id request)] key-id (::http/auth-key-id request)
token-id (::actoken/id request)
token-type (::actoken/type request)]
(d/without-nils (d/without-nils
{:external-session-id session-id {:external-session-id session-id
:initiator (or key-id "app")
:access-token-id (some-> token-id str) :access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin :client-event-origin client-event-origin
:client-user-agent client-user-agent :client-user-agent client-user-agent
:client-version client-version :client-version client-version

View File

@@ -275,8 +275,7 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
::mgmt/routes ::mgmt/routes
{::db/pool (ig/ref ::db/pool) {::db/pool (ig/ref ::db/pool)}
::setup/props (ig/ref ::setup/props)}
:app.http/router :app.http/router
{::session/manager (ig/ref ::session/manager) {::session/manager (ig/ref ::session/manager)
@@ -341,7 +340,8 @@
::email/whitelist (ig/ref ::email/whitelist)} ::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/client :app.nitrate/client
{::http.client/client (ig/ref ::http.client/client)} {::http.client/client (ig/ref ::http.client/client)
::setup/shared-keys (ig/ref ::setup/shared-keys)}
:app.rpc/management-methods :app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client) {::http.client/client (ig/ref ::http.client/client)
@@ -357,13 +357,13 @@
::setup/props (ig/ref ::setup/props)} ::setup/props (ig/ref ::setup/props)}
::rpc/routes ::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods) {::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods) ::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here ;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager) ::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)} ::setup/shared-keys (ig/ref ::setup/shared-keys)}
::wrk/registry ::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics) {::mtx/metrics (ig/ref ::mtx/metrics)
@@ -451,6 +451,11 @@
;; module requires the migrations to run before initialize. ;; module requires the migrations to run before initialize.
::migrations (ig/ref :app.migrations/migrations)} ::migrations (ig/ref :app.migrations/migrations)}
::setup/shared-keys
{::setup/props (ig/ref ::setup/props)
:nitrate (cf/get :nitrate-shared-key)
:exporter (cf/get :exporter-shared-key)}
::setup/clock ::setup/clock
{} {}

View File

@@ -1,9 +1,3 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.nitrate (ns app.nitrate
"Module that make calls to the external nitrate aplication" "Module that make calls to the external nitrate aplication"
(:require (:require
@@ -17,18 +11,17 @@
[clojure.core :as c] [clojure.core :as c]
[integrant.core :as ig])) [integrant.core :as ig]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS ;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- request-builder (defn- request-builder
[cfg method uri management-key profile-id] [cfg method uri shared-key profile-id]
(fn [] (fn []
(http/req! cfg {:method method (http/req! cfg {:method method
:headers {"content-type" "application/json" :headers {"content-type" "application/json"
"accept" "application/json" "accept" "application/json"
"x-shared-key" management-key "x-shared-key" shared-key
"x-profile-id" (str profile-id)} "x-profile-id" (str profile-id)}
:uri uri :uri uri
:version :http1.1}))) :version :http1.1})))
@@ -54,9 +47,9 @@
(defn- with-validate [handler uri schema] (defn- with-validate [handler uri schema]
(fn [] (fn []
(let [coercer-http (sm/coercer schema (let [coercer-http (sm/coercer schema
:type :validation :type :validation
:hint (str "invalid data received calling " uri))] :hint (str "invalid data received calling " uri))]
(try (try
(coercer-http (-> (handler) :body json/decode)) (coercer-http (-> (handler) :body json/decode))
(catch Exception e (catch Exception e
@@ -65,8 +58,9 @@
nil))))) nil)))))
(defn- request-to-nitrate (defn- request-to-nitrate
[{:keys [::management-key] :as cfg} method uri schema {:keys [::rpc/profile-id] :as params}] [cfg method uri schema {:keys [::rpc/profile-id] :as params}]
(let [full-http-call (-> (request-builder cfg method uri management-key profile-id) (let [shared-key (-> cfg ::setup/shared-keys :nitrate)
full-http-call (-> (request-builder cfg method uri shared-key profile-id)
(with-retries 3) (with-retries 3)
(with-validate uri schema))] (with-validate uri schema))]
(full-http-call))) (full-http-call)))
@@ -103,26 +97,15 @@
(let [baseuri (cf/get :nitrate-backend-uri)] (let [baseuri (cf/get :nitrate-backend-uri)]
(request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params))) (request-to-nitrate cfg :get (str baseuri "/api/users/" (str profile-id)) schema:user params)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; INITIALIZATION ;; INITIALIZATION
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defmethod ig/init-key ::client (defmethod ig/init-key ::client
[_ {:keys [::setup/props] :as cfg}] [_ cfg]
(if (contains? cf/flags :nitrate) (when (contains? cf/flags :nitrate)
(let [management-key (or (cf/get :management-api-key) {:get-team-org (partial get-team-org cfg)
(get props :management-key)) :is-valid-user (partial is-valid-user cfg)}))
cfg (assoc cfg ::management-key management-key)]
{:get-team-org (partial get-team-org cfg)
:is-valid-user (partial is-valid-user cfg)})
{}))
(defmethod ig/halt-key! ::client
[_ {:keys []}]
(do :stuff))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; UTILS ;; UTILS

View File

@@ -92,11 +92,11 @@
(fn [{:keys [params path-params method] :as request}] (fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params) (let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match") etag (yreq/get-header request "if-none-match")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request) profile-id (or (::session/profile-id request)
(::actoken/profile-id request) (::actoken/profile-id request)
(if (::http/auth-with-shared-key request) (if key-id uuid/zero nil))
uuid/zero
nil))
ip-addr (inet/parse-request request) ip-addr (inet/parse-request request)
@@ -298,11 +298,12 @@
(defn- resolve-management-methods (defn- resolve-management-methods
[cfg] [cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)] (let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)
(->> (sv/scan-ns mods (cond->> (list 'app.rpc.management.exporter)
'app.rpc.management.subscription (contains? cf/flags :nitrate)
'app.rpc.management.nitrate (cons 'app.rpc.management.nitrate))]
'app.rpc.management.exporter)
(->> (apply sv/scan-ns mods)
(map (partial process-method cfg "management" wrap-management)) (map (partial process-method cfg "management" wrap-management))
(into {})))) (into {}))))
@@ -346,23 +347,20 @@
(defmethod ig/assert-key ::routes (defmethod ig/assert-key ::routes
[_ params] [_ params]
(assert (map? (::setup/shared-keys params)))
(assert (db/pool? (::db/pool params)) "expect valid database pool") (assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager") (assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map") (assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map")) (assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes (defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods ::setup/props] :as cfg}] [_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
(let [public-uri (cf/get :public-uri)
management-key (or (cf/get :management-api-key)
(get props :management-key))]
(let [public-uri (cf/get :public-uri)]
["/api" ["/api"
["/management" ["/management"
["/methods/:type" ["/methods/:type"
{:middleware [[mw/shared-key-auth management-key] {:middleware [[mw/shared-key-auth shared-keys]
[session/authz cfg]] [session/authz cfg]]
:handler (make-rpc-handler management-methods)}] :handler (make-rpc-handler management-methods)}]

View File

@@ -5,13 +5,12 @@
;; Copyright (c) KALEIDOS INC ;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.nitrate (ns app.rpc.management.nitrate
"Internal Nitrate HTTP API. "Internal Nitrate HTTP RPC API. Provides authenticated access to
Provides authenticated access to organization management and token validation endpoints. organization management and token validation endpoints."
All requests must include a valid shared key token in the `x-shared-key` header, and
a cookie `auth-token` with the user token.
They will return `401 Unauthorized` if the shared key or user token are invalid."
(:require (:require
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.profile :refer [schema:profile]]
[app.common.types.team :refer [schema:team]]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf] [app.config :as cf]
[app.db :as db] [app.db :as db]
@@ -23,22 +22,14 @@
[app.util.services :as sv])) [app.util.services :as sv]))
;; ---- API: authenticate ;; ---- API: authenticate
(def ^:private schema:profile
[:map
[:id ::sm/uuid]
[:name :string]
[:email :string]
[:photo-url :string]])
(sv/defmethod ::authenticate (sv/defmethod ::authenticate
"Authenticate an user "Authenticate the current user"
@api GET /authenticate {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the authenticated user."
{::doc/added "2.12"
::sm/result schema:profile} ::sm/result schema:profile}
[cfg {:keys [::rpc/profile-id] :as params}] [cfg {:keys [::rpc/profile-id] :as params}]
(let [profile (profile/get-profile cfg profile-id)] (let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id) {:id (get profile :id)
:name (get profile :fullname) :name (get profile :fullname)
:email (get profile :email) :email (get profile :email)
@@ -51,30 +42,22 @@
FROM team AS t FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ? WHERE tpr.profile_id = ?
AND tpr.is_owner = 't' AND tpr.is_owner IS TRUE
AND t.is_default = 'f' AND t.is_default IS FALSE
AND t.deleted_at is null;") AND t.deleted_at IS NULL;")
(def ^:private schema:team
[:map
[:id ::sm/uuid]
[:name :string]])
(def ^:private schema:get-teams-result (def ^:private schema:get-teams-result
[:vector schema:team]) [:vector schema:team])
(sv/defmethod ::get-teams (sv/defmethod ::get-teams
"List teams for which current user is owner. "List teams for which current user is owner"
@api GET /get-teams {::doc/added "2.14"
@returns ::sm/params [:map]
200 OK: Returns the list of teams for the user."
{::doc/added "2.12"
::sm/result schema:get-teams-result} ::sm/result schema:get-teams-result}
[cfg {:keys [::rpc/profile-id]}] [cfg {:keys [::rpc/profile-id]}]
(when (contains? cf/flags :nitrate) (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
(let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (->> (db/exec! cfg [sql:get-teams current-user-id])
(->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name])))))
(map #(select-keys % [:id :name]))))))
;; ---- API: notify-team-change ;; ---- API: notify-team-change
@@ -83,30 +66,18 @@
[:id ::sm/uuid] [:id ::sm/uuid]
[:organization-id ::sm/text]]) [:organization-id ::sm/text]])
(sv/defmethod ::notify-team-change (sv/defmethod ::notify-team-change
"Notify to Penpot a team change from nitrate "Notify to Penpot a team change from nitrate"
@api POST /notify-team-change {::doc/added "2.14"
@returns
200 OK"
{::doc/added "2.12"
::sm/params schema:notify-team-change ::sm/params schema:notify-team-change
::rpc/auth false} ::rpc/auth false}
[cfg {:keys [id organization-id organization-name]}] [cfg {:keys [id organization-id organization-name]}]
(when (contains? cf/flags :nitrate) (let [msgbus (::mbus/msgbus cfg)]
(let [msgbus (::mbus/msgbus cfg)] (mbus/pub! msgbus
(mbus/pub! msgbus ;;TODO There is a bug on dashboard with teams notifications.
;;TODO There is a bug on dashboard with teams notifications. ;;For now we send it to uuid/zero instead of team-id
;;For now we send it to uuid/zero instead of team-id :topic uuid/zero
:topic uuid/zero :message {:type :team-org-change
:message {:type :team-org-change :team-id id
:team-id id :organization-id organization-id
:organization-id organization-id :organization-name organization-name})))
:organization-name organization-name}))))

View File

@@ -1,183 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@@ -17,6 +17,7 @@
[app.setup.templates] [app.setup.templates]
[buddy.core.codecs :as bc] [buddy.core.codecs :as bc]
[buddy.core.nonce :as bn] [buddy.core.nonce :as bn]
[cuerdas.core :as str]
[integrant.core :as ig])) [integrant.core :as ig]))
(defn- generate-random-key (defn- generate-random-key
@@ -88,7 +89,38 @@
(-> (get-all-props conn) (-> (get-all-props conn)
(assoc :secret-key secret) (assoc :secret-key secret)
(assoc :tokens-key (keys/derive secret :salt "tokens")) (assoc :tokens-key (keys/derive secret :salt "tokens"))
(assoc :management-key (keys/derive secret :salt "management"))
(update :instance-id handle-instance-id conn (db/read-only? pool))))))) (update :instance-id handle-instance-id conn (db/read-only? pool)))))))
(sm/register! ::props [:map-of :keyword ::sm/any]) (sm/register! ::props [:map-of :keyword ::sm/any])
(defmethod ig/init-key ::shared-keys
[_ {:keys [::props] :as cfg}]
(let [secret (get props :secret-key)]
(d/without-nils
{"exporter"
(let [key (or (get cfg :exporter)
(-> (keys/derive secret :salt "exporter")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "exporter key is disabled because empty string found")
nil)
(do
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key)))
"nitrate"
(let [key (or (get cfg :nitrate)
(-> (keys/derive secret :salt "nitrate")
(bc/bytes->b64-str true)))]
(if (or (str/empty? key)
(str/blank? key))
(do
(l/wrn :hint "nitrate key is disabled because empty string found")
nil)
(do
(l/inf :hint "nitrate key initialized" :key (d/obfuscate-string key))
key)))})))

View File

@@ -86,7 +86,7 @@
(t/deftest shared-key-auth (t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth (let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200}) (fn [req] {::yres/status 200})
"secret-key")] {"test1" "secret-key"})]
(let [response (handler (->DummyRequest {} {}))] (let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
@@ -95,6 +95,9 @@
(t/is (= 403 (::yres/status response)))) (t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))] (let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "test1 secret-key"} {}))]
(t/is (= 200 (::yres/status response)))))) (t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz (t/deftest access-token-authz

View File

@@ -19,3 +19,10 @@
(def schema:role (def schema:role
[::sm/one-of {:title "TeamRole"} valid-roles]) [::sm/one-of {:title "TeamRole"} valid-roles])
;; FIXME: specify more fields
(def schema:team
[:map {:title "Team"}
[:id ::sm/uuid]
[:name :string]])

View File

@@ -398,7 +398,6 @@ COPY files/Caddyfile /home/
COPY files/selfsigned.crt /home/ COPY files/selfsigned.crt /home/
COPY files/selfsigned.key /home/ COPY files/selfsigned.key /home/
COPY files/start-tmux.sh /home/start-tmux.sh COPY files/start-tmux.sh /home/start-tmux.sh
COPY files/start-tmux-back.sh /home/start-tmux-back.sh
COPY files/entrypoint.sh /home/entrypoint.sh COPY files/entrypoint.sh /home/entrypoint.sh
COPY files/init.sh /home/init.sh COPY files/init.sh /home/init.sh

View File

@@ -5,6 +5,7 @@
localhost:3449 { localhost:3449 {
reverse_proxy localhost:4449 reverse_proxy localhost:4449
tls /home/selfsigned.crt /home/selfsigned.key tls /home/selfsigned.crt /home/selfsigned.key
header -Strict-Transport-Security
} }
http://localhost:3450 { http://localhost:3450 {

View File

@@ -12,11 +12,14 @@
["node:process" :as process] ["node:process" :as process]
[app.common.data :as d] [app.common.data :as d]
[app.common.flags :as flags] [app.common.flags :as flags]
[app.common.logging :as l]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.version :as v] [app.common.version :as v]
[cljs.core :as c] [cljs.core :as c]
[cuerdas.core :as str])) [cuerdas.core :as str]))
(l/set-level! :info)
(def ^:private defaults (def ^:private defaults
{:public-uri "http://localhost:3449" {:public-uri "http://localhost:3449"
:tenant "default" :tenant "default"
@@ -30,7 +33,7 @@
[:map {:title "config"} [:map {:title "config"}
[:secret-key :string] [:secret-key :string]
[:public-uri {:optional true} ::sm/uri] [:public-uri {:optional true} ::sm/uri]
[:management-api-key {:optional true} :string] [:exporter-shared-key {:optional true} :string]
[:host {:optional true} :string] [:host {:optional true} :string]
[:tenant {:optional true} :string] [:tenant {:optional true} :string]
[:flags {:optional true} [::sm/set :keyword]] [:flags {:optional true} [::sm/set :keyword]]
@@ -98,8 +101,10 @@
(c/get config key default))) (c/get config key default)))
(def management-key (def management-key
(or (c/get config :management-api-key) (let [key (or (c/get config :exporter-shared-key)
(let [secret-key (c/get config :secret-key) (let [secret-key (c/get config :secret-key)
derived-key (crypto/hkdfSync "blake2b512" secret-key, "management" "" 32)] derived-key (crypto/hkdfSync "blake2b512" secret-key, "exporter" "" 32)]
(-> (.from buffer/Buffer derived-key) (-> (.from buffer/Buffer derived-key)
(.toString "base64url"))))) (.toString "base64url"))))]
(l/inf :hint "exporter key initialized" :key (d/obfuscate-string key))
key))

View File

@@ -73,7 +73,7 @@
(p/mcat (fn [blob] (p/mcat (fn [blob]
(let [fdata (new http/FormData) (let [fdata (new http/FormData)
agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}}) agent (new http/Agent #js {:connect #js {:rejectUnauthorized false}})
headers #js {"X-Shared-Key" cf/management-key headers #js {"X-Shared-Key" (str "exporter " cf/management-key)
"Authorization" (str "Bearer " auth-token)} "Authorization" (str "Bearer " auth-token)}
request #js {:headers headers request #js {:headers headers