diff --git a/backend/scripts/_env b/backend/scripts/_env index fb1d8d8e69..b4e95dfb1c 100644 --- a/backend/scripts/_env +++ b/backend/scripts/_env @@ -2,6 +2,7 @@ export PENPOT_MANAGEMENT_API_SHARED_KEY=super-secret-management-api-key +export PENPOT_NITRATE_API_SHARED_KEY=super-secret-nitrate-api-key export PENPOT_SECRET_KEY=super-secret-devenv-key export PENPOT_HOST=devenv diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 633697985d..4df39f296e 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -19,6 +19,7 @@ [app.http.errors :as errors] [app.http.management :as mgmt] [app.http.middleware :as mw] + [app.http.nitrate :as nitrate] [app.http.security :as sec] [app.http.session :as session] [app.http.websocket :as-alias ws] @@ -156,6 +157,7 @@ [::mtx/routes schema:routes] [::awsns/routes schema:routes] [::mgmt/routes schema:routes] + [::nitrate/routes schema:routes] ::session/manager ::setup/props ::db/pool]) @@ -187,6 +189,9 @@ ["/management" (::mgmt/routes cfg)] + ["/nitrate" + (::nitrate/routes cfg)] + (::ws/routes cfg) ["/api" {:middleware [[mw/cors] diff --git a/backend/src/app/http/nitrate.clj b/backend/src/app/http/nitrate.clj new file mode 100644 index 0000000000..1bae0f5255 --- /dev/null +++ b/backend/src/app/http/nitrate.clj @@ -0,0 +1,266 @@ +;; 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.http.nitrate + "Internal Nitrate HTTP API. + Provides authenticated access to organization management and token validation endpoints. + + All requests must include a valid shared key token in the Authorization header." + (:require + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.access-token :refer [get-token]] + [app.main :as-alias main] + [app.setup :as-alias setup] + [app.tokens :as tokens] + [app.worker :as-alias wrk] + [integrant.core :as ig] + [yetti.response :as-alias yres])) + +;; ---- ROUTES + +(declare ^:private authenticate) +(declare ^:private get-organization) +(declare ^:private create-organization) +(declare ^:private update-organization) + +(defmethod ig/assert-key ::routes + [_ params] + (assert (db/pool? (::db/pool params)) "expect valid database pool")) + +(def ^:private auth + {:name ::auth + :compile + (fn [_ _] + (fn [handler shared-key] + (if shared-key + (fn [request] + (let [token (get-token request)] + (if (= token shared-key) + (handler request) + {::yres/status 403}))) + (fn [_ _] + {::yres/status 403}))))}) + +(def ^:private default-system + {:name ::default-system + :compile + (fn [_ _] + (fn [handler cfg] + (fn [request] + (handler cfg request))))}) + +(def ^:private transaction + {:name ::transaction + :compile + (fn [data _] + (when (:transaction data) + (fn [handler] + (fn [cfg request] + (db/tx-run! cfg handler request)))))}) + +(defmethod ig/init-key ::routes + [_ cfg] + ["" {:middleware [[auth (cf/get :nitrate-api-shared-key)] + [default-system cfg] + [transaction]]} + ["/authenticate" + {:handler authenticate + :allowed-methods #{:post}}] + + ["/get-organization" + {:handler get-organization + :allowed-methods #{:post} + :transaction true}] + + ["/update-organization" + {:handler update-organization + :allowed-methods #{:post} + :transaction true}] + + ["/create-organization" + {:handler create-organization + :allowed-methods #{:post} + :transaction true}]]) + +;; ---- HELPERS + +(defn- coercer + "Returns a parameter coercion function that: + - Decodes JSON params according to the given `schema` + - Validates them using `sm/check-fn` + - Throws validation errors if data is invalid + + @param schema Schema definition for input validation + @return function that validates request params" + [schema & {:as opts}] + (let [decode-fn (sm/decoder schema sm/json-transformer) + check-fn (sm/check-fn schema opts)] + (fn [data] + (-> data decode-fn check-fn)))) + + +(defn with-current-user + "Wraps a handler to inject the current user ID if the provided token is valid. + Returns 403 if no valid user is found or token verification fails. + + @param handler - function of [cfg request current-user-id] + @return handler of [cfg request]" + [handler] + (fn [cfg request] + (let [token (-> request :params :user-token) + auth (when token (tokens/verify cfg {:token token :iss "authentication"})) + uid (:uid auth)] + (if uid + (handler cfg request uid) + {::yres/status 403})))) + +(def ^:private sql:get-role + "SELECT role + FROM organization_profile_rel + WHERE organization_id=? + and profile_id=?;") + +(defn- get-role + [cfg organization_id profile-id] + (let [result (db/exec-one! cfg [sql:get-role organization_id profile-id])] + (:role result))) + +;; ---- API: AUTHENTICATE + +(defn- authenticate + "Authenticate a service token. + + @api POST /authenticate + @auth SharedKey + @params + token (string): The access token to validate. + @returns + 200 OK: Returns decoded token claims if valid. + 403 Forbidden: If the shared key or token is invalid." + [cfg request] + (let [token (-> request :params :token) + result (tokens/verify cfg {:token token :iss "authentication"})] + {::yres/status 200 + ::yres/body result})) + +;; ---- API: GET-ORGANIZATION + +(def ^:private schema:get-organization + [:map [:id ::sm/uuid]]) + +(def ^:private coerce-get-organization-params + (coercer schema:get-organization + :type :validation + :hint "invalid data provided for `get-organization` rpc call")) + +(def get-organization + "Retrieve an organization by ID. + + @api POST /get-organization + @auth SharedKey + @params + id (uuid): Organization identifier. + @returns + 200 OK: Returns the organization record. + 400 Bad Request: Invalid input data. + 403 Forbidden: If the shared key or user token is invalid. + 404 Not Found: Organization not found." + (with-current-user + (fn [cfg request _] + (let [organization-id (-> request :params coerce-get-organization-params :id) + result (db/get-by-id cfg :organization organization-id)] + {::yres/status 200 + ::yres/body result})))) + + + +;; ---- API: CREATE-ORGANIZATION + +(def ^:private schema:create-organization + [:map + [:name [::sm/word-string {:max 250}]]]) + +(def ^:private coerce-create-organization-params + (coercer schema:create-organization + :type :validation + :hint "invalid data provided for `create-organization` rpc call")) + +(def create-organization + "Create a new organization. + + @api POST /create-organization + @auth SharedKey + @params + name (string, max 250): Name of the organization. + @returns + 201 Created: Returns the newly created organization. + 400 Bad Request: Invalid data." + (with-current-user + (fn [cfg request current-user-id] + (let [{:keys [name]} + (-> request :params coerce-create-organization-params)] + + (l/dbg :hint "create organization" + :name name) + + (let [organization (db/insert! cfg :organization {:id (uuid/next) :name name}) + _ (db/insert! cfg :organization_profile_rel {:organization_id (:id organization) :profile_id current-user-id :role :owner})] + {::yres/status 201 + ::yres/body organization}))))) + +;; ---- API: UPDATE-ORGANIZATION + +(def ^:private schema:update-organization + [:map + [:id ::sm/uuid] + [:name [::sm/word-string {:max 250}]]]) + +(def ^:private coerce-update-organization-params + (coercer schema:update-organization + :type :validation + :hint "invalid data provided for `update-organization` rpc call")) + +(def update-organization + "Update an existing organization’s name. + + @api POST /update-organization + @auth SharedKey + @params + id (uuid): Organization identifier. + name (string, max 250): New organization name. + @returns + 201 Updated: Operation successful. + 400 Bad Request: Invalid input data. + 401 Unauthorized: The user is not authenticated (missing or invalid credentials). + 403 Forbidden: Insufficient permissions." + (with-current-user + (fn [cfg request current-user-id] + (let [{:keys [id name]} + (-> request :params coerce-update-organization-params) + + organization (db/get-by-id cfg :organization id) + + role (when organization (get-role cfg id current-user-id))] + + (if (= (keyword role) :owner) + (do + (l/dbg :hint "update organization" + :id (str id) + :name name) + + (db/update! cfg :organization + {:name name} + {:id id} + {::db/return-keys false}) + + {::yres/status 201 + ::yres/body nil}) + {::yres/status 403 + ::yres/body nil}))))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 57aebd30df..e68f2e640f 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -21,6 +21,7 @@ [app.http.client :as-alias http.client] [app.http.debug :as-alias http.debug] [app.http.management :as mgmt] + [app.http.nitrate :as nitrate] [app.http.session :as-alias session] [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] @@ -276,6 +277,10 @@ {::db/pool (ig/ref ::db/pool) ::setup/props (ig/ref ::setup/props)} + ::nitrate/routes + {::db/pool (ig/ref ::db/pool) + ::setup/props (ig/ref ::setup/props)} + :app.http/router {::session/manager (ig/ref ::session/manager) ::db/pool (ig/ref ::db/pool) @@ -285,6 +290,7 @@ ::mtx/routes (ig/ref ::mtx/routes) ::oidc/routes (ig/ref ::oidc/routes) ::mgmt/routes (ig/ref ::mgmt/routes) + ::nitrate/routes (ig/ref ::nitrate/routes) ::http.debug/routes (ig/ref ::http.debug/routes) ::http.assets/routes (ig/ref ::http.assets/routes) ::http.ws/routes (ig/ref ::http.ws/routes) diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index d9b3be6824..04eb0fd7dc 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -450,7 +450,10 @@ :fn (mg/resource "app/migrations/sql/0141-add-idx-to-file-library-rel.sql")} {:name "0141-add-file-data-table.sql" - :fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")} + + {:name "0142-add-organization-tables.sql" + :fn (mg/resource "app/migrations/sql/0142-add-organization-tables.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0142-add-organization-tables.sql b/backend/src/app/migrations/sql/0142-add-organization-tables.sql new file mode 100644 index 0000000000..4a0e4e4a7d --- /dev/null +++ b/backend/src/app/migrations/sql/0142-add-organization-tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE organization ( + id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + name text NOT NULL, + + PRIMARY KEY (id) +); + +ALTER TABLE team + ADD COLUMN organization_id uuid NULL REFERENCES organization(id) ON DELETE SET NULL; + + + +CREATE TABLE organization_profile_rel ( + organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'user', + + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + modified_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + PRIMARY KEY (organization_id, profile_id) +); + +CREATE INDEX team__organization_id__idx + ON team(organization_id); + +CREATE INDEX organization_profile_rel__organization_id__idx + ON organization_profile_rel(organization_id); + +CREATE INDEX organization_profile_rel__profile_id__idx + ON organization_profile_rel(profile_id); + +