Compare commits

...

2 Commits

Author SHA1 Message Date
Pablo Alba
eca75ccecc wip 2025-11-06 14:41:13 +01:00
Pablo Alba
66c8e1e1d6 wip - initial commands 2025-11-05 11:29:45 +01:00
10 changed files with 491 additions and 2 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -0,0 +1,410 @@
;; 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.msgbus :as mbus]
[app.setup :as-alias setup]
[app.tokens :as tokens]
[app.worker :as-alias wrk]
[clojure.walk :as walk]
[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)
(declare ^:private list-organizations)
(declare ^:private list-teams)
(declare ^:private set-team-org)
(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 401})))
(fn [_ _]
{::yres/status 401}))))})
(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}}]
["/list-organizations"
{:handler list-organizations
:allowed-methods #{:post}
:transaction true}]
["/list-teams"
{:handler list-teams
:allowed-methods #{:post}
:transaction true}]
["/set-team-org"
{:handler set-team-org
:allowed-methods #{:post}
:transaction true}]
["/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 401 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 401}))))
(def ^:private sql:get-role
"SELECT role
FROM organization_profile_rel
WHERE organization_id = ?
and profile_id = ?;")
(defn- is-org-owner?
[cfg organization-id current-user-id]
(let [result (db/exec-one! cfg [sql:get-role organization-id current-user-id])]
(= "owner" (:role result))))
(def ^:private sql:is-team-owner
"SELECT COUNT(*) AS n
FROM team_profile_rel
WHERE team_id = ?
AND profile_id = ?
AND is_owner = true;")
(defn- is-team-owner?
[cfg team-id current-user-id]
(-> (db/exec-one! cfg [sql:is-team-owner team-id current-user-id])
:n
pos?))
;; ---- 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.
401 Unauthorized: 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.
401 Unauthorized: 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)
(walk/stringify-keys))]
{::yres/status 200
::yres/body result}))))
;; ---- API: LIST-ORGANIZATIONS
(def ^:private sql:list-organizations
"SELECT o.*
FROM organization AS o
JOIN organization_profile_rel AS opr ON o.id = opr.organization_id
WHERE opr.profile_id = ?
AND opr.role = 'owner';")
(def list-organizations
"List organizations for which current user is owner.
@api POST /list-organizations
@auth SharedKey
@returns
200 OK: Returns the list of organizations for the user.
401 Unauthorized: If the shared key or user token is invalid."
(with-current-user
(fn [cfg _request current-user-id]
(let [result (->> (db/exec! cfg [sql:list-organizations current-user-id])
(map #(update % :id str))
vec
walk/stringify-keys)]
{::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.
401 Unauthorized: If the shared key or user token is invalid."
(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 organizations name.
@api POST /update-organization
@auth SharedKey
@params
id (uuid): Organization identifier.
name (string, max 250): New organization name.
@returns
204 Updated: Operation successful.
400 Bad Request: Invalid input data.
401 Unauthorized: If the shared key or user token is invalid.
403 Forbidden: The user doesn't have permissions to execute this operation."
(with-current-user
(fn [cfg request current-user-id]
(let [{:keys [id name]}
(-> request :params coerce-update-organization-params)
org-owner? (is-org-owner? cfg id current-user-id)]
(if org-owner?
(do
(db/update! cfg :organization
{:name name}
{:id id}
{::db/return-keys false})
{::yres/status 204
::yres/body nil})
{::yres/status 403
::yres/body nil})))))
;; ---- API: LIST-TEAMS
(def ^:private sql:list-teams
"SELECT t.*
FROM team AS t
JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
WHERE tpr.profile_id = ?
AND tpr.is_owner = 't'
AND t.is_default = 'f';")
(def list-teams
"List teams for which current user is owner.
@api POST /list-teams
@auth SharedKey
@returns
200 OK: Returns the list of teams for the user.
401 Unauthorized: If the shared key or user token is invalid."
(with-current-user
(fn [cfg _request current-user-id]
(let [result (->> (db/exec! cfg [sql:list-teams current-user-id])
(map #(dissoc % :features :subscription :is-default))
(map #(update % :organization-id str))
(map #(update % :id str))
vec
walk/stringify-keys)]
{::yres/status 200
::yres/body result}))))
;; ---- API: SET-TEAM-ORG
(def ^:private schema:set-team-org
[:map
[:team-id ::sm/uuid]
[:organization-id {:optional true} [:maybe ::sm/uuid]]])
(def ^:private coerce-set-team-org-params
(coercer schema:set-team-org
:type :validation
:hint "invalid data provided for `set-team-org` rpc call"))
(def ^:private sql:set-team-org
"UPDATE team
SET organization_id = ?
WHERE team.id = ?;")
(def set-team-org
"Set the organization of a team.
@api POST /set-team-org
@auth SharedKey
@params
team-id (uuid): Team identifier.
organization-id (uuid | null): Organization identifier, or null to remove association.
@returns
204 Updated: Operation successful.
401 Unauthorized: If the shared key or user token is invalid.
403 Forbidden: The user doesn't have permissions to execute this operation."
(with-current-user
(fn [cfg request current-user-id]
(let [{:keys [organization-id team-id]}
(-> request :params coerce-set-team-org-params)
org-owner? (if (nil? organization-id)
true
(is-org-owner? cfg organization-id current-user-id))
team-owner? (is-team-owner? cfg team-id current-user-id)
organization (when (and team-owner? org-owner?) (db/get-by-id cfg :organization organization-id))
msgbus (::mbus/msgbus cfg)]
(if (or
(not team-owner?)
(not org-owner?))
{::yres/status 403}
(do
(db/exec! cfg [sql:set-team-org organization-id team-id])
(mbus/pub! msgbus
:topic uuid/zero #_team-id
:message {:type :team-org-change
:team-id team-id
:organization-id organization-id
:organization-name (:name organization)})
{::yres/status 204}))))))

View File

@@ -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,11 @@
{::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)
::mbus/msgbus (ig/ref ::mbus/msgbus)}
:app.http/router
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
@@ -285,6 +291,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)

View File

@@ -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]

View File

@@ -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);

View File

@@ -121,9 +121,11 @@
tp.is_owner,
tp.is_admin,
tp.can_edit,
(t.id = ?) AS is_default
(t.id = ?) AS is_default,
o.name AS organization_name
FROM team_profile_rel AS tp
JOIN team AS t ON (t.id = tp.team_id)
LEFT JOIN organization AS o ON o.id = t.organization_id
WHERE t.deleted_at IS null
AND tp.profile_id = ?
ORDER BY tp.created_at ASC")
@@ -134,6 +136,7 @@
tp.is_admin,
tp.can_edit,
(t.id = ?) AS is_default,
o.name AS organization_name,
jsonb_build_object(
'~:type', COALESCE(p.props->'~:subscription'->>'~:type', 'professional'),
@@ -149,6 +152,7 @@
ON (tpr.team_id = t.id AND tpr.is_owner IS true)
JOIN profile AS p
ON (tpr.profile_id = p.id)
LEFT JOIN organization AS o ON o.id = t.organization_id
WHERE t.deleted_at IS null
AND tp.profile_id = ?
ORDER BY tp.created_at ASC")

View File

@@ -649,10 +649,23 @@
(rx/of (dcm/change-team-role params)
(modal/hide)))))
(defn handle-change-team-org
[{:keys [team-id organization-id organization-name] :as message}]
(ptk/reify ::handle-change-team-org
ptk/UpdateEvent
(update [_ state]
(if (contains? (:teams state) team-id)
(-> state
(assoc-in [:teams team-id :organization-id] organization-id)
(assoc-in [:teams team-id :organization-name] organization-name))
state))))
(defn- process-message
[{:keys [type] :as msg}]
(case type
:notification (dcm/handle-notification msg)
:team-role-change (handle-change-team-role msg)
:team-membership-change (dcm/team-membership-change msg)
:team-org-change (handle-change-team-org msg)
nil))

View File

@@ -59,6 +59,8 @@
{:type :subscribe-team
:team-id team-id}]
_ (prn "messages for team-id" team-id)
endmsg {:type :unsubscribe-file
:file-id file-id}
@@ -72,6 +74,7 @@
(rx/filter (ptk/type? ::dws/message))
(rx/map deref)
(rx/filter (fn [{:keys [topic] :as msg}]
(prn "topic" topic)
(or (= topic uuid/zero)
(= topic profile-id)
(= topic team-id)
@@ -122,8 +125,12 @@
(dpl/close-current-plugin {:close-only-edition-plugins? true}))
(rx/of (dwly/set-options-mode :design)))))))
(defn- process-message
[{:keys [type] :as msg}]
(prn "process-message" msg)
(case type
:join-file (handle-presence msg)
:leave-file (handle-presence msg)

View File

@@ -603,6 +603,8 @@
team-id (get team :id)
_ (prn team)
projects? (= section :dashboard-recent)
fonts? (= section :dashboard-fonts)
libs? (= section :dashboard-libraries)
@@ -702,6 +704,9 @@
[:*
[:div {:class (stl/css-case :sidebar-content true)
:ref container}
[:div {:class (stl/css :sidebar-content-section)}
[:div {:class (stl/css :sidebar-section-title)}
(str (:organization-name team))]]
[:> sidebar-team-switch* {:team team :profile profile}]
[:> sidebar-search* {:search-term search-term