From 36cecf45269f26ba9c2fefa146f4a73ba8e173e8 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Thu, 27 Aug 2026 19:56:24 +0530 Subject: [PATCH 1/4] Add multi-portal support per org --- .../api-portal/configs/config-template.toml | 1 + portals/api-portal/configs/config.toml | 1 + .../api-portal/database/schema.postgres.sql | 299 +++++++++++------- portals/api-portal/database/schema.sqlite.sql | 296 ++++++++++------- .../api-portal/database/schema.sqlserver.sql | 295 ++++++++++------- portals/api-portal/it/test-config.toml | 1 + .../api-portal/src/config/configDefaults.js | 8 +- portals/api-portal/src/config/configLoader.js | 31 ++ .../src/controllers/apiPortalController.js | 2 +- .../applicationsContentController.js | 2 +- .../src/controllers/authController.js | 2 + portals/api-portal/src/dao/apiDao.js | 110 ++++--- portals/api-portal/src/dao/apiFileDao.js | 136 ++++---- portals/api-portal/src/dao/apiKeyDao.js | 39 +-- portals/api-portal/src/dao/apiWorkflowDao.js | 31 +- portals/api-portal/src/dao/applicationDao.js | 59 ++-- portals/api-portal/src/dao/auditDao.js | 9 +- portals/api-portal/src/dao/eventDao.js | 40 +-- portals/api-portal/src/dao/keyManagerDao.js | 44 ++- portals/api-portal/src/dao/labelDao.js | 66 ++-- portals/api-portal/src/dao/organizationDao.js | 91 +++--- portals/api-portal/src/dao/subscriptionDao.js | 62 ++-- .../api-portal/src/dao/subscriptionPlanDao.js | 73 +++-- portals/api-portal/src/dao/tagDao.js | 11 +- .../api-portal/src/dao/userIdpReferenceDao.js | 13 +- .../src/dao/userOrganizationMappingDao.js | 11 +- portals/api-portal/src/dao/viewDao.js | 100 +++--- .../src/dao/webhookSubscriberDao.js | 31 +- .../src/middlewares/authMiddleware.js | 10 + .../src/middlewares/ensureAuthenticated.js | 12 + .../src/services/keyManagerService.js | 6 +- portals/api-portal/src/utils/orgContext.js | 45 ++- tests/integration-e2e/devportal-config.toml | 1 + 33 files changed, 1168 insertions(+), 770 deletions(-) diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index 2f948f0d17..b4360193bf 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -372,6 +372,7 @@ subscriber = "ap_subscriber" [api_portal.organization] handle = "default" # URL slug: /{handle}/views/{viewName} display_name = "Default" # Used only when first seeding the organization +portal_id = "portal_id" # Unique portal identifier within this org auto_create_subscription_plans = true # Auto-create Bronze/Silver/Gold/Unlimited/AsyncUnlimited # default_name = "default" # DEPRECATED alias for `handle` — rename it diff --git a/portals/api-portal/configs/config.toml b/portals/api-portal/configs/config.toml index 4a34f6bc7a..cce55e083b 100644 --- a/portals/api-portal/configs/config.toml +++ b/portals/api-portal/configs/config.toml @@ -41,3 +41,4 @@ subscriber = "ap_subscriber" [api_portal.organization] handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' +portal_id = '{{ env "APIP_AP_ORGANIZATION_PORTAL_ID" "portal_id" }}' diff --git a/portals/api-portal/database/schema.postgres.sql b/portals/api-portal/database/schema.postgres.sql index b7265d434d..194d87bbdf 100644 --- a/portals/api-portal/database/schema.postgres.sql +++ b/portals/api-portal/database/schema.postgres.sql @@ -20,103 +20,118 @@ -- Organizations table CREATE TABLE IF NOT EXISTS organizations ( - uuid VARCHAR(40) PRIMARY KEY, - display_name VARCHAR(255) NOT NULL UNIQUE, + uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + display_name VARCHAR(255) NOT NULL, business_owner VARCHAR(255), business_owner_contact VARCHAR(255), business_owner_email VARCHAR(255), - handle VARCHAR(255) NOT NULL UNIQUE, + handle VARCHAR(255) NOT NULL, idp_ref_id VARCHAR(255) NOT NULL, cp_ref_id VARCHAR(255), configuration JSONB NOT NULL, created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (portal_id, uuid), + UNIQUE(portal_id, handle), + UNIQUE(portal_id, display_name) ); --- Views table (organization-scoped grouping of APIs for gateway/portal visibility) +-- Views table (portal-scoped grouping of APIs for gateway/portal visibility) CREATE TABLE IF NOT EXISTS views ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_handle_org_uuid ON views(handle, org_uuid); -CREATE INDEX IF NOT EXISTS idx_view_org_uuid ON views(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_handle_org_uuid ON views(handle, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_view_org_uuid ON views(org_uuid, portal_id); -- Organization Assets table (per-view branding/content assets, e.g. logos, docs) CREATE TABLE IF NOT EXISTS organization_assets ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, file_name VARCHAR(255) NOT NULL, file_content BYTEA NOT NULL, file_type VARCHAR(20) NOT NULL, file_path VARCHAR(255) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, -- CASCADE: an org asset is meaningless once its view is gone. - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view - ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid); + ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid); CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid); --- Labels table (organization-scoped labels used for gateway/view assignment) +-- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_label_handle_org_uuid ON labels(handle, org_uuid); -CREATE INDEX IF NOT EXISTS idx_label_org_uuid ON labels(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_label_handle_org_uuid ON labels(handle, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_label_org_uuid ON labels(org_uuid, portal_id); --- Tags table (organization-scoped free-form API tags) +-- Tags table (portal-scoped free-form API tags) CREATE TABLE IF NOT EXISTS tags ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_org_uuid ON tags(name, org_uuid); -CREATE INDEX IF NOT EXISTS idx_tag_org_uuid ON tags(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_org_uuid ON tags(name, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_tag_org_uuid ON tags(org_uuid, portal_id); -- View-Label mappings (many-to-many: which labels belong to a view) CREATE TABLE IF NOT EXISTS view_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, view_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(label_uuid, view_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) +-- API is a portal-managed entity: portal_id identifies which portal owns it. CREATE TABLE IF NOT EXISTS api_metadata ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, ref_id VARCHAR(255), name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, @@ -132,23 +147,30 @@ CREATE TABLE IF NOT EXISTS api_metadata ( production_url VARCHAR(255), metadata_search JSONB, handle VARCHAR(255) NOT NULL, - -- Nullable: SET NULL keeps the API record if its owning org reference is cleared. + -- Nullable: preserved to keep API records alive when an org is removed. + -- Nullification is handled by the application layer; ON DELETE NO ACTION is + -- used because a composite FK cannot partially SET NULL while portal_id is NOT NULL. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid); +-- org_uuid is nullable — partial indexes prevent NULL-org rows from colliding +-- with each other while still enforcing uniqueness among non-NULL org rows. +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_api_metadata_status ON api_metadata(status); -- API Contents table (spec files, docs, icons, etc. attached to an API) CREATE TABLE IF NOT EXISTS api_contents ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', file_content BYTEA NOT NULL, type VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, @@ -157,92 +179,104 @@ CREATE TABLE IF NOT EXISTS api_contents ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_file_name ON api_contents(api_uuid, type, file_name); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_lookup_key ON api_contents(api_uuid, type, lookup_key); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_file_name ON api_contents(api_uuid, type, file_name, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_lookup_key ON api_contents(api_uuid, type, lookup_key, portal_id); -- API-Label mappings (many-to-many: which labels are attached to an API) CREATE TABLE IF NOT EXISTS api_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(label_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid); -- API-Tag mappings (many-to-many: which tags are attached to an API) CREATE TABLE IF NOT EXISTS api_tag_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, tag_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (tag_uuid) REFERENCES tags(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(tag_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid); --- Subscription Plans table (organization-scoped rate/billing plans) +-- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). CREATE TABLE IF NOT EXISTS subscription_plans ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), ref_id VARCHAR(255), - -- Nullable: SET NULL keeps the plan record if its owning org reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_org_handle ON subscription_plans(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_org_handle ON subscription_plans(org_uuid, handle, portal_id); -- Subscription Plan Limits table (throttling limits for a plan) CREATE TABLE IF NOT EXISTS subscription_plan_limits ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', limit_type VARCHAR(20) NOT NULL DEFAULT 'REQUEST_COUNT', time_unit VARCHAR(20), time_amount INTEGER NOT NULL DEFAULT 1, limit_count BIGINT NOT NULL, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_subscription_plan_limits_plan ON subscription_plan_limits(plan_uuid); -- Split into two filtered unique indexes because time_unit is nullable: a plain composite -- unique index would let Postgres treat every NULL time_unit row as distinct (never colliding), -- silently allowing duplicate NULL-time_unit limits. These two indexes make both branches explicit. CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_limits - ON subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit) WHERE time_unit IS NOT NULL; + ON subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit, portal_id) WHERE time_unit IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_limits_null_unit - ON subscription_plan_limits(plan_uuid, limit_type, time_amount) WHERE time_unit IS NULL; + ON subscription_plan_limits(plan_uuid, limit_type, time_amount, portal_id) WHERE time_unit IS NULL; -- API-Subscription Plan mappings (many-to-many: which plans an API offers) CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api - ON api_subscription_plan_mappings(plan_uuid, api_uuid); + ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid); --- Key Managers table (organization-scoped identity providers used to validate app keys) +-- Key Managers table (portal-scoped identity providers used to validate app keys) CREATE TABLE IF NOT EXISTS key_managers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, enabled SMALLINT NOT NULL DEFAULT 1, @@ -251,14 +285,16 @@ CREATE TABLE IF NOT EXISTS key_managers ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_key_manager_org_handle ON key_managers(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_key_manager_org_handle ON key_managers(org_uuid, handle, portal_id); --- Applications table (developer-created consumer apps that subscribe to APIs) +-- Applications table (portal-scoped developer-created consumer apps) CREATE TABLE IF NOT EXISTS applications ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, handle VARCHAR(255) NOT NULL, @@ -266,61 +302,69 @@ CREATE TABLE IF NOT EXISTS applications ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, created_by); -CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle); +CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, portal_id, created_by); +CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle, portal_id); -- Application-KeyManager mappings (per-KM OAuth2 client registration for an application) CREATE TABLE IF NOT EXISTS app_key_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, km_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', as_client_id VARCHAR(255), type VARCHAR(20) NOT NULL DEFAULT 'PRODUCTION', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE NO ACTION, - FOREIGN KEY (km_uuid) REFERENCES key_managers(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_km_uuid ON app_key_mappings(km_uuid); --- Subscriptions table (application-level subscriptions to an API) +-- Subscriptions table (portal-scoped application-level subscriptions to an API) CREATE TABLE IF NOT EXISTS subscriptions ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the subscription record if its plan reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. plan_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + -- token is globally unique across the entire database (not just per-portal) so + -- a subscription token cannot accidentally be reused by another portal on the same DB. token VARCHAR(512), status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, UNIQUE(token) ); -CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, created_by); -CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, api_uuid); +CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, portal_id, created_by); +CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, portal_id, api_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_plan_uuid ON subscriptions(plan_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_status ON subscriptions(status); --- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a +-- api_uuid is only ever a trailing column above (org_uuid, api_uuid) — add a -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_subscription_api_uuid ON subscriptions(api_uuid); --- API Keys table (standalone, non-OAuth2 API key credentials for an API) +-- API Keys table (portal-scoped standalone, non-OAuth2 API key credentials for an API) CREATE TABLE IF NOT EXISTS api_keys ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the key record if its originating subscription is removed. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. subscription_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(128) NOT NULL, display_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', @@ -331,37 +375,39 @@ CREATE TABLE IF NOT EXISTS api_keys ( revoked_by VARCHAR(200), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, subscription_uuid) REFERENCES subscriptions(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_subscription_uuid ON api_keys(subscription_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); --- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a +-- api_uuid is only ever a trailing column above (org_uuid, api_uuid) —- add a -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_api_key_api_uuid ON api_keys(api_uuid); --- Handle is the caller-facing id used to address a key within an API, so it must be --- unique per (org, api). Enforced here for a race-free guarantee, not just in the service. -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) CREATE TABLE IF NOT EXISTS api_key_app_mappings ( - key_uuid VARCHAR(40) PRIMARY KEY, + key_uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (key_uuid) REFERENCES api_keys(uuid) ON DELETE CASCADE, - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, key_uuid), + FOREIGN KEY (portal_id, key_uuid) REFERENCES api_keys(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_api_key_app_mappings_app_uuid ON api_key_app_mappings(app_uuid); --- API Workflows table (agent/automation workflows published under a view) +-- API Workflows table (portal-scoped agent/automation workflows published under a view) CREATE TABLE IF NOT EXISTS api_workflows ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, display_name VARCHAR(255) NOT NULL, description VARCHAR(1023) NOT NULL, @@ -375,47 +421,53 @@ CREATE TABLE IF NOT EXISTS api_workflows ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history -- survives deletion of the referenced user_idp_references row) CREATE TABLE IF NOT EXISTS audit ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, action VARCHAR(50) NOT NULL, resource_uuid VARCHAR(40) NOT NULL, resource_type VARCHAR(50), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', performed_by VARCHAR(255), performed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_audit_org_uuid ON audit(org_uuid); +CREATE INDEX IF NOT EXISTS idx_audit_org_uuid ON audit(org_uuid, portal_id); -- Events table (outbox: one row per domain event; payload never contains plaintext key secrets) CREATE TABLE IF NOT EXISTS events ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, type VARCHAR(128) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', aggregate_type VARCHAR(64) NOT NULL, aggregate_uuid VARCHAR(40) NOT NULL, payload JSONB NOT NULL DEFAULT '{}', occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_status_occurred_at ON events(status, occurred_at); -CREATE INDEX IF NOT EXISTS idx_event_org_uuid ON events(org_uuid); +CREATE INDEX IF NOT EXISTS idx_event_org_uuid ON events(org_uuid, portal_id); -- Event Deliveries table (one row per event x webhook subscriber; encrypted_fields -- holds per-subscriber ciphertext so plaintext never lives in events) CREATE TABLE IF NOT EXISTS event_deliveries ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, event_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', subscriber_id VARCHAR(128) NOT NULL, target_url VARCHAR(1023) NOT NULL, encrypted_fields JSON DEFAULT NULL, @@ -424,10 +476,11 @@ CREATE TABLE IF NOT EXISTS event_deliveries ( last_error VARCHAR(255), last_attempt_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ, - FOREIGN KEY (event_uuid) REFERENCES events(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, event_uuid) REFERENCES events(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_delivery_event_uuid ON event_deliveries(event_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(event_uuid, subscriber_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(portal_id, event_uuid, subscriber_id); -- Sessions table, used by connect-pg-simple for server-side Express session storage. CREATE TABLE IF NOT EXISTS sessions ( @@ -437,30 +490,35 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_session_expire ON sessions(expire); --- User IdP References table (one durable record per distinct IdP `sub` claim; referenced --- by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so --- those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; +-- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign +-- key, so those columns keep pointing at a uuid after the row here is deleted) CREATE TABLE IF NOT EXISTS user_idp_references ( - uuid VARCHAR(40) PRIMARY KEY, - idp_id VARCHAR(255) NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + uuid VARCHAR(40) NOT NULL, + idp_id VARCHAR(255) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (portal_id, uuid) ); +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_idp_references_idpid_portal ON user_idp_references(idp_id, portal_id); --- User-Organization mappings (live membership record -- both sides cascade on delete, +-- User-Organization mappings (live membership record —- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) CREATE TABLE IF NOT EXISTS user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, - PRIMARY KEY (user_uuid, org_uuid), - FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + PRIMARY KEY (portal_id, user_uuid, org_uuid), + FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid); --- Webhook Subscribers table (organization-scoped outbound event subscribers) +-- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, target_url VARCHAR(1023) NOT NULL, @@ -472,6 +530,7 @@ CREATE TABLE IF NOT EXISTS webhook_subscribers ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_subscriber_org_handle ON webhook_subscribers(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_subscriber_org_handle ON webhook_subscribers(org_uuid, handle, portal_id); diff --git a/portals/api-portal/database/schema.sqlite.sql b/portals/api-portal/database/schema.sqlite.sql index 72e7df2475..96a7fdca77 100644 --- a/portals/api-portal/database/schema.sqlite.sql +++ b/portals/api-portal/database/schema.sqlite.sql @@ -20,103 +20,118 @@ -- Organizations table CREATE TABLE IF NOT EXISTS organizations ( - uuid VARCHAR(40) PRIMARY KEY, - display_name VARCHAR(255) NOT NULL UNIQUE, + uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + display_name VARCHAR(255) NOT NULL, business_owner VARCHAR(255), business_owner_contact VARCHAR(255), business_owner_email VARCHAR(255), - handle VARCHAR(255) NOT NULL UNIQUE, + handle VARCHAR(255) NOT NULL, idp_ref_id VARCHAR(255) NOT NULL, cp_ref_id VARCHAR(255), configuration TEXT NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (portal_id, uuid), + UNIQUE(portal_id, handle), + UNIQUE(portal_id, display_name) ); --- Views table (organization-scoped grouping of APIs for gateway/portal visibility) +-- Views table (portal-scoped grouping of APIs for gateway/portal visibility) CREATE TABLE IF NOT EXISTS views ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_handle_org_uuid ON views(handle, org_uuid); -CREATE INDEX IF NOT EXISTS idx_view_org_uuid ON views(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_handle_org_uuid ON views(handle, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_view_org_uuid ON views(org_uuid, portal_id); -- Organization Assets table (per-view branding/content assets, e.g. logos, docs) CREATE TABLE IF NOT EXISTS organization_assets ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, file_name VARCHAR(255) NOT NULL, file_content BLOB NOT NULL, file_type VARCHAR(20) NOT NULL, file_path VARCHAR(255) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, -- CASCADE: an org asset is meaningless once its view is gone. - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view - ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid); + ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid); CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid); --- Labels table (organization-scoped labels used for gateway/view assignment) +-- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_label_handle_org_uuid ON labels(handle, org_uuid); -CREATE INDEX IF NOT EXISTS idx_label_org_uuid ON labels(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_label_handle_org_uuid ON labels(handle, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_label_org_uuid ON labels(org_uuid, portal_id); --- Tags table (organization-scoped free-form API tags) +-- Tags table (portal-scoped free-form API tags) CREATE TABLE IF NOT EXISTS tags ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', name VARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_org_uuid ON tags(name, org_uuid); -CREATE INDEX IF NOT EXISTS idx_tag_org_uuid ON tags(org_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_org_uuid ON tags(name, org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_tag_org_uuid ON tags(org_uuid, portal_id); -- View-Label mappings (many-to-many: which labels belong to a view) CREATE TABLE IF NOT EXISTS view_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, view_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(label_uuid, view_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) +-- API is a portal-managed entity: portal_id identifies which portal owns it. CREATE TABLE IF NOT EXISTS api_metadata ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, ref_id VARCHAR(255), name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, @@ -132,23 +147,30 @@ CREATE TABLE IF NOT EXISTS api_metadata ( production_url VARCHAR(255), metadata_search TEXT, handle VARCHAR(255) NOT NULL, - -- Nullable: SET NULL keeps the API record if its owning org reference is cleared. + -- Nullable: preserved to keep API records alive when an org is removed. + -- Nullification is handled by the application layer; ON DELETE NO ACTION is + -- used because a composite FK cannot partially SET NULL while portal_id is NOT NULL. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid); +-- org_uuid is nullable — partial indexes prevent NULL-org rows from colliding +-- with each other while still enforcing uniqueness among non-NULL org rows. +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_api_metadata_status ON api_metadata(status); -- API Contents table (spec files, docs, icons, etc. attached to an API) CREATE TABLE IF NOT EXISTS api_contents ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', file_content BLOB NOT NULL, type VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, @@ -157,91 +179,103 @@ CREATE TABLE IF NOT EXISTS api_contents ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_file_name ON api_contents(api_uuid, type, file_name); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_lookup_key ON api_contents(api_uuid, type, lookup_key); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_file_name ON api_contents(api_uuid, type, file_name, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_content_api_type_lookup_key ON api_contents(api_uuid, type, lookup_key, portal_id); -- API-Label mappings (many-to-many: which labels are attached to an API) CREATE TABLE IF NOT EXISTS api_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(label_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid); -- API-Tag mappings (many-to-many: which tags are attached to an API) CREATE TABLE IF NOT EXISTS api_tag_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, tag_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (tag_uuid) REFERENCES tags(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(tag_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid); --- Subscription Plans table (organization-scoped rate/billing plans) +-- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). CREATE TABLE IF NOT EXISTS subscription_plans ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, description VARCHAR(1023), ref_id VARCHAR(255), - -- Nullable: SET NULL keeps the plan record if its owning org reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_org_handle ON subscription_plans(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_org_handle ON subscription_plans(org_uuid, handle, portal_id); -- Subscription Plan Limits table (throttling limits for a plan) CREATE TABLE IF NOT EXISTS subscription_plan_limits ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', limit_type VARCHAR(20) NOT NULL DEFAULT 'REQUEST_COUNT', time_unit VARCHAR(20), time_amount INTEGER NOT NULL DEFAULT 1, limit_count BIGINT NOT NULL, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_subscription_plan_limits_plan ON subscription_plan_limits(plan_uuid); -- Split into two filtered unique indexes because time_unit is nullable (see the -- postgres schema for the full rationale); SQLite supports partial indexes since 3.8.0. CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_limits - ON subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit) WHERE time_unit IS NOT NULL; + ON subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit, portal_id) WHERE time_unit IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_subscription_plan_limits_null_unit - ON subscription_plan_limits(plan_uuid, limit_type, time_amount) WHERE time_unit IS NULL; + ON subscription_plan_limits(plan_uuid, limit_type, time_amount, portal_id) WHERE time_unit IS NULL; -- API-Subscription Plan mappings (many-to-many: which plans an API offers) CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api - ON api_subscription_plan_mappings(plan_uuid, api_uuid); + ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid); --- Key Managers table (organization-scoped identity providers used to validate app keys) +-- Key Managers table (portal-scoped identity providers used to validate app keys) CREATE TABLE IF NOT EXISTS key_managers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, @@ -250,14 +284,16 @@ CREATE TABLE IF NOT EXISTS key_managers ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_key_manager_org_handle ON key_managers(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_key_manager_org_handle ON key_managers(org_uuid, handle, portal_id); --- Applications table (developer-created consumer apps that subscribe to APIs) +-- Applications table (portal-scoped developer-created consumer apps) CREATE TABLE IF NOT EXISTS applications ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, handle VARCHAR(255) NOT NULL, @@ -265,61 +301,69 @@ CREATE TABLE IF NOT EXISTS applications ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, created_by); -CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle); +CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, portal_id, created_by); +CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle, portal_id); -- Application-KeyManager mappings (per-KM OAuth2 client registration for an application) CREATE TABLE IF NOT EXISTS app_key_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, km_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', as_client_id VARCHAR(255), type VARCHAR(20) NOT NULL DEFAULT 'PRODUCTION', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE NO ACTION, - FOREIGN KEY (km_uuid) REFERENCES key_managers(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_km_uuid ON app_key_mappings(km_uuid); --- Subscriptions table (application-level subscriptions to an API) +-- Subscriptions table (portal-scoped application-level subscriptions to an API) CREATE TABLE IF NOT EXISTS subscriptions ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the subscription record if its plan reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. plan_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + -- token is globally unique across the entire database (not just per-portal) so + -- a subscription token cannot accidentally be reused by another portal on the same DB. token VARCHAR(512), status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, UNIQUE(token) ); -CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, created_by); -CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, api_uuid); +CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, portal_id, created_by); +CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, portal_id, api_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_plan_uuid ON subscriptions(plan_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_status ON subscriptions(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_subscription_api_uuid ON subscriptions(api_uuid); --- API Keys table (standalone, non-OAuth2 API key credentials for an API) +-- API Keys table (portal-scoped standalone, non-OAuth2 API key credentials for an API) CREATE TABLE IF NOT EXISTS api_keys ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the key record if its originating subscription is removed. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. subscription_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(128) NOT NULL, display_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', @@ -330,9 +374,10 @@ CREATE TABLE IF NOT EXISTS api_keys ( revoked_by VARCHAR(200), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, subscription_uuid) REFERENCES subscriptions(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); @@ -343,24 +388,28 @@ CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_api_key_api_uuid ON api_keys(api_uuid); -- Handle is the caller-facing id used to address a key within an API, so it must be --- unique per (org, api). Enforced here for a race-free guarantee, not just in the service. -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle); +-- unique per (org, portal, api). Enforced here for a race-free guarantee. +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) +-- key_uuid IS the api_keys.uuid — no separate surrogate key on this table. CREATE TABLE IF NOT EXISTS api_key_app_mappings ( - key_uuid VARCHAR(40) PRIMARY KEY, + key_uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (key_uuid) REFERENCES api_keys(uuid) ON DELETE CASCADE, - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, key_uuid), + FOREIGN KEY (portal_id, key_uuid) REFERENCES api_keys(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_api_key_app_mappings_app_uuid ON api_key_app_mappings(app_uuid); --- API Workflows table (agent/automation workflows published under a view) +-- API Workflows table (portal-scoped agent/automation workflows published under a view) CREATE TABLE IF NOT EXISTS api_workflows ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, display_name VARCHAR(255) NOT NULL, description VARCHAR(1023) NOT NULL, @@ -374,47 +423,53 @@ CREATE TABLE IF NOT EXISTS api_workflows ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history -- survives deletion of the referenced user_idp_references row) CREATE TABLE IF NOT EXISTS audit ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, action VARCHAR(50) NOT NULL, resource_uuid VARCHAR(40) NOT NULL, resource_type VARCHAR(50), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', performed_by VARCHAR(255), performed_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_audit_org_uuid ON audit(org_uuid); +CREATE INDEX IF NOT EXISTS idx_audit_org_uuid ON audit(org_uuid, portal_id); -- Events table (outbox: one row per domain event; payload never contains plaintext key secrets) CREATE TABLE IF NOT EXISTS events ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, type VARCHAR(128) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', aggregate_type VARCHAR(64) NOT NULL, aggregate_uuid VARCHAR(40) NOT NULL, payload TEXT NOT NULL DEFAULT '{}', occurred_at DATETIME DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_status_occurred_at ON events(status, occurred_at); -CREATE INDEX IF NOT EXISTS idx_event_org_uuid ON events(org_uuid); +CREATE INDEX IF NOT EXISTS idx_event_org_uuid ON events(org_uuid, portal_id); -- Event Deliveries table (one row per event x webhook subscriber; encrypted_fields -- holds per-subscriber ciphertext so plaintext never lives in events) CREATE TABLE IF NOT EXISTS event_deliveries ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, event_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', subscriber_id VARCHAR(128) NOT NULL, target_url VARCHAR(1023) NOT NULL, encrypted_fields TEXT DEFAULT NULL, @@ -423,12 +478,15 @@ CREATE TABLE IF NOT EXISTS event_deliveries ( last_error VARCHAR(255), last_attempt_at DATETIME, delivered_at DATETIME, - FOREIGN KEY (event_uuid) REFERENCES events(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, event_uuid) REFERENCES events(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_delivery_event_uuid ON event_deliveries(event_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(event_uuid, subscriber_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(portal_id, event_uuid, subscriber_id); -- Sessions table, used by connect-session-sequelize for server-side Express session storage. +-- Intentionally excluded from the portal_id composite-PK pattern: portal_id is +-- stored inside the sess JSON payload instead of as a schema column. CREATE TABLE IF NOT EXISTS sessions ( sid VARCHAR(255) PRIMARY KEY, sess TEXT NOT NULL, @@ -436,30 +494,35 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_session_expire ON sessions(expire); --- User IdP References table (one durable record per distinct IdP `sub` claim; referenced --- by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so --- those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; +-- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign +-- key, so those columns keep pointing at a uuid after the row here is deleted) CREATE TABLE IF NOT EXISTS user_idp_references ( - uuid VARCHAR(40) PRIMARY KEY, - idp_id VARCHAR(255) NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + uuid VARCHAR(40) NOT NULL, + idp_id VARCHAR(255) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (portal_id, uuid) ); +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_idp_references_idpid_portal ON user_idp_references(idp_id, portal_id); -- User-Organization mappings (live membership record -- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) CREATE TABLE IF NOT EXISTS user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, - PRIMARY KEY (user_uuid, org_uuid), - FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + PRIMARY KEY (portal_id, user_uuid, org_uuid), + FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid); --- Webhook Subscribers table (organization-scoped outbound event subscribers) +-- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, target_url VARCHAR(1023) NOT NULL, @@ -471,6 +534,7 @@ CREATE TABLE IF NOT EXISTS webhook_subscribers ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by VARCHAR(255) NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_subscriber_org_handle ON webhook_subscribers(org_uuid, handle); +CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_subscriber_org_handle ON webhook_subscribers(org_uuid, handle, portal_id); diff --git a/portals/api-portal/database/schema.sqlserver.sql b/portals/api-portal/database/schema.sqlserver.sql index 059656f24c..1a7e46f311 100644 --- a/portals/api-portal/database/schema.sqlserver.sql +++ b/portals/api-portal/database/schema.sqlserver.sql @@ -22,119 +22,134 @@ -- Organizations table IF OBJECT_ID(N'dbo.organizations', N'U') IS NULL CREATE TABLE dbo.organizations ( - uuid VARCHAR(40) PRIMARY KEY, - display_name NVARCHAR(255) NOT NULL UNIQUE, + uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + display_name NVARCHAR(255) NOT NULL, business_owner NVARCHAR(255), business_owner_contact VARCHAR(255), business_owner_email VARCHAR(255), - handle VARCHAR(255) NOT NULL UNIQUE, + handle VARCHAR(255) NOT NULL, idp_ref_id VARCHAR(255) NOT NULL, cp_ref_id VARCHAR(255), configuration NVARCHAR(MAX) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, - updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME() + updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + PRIMARY KEY (portal_id, uuid), + UNIQUE(portal_id, handle), + UNIQUE(portal_id, display_name) ); --- Views table (organization-scoped grouping of APIs for gateway/portal visibility) +-- Views table (portal-scoped grouping of APIs for gateway/portal visibility) IF OBJECT_ID(N'dbo.views', N'U') IS NULL CREATE TABLE dbo.views ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_view_handle_org_uuid' AND object_id = OBJECT_ID(N'dbo.views')) -CREATE UNIQUE INDEX uq_view_handle_org_uuid ON dbo.views(handle, org_uuid); +CREATE UNIQUE INDEX uq_view_handle_org_uuid ON dbo.views(handle, org_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_view_org_uuid' AND object_id = OBJECT_ID(N'dbo.views')) -CREATE INDEX idx_view_org_uuid ON dbo.views(org_uuid); +CREATE INDEX idx_view_org_uuid ON dbo.views(org_uuid, portal_id); -- Organization Assets table (per-view branding/content assets, e.g. logos, docs) IF OBJECT_ID(N'dbo.organization_assets', N'U') IS NULL CREATE TABLE dbo.organization_assets ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, file_name VARCHAR(255) NOT NULL, file_content VARBINARY(MAX) NOT NULL, file_type VARCHAR(20) NOT NULL, file_path VARCHAR(255) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, -- CASCADE: an org asset is meaningless once its view is gone. - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_organization_asset_type_name_path_org_view' AND object_id = OBJECT_ID(N'dbo.organization_assets')) -CREATE UNIQUE INDEX uq_organization_asset_type_name_path_org_view ON dbo.organization_assets(file_type, file_name, file_path, org_uuid, view_uuid); +CREATE UNIQUE INDEX uq_organization_asset_type_name_path_org_view ON dbo.organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_org_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) CREATE INDEX idx_organization_asset_org_uuid ON dbo.organization_assets(org_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_view_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) CREATE INDEX idx_organization_asset_view_uuid ON dbo.organization_assets(view_uuid); --- Labels table (organization-scoped labels used for gateway/view assignment) +-- Labels table (portal-scoped labels used for gateway/view assignment) IF OBJECT_ID(N'dbo.labels', N'U') IS NULL CREATE TABLE dbo.labels ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_label_handle_org_uuid' AND object_id = OBJECT_ID(N'dbo.labels')) -CREATE UNIQUE INDEX uq_label_handle_org_uuid ON dbo.labels(handle, org_uuid); +CREATE UNIQUE INDEX uq_label_handle_org_uuid ON dbo.labels(handle, org_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_label_org_uuid' AND object_id = OBJECT_ID(N'dbo.labels')) -CREATE INDEX idx_label_org_uuid ON dbo.labels(org_uuid); +CREATE INDEX idx_label_org_uuid ON dbo.labels(org_uuid, portal_id); --- Tags table (organization-scoped free-form API tags) +-- Tags table (portal-scoped free-form API tags) IF OBJECT_ID(N'dbo.tags', N'U') IS NULL CREATE TABLE dbo.tags ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', name NVARCHAR(255) NOT NULL, created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_tag_name_org_uuid' AND object_id = OBJECT_ID(N'dbo.tags')) -CREATE UNIQUE INDEX uq_tag_name_org_uuid ON dbo.tags(name, org_uuid); +CREATE UNIQUE INDEX uq_tag_name_org_uuid ON dbo.tags(name, org_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_tag_org_uuid' AND object_id = OBJECT_ID(N'dbo.tags')) -CREATE INDEX idx_tag_org_uuid ON dbo.tags(org_uuid); +CREATE INDEX idx_tag_org_uuid ON dbo.tags(org_uuid, portal_id); -- View-Label mappings (many-to-many: which labels belong to a view) IF OBJECT_ID(N'dbo.view_label_mappings', N'U') IS NULL CREATE TABLE dbo.view_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, view_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_view_label_mappings_label_view' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) -CREATE UNIQUE INDEX uq_view_label_mappings_label_view ON dbo.view_label_mappings(label_uuid, view_uuid); +CREATE UNIQUE INDEX uq_view_label_mappings_label_view ON dbo.view_label_mappings(portal_id, label_uuid, view_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_view_label_mappings_view_uuid' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) CREATE INDEX idx_view_label_mappings_view_uuid ON dbo.view_label_mappings(view_uuid); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) +-- API is a portal-managed entity: portal_id identifies which portal owns it. IF OBJECT_ID(N'dbo.api_metadata', N'U') IS NULL CREATE TABLE dbo.api_metadata ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, ref_id VARCHAR(255), name NVARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, @@ -150,13 +165,17 @@ CREATE TABLE dbo.api_metadata ( production_url VARCHAR(255), metadata_search NVARCHAR(MAX), handle VARCHAR(255) NOT NULL, - -- Nullable: SET NULL keeps the API record if its owning org reference is cleared. + -- Nullable: preserved to keep API records alive when an org is removed. + -- Nullification is handled by the application layer; ON DELETE NO ACTION is + -- used because a composite FK cannot partially SET NULL while portal_id is NOT NULL. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -- org_uuid, ref_id, and handle are all nullable/optional in combination here. SQL Server's -- plain UNIQUE INDEX treats NULL as equal to NULL (unlike Postgres/SQLite), so a bare @@ -164,19 +183,20 @@ CREATE TABLE dbo.api_metadata ( -- existed. Filtering to org_uuid IS NOT NULL (and ref_id IS NOT NULL where relevant) -- reproduces the Postgres/SQLite "NULL never collides" semantics. IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_metadata_name_version_org' AND object_id = OBJECT_ID(N'dbo.api_metadata')) -CREATE UNIQUE INDEX uq_api_metadata_name_version_org ON dbo.api_metadata(name, version, org_uuid) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX uq_api_metadata_name_version_org ON dbo.api_metadata(name, version, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_metadata_org_ref_id' AND object_id = OBJECT_ID(N'dbo.api_metadata')) -CREATE UNIQUE INDEX uq_api_metadata_org_ref_id ON dbo.api_metadata(org_uuid, ref_id) WHERE org_uuid IS NOT NULL AND ref_id IS NOT NULL; +CREATE UNIQUE INDEX uq_api_metadata_org_ref_id ON dbo.api_metadata(org_uuid, ref_id, portal_id) WHERE org_uuid IS NOT NULL AND ref_id IS NOT NULL; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_metadata_handle_org' AND object_id = OBJECT_ID(N'dbo.api_metadata')) -CREATE UNIQUE INDEX uq_api_metadata_handle_org ON dbo.api_metadata(handle, org_uuid) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX uq_api_metadata_handle_org ON dbo.api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_metadata_status' AND object_id = OBJECT_ID(N'dbo.api_metadata')) CREATE INDEX idx_api_metadata_status ON dbo.api_metadata(status); -- API Contents table (spec files, docs, icons, etc. attached to an API) IF OBJECT_ID(N'dbo.api_contents', N'U') IS NULL CREATE TABLE dbo.api_contents ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', file_content VARBINARY(MAX) NOT NULL, type VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, @@ -185,78 +205,87 @@ CREATE TABLE dbo.api_contents ( created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_content_api_type_file_name' AND object_id = OBJECT_ID(N'dbo.api_contents')) -CREATE UNIQUE INDEX uq_api_content_api_type_file_name ON dbo.api_contents(api_uuid, type, file_name); +CREATE UNIQUE INDEX uq_api_content_api_type_file_name ON dbo.api_contents(api_uuid, type, file_name, portal_id); -- lookup_key is nullable -- filtered so multiple NULL-lookup_key rows per (api_uuid, type) -- are allowed, matching Postgres/SQLite behavior (see the note on api_metadata above). IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_content_api_type_lookup_key' AND object_id = OBJECT_ID(N'dbo.api_contents')) -CREATE UNIQUE INDEX uq_api_content_api_type_lookup_key ON dbo.api_contents(api_uuid, type, lookup_key) WHERE lookup_key IS NOT NULL; +CREATE UNIQUE INDEX uq_api_content_api_type_lookup_key ON dbo.api_contents(api_uuid, type, lookup_key, portal_id) WHERE lookup_key IS NOT NULL; -- API-Label mappings (many-to-many: which labels are attached to an API) IF OBJECT_ID(N'dbo.api_label_mappings', N'U') IS NULL CREATE TABLE dbo.api_label_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, label_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (label_uuid) REFERENCES labels(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_label_mappings_label_api' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) -CREATE UNIQUE INDEX uq_api_label_mappings_label_api ON dbo.api_label_mappings(label_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_label_mappings_label_api ON dbo.api_label_mappings(portal_id, label_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_label_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) CREATE INDEX idx_api_label_mappings_api_uuid ON dbo.api_label_mappings(api_uuid); -- API-Tag mappings (many-to-many: which tags are attached to an API) IF OBJECT_ID(N'dbo.api_tag_mappings', N'U') IS NULL CREATE TABLE dbo.api_tag_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, tag_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (tag_uuid) REFERENCES tags(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_tag_mappings_tag_api' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) -CREATE UNIQUE INDEX uq_api_tag_mappings_tag_api ON dbo.api_tag_mappings(tag_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_tag_mappings_tag_api ON dbo.api_tag_mappings(portal_id, tag_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_tag_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) CREATE INDEX idx_api_tag_mappings_api_uuid ON dbo.api_tag_mappings(api_uuid); --- Subscription Plans table (organization-scoped rate/billing plans) +-- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). IF OBJECT_ID(N'dbo.subscription_plans', N'U') IS NULL CREATE TABLE dbo.subscription_plans ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, handle VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, description NVARCHAR(1023), ref_id VARCHAR(255), - -- Nullable: SET NULL keeps the plan record if its owning org reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. org_uuid VARCHAR(40), + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE SET NULL + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -- org_uuid is nullable -- filtered for the same NULL-handling reason as api_metadata above. IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_subscription_plan_org_handle' AND object_id = OBJECT_ID(N'dbo.subscription_plans')) -CREATE UNIQUE INDEX uq_subscription_plan_org_handle ON dbo.subscription_plans(org_uuid, handle) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX uq_subscription_plan_org_handle ON dbo.subscription_plans(org_uuid, handle, portal_id) WHERE org_uuid IS NOT NULL; -- Subscription Plan Limits table (throttling limits for a plan) IF OBJECT_ID(N'dbo.subscription_plan_limits', N'U') IS NULL CREATE TABLE dbo.subscription_plan_limits ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', limit_type VARCHAR(20) NOT NULL DEFAULT 'REQUEST_COUNT', time_unit VARCHAR(20), time_amount INT NOT NULL DEFAULT 1, limit_count BIGINT NOT NULL, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_plan_limits_plan' AND object_id = OBJECT_ID(N'dbo.subscription_plan_limits')) CREATE INDEX idx_subscription_plan_limits_plan ON dbo.subscription_plan_limits(plan_uuid); @@ -264,31 +293,34 @@ CREATE INDEX idx_subscription_plan_limits_plan ON dbo.subscription_plan_limits(p -- postgres schema for the full rationale); this is already how the source model -- declares it (two named partial indexes), so all three dialects agree exactly. IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_subscription_plan_limits' AND object_id = OBJECT_ID(N'dbo.subscription_plan_limits')) -CREATE UNIQUE INDEX uq_subscription_plan_limits ON dbo.subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit) WHERE time_unit IS NOT NULL; +CREATE UNIQUE INDEX uq_subscription_plan_limits ON dbo.subscription_plan_limits(plan_uuid, limit_type, time_amount, time_unit, portal_id) WHERE time_unit IS NOT NULL; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_subscription_plan_limits_null_unit' AND object_id = OBJECT_ID(N'dbo.subscription_plan_limits')) -CREATE UNIQUE INDEX uq_subscription_plan_limits_null_unit ON dbo.subscription_plan_limits(plan_uuid, limit_type, time_amount) WHERE time_unit IS NULL; +CREATE UNIQUE INDEX uq_subscription_plan_limits_null_unit ON dbo.subscription_plan_limits(plan_uuid, limit_type, time_amount, portal_id) WHERE time_unit IS NULL; -- API-Subscription Plan mappings (many-to-many: which plans an API offers) IF OBJECT_ID(N'dbo.api_subscription_plan_mappings', N'U') IS NULL CREATE TABLE dbo.api_subscription_plan_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, plan_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE CASCADE, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_subscription_plan_mappings_plan_api' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) -CREATE UNIQUE INDEX uq_api_subscription_plan_mappings_plan_api ON dbo.api_subscription_plan_mappings(plan_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_subscription_plan_mappings_plan_api ON dbo.api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_subscription_plan_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) CREATE INDEX idx_api_subscription_plan_mappings_api_uuid ON dbo.api_subscription_plan_mappings(api_uuid); --- Key Managers table (organization-scoped identity providers used to validate app keys) +-- Key Managers table (portal-scoped identity providers used to validate app keys) IF OBJECT_ID(N'dbo.key_managers', N'U') IS NULL CREATE TABLE dbo.key_managers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, enabled SMALLINT NOT NULL DEFAULT 1, @@ -297,16 +329,18 @@ CREATE TABLE dbo.key_managers ( created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_key_manager_org_handle' AND object_id = OBJECT_ID(N'dbo.key_managers')) -CREATE UNIQUE INDEX uq_key_manager_org_handle ON dbo.key_managers(org_uuid, handle); +CREATE UNIQUE INDEX uq_key_manager_org_handle ON dbo.key_managers(org_uuid, handle, portal_id); --- Applications table (developer-created consumer apps that subscribe to APIs) +-- Applications table (portal-scoped developer-created consumer apps) IF OBJECT_ID(N'dbo.applications', N'U') IS NULL CREATE TABLE dbo.applications ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, handle VARCHAR(255) NOT NULL, @@ -314,55 +348,62 @@ CREATE TABLE dbo.applications ( created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_org_created_by' AND object_id = OBJECT_ID(N'dbo.applications')) -CREATE INDEX idx_application_org_created_by ON dbo.applications(org_uuid, created_by); +CREATE INDEX idx_application_org_created_by ON dbo.applications(org_uuid, portal_id, created_by); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_application_org_handle' AND object_id = OBJECT_ID(N'dbo.applications')) -CREATE UNIQUE INDEX uq_application_org_handle ON dbo.applications(org_uuid, handle); +CREATE UNIQUE INDEX uq_application_org_handle ON dbo.applications(org_uuid, handle, portal_id); -- Application-KeyManager mappings (per-KM OAuth2 client registration for an application) IF OBJECT_ID(N'dbo.app_key_mappings', N'U') IS NULL CREATE TABLE dbo.app_key_mappings ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, km_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', as_client_id VARCHAR(255), type VARCHAR(20) NOT NULL DEFAULT 'PRODUCTION', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE NO ACTION, - FOREIGN KEY (km_uuid) REFERENCES key_managers(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_app_key_mappings_app_uuid' AND object_id = OBJECT_ID(N'dbo.app_key_mappings')) CREATE INDEX idx_app_key_mappings_app_uuid ON dbo.app_key_mappings(app_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_app_key_mappings_km_uuid' AND object_id = OBJECT_ID(N'dbo.app_key_mappings')) CREATE INDEX idx_app_key_mappings_km_uuid ON dbo.app_key_mappings(km_uuid); --- Subscriptions table (application-level subscriptions to an API) +-- Subscriptions table (portal-scoped application-level subscriptions to an API) IF OBJECT_ID(N'dbo.subscriptions', N'U') IS NULL CREATE TABLE dbo.subscriptions ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, created_by VARCHAR(255) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the subscription record if its plan reference is cleared. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. plan_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + -- token is globally unique across the entire database (not just per-portal) so + -- a subscription token cannot accidentally be reused by another portal on the same DB. token VARCHAR(512), status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (plan_uuid) REFERENCES subscription_plans(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_org_created_by' AND object_id = OBJECT_ID(N'dbo.subscriptions')) -CREATE INDEX idx_subscription_org_created_by ON dbo.subscriptions(org_uuid, created_by); +CREATE INDEX idx_subscription_org_created_by ON dbo.subscriptions(org_uuid, portal_id, created_by); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_org_api_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions')) -CREATE INDEX idx_subscription_org_api_uuid ON dbo.subscriptions(org_uuid, api_uuid); +CREATE INDEX idx_subscription_org_api_uuid ON dbo.subscriptions(org_uuid, portal_id, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_plan_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions')) CREATE INDEX idx_subscription_plan_uuid ON dbo.subscriptions(plan_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_status' AND object_id = OBJECT_ID(N'dbo.subscriptions')) @@ -378,14 +419,15 @@ CREATE INDEX idx_subscription_api_uuid ON dbo.subscriptions(api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_subscription_token' AND object_id = OBJECT_ID(N'dbo.subscriptions')) CREATE UNIQUE INDEX uq_subscription_token ON dbo.subscriptions(token) WHERE token IS NOT NULL; --- API Keys table (standalone, non-OAuth2 API key credentials for an API) +-- API Keys table (portal-scoped standalone, non-OAuth2 API key credentials for an API) IF OBJECT_ID(N'dbo.api_keys', N'U') IS NULL CREATE TABLE dbo.api_keys ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, api_uuid VARCHAR(40) NOT NULL, - -- Nullable: SET NULL keeps the key record if its originating subscription is removed. + -- Nullable: same ON DELETE NO ACTION rationale as api_metadata.org_uuid above. subscription_uuid VARCHAR(40), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(128) NOT NULL, display_name NVARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', @@ -396,9 +438,10 @@ CREATE TABLE dbo.api_keys ( revoked_by VARCHAR(200), created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (api_uuid) REFERENCES api_metadata(uuid) ON DELETE NO ACTION, - FOREIGN KEY (subscription_uuid) REFERENCES subscriptions(uuid) ON DELETE SET NULL, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, subscription_uuid) REFERENCES subscriptions(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); @@ -413,28 +456,32 @@ CREATE INDEX idx_api_key_status ON dbo.api_keys(status); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_keys')) CREATE INDEX idx_api_key_api_uuid ON dbo.api_keys(api_uuid); -- Handle is the caller-facing id used to address a key within an API, so it must be --- unique per (org, api). Enforced here for a race-free guarantee, not just in the service. +-- unique per (org, portal, api). Enforced here for a race-free guarantee. IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_key_org_api_handle' AND object_id = OBJECT_ID(N'dbo.api_keys')) -CREATE UNIQUE INDEX uq_api_key_org_api_handle ON dbo.api_keys(org_uuid, api_uuid, handle); +CREATE UNIQUE INDEX uq_api_key_org_api_handle ON dbo.api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) +-- key_uuid IS the api_keys.uuid — no separate surrogate key on this table. IF OBJECT_ID(N'dbo.api_key_app_mappings', N'U') IS NULL CREATE TABLE dbo.api_key_app_mappings ( - key_uuid VARCHAR(40) PRIMARY KEY, + key_uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', created_by VARCHAR(255) NOT NULL, created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (key_uuid) REFERENCES api_keys(uuid) ON DELETE CASCADE, - FOREIGN KEY (app_uuid) REFERENCES applications(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, key_uuid), + FOREIGN KEY (portal_id, key_uuid) REFERENCES api_keys(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_app_mappings_app_uuid' AND object_id = OBJECT_ID(N'dbo.api_key_app_mappings')) CREATE INDEX idx_api_key_app_mappings_app_uuid ON dbo.api_key_app_mappings(app_uuid); --- API Workflows table (agent/automation workflows published under a view) +-- API Workflows table (portal-scoped agent/automation workflows published under a view) IF OBJECT_ID(N'dbo.api_workflows', N'U') IS NULL CREATE TABLE dbo.api_workflows ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', view_uuid VARCHAR(40) NOT NULL, display_name NVARCHAR(255) NOT NULL, description NVARCHAR(1023) NOT NULL, @@ -448,11 +495,12 @@ CREATE TABLE dbo.api_workflows ( created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION, - FOREIGN KEY (view_uuid) REFERENCES views(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, + FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_workflow_org_view_handle' AND object_id = OBJECT_ID(N'dbo.api_workflows')) -CREATE UNIQUE INDEX uq_api_workflow_org_view_handle ON dbo.api_workflows(org_uuid, view_uuid, handle); +CREATE UNIQUE INDEX uq_api_workflow_org_view_handle ON dbo.api_workflows(org_uuid, view_uuid, handle, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_view_uuid' AND object_id = OBJECT_ID(N'dbo.api_workflows')) CREATE INDEX idx_api_workflow_view_uuid ON dbo.api_workflows(view_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_status' AND object_id = OBJECT_ID(N'dbo.api_workflows')) @@ -462,42 +510,47 @@ CREATE INDEX idx_api_workflow_status ON dbo.api_workflows(status); -- survives deletion of the referenced user_idp_references row) IF OBJECT_ID(N'dbo.audit', N'U') IS NULL CREATE TABLE dbo.audit ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, action VARCHAR(50) NOT NULL, resource_uuid VARCHAR(40) NOT NULL, resource_type VARCHAR(50), org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', performed_by VARCHAR(255), performed_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_audit_org_uuid' AND object_id = OBJECT_ID(N'dbo.audit')) -CREATE INDEX idx_audit_org_uuid ON dbo.audit(org_uuid); +CREATE INDEX idx_audit_org_uuid ON dbo.audit(org_uuid, portal_id); -- Events table (outbox: one row per domain event; payload never contains plaintext key secrets) IF OBJECT_ID(N'dbo.events', N'U') IS NULL CREATE TABLE dbo.events ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, type VARCHAR(128) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', aggregate_type VARCHAR(64) NOT NULL, aggregate_uuid VARCHAR(40) NOT NULL, payload NVARCHAR(MAX) NOT NULL DEFAULT '{}', occurred_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), status VARCHAR(20) NOT NULL DEFAULT 'PENDING', - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_event_status_occurred_at' AND object_id = OBJECT_ID(N'dbo.events')) CREATE INDEX idx_event_status_occurred_at ON dbo.events(status, occurred_at); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_event_org_uuid' AND object_id = OBJECT_ID(N'dbo.events')) -CREATE INDEX idx_event_org_uuid ON dbo.events(org_uuid); +CREATE INDEX idx_event_org_uuid ON dbo.events(org_uuid, portal_id); -- Event Deliveries table (one row per event x webhook subscriber; encrypted_fields -- holds per-subscriber ciphertext so plaintext never lives in events) IF OBJECT_ID(N'dbo.event_deliveries', N'U') IS NULL CREATE TABLE dbo.event_deliveries ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, event_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', subscriber_id VARCHAR(128) NOT NULL, target_url VARCHAR(1023) NOT NULL, encrypted_fields NVARCHAR(MAX) DEFAULT NULL, @@ -506,14 +559,17 @@ CREATE TABLE dbo.event_deliveries ( last_error VARCHAR(255), last_attempt_at DATETIME2(7), delivered_at DATETIME2(7), - FOREIGN KEY (event_uuid) REFERENCES events(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, event_uuid) REFERENCES events(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_event_delivery_event_uuid' AND object_id = OBJECT_ID(N'dbo.event_deliveries')) CREATE INDEX idx_event_delivery_event_uuid ON dbo.event_deliveries(event_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_event_delivery_event_subscriber' AND object_id = OBJECT_ID(N'dbo.event_deliveries')) -CREATE UNIQUE INDEX uq_event_delivery_event_subscriber ON dbo.event_deliveries(event_uuid, subscriber_id); +CREATE UNIQUE INDEX uq_event_delivery_event_subscriber ON dbo.event_deliveries(portal_id, event_uuid, subscriber_id); -- Sessions table, used by connect-mssql-v2 (or equivalent) for server-side Express session storage. +-- Intentionally excluded from the portal_id composite-PK pattern: portal_id is +-- stored inside the sess JSON payload instead of as a schema column. IF OBJECT_ID(N'dbo.sessions', N'U') IS NULL CREATE TABLE dbo.sessions ( sid VARCHAR(255) PRIMARY KEY, @@ -523,15 +579,19 @@ CREATE TABLE dbo.sessions ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_session_expire' AND object_id = OBJECT_ID(N'dbo.sessions')) CREATE INDEX idx_session_expire ON dbo.sessions(expire); --- User IdP References table (one durable record per distinct IdP `sub` claim; referenced --- by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so --- those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; +-- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign +-- key, so those columns keep pointing at a uuid after the row here is deleted) IF OBJECT_ID(N'dbo.user_idp_references', N'U') IS NULL CREATE TABLE dbo.user_idp_references ( - uuid VARCHAR(40) PRIMARY KEY, - idp_id VARCHAR(255) NOT NULL UNIQUE, - created_at DATETIME2(7) DEFAULT SYSUTCDATETIME() + uuid VARCHAR(40) NOT NULL, + idp_id VARCHAR(255) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), + PRIMARY KEY (portal_id, uuid) ); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_user_idp_references_idpid_portal' AND object_id = OBJECT_ID(N'dbo.user_idp_references')) +CREATE UNIQUE INDEX uq_user_idp_references_idpid_portal ON dbo.user_idp_references(idp_id, portal_id); -- User-Organization mappings (live membership record -- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) @@ -539,18 +599,20 @@ IF OBJECT_ID(N'dbo.user_organization_mappings', N'U') IS NULL CREATE TABLE dbo.user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, - PRIMARY KEY (user_uuid, org_uuid), - FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE CASCADE + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', + PRIMARY KEY (portal_id, user_uuid, org_uuid), + FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_organization_mappings_org_uuid' AND object_id = OBJECT_ID(N'dbo.user_organization_mappings')) CREATE INDEX idx_user_organization_mappings_org_uuid ON dbo.user_organization_mappings(org_uuid); --- Webhook Subscribers table (organization-scoped outbound event subscribers) +-- Webhook Subscribers table (portal-scoped outbound event subscribers) IF OBJECT_ID(N'dbo.webhook_subscribers', N'U') IS NULL CREATE TABLE dbo.webhook_subscribers ( - uuid VARCHAR(40) PRIMARY KEY, + uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, + portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', handle VARCHAR(255) NOT NULL, display_name NVARCHAR(255) NOT NULL, target_url VARCHAR(1023) NOT NULL, @@ -562,7 +624,8 @@ CREATE TABLE dbo.webhook_subscribers ( created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), updated_by VARCHAR(255) NOT NULL, updated_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - FOREIGN KEY (org_uuid) REFERENCES organizations(uuid) ON DELETE NO ACTION + PRIMARY KEY (portal_id, uuid), + FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_webhook_subscriber_org_handle' AND object_id = OBJECT_ID(N'dbo.webhook_subscribers')) -CREATE UNIQUE INDEX uq_webhook_subscriber_org_handle ON dbo.webhook_subscribers(org_uuid, handle); +CREATE UNIQUE INDEX uq_webhook_subscriber_org_handle ON dbo.webhook_subscribers(org_uuid, handle, portal_id); diff --git a/portals/api-portal/it/test-config.toml b/portals/api-portal/it/test-config.toml index a290f4abf8..422a742651 100644 --- a/portals/api-portal/it/test-config.toml +++ b/portals/api-portal/it/test-config.toml @@ -45,6 +45,7 @@ session_secret = '{{ env "APIP_AP_SECURITY_SESSION_SECRET" }}' # login-time organization-mismatch rejection. handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' +portal_id = '{{ env "APIP_AP_ORGANIZATION_PORTAL_ID" "portal_id" }}' auto_create_subscription_plans = true # Most of the suite authorizes against the dp:* scopes the Platform API sidecar mints diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index 8566e39ccf..4c4208294f 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -193,7 +193,7 @@ const DEFAULTS = { // Which role name, as it appears in the token's roles claim, grants each // of the portal's two access tiers. Was auth.idp.roles, despite being read in // local mode too (authController.js's login). A third tier, superAdmin, used - // to gate the earlier devportal's /portal pages; those are not served here, so + // to gate the earlier api portal's /portal pages; those are not served here, so // it guarded nothing and was removed. portalRoles: { admin: 'admin', @@ -291,6 +291,12 @@ const DEFAULTS = { // default_name keeps working. Resolved (with a warning) in configLoader.js. defaultName: '', autoCreateSubscriptionPlans: true, + // API Portal identifier this portal instance is pinned to. + // Resolved by the config.toml template before reaching this default. + // Set APIP_AP_ORGANIZATION_PORTAL_ID for cloud/K8s deployments, or override + // organization.portal_id in a local config file for on-premise. When neither + // is set the template resolves to 'portal_id'. + portalId: '', }, // Which artifact types this portal serves. An allowlist: a type not listed here // gets no nav entry, no landing-page section, and 404s on its routes. Any diff --git a/portals/api-portal/src/config/configLoader.js b/portals/api-portal/src/config/configLoader.js index 7d4dc1b98d..8de9f99ec4 100644 --- a/portals/api-portal/src/config/configLoader.js +++ b/portals/api-portal/src/config/configLoader.js @@ -590,6 +590,37 @@ function resolveOrganizationConfig(cfg, tomlOrg) { resolveOrganizationConfig(config, interpolatedTomlConfig.organization); +/** + * Validates the portal identifier this instance will be pinned to, and fails + * closed when it is empty or malformed. + * Design mode renders from disk and uses no database, so portal_id is unused + * and validation is skipped. + */ +function resolvePortalIdConfig(cfg) { + if (cfg.designMode?.enabled) return; + + const portalId = cfg.organization?.portalId; + const trimmed = typeof portalId === 'string' ? portalId.trim() : ''; + + if (!trimmed) { + process.stderr.write( + '[FATAL] organization.portal_id is not configured or resolves to an empty string. ' + + 'Set APIP_AP_ORGANIZATION_PORTAL_ID in your environment, or organization.portal_id ' + + "in configs/config.toml, e.g. portal_id = '{{ env \"APIP_AP_ORGANIZATION_PORTAL_ID\" \"portal_id\" }}'.\n" + ); + process.exit(1); + } + if (/\s/.test(portalId)) { + process.stderr.write( + '[FATAL] organization.portal_id contains whitespace. API Portal identifiers ' + + 'must not contain spaces or tabs.\n' + ); + process.exit(1); + } +} + +resolvePortalIdConfig(config); + /** * Refuses to start when auth.mode = "idp" is selected without the endpoints OIDC login * actually needs. diff --git a/portals/api-portal/src/controllers/apiPortalController.js b/portals/api-portal/src/controllers/apiPortalController.js index c3abd31160..80c4fad5f4 100644 --- a/portals/api-portal/src/controllers/apiPortalController.js +++ b/portals/api-portal/src/controllers/apiPortalController.js @@ -328,7 +328,7 @@ const generateOAuthKeys = async (req, res) => { if (!keyMapping || !keyMapping.km_uuid) { throw new CustomError(404, 'Key mapping not found or missing key manager reference'); } - const kmRecord = await kmDao.get(keyMapping.km_uuid); + const kmRecord = await kmDao.get(orgId, keyMapping.km_uuid); if (!kmRecord) { throw new CustomError(404, 'Key manager not found'); } diff --git a/portals/api-portal/src/controllers/applicationsContentController.js b/portals/api-portal/src/controllers/applicationsContentController.js index 4c361b81f0..a1e3d1d445 100644 --- a/portals/api-portal/src/controllers/applicationsContentController.js +++ b/portals/api-portal/src/controllers/applicationsContentController.js @@ -77,7 +77,7 @@ const loadApplicationData = async (req, orgName, applicationHandle, viewName) => for (const mapping of localMappings) { if (mapping.as_client_id && mapping.km_uuid) { try { - const km = await kmDao.get(mapping.km_uuid); + const km = await kmDao.get(orgId, mapping.km_uuid); keyList.push({ keyManager: km.handle, consumerKey: mapping.as_client_id, diff --git a/portals/api-portal/src/controllers/authController.js b/portals/api-portal/src/controllers/authController.js index 4be7b18cc3..d67a2646a0 100644 --- a/portals/api-portal/src/controllers/authController.js +++ b/portals/api-portal/src/controllers/authController.js @@ -127,6 +127,7 @@ const handleCallback = async (req, res, next) => { } returnTo = returnTo || `${constants.ROUTE.BASE_PATH}/${req.params.orgName}`; delete req.session.returnTo; + req.session.portalId = orgContext.getPortalId(); logUserAction('USER_LOGIN', req, { orgName: req.params.orgName }); req.session.save((saveErr) => { if (saveErr) { @@ -402,6 +403,7 @@ const handleLocalLogin = async (req, res) => { logger.error('Platform-auth login session error', { error: loginErr.message, stack: loginErr.stack }); return res.redirect(`${baseUrl}/login?error=Login+failed%2C+please+try+again`); } + req.session.portalId = orgContext.getPortalId(); logUserAction('USER_LOGIN', req, { orgName, isLocalAuth: true }); res.set('Cache-Control', 'no-store'); const redirectTo = returnTo || baseUrl; diff --git a/portals/api-portal/src/dao/apiDao.js b/portals/api-portal/src/dao/apiDao.js index bd127062a3..50f0ec7613 100644 --- a/portals/api-portal/src/dao/apiDao.js +++ b/portals/api-portal/src/dao/apiDao.js @@ -22,6 +22,7 @@ const db = require('../db/driver'); const { groupBy } = require('../db/rows'); const constants = require('../utils/constants'); const logger = require('../config/logger'); +const { getPortalId } = require('../utils/orgContext'); const API_METADATA_TABLE = 'api_metadata'; const CONTENT_TABLE = 'api_contents'; @@ -80,6 +81,7 @@ const SEARCH_APIS_POSTGRES_SQL = ` LEFT JOIN api_contents content ON metadata.uuid = content.api_uuid + AND content.portal_id = metadata.portal_id AND ( content.file_name LIKE '%.hbs' OR content.file_name LIKE '%.md%' @@ -96,6 +98,7 @@ const SEARCH_APIS_POSTGRES_SQL = ` ) ) AND metadata.org_uuid = :orgId + AND metadata.portal_id = :portalId AND (:includeType::text IS NULL OR metadata.type = :includeType) AND (:excludeType::text IS NULL OR metadata.type != :excludeType) AND ( @@ -103,8 +106,9 @@ const SEARCH_APIS_POSTGRES_SQL = ` OR EXISTS ( SELECT 1 FROM api_label_mappings alm - JOIN view_label_mappings vlm ON alm.label_uuid = vlm.label_uuid + JOIN view_label_mappings vlm ON alm.label_uuid = vlm.label_uuid AND alm.portal_id = vlm.portal_id WHERE alm.api_uuid = metadata.uuid AND vlm.view_uuid = :viewId + AND alm.portal_id = metadata.portal_id ) ) GROUP BY @@ -136,25 +140,25 @@ async function attachAssociations(apiRows, t) { const labelRows = await exec.query( `SELECT alm.api_uuid AS api_uuid, l.handle AS handle - FROM ${API_LABEL_MAPPINGS_TABLE} alm JOIN ${LABELS_TABLE} l ON alm.label_uuid = l.uuid - WHERE alm.api_uuid IN (${placeholders})`, - apiIds + FROM ${API_LABEL_MAPPINGS_TABLE} alm JOIN ${LABELS_TABLE} l ON alm.label_uuid = l.uuid AND alm.portal_id = l.portal_id + WHERE alm.api_uuid IN (${placeholders}) AND alm.portal_id = ?`, + [...apiIds, getPortalId()] ); const labelsByApi = groupBy(labelRows, 'api_uuid'); const tagRows = await exec.query( `SELECT atm.api_uuid AS api_uuid, tg.name AS name - FROM ${API_TAG_MAPPINGS_TABLE} atm JOIN ${TAGS_TABLE} tg ON atm.tag_uuid = tg.uuid - WHERE atm.api_uuid IN (${placeholders})`, - apiIds + FROM ${API_TAG_MAPPINGS_TABLE} atm JOIN ${TAGS_TABLE} tg ON atm.tag_uuid = tg.uuid AND atm.portal_id = tg.portal_id + WHERE atm.api_uuid IN (${placeholders}) AND atm.portal_id = ?`, + [...apiIds, getPortalId()] ); const tagsByApi = groupBy(tagRows, 'api_uuid'); const planMappingRows = await exec.query( `SELECT m.api_uuid AS mapping_api_uuid, sp.* - FROM ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} m JOIN ${SUBSCRIPTION_PLANS_TABLE} sp ON m.plan_uuid = sp.uuid - WHERE m.api_uuid IN (${placeholders})`, - apiIds + FROM ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} m JOIN ${SUBSCRIPTION_PLANS_TABLE} sp ON m.plan_uuid = sp.uuid AND m.portal_id = sp.portal_id + WHERE m.api_uuid IN (${placeholders}) AND m.portal_id = ?`, + [...apiIds, getPortalId()] ); const planIds = [...new Set(planMappingRows.map((p) => p.uuid))]; let limitsByPlan = new Map(); @@ -188,17 +192,18 @@ const create = async (orgId, apiMetadata, createdBy, t) => { const handle = apiMetadata.handle || `${apiMetadata.name.toLowerCase().replace(/\s+/g, '')}-v${apiMetadata.version}`; const agentVisibility = (apiMetadata.agentVisibility || constants.AGENT_VISIBILITY.VISIBLE).toUpperCase(); + const portalId = getPortalId(); await exec.execute( `INSERT INTO ${API_METADATA_TABLE} (uuid, ref_id, status, name, handle, description, version, type, agent_visibility, technical_owner, technical_owner_email, business_owner_email, business_owner, - sandbox_url, production_url, metadata_search, org_uuid, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sandbox_url, production_url, metadata_search, org_uuid, portal_id, created_by, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, apiMetadata.referenceId, apiMetadata.status, apiMetadata.name, handle, apiMetadata.description, apiMetadata.version, apiMetadata.type, agentVisibility, owners.technicalOwner, owners.technicalOwnerEmail, owners.businessOwnerEmail, owners.businessOwner, apiMetadata.endPoints.sandboxURL, - apiMetadata.endPoints.productionURL, apiMetadata, orgId, createdBy, createdBy, now, now, + apiMetadata.endPoints.productionURL, apiMetadata, orgId, portalId, createdBy, createdBy, now, now, ] ); return { @@ -208,7 +213,7 @@ const create = async (orgId, apiMetadata, createdBy, t) => { technical_owner_email: owners.technicalOwnerEmail, business_owner_email: owners.businessOwnerEmail, business_owner: owners.businessOwner, sandbox_url: apiMetadata.endPoints.sandboxURL, production_url: apiMetadata.endPoints.productionURL, metadata_search: apiMetadata, org_uuid: orgId, - created_by: createdBy, updated_by: createdBy, created_at: now, updated_at: now, + portal_id: portalId, created_by: createdBy, updated_by: createdBy, created_at: now, updated_at: now, }; }; @@ -218,26 +223,27 @@ const update = async (orgId, apiId, apiMetadata, updatedBy, t) => { const agentVisibility = (apiMetadata.agentVisibility || constants.AGENT_VISIBILITY.VISIBLE).toUpperCase(); const updatedAt = new Date(); + const portalId = getPortalId(); const { rowCount } = await exec.execute( `UPDATE ${API_METADATA_TABLE} SET ref_id = ?, status = ?, name = ?, description = ?, version = ?, type = ?, agent_visibility = ?, technical_owner = ?, technical_owner_email = ?, business_owner_email = ?, business_owner = ?, sandbox_url = ?, production_url = ?, metadata_search = ?, updated_by = ?, updated_at = ? - WHERE uuid = ? AND org_uuid = ?`, + WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, [ apiMetadata.referenceId, apiMetadata.status, apiMetadata.name, apiMetadata.description, apiMetadata.version, apiMetadata.type, agentVisibility, owners.technicalOwner, owners.technicalOwnerEmail, owners.businessOwnerEmail, owners.businessOwner, apiMetadata.endPoints.sandboxURL, apiMetadata.endPoints.productionURL, apiMetadata, - updatedBy, updatedAt, apiId, orgId, + updatedBy, updatedAt, apiId, orgId, portalId, ] ); if (!rowCount) { return [0, null]; } const updatedInstance = await exec.queryOne( - `SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [apiId, orgId] + `SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [apiId, orgId, portalId] ); return [rowCount, [updatedInstance]]; }; @@ -245,8 +251,8 @@ const update = async (orgId, apiId, apiMetadata, updatedBy, t) => { const deleteApi = async (orgId, apiId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${API_METADATA_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [apiId, orgId] + `DELETE FROM ${API_METADATA_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [apiId, orgId, getPortalId()] ); return rowCount; }; @@ -254,8 +260,8 @@ const deleteApi = async (orgId, apiId, t) => { const get = async (orgId, apiId, t) => { const exec = t || db; const rows = await exec.query( - `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND uuid = ? AND status IN (${STATUS_PLACEHOLDERS})`, - [orgId, apiId, ...PUBLISHED_STATUSES] + `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND portal_id = ? AND uuid = ? AND status IN (${STATUS_PLACEHOLDERS})`, + [orgId, getPortalId(), apiId, ...PUBLISHED_STATUSES] ); await attachAssociations(rows, t); return rows; @@ -272,8 +278,8 @@ const get = async (orgId, apiId, t) => { */ const getByCondition = async ({ orgId, uuid, typeFilter } = {}, t, tags) => { const exec = t || db; - const conditions = []; - const params = []; + const conditions = ['portal_id = ?']; + const params = [getPortalId()]; if (orgId !== undefined) { conditions.push('org_uuid = ?'); params.push(orgId); } if (uuid !== undefined) { conditions.push('uuid = ?'); params.push(uuid); } if (typeFilter?.include) { conditions.push('type = ?'); params.push(typeFilter.include); } @@ -283,8 +289,9 @@ const getByCondition = async ({ orgId, uuid, typeFilter } = {}, t, tags) => { if (tagsArray.length > 0) { const tagPlaceholders = tagsArray.map(() => '?').join(', '); conditions.push( - `EXISTS (SELECT 1 FROM ${API_TAG_MAPPINGS_TABLE} atm JOIN ${TAGS_TABLE} tg ON atm.tag_uuid = tg.uuid - WHERE atm.api_uuid = ${API_METADATA_TABLE}.uuid AND tg.name IN (${tagPlaceholders}))` + `EXISTS (SELECT 1 FROM ${API_TAG_MAPPINGS_TABLE} atm JOIN ${TAGS_TABLE} tg ON atm.tag_uuid = tg.uuid AND atm.portal_id = tg.portal_id + WHERE atm.api_uuid = ${API_METADATA_TABLE}.uuid AND tg.name IN (${tagPlaceholders}) + AND atm.portal_id = ${API_METADATA_TABLE}.portal_id)` ); params.push(...tagsArray); } @@ -300,8 +307,8 @@ const list = async (orgId, viewName, t, typeFilter) => { const viewDao = require('./viewDao'); const viewId = await viewDao.getId(orgId, viewName, t); - const conditions = ['org_uuid = ?', `status IN (${STATUS_PLACEHOLDERS})`]; - const params = [orgId, ...PUBLISHED_STATUSES]; + const conditions = ['org_uuid = ?', 'portal_id = ?', `status IN (${STATUS_PLACEHOLDERS})`]; + const params = [orgId, getPortalId(), ...PUBLISHED_STATUSES]; if (typeFilter?.include) { conditions.push('type = ?'); params.push(typeFilter.include); } if (typeFilter?.exclude) { conditions.push('type != ?'); params.push(typeFilter.exclude); } // Required label-in-view filter — mirrors the previous `required: true` Labels include @@ -320,8 +327,8 @@ const list = async (orgId, viewName, t, typeFilter) => { const listFromAllViews = async (orgId, t, typeFilter) => { const exec = t || db; - const conditions = ['org_uuid = ?', `status IN (${STATUS_PLACEHOLDERS})`]; - const params = [orgId, ...PUBLISHED_STATUSES]; + const conditions = ['org_uuid = ?', 'portal_id = ?', `status IN (${STATUS_PLACEHOLDERS})`]; + const params = [orgId, getPortalId(), ...PUBLISHED_STATUSES]; if (typeFilter?.include) { conditions.push('type = ?'); params.push(typeFilter.include); } if (typeFilter?.exclude) { conditions.push('type != ?'); params.push(typeFilter.exclude); } // Required label filter — mirrors the previous `required: true` Labels include with no @@ -343,8 +350,8 @@ const searchFallback = async (orgId, searchTerm, viewName, t, typeFilter) => { const viewId = await viewDao.getId(orgId, viewName, t); const matchingTags = await exec.query( - `SELECT uuid FROM ${TAGS_TABLE} WHERE org_uuid = ? AND name LIKE ?`, - [orgId, pattern] + `SELECT uuid FROM ${TAGS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND name LIKE ?`, + [orgId, getPortalId(), pattern] ); const matchingTagIds = matchingTags.map((tag) => tag.uuid); let taggedApiIds = []; @@ -357,8 +364,8 @@ const searchFallback = async (orgId, searchTerm, viewName, t, typeFilter) => { taggedApiIds = [...new Set(matchingTagApis.map((row) => row.api_uuid))]; } - const conditions = ['org_uuid = ?', `status IN (${STATUS_PLACEHOLDERS})`]; - const params = [orgId, ...PUBLISHED_STATUSES]; + const conditions = ['org_uuid = ?', 'portal_id = ?', `status IN (${STATUS_PLACEHOLDERS})`]; + const params = [orgId, getPortalId(), ...PUBLISHED_STATUSES]; if (typeFilter?.include) { conditions.push('type = ?'); params.push(typeFilter.include); } if (typeFilter?.exclude) { conditions.push('type != ?'); params.push(typeFilter.exclude); } @@ -398,6 +405,7 @@ const search = async (orgId, searchTerm, viewName, t, typeFilter) => { const { sql, params } = db.bindNamedParams(SEARCH_APIS_POSTGRES_SQL, { searchTerm, orgId, + portalId: getPortalId(), viewId: viewId || null, includeType: typeFilter?.include || null, excludeType: typeFilter?.exclude || null, @@ -409,8 +417,8 @@ const search = async (orgId, searchTerm, viewName, t, typeFilter) => { const getId = async (orgId, apiHandle) => { const api = await db.queryOne( - `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ?`, - [apiHandle, orgId] + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [apiHandle, orgId, getPortalId()] ); return api?.uuid; }; @@ -435,8 +443,8 @@ const getIdInView = async (orgId, apiHandle, viewName, { type, excludeType } = { const viewDao = require('./viewDao'); const viewId = await viewDao.getId(orgId, viewName, t); - const conditions = ['handle = ?', 'org_uuid = ?', `status IN (${STATUS_PLACEHOLDERS})`]; - const params = [apiHandle, orgId, ...PUBLISHED_STATUSES]; + const conditions = ['handle = ?', 'org_uuid = ?', 'portal_id = ?', `status IN (${STATUS_PLACEHOLDERS})`]; + const params = [apiHandle, orgId, getPortalId(), ...PUBLISHED_STATUSES]; if (type) { conditions.push('type = ?'); params.push(type); } if (excludeType) { conditions.push('type != ?'); params.push(excludeType); } conditions.push( @@ -457,8 +465,8 @@ const getIdInView = async (orgId, apiHandle, viewName, { type, excludeType } = { // single query — used by resource families that only manage one API type. const getIdByType = async (orgId, apiHandle, type) => { const api = await db.queryOne( - `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ? AND type = ?`, - [apiHandle, orgId, type] + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ? AND type = ?`, + [apiHandle, orgId, getPortalId(), type] ); return api?.uuid; }; @@ -468,16 +476,16 @@ const getIdByType = async (orgId, apiHandle, type) => { // /apis/* stops resolving handles that belong to that dedicated family. const getIdExcludingType = async (orgId, apiHandle, excludedType) => { const api = await db.queryOne( - `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ? AND type != ?`, - [apiHandle, orgId, excludedType] + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ? AND type != ?`, + [apiHandle, orgId, getPortalId(), excludedType] ); return api?.uuid; }; const getHandle = async (orgId, apiRefId) => { const api = await db.queryOne( - `SELECT handle FROM ${API_METADATA_TABLE} WHERE ref_id = ? AND org_uuid = ?`, - [apiRefId, orgId] + `SELECT handle FROM ${API_METADATA_TABLE} WHERE ref_id = ? AND org_uuid = ? AND portal_id = ?`, + [apiRefId, orgId, getPortalId()] ); return api?.handle ?? null; }; @@ -485,8 +493,8 @@ const getHandle = async (orgId, apiRefId) => { const getIdByRef = async (orgId, referenceId, t) => { const exec = t || db; const api = await exec.queryOne( - `SELECT uuid FROM ${API_METADATA_TABLE} WHERE ref_id = ? AND org_uuid = ?`, - [referenceId, orgId] + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE ref_id = ? AND org_uuid = ? AND portal_id = ?`, + [referenceId, orgId, getPortalId()] ); return api?.uuid; }; @@ -497,9 +505,9 @@ const getSpecs = async (orgId, apiIds) => { const placeholders = apiIds.map(() => '?').join(', '); const rows = await db.query( `SELECT c.api_uuid AS api_uuid, c.file_name AS file_name, c.file_content AS file_content - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid IN (${placeholders}) AND c.type = ? AND m.org_uuid = ?`, - [...apiIds, constants.DOC_TYPES.API_DEFINITION, orgId] + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid IN (${placeholders}) AND c.type = ? AND m.org_uuid = ? AND m.portal_id = ?`, + [...apiIds, constants.DOC_TYPES.API_DEFINITION, orgId, getPortalId()] ); return rows.map((spec) => ({ apiId: spec.api_uuid, @@ -518,8 +526,8 @@ const getSpecs = async (orgId, apiIds) => { const existsByNameVersion = async (orgId, apiName, apiVersion) => { const row = await db.queryOne( - `SELECT uuid FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND name = ? AND version = ?`, - [orgId, apiName, apiVersion] + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND portal_id = ? AND name = ? AND version = ?`, + [orgId, getPortalId(), apiName, apiVersion] ); return !!row; }; diff --git a/portals/api-portal/src/dao/apiFileDao.js b/portals/api-portal/src/dao/apiFileDao.js index 3dbe7ce288..474ac3adff 100644 --- a/portals/api-portal/src/dao/apiFileDao.js +++ b/portals/api-portal/src/dao/apiFileDao.js @@ -21,6 +21,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { groupBy, toBlobBuffer } = require('../db/rows'); const constants = require('../utils/constants'); +const { getPortalId } = require('../utils/orgContext'); const CONTENT_TABLE = 'api_contents'; const API_METADATA_TABLE = 'api_metadata'; @@ -30,19 +31,20 @@ const API_METADATA_TABLE = 'api_metadata'; // doesn't support portably) is appended to UPDATE/DELETE statements that need // to verify org ownership. Requires org_uuid as the LAST bind param. const TENANT_SCOPE_EXISTS = - `EXISTS (SELECT 1 FROM ${API_METADATA_TABLE} m WHERE m.uuid = ${CONTENT_TABLE}.api_uuid AND m.org_uuid = ?)`; + `EXISTS (SELECT 1 FROM ${API_METADATA_TABLE} m WHERE m.uuid = ${CONTENT_TABLE}.api_uuid AND m.org_uuid = ? AND m.portal_id = ${CONTENT_TABLE}.portal_id)`; const store = async (apiFile, fileName, apiId, type, createdBy, t, key) => { const exec = t || db; const uuid = crypto.randomUUID(); const content = toBlobBuffer(apiFile); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${CONTENT_TABLE} (uuid, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, content, fileName, apiId, type, key ?? null, createdBy, createdBy] + `INSERT INTO ${CONTENT_TABLE} (uuid, portal_id, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, content, fileName, apiId, type, key ?? null, createdBy, createdBy] ); return { - uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: createdBy, updated_by: createdBy, }; }; @@ -50,16 +52,17 @@ const store = async (apiFile, fileName, apiId, type, createdBy, t, key) => { const storeMany = async (files, apiId, createdBy, t) => { const exec = t || db; const created = []; + const portalId = getPortalId(); for (const file of files) { const uuid = crypto.randomUUID(); const content = toBlobBuffer(file.content); await exec.execute( - `INSERT INTO ${CONTENT_TABLE} (uuid, file_content, file_name, type, api_uuid, lookup_key, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, content, file.fileName, file.type, apiId, file.key ?? null, createdBy, createdBy] + `INSERT INTO ${CONTENT_TABLE} (uuid, portal_id, file_content, file_name, type, api_uuid, lookup_key, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, content, file.fileName, file.type, apiId, file.key ?? null, createdBy, createdBy] ); created.push({ - uuid, file_content: content, file_name: file.fileName, type: file.type, + uuid, portal_id: portalId, file_content: content, file_name: file.fileName, type: file.type, api_uuid: apiId, lookup_key: file.key ?? null, created_by: createdBy, updated_by: createdBy, }); } @@ -69,18 +72,18 @@ const storeMany = async (files, apiId, createdBy, t) => { const get = async (fileName, type, orgId, apiId, t) => { const exec = t || db; return exec.queryOne( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.file_name = ? AND c.api_uuid = ? AND c.type = ? AND m.org_uuid = ?`, - [fileName, apiId, type, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.file_name = ? AND c.api_uuid = ? AND c.type = ? AND m.org_uuid = ? AND m.portal_id = ?`, + [fileName, apiId, type, orgId, getPortalId()] ); }; const getByType = async (type, orgId, apiId, t) => { const exec = t || db; return exec.queryOne( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid = ? AND c.type = ? AND m.org_uuid = ?`, - [apiId, type, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid = ? AND c.type = ? AND m.org_uuid = ? AND m.portal_id = ?`, + [apiId, type, orgId, getPortalId()] ); }; @@ -141,12 +144,13 @@ const upsertMany = async (files, apiId, orgId, updatedBy, t) => { } } + const portalId = getPortalId(); for (const file of filesToCreate) { const uuid = crypto.randomUUID(); await exec.execute( - `INSERT INTO ${CONTENT_TABLE} (uuid, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, file.file_content, file.file_name, file.api_uuid, file.type, file.lookup_key, file.created_by, file.updated_by] + `INSERT INTO ${CONTENT_TABLE} (uuid, portal_id, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, file.file_content, file.file_name, file.api_uuid, file.type, file.lookup_key, file.created_by, file.updated_by] ); } }; @@ -158,13 +162,14 @@ const upsert = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) if (existing == null) { const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${CONTENT_TABLE} (uuid, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] + `INSERT INTO ${CONTENT_TABLE} (uuid, portal_id, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] ); return { - uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: updatedBy, updated_by: updatedBy, }; } @@ -186,13 +191,14 @@ const update = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) if (existing == null) { const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${CONTENT_TABLE} (uuid, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] + `INSERT INTO ${CONTENT_TABLE} (uuid, portal_id, file_content, file_name, api_uuid, type, lookup_key, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] ); return { - uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: updatedBy, updated_by: updatedBy, }; } @@ -210,15 +216,15 @@ const update = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) const deleteFile = async (fileName, type, orgId, apiId, t) => { const exec = t || db; const contentsToDelete = await exec.query( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.file_name = ? AND c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ?`, - [fileName, apiId, `%${type}%`, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.file_name = ? AND c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ? AND m.portal_id = ?`, + [fileName, apiId, `%${type}%`, orgId, getPortalId()] ); let rowCount = 0; for (const content of contentsToDelete) { ({ rowCount } = await exec.execute( - `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ?`, - [content.api_uuid, content.file_name, content.type] + `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ? AND portal_id = ?`, + [content.api_uuid, content.file_name, content.type, content.portal_id] )); } return rowCount; @@ -227,15 +233,15 @@ const deleteFile = async (fileName, type, orgId, apiId, t) => { const deleteAll = async (type, orgId, apiId, t) => { const exec = t || db; const contentsToDelete = await exec.query( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ?`, - [apiId, `%${type}%`, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ? AND m.portal_id = ?`, + [apiId, `%${type}%`, orgId, getPortalId()] ); let rowCount = 0; for (const content of contentsToDelete) { ({ rowCount } = await exec.execute( - `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ?`, - [content.api_uuid, content.file_name, content.type] + `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ? AND portal_id = ?`, + [content.api_uuid, content.file_name, content.type, content.portal_id] )); } return rowCount; @@ -258,18 +264,18 @@ const deleteAllByType = async (type, apiId, t) => { const getDoc = async (type, orgId, apiId, t) => { const exec = t || db; return exec.queryOne( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid = ? AND c.type = ? AND m.org_uuid = ?`, - [apiId, type, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid = ? AND c.type = ? AND m.org_uuid = ? AND m.portal_id = ?`, + [apiId, type, orgId, getPortalId()] ); }; const getDocByName = async (type, name, orgId, apiId, t) => { const exec = t || db; return exec.queryOne( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid = ? AND c.type = ? AND c.file_name = ? AND m.org_uuid = ?`, - [apiId, type, name, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid = ? AND c.type = ? AND c.file_name = ? AND m.org_uuid = ? AND m.portal_id = ?`, + [apiId, type, name, orgId, getPortalId()] ); }; @@ -281,13 +287,13 @@ const getDocByName = async (type, name, orgId, apiId, t) => { */ const getDocTypes = async (orgId, apiId) => { const dialect = db.getDialect(); - const whereSql = 'c.api_uuid = ? AND (c.type LIKE ? OR c.type LIKE ?) AND m.org_uuid = ?'; - const params = [apiId, 'DOC_%', constants.DOC_TYPES.API_DEFINITION, orgId]; + const whereSql = 'c.api_uuid = ? AND (c.type LIKE ? OR c.type LIKE ?) AND m.org_uuid = ? AND m.portal_id = ?'; + const params = [apiId, 'DOC_%', constants.DOC_TYPES.API_DEFINITION, orgId, getPortalId()]; if (dialect === 'postgres') { return db.query( `SELECT c.type AS type, ARRAY_AGG(c.file_name) AS file_names - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql} GROUP BY c.type`, params ); @@ -296,7 +302,7 @@ const getDocTypes = async (orgId, apiId) => { const aggFn = dialect === 'mssql' ? 'STRING_AGG' : 'GROUP_CONCAT'; const rows = await db.query( `SELECT c.type AS type, ${aggFn}(c.file_name, '|||') AS file_names - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql} GROUP BY c.type`, params ); @@ -315,15 +321,15 @@ const getDocTypes = async (orgId, apiId) => { */ const getDocs = async (orgId, apiId) => { const dialect = db.getDialect(); - const whereSql = 'c.api_uuid = ? AND (c.type LIKE ? OR c.file_name LIKE ?) AND m.org_uuid = ?'; - const params = [apiId, 'DOC_%', 'LINK_%', orgId]; + const whereSql = 'c.api_uuid = ? AND (c.type LIKE ? OR c.file_name LIKE ?) AND m.org_uuid = ? AND m.portal_id = ?'; + const params = [apiId, 'DOC_%', 'LINK_%', orgId, getPortalId()]; if (dialect === 'postgres') { return db.query( `SELECT c.type AS type, ARRAY_AGG(c.file_name) AS file_names, ARRAY_AGG(c.file_content) AS api_files - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql} GROUP BY c.type`, params ); @@ -331,7 +337,7 @@ const getDocs = async (orgId, apiId) => { const rows = await db.query( `SELECT c.type AS type, c.file_name AS file_name, c.file_content AS file_content - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql}`, params ); @@ -346,15 +352,15 @@ const getDocs = async (orgId, apiId) => { /** Same shape as getDocs, scoped to file_name LIKE 'LINK_%' only. */ const getDocLinks = async (orgId, apiId) => { const dialect = db.getDialect(); - const whereSql = "c.api_uuid = ? AND c.file_name LIKE ? AND m.org_uuid = ?"; - const params = [apiId, 'LINK_%', orgId]; + const whereSql = "c.api_uuid = ? AND c.file_name LIKE ? AND m.org_uuid = ? AND m.portal_id = ?"; + const params = [apiId, 'LINK_%', orgId, getPortalId()]; if (dialect === 'postgres') { return db.query( `SELECT c.type AS type, ARRAY_AGG(c.file_name) AS file_names, ARRAY_AGG(c.file_content) AS api_files - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql} GROUP BY c.type`, params ); @@ -362,7 +368,7 @@ const getDocLinks = async (orgId, apiId) => { const rows = await db.query( `SELECT c.type AS type, c.file_name AS file_name, c.file_content AS file_content - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id WHERE ${whereSql}`, params ); @@ -377,9 +383,9 @@ const getDocLinks = async (orgId, apiId) => { const listDocNames = async (orgId, apiId) => { const rows = await db.query( `SELECT c.file_name AS file_name - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ?`, - [apiId, `${constants.DOC_TYPES.DOC_ID}%`, orgId] + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ? AND m.portal_id = ?`, + [apiId, `${constants.DOC_TYPES.DOC_ID}%`, orgId, getPortalId()] ); return rows.map((r) => r.file_name); }; @@ -392,9 +398,9 @@ const listDocNamesForApis = async (orgId, apiIds) => { const placeholders = apiIds.map(() => '?').join(', '); const rows = await db.query( `SELECT c.file_name AS file_name, c.api_uuid AS api_uuid - FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.api_uuid IN (${placeholders}) AND c.type LIKE ? AND m.org_uuid = ?`, - [...apiIds, `${constants.DOC_TYPES.DOC_ID}%`, orgId] + FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.api_uuid IN (${placeholders}) AND c.type LIKE ? AND m.org_uuid = ? AND m.portal_id = ?`, + [...apiIds, `${constants.DOC_TYPES.DOC_ID}%`, orgId, getPortalId()] ); for (const row of rows) { docNamesByApiId[row.api_uuid].push(row.file_name); @@ -407,14 +413,14 @@ const deleteByFileName = async (fileName, orgId, apiId, t) => { // Scope to document rows only (type LIKE 'DOC_%'), matching listDocNames. Without this, // a non-doc row (image, spec) that happens to share the file_name would also be deleted. const contentsToDelete = await exec.query( - `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid - WHERE c.file_name = ? AND c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ?`, - [fileName, apiId, `${constants.DOC_TYPES.DOC_ID}%`, orgId] + `SELECT c.* FROM ${CONTENT_TABLE} c JOIN ${API_METADATA_TABLE} m ON c.api_uuid = m.uuid AND c.portal_id = m.portal_id + WHERE c.file_name = ? AND c.api_uuid = ? AND c.type LIKE ? AND m.org_uuid = ? AND m.portal_id = ?`, + [fileName, apiId, `${constants.DOC_TYPES.DOC_ID}%`, orgId, getPortalId()] ); for (const content of contentsToDelete) { await exec.execute( - `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ?`, - [apiId, content.file_name, content.type] + `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND file_name = ? AND type = ? AND portal_id = ?`, + [apiId, content.file_name, content.type, content.portal_id] ); } }; diff --git a/portals/api-portal/src/dao/apiKeyDao.js b/portals/api-portal/src/dao/apiKeyDao.js index 1cf81567b3..1622e87036 100644 --- a/portals/api-portal/src/dao/apiKeyDao.js +++ b/portals/api-portal/src/dao/apiKeyDao.js @@ -21,6 +21,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { indexBy } = require('../db/rows'); const constants = require('../utils/constants'); +const { getPortalId } = require('../utils/orgContext'); const API_KEYS_TABLE = 'api_keys'; const APP_KEY_MAPPINGS_TABLE = 'api_key_app_mappings'; @@ -33,8 +34,8 @@ const APPLICATIONS_TABLE = 'applications'; // conflict on the key_uuid primary key). const UPSERT_KEY_APP_MAPPING_SQL = db.buildUpsert( APP_KEY_MAPPINGS_TABLE, - ['key_uuid', 'app_uuid', 'created_by', 'created_at'], - ['key_uuid'], + ['portal_id', 'key_uuid', 'app_uuid', 'created_by', 'created_at'], + ['portal_id', 'key_uuid'], ['app_uuid', 'created_by'] ); @@ -91,14 +92,14 @@ async function create({ apiId, subscriptionId, appId, orgId, handle, displayName await exec.execute( `INSERT INTO ${API_KEYS_TABLE} - (uuid, api_uuid, subscription_uuid, org_uuid, handle, display_name, status, expires_at, + (uuid, api_uuid, subscription_uuid, org_uuid, portal_id, handle, display_name, status, expires_at, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, apiId, subscriptionId || null, orgId, handle, displayName, constants.API_KEY_STATUS.ACTIVE, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, apiId, subscriptionId || null, orgId, getPortalId(), handle, displayName, constants.API_KEY_STATUS.ACTIVE, expiresAt || null, createdBy, createdBy, now, now] ); if (appId) { - await exec.execute(UPSERT_KEY_APP_MAPPING_SQL, [uuid, appId, createdBy, now]); + await exec.execute(UPSERT_KEY_APP_MAPPING_SQL, [getPortalId(), uuid, appId, createdBy, now]); } return { @@ -122,8 +123,8 @@ async function create({ apiId, subscriptionId, appId, orgId, handle, displayName async function get(orgId, keyId, transaction) { const exec = transaction || db; const key = await exec.queryOne( - `SELECT * FROM ${API_KEYS_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [keyId, orgId] + `SELECT * FROM ${API_KEYS_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [keyId, orgId, getPortalId()] ); if (!key) return null; await attachAssociations(exec, [key]); @@ -133,16 +134,16 @@ async function get(orgId, keyId, transaction) { // Resolves a key's handle (scoped to the given API) to its uuid, or null if not found. async function getIdByHandle(orgId, apiId, handle) { const key = await db.queryOne( - `SELECT uuid FROM ${API_KEYS_TABLE} WHERE org_uuid = ? AND api_uuid = ? AND handle = ?`, - [orgId, apiId, handle] + `SELECT uuid FROM ${API_KEYS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND api_uuid = ? AND handle = ?`, + [orgId, getPortalId(), apiId, handle] ); return key ? key.uuid : null; } async function list(orgId, { apiId, subscriptionId, appId, status, createdBy, limit } = {}, transaction) { const exec = transaction || db; - const conditions = ['org_uuid = ?']; - const params = [orgId]; + const conditions = ['org_uuid = ?', 'portal_id = ?']; + const params = [orgId, getPortalId()]; if (apiId) { conditions.push('api_uuid = ?'); params.push(apiId); } if (subscriptionId) { conditions.push('subscription_uuid = ?'); params.push(subscriptionId); } if (status) { conditions.push('status = ?'); params.push(status); } @@ -170,23 +171,23 @@ async function revoke(orgId, keyId, updatedBy, transaction) { const exec = transaction || db; const { rowCount } = await exec.execute( `UPDATE ${API_KEYS_TABLE} SET status = ?, revoked_at = ?, revoked_by = ?, updated_by = ? - WHERE uuid = ? AND org_uuid = ? AND status = ?`, - [constants.API_KEY_STATUS.REVOKED, new Date(), updatedBy, updatedBy, keyId, orgId, constants.API_KEY_STATUS.ACTIVE] + WHERE uuid = ? AND org_uuid = ? AND portal_id = ? AND status = ?`, + [constants.API_KEY_STATUS.REVOKED, new Date(), updatedBy, updatedBy, keyId, orgId, getPortalId(), constants.API_KEY_STATUS.ACTIVE] ); return rowCount > 0; } async function setApplication(orgId, keyId, appId, updatedBy, transaction, { activeOnly = false } = {}) { const exec = transaction || db; - const conditions = ['uuid = ?', 'org_uuid = ?']; - const params = [keyId, orgId]; + const conditions = ['uuid = ?', 'org_uuid = ?', 'portal_id = ?']; + const params = [keyId, orgId, getPortalId()]; if (activeOnly) { conditions.push('status = ?'); params.push(constants.API_KEY_STATUS.ACTIVE); } const key = await exec.queryOne(`SELECT * FROM ${API_KEYS_TABLE} WHERE ${conditions.join(' AND ')}`, params); if (!key) return false; if (appId) { - await exec.execute(UPSERT_KEY_APP_MAPPING_SQL, [keyId, appId, updatedBy, new Date()]); + await exec.execute(UPSERT_KEY_APP_MAPPING_SQL, [getPortalId(), keyId, appId, updatedBy, new Date()]); } else { await exec.execute(`DELETE FROM ${APP_KEY_MAPPINGS_TABLE} WHERE key_uuid = ?`, [keyId]); } @@ -197,8 +198,8 @@ async function updateExpiry(orgId, keyId, expiresAt, updatedBy, transaction) { const exec = transaction || db; const { rowCount } = await exec.execute( `UPDATE ${API_KEYS_TABLE} SET expires_at = ?, updated_by = ?, updated_at = ? - WHERE uuid = ? AND org_uuid = ? AND status = ?`, - [expiresAt, updatedBy, new Date(), keyId, orgId, constants.API_KEY_STATUS.ACTIVE] + WHERE uuid = ? AND org_uuid = ? AND portal_id = ? AND status = ?`, + [expiresAt, updatedBy, new Date(), keyId, orgId, getPortalId(), constants.API_KEY_STATUS.ACTIVE] ); return rowCount > 0; } diff --git a/portals/api-portal/src/dao/apiWorkflowDao.js b/portals/api-portal/src/dao/apiWorkflowDao.js index 98fa8b9014..0e1c6a89f1 100644 --- a/portals/api-portal/src/dao/apiWorkflowDao.js +++ b/portals/api-portal/src/dao/apiWorkflowDao.js @@ -22,6 +22,7 @@ const db = require('../db/driver'); const constants = require('../utils/constants'); const logger = require('../config/logger'); const { bufferToUtf8 } = require('../utils/cryptoUtil'); +const { getPortalId } = require('../utils/orgContext'); const TABLE = 'api_workflows'; @@ -48,10 +49,10 @@ const create = async (orgId, viewId, apiWorkflowData, createdBy, t) => { try { await exec.execute( `INSERT INTO ${TABLE} - (uuid, org_uuid, view_uuid, display_name, handle, description, agent_prompt, status, + (uuid, org_uuid, portal_id, view_uuid, display_name, handle, description, agent_prompt, status, agent_visibility, file_content, content_type, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, orgId, viewId, apiWorkflowData.displayName, apiWorkflowData.handle, apiWorkflowData.description, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, orgId, getPortalId(), viewId, apiWorkflowData.displayName, apiWorkflowData.handle, apiWorkflowData.description, Buffer.from(apiWorkflowData.agentPrompt), status, agentVisibility, db.binaryParam(fileContent), contentType, createdBy, createdBy, now, now] ); @@ -99,10 +100,10 @@ const update = async (orgId, viewId, apiWorkflowId, apiWorkflowData, updatedBy, params.push(db.binaryParam(apiWorkflowData.apiWorkflowDefinition != null ? Buffer.from(apiWorkflowData.apiWorkflowDefinition) : null)); } if (apiWorkflowData.contentType !== undefined) { setClauses.push('content_type = ?'); params.push(apiWorkflowData.contentType); } - params.push(apiWorkflowId, orgId, viewId); + params.push(apiWorkflowId, orgId, viewId, getPortalId()); const { rowCount } = await exec.execute( - `UPDATE ${TABLE} SET ${setClauses.join(', ')} WHERE uuid = ? AND org_uuid = ? AND view_uuid = ?`, + `UPDATE ${TABLE} SET ${setClauses.join(', ')} WHERE uuid = ? AND org_uuid = ? AND view_uuid = ? AND portal_id = ?`, params ); if (rowCount === 0) { @@ -115,31 +116,31 @@ const update = async (orgId, viewId, apiWorkflowId, apiWorkflowData, updatedBy, const deleteFlow = async (orgId, viewId, apiWorkflowId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${TABLE} WHERE uuid = ? AND org_uuid = ? AND view_uuid = ?`, - [apiWorkflowId, orgId, viewId] + `DELETE FROM ${TABLE} WHERE uuid = ? AND org_uuid = ? AND view_uuid = ? AND portal_id = ?`, + [apiWorkflowId, orgId, viewId, getPortalId()] ); return rowCount; }; const getByHandle = async (orgId, viewId, handle) => { const row = await db.queryOne( - `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ? AND view_uuid = ?`, - [handle, orgId, viewId] + `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ? AND view_uuid = ? AND portal_id = ?`, + [handle, orgId, viewId, getPortalId()] ); return mapRow(row); }; const list = async (orgId, viewId) => { const rows = await db.query( - `SELECT * FROM ${TABLE} WHERE org_uuid = ? AND view_uuid = ? ORDER BY created_at DESC`, - [orgId, viewId] + `SELECT * FROM ${TABLE} WHERE org_uuid = ? AND view_uuid = ? AND portal_id = ? ORDER BY created_at DESC`, + [orgId, viewId, getPortalId()] ); return rows.map(mapRow); }; const listPublished = async (orgId, viewId, { agentVisibility } = {}) => { - const conditions = ['org_uuid = ?', 'view_uuid = ?', "status = 'PUBLISHED'"]; - const params = [orgId, viewId]; + const conditions = ['org_uuid = ?', 'view_uuid = ?', 'portal_id = ?', "status = 'PUBLISHED'"]; + const params = [orgId, viewId, getPortalId()]; if (agentVisibility) { conditions.push('agent_visibility = ?'); params.push(agentVisibility); } const rows = await db.query( `SELECT * FROM ${TABLE} WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC`, @@ -149,8 +150,8 @@ const listPublished = async (orgId, viewId, { agentVisibility } = {}) => { }; const getPublishedByHandle = async (orgId, viewId, handle, { agentVisibility } = {}) => { - const conditions = ['handle = ?', 'org_uuid = ?', 'view_uuid = ?', "status = 'PUBLISHED'"]; - const params = [handle, orgId, viewId]; + const conditions = ['handle = ?', 'org_uuid = ?', 'view_uuid = ?', 'portal_id = ?', "status = 'PUBLISHED'"]; + const params = [handle, orgId, viewId, getPortalId()]; if (agentVisibility) { conditions.push('agent_visibility = ?'); params.push(agentVisibility); } const row = await db.queryOne(`SELECT * FROM ${TABLE} WHERE ${conditions.join(' AND ')}`, params); return mapRow(row); diff --git a/portals/api-portal/src/dao/applicationDao.js b/portals/api-portal/src/dao/applicationDao.js index d888bc5617..7e7581f113 100644 --- a/portals/api-portal/src/dao/applicationDao.js +++ b/portals/api-portal/src/dao/applicationDao.js @@ -21,6 +21,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { NotFoundError } = require('../utils/errors/customErrors'); const logger = require('../config/logger'); +const { getPortalId } = require('../utils/orgContext'); const APPLICATION_TABLE = 'applications'; const KEY_MAPPING_TABLE = 'app_key_mappings'; @@ -43,16 +44,18 @@ const create = async (orgId, userId, appData) => { // fall back to the uuid when the display name slugifies to nothing. const suppliedHandle = appData.handle != null ? String(appData.handle).trim() : ''; const handle = suppliedHandle || slugify(appData.displayName) || uuid; + const portalId = getPortalId(); await db.execute( - `INSERT INTO ${APPLICATION_TABLE} (uuid, display_name, handle, org_uuid, description, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuid, appData.displayName, handle, orgId, appData.description, userId, userId] + `INSERT INTO ${APPLICATION_TABLE} (uuid, display_name, handle, org_uuid, portal_id, description, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, appData.displayName, handle, orgId, portalId, appData.description, userId, userId] ); return { uuid, display_name: appData.displayName, handle, org_uuid: orgId, + portal_id: portalId, description: appData.description, created_by: userId, updated_by: userId, @@ -63,15 +66,15 @@ const update = async (orgId, appId, userId, appData) => { const updatedAt = new Date(); const { rowCount } = await db.execute( `UPDATE ${APPLICATION_TABLE} SET display_name = ?, description = ?, updated_by = ?, updated_at = ? - WHERE org_uuid = ? AND uuid = ? AND created_by = ?`, - [appData.displayName, appData.description, userId, updatedAt, orgId, appId, userId] + WHERE org_uuid = ? AND portal_id = ? AND uuid = ? AND created_by = ?`, + [appData.displayName, appData.description, userId, updatedAt, orgId, getPortalId(), appId, userId] ); if (!rowCount) { return [rowCount, null]; } const updatedApp = await db.queryOne( - `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND uuid = ?`, - [orgId, appId] + `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND uuid = ?`, + [orgId, getPortalId(), appId] ); return [rowCount, [updatedApp]]; }; @@ -79,30 +82,30 @@ const update = async (orgId, appId, userId, appData) => { const get = async (orgId, appId, userId, t) => { const exec = t || db; return exec.queryOne( - `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND uuid = ? AND created_by = ?`, - [orgId, appId, userId] + `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND uuid = ? AND created_by = ?`, + [orgId, getPortalId(), appId, userId] ); }; const getId = async (orgId, userId, handle) => { return db.queryOne( - `SELECT uuid FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND created_by = ? AND handle = ?`, - [orgId, userId, handle] + `SELECT uuid FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND created_by = ? AND handle = ?`, + [orgId, getPortalId(), userId, handle] ); }; const list = async (orgId, userId) => { return db.query( - `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND created_by = ?`, - [orgId, userId] + `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND created_by = ?`, + [orgId, getPortalId(), userId] ); }; const deleteApp = async (orgId, appId, userId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND uuid = ? AND created_by = ?`, - [orgId, appId, userId] + `DELETE FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND uuid = ? AND created_by = ?`, + [orgId, getPortalId(), appId, userId] ); if (rowCount < 1) { throw new NotFoundError('Application not found'); @@ -120,8 +123,8 @@ const deleteApp = async (orgId, appId, userId, t) => { const getKeyMapping = async (orgId, appId, t) => { const exec = t || db; const application = await exec.queryOne( - `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND uuid = ?`, - [orgId, appId] + `SELECT * FROM ${APPLICATION_TABLE} WHERE org_uuid = ? AND portal_id = ? AND uuid = ?`, + [orgId, getPortalId(), appId] ); if (!application) return null; @@ -157,13 +160,15 @@ const upsertKeyMapping = async (mappingData, t) => { } const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${KEY_MAPPING_TABLE} (uuid, app_uuid, km_uuid, as_client_id, type, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuid, mappingData.appId, mappingData.kmId || null, mappingData.asClientId, mappingData.type, mappingData.createdBy, mappingData.createdBy] + `INSERT INTO ${KEY_MAPPING_TABLE} (uuid, portal_id, app_uuid, km_uuid, as_client_id, type, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, portalId, mappingData.appId, mappingData.kmId || null, mappingData.asClientId, mappingData.type, mappingData.createdBy, mappingData.createdBy] ); return { uuid, + portal_id: portalId, app_uuid: mappingData.appId, km_uuid: mappingData.kmId || null, as_client_id: mappingData.asClientId, @@ -199,9 +204,9 @@ const deleteMappingsByIds = async (orgId, mappingIds, t) => { const idPlaceholders = mappingIds.map(() => '?').join(', '); const ownedMappings = await exec.query( `SELECT m.uuid FROM ${KEY_MAPPING_TABLE} m - JOIN ${APPLICATION_TABLE} a ON m.app_uuid = a.uuid - WHERE m.uuid IN (${idPlaceholders}) AND a.org_uuid = ?`, - [...mappingIds, orgId] + JOIN ${APPLICATION_TABLE} a ON m.app_uuid = a.uuid AND m.portal_id = a.portal_id + WHERE m.uuid IN (${idPlaceholders}) AND a.org_uuid = ? AND a.portal_id = ?`, + [...mappingIds, orgId, getPortalId()] ); const ownedIds = ownedMappings.map((m) => m.uuid); if (ownedIds.length === 0) return 0; @@ -236,16 +241,18 @@ const deleteKeyMappingById = async (appId, mappingId, t) => { const createKeyMapping = async (mappingData, t) => { const exec = t || db; const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${KEY_MAPPING_TABLE} (uuid, app_uuid, km_uuid, as_client_id, type, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO ${KEY_MAPPING_TABLE} (uuid, portal_id, app_uuid, km_uuid, as_client_id, type, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ - uuid, mappingData.appId, mappingData.kmId || null, mappingData.asClientId || null, + uuid, portalId, mappingData.appId, mappingData.kmId || null, mappingData.asClientId || null, mappingData.type || 'PRODUCTION', mappingData.createdBy, mappingData.createdBy, ] ); return { uuid, + portal_id: portalId, app_uuid: mappingData.appId, km_uuid: mappingData.kmId || null, as_client_id: mappingData.asClientId || null, diff --git a/portals/api-portal/src/dao/auditDao.js b/portals/api-portal/src/dao/auditDao.js index 203d0f0d2f..b135e6e29d 100644 --- a/portals/api-portal/src/dao/auditDao.js +++ b/portals/api-portal/src/dao/auditDao.js @@ -19,10 +19,11 @@ const crypto = require('crypto'); const db = require('../db/driver'); +const { getPortalId } = require('../utils/orgContext'); const INSERT_AUDIT_SQL = ` - INSERT INTO audit (uuid, action, resource_uuid, resource_type, org_uuid, performed_by) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO audit (uuid, action, resource_uuid, resource_type, org_uuid, portal_id, performed_by) + VALUES (?, ?, ?, ?, ?, ?, ?) `; /** @@ -37,13 +38,15 @@ const INSERT_AUDIT_SQL = ` */ const record = async (action, resourceUuid, resourceType, orgUuid, performedBy) => { const uuid = crypto.randomUUID(); - await db.execute(INSERT_AUDIT_SQL, [uuid, action, resourceUuid, resourceType, orgUuid, performedBy]); + const portalId = getPortalId(); + await db.execute(INSERT_AUDIT_SQL, [uuid, action, resourceUuid, resourceType, orgUuid, portalId, performedBy]); return { uuid, action, resource_uuid: resourceUuid, resource_type: resourceType, org_uuid: orgUuid, + portal_id: portalId, performed_by: performedBy, }; }; diff --git a/portals/api-portal/src/dao/eventDao.js b/portals/api-portal/src/dao/eventDao.js index a07605af45..3c8036b677 100644 --- a/portals/api-portal/src/dao/eventDao.js +++ b/portals/api-portal/src/dao/eventDao.js @@ -20,6 +20,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { groupBy, parseJsonColumn } = require('../db/rows'); +const { getPortalId } = require('../utils/orgContext'); const EVENTS_TABLE = 'events'; const DELIVERIES_TABLE = 'event_deliveries'; @@ -47,6 +48,7 @@ async function create({ eventType, orgId, aggregateType, aggregateId, payload }, uuid, type: eventType, org_uuid: orgId, + portal_id: getPortalId(), aggregate_type: aggregateType, aggregate_uuid: aggregateId, payload: payload || {}, @@ -55,9 +57,9 @@ async function create({ eventType, orgId, aggregateType, aggregateId, payload }, }; await exec.execute( - `INSERT INTO ${EVENTS_TABLE} (uuid, type, org_uuid, aggregate_type, aggregate_uuid, payload, occurred_at, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [row.uuid, row.type, row.org_uuid, row.aggregate_type, row.aggregate_uuid, + `INSERT INTO ${EVENTS_TABLE} (uuid, type, org_uuid, portal_id, aggregate_type, aggregate_uuid, payload, occurred_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [row.uuid, row.type, row.org_uuid, row.portal_id, row.aggregate_type, row.aggregate_uuid, JSON.stringify(row.payload), row.occurred_at, row.status] ); @@ -84,8 +86,10 @@ async function create({ eventType, orgId, aggregateType, aggregateId, payload }, */ async function createDeliveries(eventId, subscribers, perSubscriberEncrypted, transaction) { const exec = transaction || db; + const portalId = getPortalId(); const rows = subscribers.map((sub) => ({ uuid: crypto.randomUUID(), + portal_id: portalId, event_uuid: eventId, subscriber_id: sub.id, target_url: sub.url, @@ -95,10 +99,10 @@ async function createDeliveries(eventId, subscribers, perSubscriberEncrypted, tr for (const row of rows) { await exec.execute( - `INSERT INTO ${DELIVERIES_TABLE} (uuid, event_uuid, subscriber_id, target_url, encrypted_fields, status) - VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO ${DELIVERIES_TABLE} (uuid, portal_id, event_uuid, subscriber_id, target_url, encrypted_fields, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, [ - row.uuid, row.event_uuid, row.subscriber_id, row.target_url, + row.uuid, row.portal_id, row.event_uuid, row.subscriber_id, row.target_url, row.encrypted_fields !== null ? JSON.stringify(row.encrypted_fields) : null, row.status, ] @@ -124,8 +128,8 @@ async function claimPending(batchSize, orgUuid) { const lockClause = isPostgres ? ' FOR UPDATE SKIP LOCKED' : ''; const { clause, params: pageParams } = db.paginationClause(batchSize, 0); const events = await tx.query( - `SELECT * FROM ${EVENTS_TABLE} WHERE status = ? AND org_uuid = ? ORDER BY occurred_at ASC ${clause}${lockClause}`, - ['PENDING', orgUuid, ...pageParams] + `SELECT * FROM ${EVENTS_TABLE} WHERE status = ? AND org_uuid = ? AND portal_id = ? ORDER BY occurred_at ASC ${clause}${lockClause}`, + ['PENDING', orgUuid, getPortalId(), ...pageParams] ); if (events.length === 0) return []; @@ -162,17 +166,17 @@ async function claimDueDeliveries(batchSize, orgUuid) { await tx.execute( `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_error = ? WHERE status = ? AND last_attempt_at < ? - AND event_uuid IN (SELECT uuid FROM ${EVENTS_TABLE} WHERE org_uuid = ?)`, - ['FAILED', 'Delivery abandoned: worker stopped mid-flight', 'IN_FLIGHT', staleThreshold, orgUuid] + AND event_uuid IN (SELECT uuid FROM ${EVENTS_TABLE} WHERE org_uuid = ? AND portal_id = ?)`, + ['FAILED', 'Delivery abandoned: worker stopped mid-flight', 'IN_FLIGHT', staleThreshold, orgUuid, getPortalId()] ); const lockClause = isPostgres ? ' FOR UPDATE OF d SKIP LOCKED' : ''; const { clause, params: pageParams } = db.paginationClause(batchSize, 0); const rows = await tx.query( `SELECT d.* FROM ${DELIVERIES_TABLE} d - JOIN ${EVENTS_TABLE} e ON e.uuid = d.event_uuid - WHERE d.status = ? AND e.org_uuid = ? ORDER BY e.occurred_at ASC ${clause}${lockClause}`, - ['PENDING', orgUuid, ...pageParams] + JOIN ${EVENTS_TABLE} e ON e.uuid = d.event_uuid AND d.portal_id = e.portal_id + WHERE d.status = ? AND e.org_uuid = ? AND e.portal_id = ? ORDER BY e.occurred_at ASC ${clause}${lockClause}`, + ['PENDING', orgUuid, getPortalId(), ...pageParams] ); if (rows.length === 0) return []; @@ -231,8 +235,8 @@ async function reconcile(delivery) { * Admin: list recent events with delivery counts. */ async function list({ orgId, status, limit = 50, offset = 0 }) { - const conditions = []; - const params = []; + const conditions = ['portal_id = ?']; + const params = [getPortalId()]; if (orgId) { conditions.push('org_uuid = ?'); params.push(orgId); @@ -289,11 +293,11 @@ async function listDeliveriesForSubscriber(orgId, subscriberId, limit = 20) { const rows = await db.query( `SELECT d.*, e.type AS event_type, e.occurred_at AS event_occurred_at FROM ${DELIVERIES_TABLE} d - INNER JOIN ${EVENTS_TABLE} e ON e.uuid = d.event_uuid - WHERE d.subscriber_id = ? AND e.org_uuid = ? + INNER JOIN ${EVENTS_TABLE} e ON e.uuid = d.event_uuid AND d.portal_id = e.portal_id + WHERE d.subscriber_id = ? AND e.org_uuid = ? AND e.portal_id = ? ORDER BY e.occurred_at DESC ${clause}`, - [subscriberId, orgId, ...pageParams] + [subscriberId, orgId, getPortalId(), ...pageParams] ); return rows.map(({ event_type, event_occurred_at, ...delivery }) => parseDeliveryRow({ ...delivery, diff --git a/portals/api-portal/src/dao/keyManagerDao.js b/portals/api-portal/src/dao/keyManagerDao.js index 8aa7b0976e..ad9fe9d2f4 100644 --- a/portals/api-portal/src/dao/keyManagerDao.js +++ b/portals/api-portal/src/dao/keyManagerDao.js @@ -21,6 +21,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { NotFoundError } = require('../utils/errors/customErrors'); const logger = require('../config/logger'); +const { getPortalId } = require('../utils/orgContext'); const TABLE = 'key_managers'; @@ -36,9 +37,9 @@ const create = async (orgId, kmData, createdBy) => { try { await db.execute( - `INSERT INTO ${TABLE} (uuid, org_uuid, handle, display_name, enabled, token_endpoint, created_by, created_at, updated_by, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, orgId, kmData.handle, kmData.displayName, enabled, kmData.tokenEndpoint, createdBy, now, createdBy, now] + `INSERT INTO ${TABLE} (uuid, org_uuid, portal_id, handle, display_name, enabled, token_endpoint, created_by, created_at, updated_by, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, orgId, getPortalId(), kmData.handle, kmData.displayName, enabled, kmData.tokenEndpoint, createdBy, now, createdBy, now] ); } catch (error) { // Let the raw driver error (pg 23505 / sqlite UNIQUE / mssql 2601-2627) propagate @@ -67,7 +68,7 @@ const create = async (orgId, kmData, createdBy) => { /** * Update an existing key manager. */ -const update = async (kmId, kmData, updatedBy) => { +const update = async (orgId, kmId, kmData, updatedBy) => { const now = new Date(); const setClauses = ['updated_by = ?', 'updated_at = ?']; const params = [updatedBy, now]; @@ -75,18 +76,21 @@ const update = async (kmId, kmData, updatedBy) => { if (kmData.displayName) { setClauses.push('display_name = ?'); params.push(kmData.displayName); } if (kmData.enabled !== undefined) { setClauses.push('enabled = ?'); params.push(kmData.enabled ? 1 : 0); } if (kmData.tokenEndpoint) { setClauses.push('token_endpoint = ?'); params.push(kmData.tokenEndpoint); } - params.push(kmId); + params.push(kmId, orgId, getPortalId()); try { const { rowCount: updatedRowsCount } = await db.execute( - `UPDATE ${TABLE} SET ${setClauses.join(', ')} WHERE uuid = ?`, + `UPDATE ${TABLE} SET ${setClauses.join(', ')} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, params ); if (updatedRowsCount < 1) { throw new NotFoundError('Key manager not found'); } // Re-fetch explicitly so the result is reliable across every dialect. - const updated = await db.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ?`, [kmId]); + const updated = await db.queryOne( + `SELECT * FROM ${TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [kmId, orgId, getPortalId()] + ); return [updatedRowsCount, [updated]]; } catch (error) { if (error instanceof NotFoundError || db.isDuplicateKeyError(error)) { @@ -102,7 +106,7 @@ const update = async (kmId, kmData, updatedBy) => { */ const list = async (orgId) => { try { - return await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ?`, [orgId]); + return await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ?`, [orgId, getPortalId()]); } catch (error) { logger.error('Error fetching key managers', { error }); throw error; @@ -114,7 +118,7 @@ const list = async (orgId) => { */ const listEnabled = async (orgId) => { try { - return await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND enabled = ?`, [orgId, 1]); + return await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ? AND enabled = ?`, [orgId, getPortalId(), 1]); } catch (error) { logger.error('Error fetching enabled key managers', { error }); throw error; @@ -122,11 +126,14 @@ const listEnabled = async (orgId) => { }; /** - * Get a single key manager by UUID. + * Get a single key manager by UUID, scoped to the caller's organization and portal. */ -const get = async (kmId) => { +const get = async (orgId, kmId) => { try { - const km = await db.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ?`, [kmId]); + const km = await db.queryOne( + `SELECT * FROM ${TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [kmId, orgId, getPortalId()] + ); if (!km) { throw new NotFoundError('Key manager not found'); } @@ -145,7 +152,7 @@ const get = async (kmId) => { */ const getByHandle = async (orgId, handle) => { try { - const km = await db.queryOne(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND handle = ?`, [orgId, handle]); + const km = await db.queryOne(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ? AND handle = ?`, [orgId, getPortalId(), handle]); if (!km) { throw new NotFoundError('Key manager not found'); } @@ -163,16 +170,19 @@ const getByHandle = async (orgId, handle) => { * Resolve a key manager's handle to its internal uuid, or null if not found. */ const getIdByHandle = async (orgId, handle) => { - const km = await db.queryOne(`SELECT uuid FROM ${TABLE} WHERE org_uuid = ? AND handle = ?`, [orgId, handle]); + const km = await db.queryOne(`SELECT uuid FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ? AND handle = ?`, [orgId, getPortalId(), handle]); return km ? km.uuid : null; }; /** - * Delete a key manager. + * Delete a key manager, scoped to the caller's organization and portal. */ -const deleteKm = async (kmId) => { +const deleteKm = async (orgId, kmId) => { try { - const { rowCount: deleted } = await db.execute(`DELETE FROM ${TABLE} WHERE uuid = ?`, [kmId]); + const { rowCount: deleted } = await db.execute( + `DELETE FROM ${TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [kmId, orgId, getPortalId()] + ); if (deleted < 1) { throw new NotFoundError('Key manager not found'); } diff --git a/portals/api-portal/src/dao/labelDao.js b/portals/api-portal/src/dao/labelDao.js index 9039fe81cf..a4b06c5b9b 100644 --- a/portals/api-portal/src/dao/labelDao.js +++ b/portals/api-portal/src/dao/labelDao.js @@ -22,6 +22,7 @@ const db = require('../db/driver'); const { findOrCreateSafe } = require('./findOrCreateHelper'); const constants = require('../utils/constants'); const { CustomError } = require('../utils/errors/customErrors'); +const { getPortalId } = require('../utils/orgContext'); const LABELS_TABLE = 'labels'; const API_LABELS_TABLE = 'api_label_mappings'; @@ -31,24 +32,26 @@ const VIEW_LABELS_TABLE = 'view_label_mappings'; // and column list, not on any per-call data. const UPSERT_API_LABEL_SQL = db.buildUpsert( API_LABELS_TABLE, - ['uuid', 'label_uuid', 'api_uuid', 'created_by'], - ['label_uuid', 'api_uuid'], + ['uuid', 'portal_id', 'label_uuid', 'api_uuid', 'created_by'], + ['portal_id', 'label_uuid', 'api_uuid'], [] // ignoreDuplicates semantics — leave the existing mapping row untouched on conflict ); const create = async (orgId, label, createdBy, t) => { const exec = t || db; const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [uuid, label.handle, label.displayName, orgId, createdBy, createdBy] + `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [uuid, label.handle, label.displayName, orgId, portalId, createdBy, createdBy] ); return { uuid, handle: label.handle, display_name: label.displayName, org_uuid: orgId, + portal_id: portalId, created_by: createdBy, updated_by: createdBy, }; @@ -57,8 +60,8 @@ const create = async (orgId, label, createdBy, t) => { const findById = async (orgId, labelId, t) => { const exec = t || db; const record = await exec.queryOne( - `SELECT * FROM ${LABELS_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [labelId, orgId] + `SELECT * FROM ${LABELS_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [labelId, orgId, getPortalId()] ); if (!record) { throw new CustomError(404, constants.ERROR_CODE[404], 'Label not found'); @@ -68,8 +71,8 @@ const findById = async (orgId, labelId, t) => { const getIdByHandle = async (orgId, handle) => { const label = await db.queryOne( - `SELECT uuid FROM ${LABELS_TABLE} WHERE org_uuid = ? AND handle = ?`, - [orgId, handle] + `SELECT uuid FROM ${LABELS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND handle = ?`, + [orgId, getPortalId(), handle] ); return label ? label.uuid : null; }; @@ -79,16 +82,16 @@ const updateById = async (orgId, labelId, label, updatedBy, t) => { const record = await findById(orgId, labelId, t); const updatedAt = new Date(); await exec.execute( - `UPDATE ${LABELS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, - [label.displayName, updatedBy, updatedAt, labelId, orgId] + `UPDATE ${LABELS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [label.displayName, updatedBy, updatedAt, labelId, orgId, getPortalId()] ); return { ...record, display_name: label.displayName, updated_by: updatedBy, updated_at: updatedAt }; }; const deleteById = async (orgId, labelId) => { const { rowCount } = await db.execute( - `DELETE FROM ${LABELS_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [labelId, orgId] + `DELETE FROM ${LABELS_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [labelId, orgId, getPortalId()] ); if (rowCount === 0) { throw new CustomError(404, constants.ERROR_CODE[404], 'Label not found'); @@ -98,19 +101,21 @@ const deleteById = async (orgId, labelId) => { const createMany = async (orgId, labels, createdBy, t) => { const exec = t || db; + const portalId = getPortalId(); const created = []; for (const label of labels) { const uuid = crypto.randomUUID(); await exec.execute( - `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [uuid, label.handle, label.displayName, orgId, createdBy, createdBy] + `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [uuid, label.handle, label.displayName, orgId, portalId, createdBy, createdBy] ); created.push({ uuid, handle: label.handle, display_name: label.displayName, org_uuid: orgId, + portal_id: portalId, created_by: createdBy, updated_by: createdBy, }); @@ -122,7 +127,7 @@ const createApiMapping = async (orgId, apiId, labels, createdBy, t) => { const exec = t || db; const idList = await getId(orgId, labels, t); for (const labelId of idList) { - await exec.execute(UPSERT_API_LABEL_SQL, [crypto.randomUUID(), labelId, apiId, createdBy]); + await exec.execute(UPSERT_API_LABEL_SQL, [crypto.randomUUID(), getPortalId(), labelId, apiId, createdBy]); } return idList; }; @@ -135,9 +140,10 @@ const createApiMapping = async (orgId, apiId, labels, createdBy, t) => { */ const update = async (orgId, label, updatedBy, t) => { const exec = t || db; + const portalId = getPortalId(); const existing = await exec.queryOne( - `SELECT * FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [label.handle, orgId] + `SELECT * FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [label.handle, orgId, portalId] ); let row = existing; @@ -145,15 +151,16 @@ const update = async (orgId, label, updatedBy, t) => { const uuid = crypto.randomUUID(); try { await db.withSavepoint(exec, () => exec.execute( - `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [uuid, label.handle, label.displayName, orgId, updatedBy, updatedBy] + `INSERT INTO ${LABELS_TABLE} (uuid, handle, display_name, org_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [uuid, label.handle, label.displayName, orgId, portalId, updatedBy, updatedBy] )); return { uuid, handle: label.handle, display_name: label.displayName, org_uuid: orgId, + portal_id: portalId, created_by: updatedBy, updated_by: updatedBy, }; @@ -161,8 +168,8 @@ const update = async (orgId, label, updatedBy, t) => { if (!db.isDuplicateKeyError(error)) throw error; // Lost a race to create this label — fall through to the update path below. row = await exec.queryOne( - `SELECT * FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [label.handle, orgId] + `SELECT * FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [label.handle, orgId, portalId] ); } } @@ -186,8 +193,8 @@ const getId = async (orgId, labels, t) => { const getIdList = async (orgId, label, t) => { const exec = t || db; const labelResponse = await exec.queryOne( - `SELECT uuid FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [label, orgId] + `SELECT uuid FROM ${LABELS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [label, orgId, getPortalId()] ); if (!labelResponse) { throw new CustomError(404, constants.ERROR_CODE[404], 'Label not found'); @@ -196,7 +203,7 @@ const getIdList = async (orgId, label, t) => { }; const list = async (orgId) => { - return db.query(`SELECT * FROM ${LABELS_TABLE} WHERE org_uuid = ?`, [orgId]); + return db.query(`SELECT * FROM ${LABELS_TABLE} WHERE org_uuid = ? AND portal_id = ?`, [orgId, getPortalId()]); }; const deleteApiMapping = async (orgId, apiId, labels, t) => { @@ -213,10 +220,11 @@ const deleteApiMapping = async (orgId, apiId, labels, t) => { const addToView = async (orgId, labelId, viewId, createdBy, t) => { const exec = t || db; + const portalId = getPortalId(); return findOrCreateSafe( VIEW_LABELS_TABLE, - { label_uuid: labelId, view_uuid: viewId }, - { uuid: crypto.randomUUID(), label_uuid: labelId, view_uuid: viewId, created_by: createdBy }, + { label_uuid: labelId, view_uuid: viewId, portal_id: portalId }, + { uuid: crypto.randomUUID(), portal_id: portalId, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }, exec ); }; diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index db80898f41..e9c14c37f8 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -27,24 +27,28 @@ const constants = require('../utils/constants'); const ORG_TABLE = 'organizations'; const ORG_CONTENT_TABLE = 'organization_assets'; +const getPortalId = () => require('../utils/orgContext').getPortalId(); + const create = async (orgData, t) => { const exec = t || db; const orgHandle = orgData.handle ? orgData.handle.toLowerCase() : ''; const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( `INSERT INTO ${ORG_TABLE} - (uuid, display_name, business_owner, business_owner_contact, business_owner_email, + (uuid, portal_id, display_name, business_owner, business_owner_contact, business_owner_email, handle, idp_ref_id, cp_ref_id, configuration, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - uuid, orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, + uuid, portalId, orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.cpRefId, orgData.configuration, orgData.createdBy, orgData.createdBy, ] ); return { uuid, + portal_id: portalId, display_name: orgData.displayName, business_owner: orgData.businessOwner, business_owner_contact: orgData.businessOwnerContact, @@ -78,10 +82,11 @@ const normalizeOrgRow = (row) => { const findOrgByIdentifier = async (param, t) => { const exec = t || db; const handle = typeof param === 'string' ? param.toLowerCase() : param; + const portalId = getPortalId(); return normalizeOrgRow( - (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE handle = ?`, [handle])) || - (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE display_name = ?`, [param])) || - (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE idp_ref_id = ?`, [param])) + (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE handle = ? AND portal_id = ?`, [handle, portalId])) || + (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE display_name = ? AND portal_id = ?`, [param, portalId])) || + (await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE idp_ref_id = ? AND portal_id = ?`, [param, portalId])) ); }; @@ -97,7 +102,7 @@ const get = async (param, t) => { // auth middleware) — not for public REST lookups, which should use get()/handle instead. const getByUuid = async (uuid, t) => { const exec = t || db; - const organization = await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE uuid = ?`, [uuid]); + const organization = await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE uuid = ? AND portal_id = ?`, [uuid, getPortalId()]); if (!organization) { throw new NotFoundError('Organization not found'); } @@ -112,7 +117,7 @@ const getByUuid = async (uuid, t) => { const getByHandle = async (handle, t) => { const exec = t || db; const organization = normalizeOrgRow( - await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE handle = ?`, [String(handle).toLowerCase()]) + await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE handle = ? AND portal_id = ?`, [String(handle).toLowerCase(), getPortalId()]) ); if (!organization) { throw new NotFoundError('Organization not found'); @@ -129,7 +134,7 @@ const getId = async (orgName) => { }; const list = async () => { - return (await db.query(`SELECT * FROM ${ORG_TABLE}`)).map(normalizeOrgRow); + return (await db.query(`SELECT * FROM ${ORG_TABLE} WHERE portal_id = ?`, [getPortalId()])).map(normalizeOrgRow); }; const update = async (orgData, t) => { @@ -155,10 +160,10 @@ const update = async (orgData, t) => { setClauses.push('configuration = ?'); params.push(orgData.configuration); } - params.push(existing.uuid); + params.push(existing.uuid, getPortalId()); const { rowCount } = await exec.execute( - `UPDATE ${ORG_TABLE} SET ${setClauses.join(', ')} WHERE uuid = ?`, + `UPDATE ${ORG_TABLE} SET ${setClauses.join(', ')} WHERE uuid = ? AND portal_id = ?`, params ); if (rowCount < 1) { @@ -166,7 +171,7 @@ const update = async (orgData, t) => { } // Some dialects don't support RETURNING on UPDATE — re-fetch explicitly instead // (same pattern as applicationDao.update). - const updatedOrg = normalizeOrgRow(await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE uuid = ?`, [existing.uuid])); + const updatedOrg = normalizeOrgRow(await exec.queryOne(`SELECT * FROM ${ORG_TABLE} WHERE uuid = ? AND portal_id = ?`, [existing.uuid, getPortalId()])); return [rowCount, [updatedOrg]]; }; @@ -179,8 +184,8 @@ const update = async (orgData, t) => { const updateIdpRefId = async (orgUuid, idpRefId, actor, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `UPDATE ${ORG_TABLE} SET idp_ref_id = ?, updated_by = ?, updated_at = ? WHERE uuid = ?`, - [idpRefId, actor, new Date(), orgUuid] + `UPDATE ${ORG_TABLE} SET idp_ref_id = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND portal_id = ?`, + [idpRefId, actor, new Date(), orgUuid, getPortalId()] ); if (rowCount < 1) { throw new NotFoundError('Organization not found'); @@ -199,17 +204,19 @@ const updateIdpRefId = async (orgUuid, idpRefId, actor, t) => { const findOtherOrgClaimingIdentifier = async (value, excludeUuid, t) => { const exec = t || db; const rows = await exec.query( - `SELECT * FROM ${ORG_TABLE} WHERE (handle = ? OR display_name = ? OR idp_ref_id = ?) AND uuid <> ?`, - [String(value).toLowerCase(), value, value, excludeUuid] + `SELECT * FROM ${ORG_TABLE} WHERE (handle = ? OR display_name = ? OR idp_ref_id = ?) AND uuid <> ? AND portal_id = ?`, + [String(value).toLowerCase(), value, value, excludeUuid, getPortalId()] ); return rows.length ? normalizeOrgRow(rows[0]) : null; }; // Tables whose org_uuid FK is ON DELETE NO ACTION (database/schema.*.sql) block -// deleting the organization row unless their rows are removed first. Tables with -// ON DELETE CASCADE/SET NULL (api_metadata, subscription_plans, audit, -// user_organization_mappings, and the *_mappings join tables) are left to the -// database to handle and aren't touched here. +// deleting the organization row unless their rows are removed first. +// api_metadata.org_uuid and subscription_plans.org_uuid are nullable and use +// ON DELETE NO ACTION (composite FKs cannot partially SET NULL while portal_id is +// NOT NULL), so we nullify them here before deleting the org row. +// Tables with ON DELETE CASCADE (audit, user_organization_mappings, and the +// *_mappings join tables) are left to the database to handle. const deleteOrgDependents = async (orgUuid, t) => { const exec = t || db; @@ -223,6 +230,11 @@ const deleteOrgDependents = async (orgUuid, t) => { } await exec.execute('DELETE FROM events WHERE org_uuid = ?', [orgUuid]); + // Nullify nullable org_uuid references before deleting the org row. + // The DB constraint is ON DELETE NO ACTION; application code owns the nullification. + await exec.execute('UPDATE api_metadata SET org_uuid = NULL WHERE org_uuid = ?', [orgUuid]); + await exec.execute('UPDATE subscription_plans SET org_uuid = NULL WHERE org_uuid = ?', [orgUuid]); + await exec.execute('DELETE FROM api_keys WHERE org_uuid = ?', [orgUuid]); await exec.execute('DELETE FROM subscriptions WHERE org_uuid = ?', [orgUuid]); @@ -261,7 +273,7 @@ const deleteOrg = async (orgId, t) => { const exec = t || db; const existing = await get(orgId, t); await deleteOrgDependents(existing.uuid, t); - const { rowCount } = await exec.execute(`DELETE FROM ${ORG_TABLE} WHERE uuid = ?`, [existing.uuid]); + const { rowCount } = await exec.execute(`DELETE FROM ${ORG_TABLE} WHERE uuid = ? AND portal_id = ?`, [existing.uuid, getPortalId()]); if (rowCount < 1) { throw new NotFoundError('Organization not found'); } @@ -275,11 +287,11 @@ const createContent = async (orgData, t) => { const content = toBlobBuffer(orgData.fileContent); await exec.execute( `INSERT INTO ${ORG_CONTENT_TABLE} - (uuid, file_type, file_name, file_content, file_path, org_uuid, view_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (uuid, file_type, file_name, file_content, file_path, org_uuid, view_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, orgData.fileType, orgData.fileName, content, orgData.filePath, - orgData.orgId, viewId, orgData.createdBy, orgData.createdBy, + orgData.orgId, viewId, getPortalId(), orgData.createdBy, orgData.createdBy, ] ); return { @@ -290,6 +302,7 @@ const createContent = async (orgData, t) => { file_path: orgData.filePath, org_uuid: orgData.orgId, view_uuid: viewId, + portal_id: getPortalId(), created_by: orgData.createdBy, updated_by: orgData.createdBy, }; @@ -299,13 +312,14 @@ const updateContent = async (orgData) => { const viewId = await viewDao.getId(orgData.orgId, orgData.viewName); const updatedAt = new Date(); const content = toBlobBuffer(orgData.fileContent); + const portalId = getPortalId(); const { rowCount } = await db.execute( `UPDATE ${ORG_CONTENT_TABLE} SET file_type = ?, file_name = ?, file_content = ?, file_path = ?, updated_by = ?, updated_at = ? - WHERE file_type = ? AND file_name = ? AND file_path = ? AND org_uuid = ? AND view_uuid = ?`, + WHERE file_type = ? AND file_name = ? AND file_path = ? AND org_uuid = ? AND view_uuid = ? AND portal_id = ?`, [ orgData.fileType, orgData.fileName, content, orgData.filePath, orgData.updatedBy, updatedAt, - orgData.fileType, orgData.fileName, orgData.filePath, orgData.orgId, viewId, + orgData.fileType, orgData.fileName, orgData.filePath, orgData.orgId, viewId, portalId, ] ); if (rowCount < 1) { @@ -313,17 +327,18 @@ const updateContent = async (orgData) => { } const updatedOrgContent = await db.query( `SELECT * FROM ${ORG_CONTENT_TABLE} - WHERE file_type = ? AND file_name = ? AND file_path = ? AND org_uuid = ? AND view_uuid = ?`, - [orgData.fileType, orgData.fileName, orgData.filePath, orgData.orgId, viewId] + WHERE file_type = ? AND file_name = ? AND file_path = ? AND org_uuid = ? AND view_uuid = ? AND portal_id = ?`, + [orgData.fileType, orgData.fileName, orgData.filePath, orgData.orgId, viewId, portalId] ); return [rowCount, updatedOrgContent]; }; const getContent = async (orgData) => { const viewId = await viewDao.getId(orgData.orgId, orgData.viewName); + const portalId = getPortalId(); if (orgData.fileName || orgData.filePath) { - const conditions = ['org_uuid = ?', 'view_uuid = ?', 'file_type = ?']; - const params = [orgData.orgId, viewId, orgData.fileType]; + const conditions = ['org_uuid = ?', 'view_uuid = ?', 'file_type = ?', 'portal_id = ?']; + const params = [orgData.orgId, viewId, orgData.fileType, portalId]; if (orgData.fileName) { conditions.push('file_name = ?'); params.push(orgData.fileName); @@ -335,16 +350,16 @@ const getContent = async (orgData) => { return db.queryOne(`SELECT * FROM ${ORG_CONTENT_TABLE} WHERE ${conditions.join(' AND ')}`, params); } return db.query( - `SELECT * FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_type = ?`, - [orgData.orgId, viewId, orgData.fileType] + `SELECT * FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_type = ? AND portal_id = ?`, + [orgData.orgId, viewId, orgData.fileType, portalId] ); }; const deleteContent = async (orgId, viewName, fileName) => { const viewId = await viewDao.getId(orgId, viewName); const { rowCount } = await db.execute( - `DELETE FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_name = ?`, - [orgId, viewId, fileName] + `DELETE FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_name = ? AND portal_id = ?`, + [orgId, viewId, fileName, getPortalId()] ); if (rowCount < 1) { throw new NotFoundError('Organization content not found'); @@ -360,8 +375,8 @@ const deleteThemeContent = async (orgId, viewName, t) => { const viewId = await viewDao.getId(orgId, viewName); const placeholders = constants.THEME_FILE_TYPES.map(() => '?').join(', '); const { rowCount } = await exec.execute( - `DELETE FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_type IN (${placeholders})`, - [orgId, viewId, ...constants.THEME_FILE_TYPES] + `DELETE FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND portal_id = ? AND file_type IN (${placeholders})`, + [orgId, viewId, getPortalId(), ...constants.THEME_FILE_TYPES] ); return rowCount; }; @@ -371,8 +386,8 @@ const hasThemeContent = async (orgId, viewName) => { if (!viewId) return false; const placeholders = constants.THEME_FILE_TYPES.map(() => '?').join(', '); const rows = await db.query( - `SELECT 1 AS found FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND file_type IN (${placeholders})`, - [orgId, viewId, ...constants.THEME_FILE_TYPES] + `SELECT 1 AS found FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ? AND view_uuid = ? AND portal_id = ? AND file_type IN (${placeholders})`, + [orgId, viewId, getPortalId(), ...constants.THEME_FILE_TYPES] ); return rows.length > 0; }; diff --git a/portals/api-portal/src/dao/subscriptionDao.js b/portals/api-portal/src/dao/subscriptionDao.js index 0a8b0b646c..f58961019e 100644 --- a/portals/api-portal/src/dao/subscriptionDao.js +++ b/portals/api-portal/src/dao/subscriptionDao.js @@ -24,6 +24,7 @@ const { NotFoundError } = require('../utils/errors/customErrors'); const { createCryptoUtil } = require('../utils/cryptoUtil'); const { config } = require('../config/configLoader'); const logger = require('../config/logger'); +const { getPortalId } = require('../utils/orgContext'); const subCrypto = createCryptoUtil(config.security.encryptionKey); @@ -111,9 +112,9 @@ async function create(orgId, apiId, planId, createdBy, transaction, opts = {}) { const uuid = crypto.randomUUID(); await exec.execute( `INSERT INTO ${SUBSCRIPTIONS_TABLE} - (uuid, created_by, updated_by, org_uuid, api_uuid, plan_uuid, token, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, createdBy, createdBy, orgId, apiId, planId || null, encryptToken(opts.subToken), 'ACTIVE', now, now] + (uuid, created_by, updated_by, org_uuid, portal_id, api_uuid, plan_uuid, token, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, createdBy, createdBy, orgId, getPortalId(), apiId, planId || null, encryptToken(opts.subToken), 'ACTIVE', now, now] ); return { uuid, @@ -136,9 +137,9 @@ async function create(orgId, apiId, planId, createdBy, transaction, opts = {}) { try { await exec.execute( `INSERT INTO ${SUBSCRIPTIONS_TABLE} - (uuid, created_by, updated_by, org_uuid, api_uuid, plan_uuid, token, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, createdBy, createdBy, orgId, apiId, planId || null, encryptToken(subToken), 'ACTIVE', now, now] + (uuid, created_by, updated_by, org_uuid, portal_id, api_uuid, plan_uuid, token, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, createdBy, createdBy, orgId, getPortalId(), apiId, planId || null, encryptToken(subToken), 'ACTIVE', now, now] ); // Expose the plaintext token to callers (never the encrypted form). return { @@ -162,8 +163,8 @@ async function create(orgId, apiId, planId, createdBy, transaction, opts = {}) { } async function list(orgId, { apiId, createdBy } = {}) { - const where = ['org_uuid = ?']; - const params = [orgId]; + const where = ['org_uuid = ?', 'portal_id = ?']; + const params = [orgId, getPortalId()]; if (apiId) { where.push('api_uuid = ?'); params.push(apiId); @@ -181,8 +182,8 @@ async function list(orgId, { apiId, createdBy } = {}) { } async function get(orgId, subId, createdBy) { - const where = ['uuid = ?', 'org_uuid = ?']; - const params = [subId, orgId]; + const where = ['uuid = ?', 'org_uuid = ?', 'portal_id = ?']; + const params = [subId, orgId, getPortalId()]; if (createdBy) { where.push('created_by = ?'); params.push(createdBy); @@ -195,8 +196,8 @@ async function get(orgId, subId, createdBy) { async function updateStatus(orgId, subId, status, createdBy, transaction) { const exec = transaction || db; - const where = ['uuid = ?', 'org_uuid = ?']; - const params = [subId, orgId]; + const where = ['uuid = ?', 'org_uuid = ?', 'portal_id = ?']; + const params = [subId, orgId, getPortalId()]; if (createdBy) { where.push('created_by = ?'); params.push(createdBy); @@ -212,8 +213,8 @@ async function updatePlan(orgId, subId, planId, updatedBy, transaction) { const exec = transaction || db; const { rowCount } = await exec.execute( `UPDATE ${SUBSCRIPTIONS_TABLE} SET plan_uuid = ?, updated_by = ?, updated_at = ? - WHERE uuid = ? AND org_uuid = ? AND created_by = ?`, - [planId, updatedBy, new Date(), subId, orgId, updatedBy] + WHERE uuid = ? AND org_uuid = ? AND portal_id = ? AND created_by = ?`, + [planId, updatedBy, new Date(), subId, orgId, getPortalId(), updatedBy] ); return rowCount > 0; } @@ -225,8 +226,8 @@ async function regenerateToken(orgId, subId, updatedBy, transaction) { try { const { rowCount } = await exec.execute( `UPDATE ${SUBSCRIPTIONS_TABLE} SET token = ?, updated_by = ?, updated_at = ? - WHERE uuid = ? AND org_uuid = ? AND created_by = ?`, - [encryptToken(newToken), updatedBy, new Date(), subId, orgId, updatedBy] + WHERE uuid = ? AND org_uuid = ? AND portal_id = ? AND created_by = ?`, + [encryptToken(newToken), updatedBy, new Date(), subId, orgId, getPortalId(), updatedBy] ); if (rowCount === 0) return null; return newToken; @@ -240,8 +241,12 @@ async function regenerateToken(orgId, subId, updatedBy, transaction) { async function deleteSubscription(orgId, subId, createdBy, transaction) { const exec = transaction || db; - const where = ['uuid = ?', 'org_uuid = ?']; - const params = [subId, orgId]; + await exec.execute( + 'UPDATE api_keys SET subscription_uuid = NULL WHERE subscription_uuid = ?', + [subId] + ); + const where = ['uuid = ?', 'org_uuid = ?', 'portal_id = ?']; + const params = [subId, orgId, getPortalId()]; if (createdBy) { where.push('created_by = ?'); params.push(createdBy); @@ -255,8 +260,8 @@ async function deleteSubscription(orgId, subId, createdBy, transaction) { async function getById(orgId, subId) { const sub = await db.queryOne( - `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [subId, orgId] + `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [subId, orgId, getPortalId()] ); if (!sub) return null; await attachApiAndPlan([sub]); @@ -265,20 +270,23 @@ async function getById(orgId, subId) { const listByApi = async (orgId, apiId) => { return db.query( - `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND api_uuid = ?`, - [orgId, apiId] + `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND api_uuid = ?`, + [orgId, getPortalId(), apiId] ); }; const listByOrg = async (orgId) => { - return db.query(`SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ?`, [orgId]); + return db.query( + `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND portal_id = ?`, + [orgId, getPortalId()] + ); }; const listByUser = async (orgId, userId) => { try { return await db.query( - `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND created_by = ?`, - [orgId, userId] + `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND created_by = ?`, + [orgId, getPortalId(), userId] ); } catch (error) { logger.error('listByUser failed', { error, orgId, userId }); @@ -290,8 +298,8 @@ const findByKey = async (orgId, apiId, planId, t) => { const exec = t || db; try { return await exec.queryOne( - `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND api_uuid = ? AND plan_uuid = ?`, - [orgId, apiId, planId] + `SELECT * FROM ${SUBSCRIPTIONS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND api_uuid = ? AND plan_uuid = ?`, + [orgId, getPortalId(), apiId, planId] ); } catch (error) { if (error instanceof NotFoundError) return null; diff --git a/portals/api-portal/src/dao/subscriptionPlanDao.js b/portals/api-portal/src/dao/subscriptionPlanDao.js index 33abf7dfe8..1b1ca1303d 100644 --- a/portals/api-portal/src/dao/subscriptionPlanDao.js +++ b/portals/api-portal/src/dao/subscriptionPlanDao.js @@ -21,6 +21,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { groupBy } = require('../db/rows'); const { ValidationError } = require('../utils/errors/customErrors'); +const { getPortalId } = require('../utils/orgContext'); const SUBSCRIPTION_PLANS_TABLE = 'subscription_plans'; const SUBSCRIPTION_PLAN_LIMITS_TABLE = 'subscription_plan_limits'; @@ -67,11 +68,12 @@ const replaceLimits = async (planId, limits, t) => { const exec = t || db; await exec.execute(`DELETE FROM ${SUBSCRIPTION_PLAN_LIMITS_TABLE} WHERE plan_uuid = ?`, [planId]); const rows = normalizeLimits(limits); + const portalId = getPortalId(); for (const r of rows) { await exec.execute( - `INSERT INTO ${SUBSCRIPTION_PLAN_LIMITS_TABLE} (uuid, plan_uuid, limit_type, time_unit, time_amount, limit_count) - VALUES (?, ?, ?, ?, ?, ?)`, - [r.uuid, planId, r.limit_type, r.time_unit, r.time_amount, r.limit_count] + `INSERT INTO ${SUBSCRIPTION_PLAN_LIMITS_TABLE} (uuid, portal_id, plan_uuid, limit_type, time_unit, time_amount, limit_count) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [r.uuid, portalId, planId, r.limit_type, r.time_unit, r.time_amount, r.limit_count] ); } }; @@ -98,12 +100,12 @@ const attachLimits = async (plans, t) => { return plans; }; -/** Fetches a single plan (scoped to its organization) with `.limits` attached. */ +/** Fetches a single plan (scoped to its organization and portal) with `.limits` attached. */ const findPlanByUuid = async (orgId, planId, t) => { const exec = t || db; const plan = await exec.queryOne( - `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid = ? AND org_uuid = ?`, - [planId, orgId] + `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [planId, orgId, getPortalId()] ); if (!plan) return null; await attachLimits([plan], t); @@ -118,9 +120,9 @@ const create = async (orgId, plan, createdBy, t) => { await exec.execute( `INSERT INTO ${SUBSCRIPTION_PLANS_TABLE} - (uuid, org_uuid, handle, display_name, description, ref_id, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, row.org_uuid, row.handle, row.display_name, row.description, row.ref_id, createdBy, createdBy, now, now] + (uuid, org_uuid, portal_id, handle, display_name, description, ref_id, created_by, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, row.org_uuid, getPortalId(), row.handle, row.display_name, row.description, row.ref_id, createdBy, createdBy, now, now] ); await replaceLimits(uuid, plan.limits || [], t); return findPlanByUuid(orgId, uuid, t); @@ -128,6 +130,7 @@ const create = async (orgId, plan, createdBy, t) => { const createMany = async (orgId, plans, createdBy, t) => { const exec = t || db; + const portalId = getPortalId(); const uuids = []; for (const plan of plans) { const uuid = crypto.randomUUID(); @@ -135,9 +138,9 @@ const createMany = async (orgId, plans, createdBy, t) => { const row = buildSubscriptionPlanRow(orgId, plan); await exec.execute( `INSERT INTO ${SUBSCRIPTION_PLANS_TABLE} - (uuid, org_uuid, handle, display_name, description, ref_id, created_by, updated_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [uuid, row.org_uuid, row.handle, row.display_name, row.description, row.ref_id, createdBy, createdBy, now, now] + (uuid, org_uuid, portal_id, handle, display_name, description, ref_id, created_by, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [uuid, row.org_uuid, portalId, row.handle, row.display_name, row.description, row.ref_id, createdBy, createdBy, now, now] ); await replaceLimits(uuid, plan.limits || [], t); uuids.push(uuid); @@ -145,8 +148,8 @@ const createMany = async (orgId, plans, createdBy, t) => { if (uuids.length === 0) return []; const placeholders = uuids.map(() => '?').join(', '); const rows = await exec.query( - `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid IN (${placeholders}) AND org_uuid = ?`, - [...uuids, orgId] + `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid IN (${placeholders}) AND org_uuid = ? AND portal_id = ?`, + [...uuids, orgId, portalId] ); await attachLimits(rows, t); return rows; @@ -178,11 +181,13 @@ const update = async (orgId, planId, plan, updatedBy, t) => { setCols.push('updated_by = ?', 'updated_at = ?'); params.push(updatedBy, updatedAt); - await exec.execute( - `UPDATE ${SUBSCRIPTION_PLANS_TABLE} SET ${setCols.join(', ')} WHERE uuid = ? AND org_uuid = ?`, - [...params, planId, orgId] + const { rowCount } = await exec.execute( + `UPDATE ${SUBSCRIPTION_PLANS_TABLE} SET ${setCols.join(', ')} WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [...params, planId, orgId, getPortalId()] ); + if (rowCount === 0) return null; + if (Object.prototype.hasOwnProperty.call(plan, 'limits')) { await replaceLimits(planId, plan.limits || [], t); } @@ -192,9 +197,15 @@ const update = async (orgId, planId, plan, updatedBy, t) => { const deletePlan = async (orgId, planName, t) => { const exec = t || db; + await exec.execute( + `UPDATE subscriptions SET plan_uuid = NULL WHERE plan_uuid IN ( + SELECT uuid FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ? + )`, + [planName, orgId, getPortalId()] + ); const { rowCount } = await exec.execute( - `DELETE FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [planName, orgId] + `DELETE FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [planName, orgId, getPortalId()] ); return rowCount; }; @@ -202,8 +213,8 @@ const deletePlan = async (orgId, planName, t) => { const getByName = async (orgId, planName, t) => { const exec = t || db; const plan = await exec.queryOne( - `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [planName, orgId] + `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [planName, orgId, getPortalId()] ); if (!plan) return null; await attachLimits([plan], t); @@ -220,9 +231,9 @@ const listByApi = async (apiId, t) => { const exec = t || db; const plans = await exec.query( `SELECT sp.* FROM ${SUBSCRIPTION_PLANS_TABLE} sp - JOIN ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} m ON m.plan_uuid = sp.uuid - WHERE m.api_uuid = ?`, - [apiId] + JOIN ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} m ON m.plan_uuid = sp.uuid AND m.portal_id = sp.portal_id + WHERE m.api_uuid = ? AND m.portal_id = ?`, + [apiId, getPortalId()] ); await attachLimits(plans, t); return plans; @@ -230,7 +241,10 @@ const listByApi = async (apiId, t) => { const list = async (orgId, t) => { const exec = t || db; - const plans = await exec.query(`SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE org_uuid = ?`, [orgId]); + const plans = await exec.query( + `SELECT * FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE org_uuid = ? AND portal_id = ?`, + [orgId, getPortalId()] + ); await attachLimits(plans, t); return plans; }; @@ -238,15 +252,16 @@ const list = async (orgId, t) => { const createApiMapping = async (apiSubscriptionPlans, apiId, createdBy, t) => { const exec = t || db; const now = new Date(); + const portalId = getPortalId(); const created = []; for (const plan of apiSubscriptionPlans) { const uuid = crypto.randomUUID(); await exec.execute( - `INSERT INTO ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} (uuid, plan_uuid, api_uuid, created_by, created_at) - VALUES (?, ?, ?, ?, ?)`, - [uuid, plan.planId, apiId, createdBy, now] + `INSERT INTO ${API_SUBSCRIPTION_PLAN_MAPPINGS_TABLE} (uuid, portal_id, plan_uuid, api_uuid, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [uuid, portalId, plan.planId, apiId, createdBy, now] ); - created.push({ uuid, plan_uuid: plan.planId, api_uuid: apiId, created_by: createdBy, created_at: now }); + created.push({ uuid, portal_id: portalId, plan_uuid: plan.planId, api_uuid: apiId, created_by: createdBy, created_at: now }); } return created; }; diff --git a/portals/api-portal/src/dao/tagDao.js b/portals/api-portal/src/dao/tagDao.js index b83b4593a8..ff667cbebd 100644 --- a/portals/api-portal/src/dao/tagDao.js +++ b/portals/api-portal/src/dao/tagDao.js @@ -20,6 +20,7 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { findOrCreateSafe } = require('./findOrCreateHelper'); +const { getPortalId } = require('../utils/orgContext'); const TAGS_TABLE = 'tags'; const API_TAGS_TABLE = 'api_tag_mappings'; @@ -28,8 +29,8 @@ const API_TAGS_TABLE = 'api_tag_mappings'; // and column list, not on any per-call data. const UPSERT_API_TAG_SQL = db.buildUpsert( API_TAGS_TABLE, - ['uuid', 'tag_uuid', 'api_uuid', 'created_by'], - ['tag_uuid', 'api_uuid'], + ['uuid', 'portal_id', 'tag_uuid', 'api_uuid', 'created_by'], + ['portal_id', 'tag_uuid', 'api_uuid'], [] // ignoreDuplicates semantics — leave the existing mapping row untouched on conflict ); @@ -43,13 +44,15 @@ const getOrCreateIds = async (orgId, tagNames, createdBy, t) => { for (const name of tagNames) { const trimmed = String(name).trim(); if (!trimmed) continue; + const portalId = getPortalId(); const tag = await findOrCreateSafe( TAGS_TABLE, - { name: trimmed, org_uuid: orgId }, + { name: trimmed, org_uuid: orgId, portal_id: portalId }, { uuid: crypto.randomUUID(), name: trimmed, org_uuid: orgId, + portal_id: portalId, created_by: createdBy, updated_by: createdBy, }, @@ -64,7 +67,7 @@ const createApiMapping = async (orgId, apiId, tagNames, createdBy, t) => { const exec = t || db; const idList = await getOrCreateIds(orgId, tagNames || [], createdBy, t); for (const tagId of idList) { - await exec.execute(UPSERT_API_TAG_SQL, [crypto.randomUUID(), tagId, apiId, createdBy]); + await exec.execute(UPSERT_API_TAG_SQL, [crypto.randomUUID(), getPortalId(), tagId, apiId, createdBy]); } return idList; }; diff --git a/portals/api-portal/src/dao/userIdpReferenceDao.js b/portals/api-portal/src/dao/userIdpReferenceDao.js index d6b02c35b6..06fcbb1a77 100644 --- a/portals/api-portal/src/dao/userIdpReferenceDao.js +++ b/portals/api-portal/src/dao/userIdpReferenceDao.js @@ -20,20 +20,23 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { findOrCreateSafe } = require('./findOrCreateHelper'); +const { getPortalId } = require('../utils/orgContext'); const TABLE = 'user_idp_references'; const DELETED_USER = 'deleted_user'; /** - * Find-or-create the idp reference row for this idp id, returning its uuid. - * Falls back to a plain lookup on a unique-constraint race between concurrent - * requests for the same idp id. + * Find-or-create the idp reference row for this (idp_id, portal_id) pair, + * returning its uuid. portal_id is resolved internally — never accepted from + * request input (IDOR prevention). Falls back to a plain lookup on a + * unique-constraint race between concurrent requests for the same pair. */ const resolveUuid = async (idpId) => { + const portalId = getPortalId(); const reference = await findOrCreateSafe( TABLE, - { idp_id: idpId }, - { uuid: crypto.randomUUID(), idp_id: idpId } + { idp_id: idpId, portal_id: portalId }, + { uuid: crypto.randomUUID(), idp_id: idpId, portal_id: portalId } ); return reference.uuid; }; diff --git a/portals/api-portal/src/dao/userOrganizationMappingDao.js b/portals/api-portal/src/dao/userOrganizationMappingDao.js index 1bfb30ca03..49240c9076 100644 --- a/portals/api-portal/src/dao/userOrganizationMappingDao.js +++ b/portals/api-portal/src/dao/userOrganizationMappingDao.js @@ -18,19 +18,20 @@ 'use strict'; const { findOrCreateSafe } = require('./findOrCreateHelper'); +const { getPortalId } = require('../utils/orgContext'); const TABLE = 'user_organization_mappings'; /** - * Record that this user has been seen in this org. No-op if already recorded. - * (user_uuid, org_uuid) is the table's composite primary key, so no separate - * generated id is needed on insert. + * Record that this user belongs to this org. No-op if already recorded. + * PRIMARY KEY is (portal_id, user_uuid, org_uuid). */ const ensureMapping = async (userUuid, orgUuid) => { + const portalId = getPortalId(); await findOrCreateSafe( TABLE, - { user_uuid: userUuid, org_uuid: orgUuid }, - { user_uuid: userUuid, org_uuid: orgUuid } + { portal_id: portalId, user_uuid: userUuid, org_uuid: orgUuid }, + { portal_id: portalId, user_uuid: userUuid, org_uuid: orgUuid } ); }; diff --git a/portals/api-portal/src/dao/viewDao.js b/portals/api-portal/src/dao/viewDao.js index afba986878..13af7142fe 100644 --- a/portals/api-portal/src/dao/viewDao.js +++ b/portals/api-portal/src/dao/viewDao.js @@ -33,13 +33,15 @@ const ORG_ASSETS_TABLE = 'organization_assets'; const create = async (orgId, payload, createdBy, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); const displayName = payload.displayName ? payload.displayName : payload.handle; const uuid = crypto.randomUUID(); + const portalId = getPortalId(); await exec.execute( - `INSERT INTO ${VIEWS_TABLE} (uuid, handle, display_name, org_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [uuid, payload.handle, displayName, orgId, createdBy, createdBy] + `INSERT INTO ${VIEWS_TABLE} (uuid, handle, display_name, org_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [uuid, payload.handle, displayName, orgId, portalId, createdBy, createdBy] ); return { @@ -47,6 +49,7 @@ const create = async (orgId, payload, createdBy, t) => { handle: payload.handle, display_name: displayName, org_uuid: orgId, + portal_id: portalId, created_by: createdBy, updated_by: createdBy, }; @@ -61,9 +64,11 @@ const create = async (orgId, payload, createdBy, t) => { */ const update = async (orgId, handle, displayName, updatedBy, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); + const portalId = getPortalId(); const existing = await exec.queryOne( - `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [handle, orgId] + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [handle, orgId, portalId] ); let row = existing; @@ -72,15 +77,16 @@ const update = async (orgId, handle, displayName, updatedBy, t) => { const initialDisplayName = displayName ? displayName : handle; try { await db.withSavepoint(exec, () => exec.execute( - `INSERT INTO ${VIEWS_TABLE} (uuid, handle, display_name, org_uuid, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?)`, - [uuid, handle, initialDisplayName, orgId, updatedBy, updatedBy] + `INSERT INTO ${VIEWS_TABLE} (uuid, handle, display_name, org_uuid, portal_id, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [uuid, handle, initialDisplayName, orgId, portalId, updatedBy, updatedBy] )); return { uuid, handle, display_name: initialDisplayName, org_uuid: orgId, + portal_id: portalId, created_by: updatedBy, updated_by: updatedBy, }; @@ -88,8 +94,8 @@ const update = async (orgId, handle, displayName, updatedBy, t) => { if (!db.isDuplicateKeyError(error)) throw error; // Lost a race to create this view — fall through to the update path below. row = await exec.queryOne( - `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [handle, orgId] + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [handle, orgId, portalId] ); } } @@ -97,17 +103,18 @@ const update = async (orgId, handle, displayName, updatedBy, t) => { const updatedAt = new Date(); const newDisplayName = displayName ? displayName : row.display_name; await exec.execute( - `UPDATE ${VIEWS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, - [newDisplayName, updatedBy, updatedAt, row.uuid, orgId] + `UPDATE ${VIEWS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [newDisplayName, updatedBy, updatedAt, row.uuid, orgId, portalId] ); return { ...row, display_name: newDisplayName, updated_by: updatedBy, updated_at: updatedAt }; }; const deleteView = async (orgId, handle, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); const view = await exec.queryOne( - `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [handle, orgId] + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [handle, orgId, getPortalId()] ); if (!view) { return 0; @@ -118,16 +125,17 @@ const deleteView = async (orgId, handle, t) => { await exec.execute(`DELETE FROM ${VIEW_LABELS_TABLE} WHERE view_uuid = ?`, [view.uuid]); await exec.execute(`DELETE FROM ${ORG_ASSETS_TABLE} WHERE view_uuid = ?`, [view.uuid]); const { rowCount } = await exec.execute( - `DELETE FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [handle, orgId] + `DELETE FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [handle, orgId, getPortalId()] ); return rowCount; }; const get = async (orgId, handle) => { + const { getPortalId } = require('../utils/orgContext'); const view = await db.queryOne( - `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [handle, orgId] + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [handle, orgId, getPortalId()] ); if (!view) { return null; @@ -135,9 +143,9 @@ const get = async (orgId, handle) => { const labels = await db.query( `SELECT l.handle AS handle FROM ${LABELS_TABLE} l - INNER JOIN ${VIEW_LABELS_TABLE} vl ON vl.label_uuid = l.uuid - WHERE vl.view_uuid = ?`, - [view.uuid] + INNER JOIN ${VIEW_LABELS_TABLE} vl ON vl.label_uuid = l.uuid AND vl.portal_id = l.portal_id + WHERE vl.view_uuid = ? AND vl.portal_id = ?`, + [view.uuid, getPortalId()] ); return { ...view, labels: labels }; }; @@ -149,10 +157,11 @@ const getId = async (orgId, viewName, t) => { // short-circuit before ever building that query. if (!viewName) return undefined; + const { getPortalId } = require('../utils/orgContext'); const exec = t || db; const view = await exec.queryOne( - `SELECT uuid FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [viewName, orgId] + `SELECT uuid FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [viewName, orgId, getPortalId()] ); if (!view) { throw new CustomError(404, constants.ERROR_CODE[404], "View not found"); @@ -175,24 +184,30 @@ const getId = async (orgId, viewName, t) => { // API — a fresh org is seeded with one. const getFallbackHandle = async (orgId, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); + const portalId = getPortalId(); const preferred = await exec.queryOne( - `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND handle = ?`, - [orgId, DEFAULT_VIEW_HANDLE] + `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND portal_id = ? AND handle = ?`, + [orgId, portalId, DEFAULT_VIEW_HANDLE] ); if (preferred) { return preferred.handle; } const earliest = await exec.queryOne( - `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? ORDER BY created_at ASC, handle ASC`, - [orgId] + `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND portal_id = ? ORDER BY created_at ASC, handle ASC`, + [orgId, portalId] ); return earliest ? earliest.handle : DEFAULT_VIEW_HANDLE; }; -// Number of views in the org — the last-view delete guard's input. +// Number of views in the api portal — the last-view delete guard's input. const count = async (orgId, t) => { const exec = t || db; - const row = await exec.queryOne(`SELECT COUNT(*) AS total FROM ${VIEWS_TABLE} WHERE org_uuid = ?`, [orgId]); + const { getPortalId } = require('../utils/orgContext'); + const row = await exec.queryOne( + `SELECT COUNT(*) AS total FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND portal_id = ?`, + [orgId, getPortalId()] + ); return Number(row?.total ?? 0); }; @@ -210,9 +225,11 @@ const count = async (orgId, t) => { */ const rename = async (orgId, oldHandle, newHandle, updatedBy, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); + const portalId = getPortalId(); const existing = await exec.queryOne( - `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, - [oldHandle, orgId] + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [oldHandle, orgId, portalId] ); if (!existing) { return null; @@ -223,8 +240,8 @@ const rename = async (orgId, oldHandle, newHandle, updatedBy, t) => { const updatedAt = new Date(); try { await db.withSavepoint(exec, () => exec.execute( - `UPDATE ${VIEWS_TABLE} SET handle = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, - [newHandle, updatedBy, updatedAt, existing.uuid, orgId] + `UPDATE ${VIEWS_TABLE} SET handle = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [newHandle, updatedBy, updatedAt, existing.uuid, orgId, portalId] )); } catch (error) { // uq_view_handle_org_uuid — another view already answers to this handle. Report @@ -238,7 +255,8 @@ const rename = async (orgId, oldHandle, newHandle, updatedBy, t) => { }; const list = async (orgId) => { - const views = await db.query(`SELECT * FROM ${VIEWS_TABLE} WHERE org_uuid = ?`, [orgId]); + const { getPortalId } = require('../utils/orgContext'); + const views = await db.query(`SELECT * FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND portal_id = ?`, [orgId, getPortalId()]); if (views.length === 0) return views; const viewIds = views.map((v) => v.uuid); @@ -246,9 +264,9 @@ const list = async (orgId) => { const labelRows = await db.query( `SELECT vl.view_uuid AS view_uuid, l.handle AS handle FROM ${VIEW_LABELS_TABLE} vl - INNER JOIN ${LABELS_TABLE} l ON l.uuid = vl.label_uuid - WHERE vl.view_uuid IN (${placeholders})`, - viewIds + INNER JOIN ${LABELS_TABLE} l ON l.uuid = vl.label_uuid AND vl.portal_id = l.portal_id + WHERE vl.view_uuid IN (${placeholders}) AND vl.portal_id = ?`, + [...viewIds, getPortalId()] ); const labelsByView = groupBy(labelRows, 'view_uuid'); @@ -260,15 +278,17 @@ const list = async (orgId) => { const addLabels = async (orgId, viewId, labels, createdBy, t) => { const exec = t || db; + const { getPortalId } = require('../utils/orgContext'); + const portalId = getPortalId(); const idList = await getLabelId(orgId, labels, t); const created = []; for (const labelId of idList) { const uuid = crypto.randomUUID(); await exec.execute( - `INSERT INTO ${VIEW_LABELS_TABLE} (uuid, label_uuid, view_uuid, created_by) VALUES (?, ?, ?, ?)`, - [uuid, labelId, viewId, createdBy] + `INSERT INTO ${VIEW_LABELS_TABLE} (uuid, portal_id, label_uuid, view_uuid, created_by) VALUES (?, ?, ?, ?, ?)`, + [uuid, portalId, labelId, viewId, createdBy] ); - created.push({ uuid, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }); + created.push({ uuid, portal_id: portalId, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }); } return created; }; diff --git a/portals/api-portal/src/dao/webhookSubscriberDao.js b/portals/api-portal/src/dao/webhookSubscriberDao.js index 568661b7fb..412c846814 100644 --- a/portals/api-portal/src/dao/webhookSubscriberDao.js +++ b/portals/api-portal/src/dao/webhookSubscriberDao.js @@ -23,6 +23,7 @@ const { parseJsonColumn } = require('../db/rows'); const { createCryptoUtil, bufferToUtf8 } = require('../utils/cryptoUtil'); const { config } = require('../config/configLoader'); const { NotFoundError } = require('../utils/errors/customErrors'); +const { getPortalId } = require('../utils/orgContext'); const TABLE = 'webhook_subscribers'; @@ -68,12 +69,13 @@ const create = async (orgId, subData, createdBy) => { updated_by: createdBy, }; + const portalId = getPortalId(); await db.execute( `INSERT INTO ${TABLE} - (uuid, org_uuid, handle, display_name, target_url, secret_enc, event_patterns, enabled, timeout_ms, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (uuid, org_uuid, portal_id, handle, display_name, target_url, secret_enc, event_patterns, enabled, timeout_ms, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - row.uuid, row.org_uuid, row.handle, row.display_name, row.target_url, + row.uuid, row.org_uuid, portalId, row.handle, row.display_name, row.target_url, db.binaryParam(row.secret_enc !== null ? Buffer.from(row.secret_enc, 'utf8') : null), JSON.stringify(row.event_patterns), row.enabled, row.timeout_ms, row.created_by, row.updated_by, @@ -117,9 +119,10 @@ const update = async (orgId, subscriberHandle, subData, updatedBy) => { return value; }); + const portalId = getPortalId(); const { rowCount } = await db.execute( - `UPDATE ${TABLE} SET ${setClause} WHERE handle = ? AND org_uuid = ?`, - [...values, subscriberHandle, orgId] + `UPDATE ${TABLE} SET ${setClause} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [...values, subscriberHandle, orgId, portalId] ); if (rowCount < 1) { throw new NotFoundError('Webhook subscriber not found'); @@ -128,8 +131,8 @@ const update = async (orgId, subscriberHandle, subData, updatedBy) => { // result is reliable across every dialect (including sqlite, which has no // portable equivalent wired up here). const updated = await db.queryOne( - `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ?`, - [updatePayload.handle || subscriberHandle, orgId] + `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [updatePayload.handle || subscriberHandle, orgId, portalId] ); return [rowCount, [toSubscriber(updated)]]; }; @@ -138,7 +141,7 @@ const update = async (orgId, subscriberHandle, subData, updatedBy) => { * List all webhook subscribers for an organization. */ const list = async (orgId) => { - const rows = await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ?`, [orgId]); + const rows = await db.query(`SELECT * FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ?`, [orgId, getPortalId()]); return rows.map(toSubscriber); }; @@ -148,8 +151,8 @@ const list = async (orgId) => { */ const matchSubscribers = async (orgId, eventType) => { const rows = await db.query( - `SELECT * FROM ${TABLE} WHERE org_uuid = ? AND enabled = 1`, - [orgId] + `SELECT * FROM ${TABLE} WHERE org_uuid = ? AND portal_id = ? AND enabled = 1`, + [orgId, getPortalId()] ); return rows.map(toSubscriber).filter((sub) => { const patterns = sub.event_patterns; @@ -171,8 +174,8 @@ const matchSubscribers = async (orgId, eventType) => { */ const get = async (orgId, subscriberHandle) => { const sub = await db.queryOne( - `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ?`, - [subscriberHandle, orgId] + `SELECT * FROM ${TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [subscriberHandle, orgId, getPortalId()] ); if (!sub) { throw new NotFoundError('Webhook subscriber not found'); @@ -199,8 +202,8 @@ const getById = async (subscriberId) => { */ const deleteSubscriber = async (orgId, subscriberHandle) => { const { rowCount } = await db.execute( - `DELETE FROM ${TABLE} WHERE handle = ? AND org_uuid = ?`, - [subscriberHandle, orgId] + `DELETE FROM ${TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ?`, + [subscriberHandle, orgId, getPortalId()] ); if (rowCount < 1) { throw new NotFoundError('Webhook subscriber not found'); diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index f09b93afa4..20d3e5ebab 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -284,6 +284,11 @@ async function authResolver(req, res, next) { // sessions, so resolveScopedOrg works via the HANDLE lookup in orgDao.getId. if (req.isAuthenticated && req.isAuthenticated() && req.user?.isLocalAuth && config.auth.mode !== 'idp') { + if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { + const err = new Error('Forbidden'); + err.status = 403; + return next(err); + } const platformToken = req.user[constants.ACCESS_TOKEN]; const claims = platformToken ? decodePlatformJwtClaims(platformToken) : null; const orgHandle = req.user[constants.ROLES.ORGANIZATION_CLAIM]; @@ -324,6 +329,11 @@ async function authResolver(req, res, next) { // derive dp:* scopes, so the operation-level check is enforced here instead of // bypassed — that is the gap role mode exists to close. if (req.isAuthenticated && req.isAuthenticated() && req.user?.grantedScopes !== undefined && config.auth.mode === 'idp') { + if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { + const err = new Error('Forbidden'); + err.status = 403; + return next(err); + } // The session's org claim is populated at login from // config.auth.claimMappings.organization (see passportConfig) and stored // under ORGANIZATION_CLAIM. Resolve req.orgId from it directly — do NOT diff --git a/portals/api-portal/src/middlewares/ensureAuthenticated.js b/portals/api-portal/src/middlewares/ensureAuthenticated.js index 5e9f7c37b3..913e0f5d29 100644 --- a/portals/api-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/api-portal/src/middlewares/ensureAuthenticated.js @@ -19,6 +19,8 @@ const minimatch = require('minimatch'); const constants = require('../utils/constants'); const { config } = require('../config/configLoader'); const orgDao = require('../dao/organizationDao'); +const orgContext = require('../utils/orgContext'); +const { clearPortalCookies } = require('../utils/sessionCookies'); const { validationResult } = require('express-validator'); const util = require('../utils/util'); const { CustomError } = require('../utils/errors/customErrors'); @@ -252,6 +254,16 @@ const ensureAuthenticated = async (req, res, next) => { let role; logger.debug("Request authentication status", { isAuthenticated: req.isAuthenticated() }); if (req.isAuthenticated()) { + if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { + logger.warn('Rejected cross-portal session', { operation: 'ensureAuthenticated' }); + return req.session.destroy(() => { + clearPortalCookies(res); + const loginPath = req.params.orgName + ? `${constants.ROUTE.BASE_PATH}/${req.params.orgName}/views/${req.params.viewName || 'default'}/login` + : `${constants.ROUTE.BASE_PATH}/login`; + res.redirect(303, loginPath); + }); + } // Config-auth: skip all token/exchange checks; roles already in session if (req.user && req.user.isLocalAuth && config.auth.mode !== 'idp') { req.orgId = req.orgId || orgDetails?.uuid; diff --git a/portals/api-portal/src/services/keyManagerService.js b/portals/api-portal/src/services/keyManagerService.js index 974d9ec631..f3ab0498ad 100644 --- a/portals/api-portal/src/services/keyManagerService.js +++ b/portals/api-portal/src/services/keyManagerService.js @@ -191,7 +191,7 @@ const updateKeyManager = async (req, res) => { } const userId = util.resolveActor(req); - const [, updatedRows] = await kmDao.update(kmId, payload, userId); + const [, updatedRows] = await kmDao.update(orgId, kmId, payload, userId); logUserAction('KEY_MANAGER_UPDATED', req, { orgId, kmId, resourceUuid: kmId, resourceType: 'key_manager' }); let audit; try { @@ -246,7 +246,7 @@ const getKeyManager = async (req, res) => { if (!kmId) { return util.sendError(res, 404, constants.ERROR_MESSAGE.KEY_MANAGER_NOT_FOUND); } - const record = await kmDao.get(kmId); + const record = await kmDao.get(orgId, kmId); const audit = await userIdpReferenceDao.buildSingleAuditFields(record); const dto = new KeyManagerDTO(record, audit); return res.status(200).json(dto); @@ -267,7 +267,7 @@ const deleteKeyManager = async (req, res) => { if (!kmId) { return util.sendError(res, 404, constants.ERROR_MESSAGE.KEY_MANAGER_NOT_FOUND); } - await kmDao.delete(kmId); + await kmDao.delete(orgId, kmId); logUserAction('KEY_MANAGER_DELETED', req, { orgId, kmId, resourceUuid: kmId, resourceType: 'key_manager' }); return res.status(204).send(); } catch (error) { diff --git a/portals/api-portal/src/utils/orgContext.js b/portals/api-portal/src/utils/orgContext.js index 60ed64f59d..fb1484d25c 100644 --- a/portals/api-portal/src/utils/orgContext.js +++ b/portals/api-portal/src/utils/orgContext.js @@ -18,12 +18,15 @@ 'use strict'; /* - * The single organization this portal instance serves. + * The single organization and portal this portal instance serves. * * The database schema is multi-organization — one shared database can hold many * organizations, each served by its own portal instance — but a given instance is - * pinned to exactly one, named by `organization.handle` in config. This module is - * the only place that resolves it, and every org-scoped surface goes through here: + * pinned to exactly one org, named by `organization.handle` in config, and exactly + * one portal, identified by `organization.portal_id` in config.toml, resolved + * from the APIP_AP_ORGANIZATION_PORTAL_ID env var via the {{ env }} template token. + * This module is the only place that resolves both, + * and every org/portal-scoped surface goes through here: * * - authMiddleware.js — verifies a token/header-supplied org resolves to the pin * - orgGuard.js — verifies the {orgHandle} URL segment matches the pin @@ -34,10 +37,10 @@ * than at import time because this module is required by middleware that loads * before the database is ready. * - * A note on the eventual portalId: the plan is for one organization to hold several - * portals, with an instance pinned to one portal under one organization. That lands - * as a getPortalId()/assertPortalPinned() pair alongside these, so callers keep - * asking this module "what am I scoped to?" and nothing else has to change. + * getPortalId() is synchronous — env vars and config are stable at startup — + * so DAO callers do not need to await it. The value is never accepted from + * request input: that would be equivalent to accepting org_id from the request, + * which is the IDOR vulnerability class described in JS-AUTH-005. */ const { config } = require('../config/configLoader'); @@ -54,6 +57,10 @@ const { CustomError, NotFoundError } = require('./errors/customErrors'); let cachedOrgUuid = null; let pendingLookup = null; +// Cached after first call. The value is stable for the lifetime of the process: +// it is read from the environment or config at startup and never changes. +let cachedPortalId = null; + /** * Handle of the organization this instance serves. Always lowercase — normalized * and format-validated at config load (configLoader.js#resolveOrganizationConfig), @@ -181,6 +188,29 @@ function resetCache() { cachedOrgUuid = null; } +/** + * The API portal identifier this instance is pinned to. + * + * config.organization.portalId is populated by the config.toml template: + * portal_id = '{{ env "APIP_AP_ORGANIZATION_PORTAL_ID" "portal_id" }}' + * + * so env var resolution and the sentinel fallback are already handled before this + * function runs — mirroring how getHandle() reads config.organization.handle without + * separately checking process.env.APIP_AP_ORGANIZATION_HANDLE. + * + * Synchronous: env vars and config are stable after startup, so no await is needed + * and every DAO method can call this inline. Never accept a portalId from request + * input — that is the same IDOR class as accepting org_id from the request. + * + * @returns {string} + */ +function getPortalId() { + if (cachedPortalId) return cachedPortalId; + const fromConfig = config.organization?.portalId; + cachedPortalId = typeof fromConfig === 'string' ? fromConfig.trim() : ''; + return cachedPortalId; +} + /** * True when `uuid` is this instance's organization. * @@ -255,4 +285,5 @@ module.exports = { isPinnedOrg, requirePinnedOrg, resetCache, + getPortalId, }; diff --git a/tests/integration-e2e/devportal-config.toml b/tests/integration-e2e/devportal-config.toml index 6737fec25e..c448ee3929 100644 --- a/tests/integration-e2e/devportal-config.toml +++ b/tests/integration-e2e/devportal-config.toml @@ -42,6 +42,7 @@ password = '{{ env "APIP_AP_DATABASE_PASSWORD" "apip" }}' # login whose token names any other organization. handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' +portal_id = '{{ env "APIP_AP_ORGANIZATION_PORTAL_ID" "portal_id" }}' auto_create_subscription_plans = false [api_portal.auth] From 1e47a0d7192a52256f564c9738157086853f57d9 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Sun, 30 Aug 2026 20:07:54 +0530 Subject: [PATCH 2/4] Fix DB indexes and DAO level issues --- .../api-portal/database/schema.postgres.sql | 59 +++++++++++-------- portals/api-portal/database/schema.sqlite.sql | 58 +++++++++--------- .../api-portal/database/schema.sqlserver.sql | 55 +++++++++-------- portals/api-portal/src/dao/apiDao.js | 4 +- portals/api-portal/src/dao/apiFileDao.js | 16 ++--- portals/api-portal/src/dao/apiKeyDao.js | 4 +- portals/api-portal/src/dao/apiWorkflowDao.js | 2 +- portals/api-portal/src/dao/applicationDao.js | 13 ++-- portals/api-portal/src/dao/auditDao.js | 1 - portals/api-portal/src/dao/eventDao.js | 7 +-- portals/api-portal/src/dao/labelDao.js | 15 +++-- portals/api-portal/src/dao/organizationDao.js | 8 ++- portals/api-portal/src/dao/subscriptionDao.js | 6 +- .../api-portal/src/dao/subscriptionPlanDao.js | 4 +- portals/api-portal/src/dao/tagDao.js | 2 +- .../api-portal/src/dao/userIdpReferenceDao.js | 14 ++--- .../src/dao/userOrganizationMappingDao.js | 4 +- portals/api-portal/src/dao/viewDao.js | 4 +- .../src/dao/webhookSubscriberDao.js | 4 +- 19 files changed, 147 insertions(+), 133 deletions(-) diff --git a/portals/api-portal/database/schema.postgres.sql b/portals/api-portal/database/schema.postgres.sql index 194d87bbdf..bfca758dcb 100644 --- a/portals/api-portal/database/schema.postgres.sql +++ b/portals/api-portal/database/schema.postgres.sql @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS organizations ( UNIQUE(portal_id, handle), UNIQUE(portal_id, display_name) ); +CREATE INDEX IF NOT EXISTS idx_org_idp_ref_id ON organizations(idp_ref_id, portal_id); -- Views table (portal-scoped grouping of APIs for gateway/portal visibility) CREATE TABLE IF NOT EXISTS views ( @@ -77,8 +78,8 @@ CREATE TABLE IF NOT EXISTS organization_assets ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); -CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid); -CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(portal_id, org_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(portal_id, view_uuid); -- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( @@ -126,7 +127,7 @@ CREATE TABLE IF NOT EXISTS view_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); -CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid); +CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid, portal_id); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) -- API is a portal-managed entity: portal_id identifies which portal owns it. @@ -161,10 +162,11 @@ CREATE TABLE IF NOT EXISTS api_metadata ( ); -- org_uuid is nullable — partial indexes prevent NULL-org rows from colliding -- with each other while still enforcing uniqueness among non-NULL org rows. -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id) WHERE org_uuid IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_metadata_status ON api_metadata(status); +CREATE INDEX IF NOT EXISTS idx_api_metadata_org_uuid ON api_metadata(org_uuid, portal_id); -- API Contents table (spec files, docs, icons, etc. attached to an API) CREATE TABLE IF NOT EXISTS api_contents ( @@ -198,7 +200,7 @@ CREATE TABLE IF NOT EXISTS api_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid, portal_id); -- API-Tag mappings (many-to-many: which tags are attached to an API) CREATE TABLE IF NOT EXISTS api_tag_mappings ( @@ -213,7 +215,7 @@ CREATE TABLE IF NOT EXISTS api_tag_mappings ( FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid, portal_id); -- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). @@ -270,7 +272,7 @@ CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid, portal_id); -- Key Managers table (portal-scoped identity providers used to validate app keys) CREATE TABLE IF NOT EXISTS key_managers ( @@ -324,7 +326,7 @@ CREATE TABLE IF NOT EXISTS app_key_mappings ( FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE NO ACTION, FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid); +CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_km_uuid ON app_key_mappings(km_uuid); -- Subscriptions table (portal-scoped application-level subscriptions to an API) @@ -353,7 +355,7 @@ CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_ CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, portal_id, api_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_plan_uuid ON subscriptions(plan_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_status ON subscriptions(status); --- api_uuid is only ever a trailing column above (org_uuid, api_uuid) — add a +-- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_subscription_api_uuid ON subscriptions(api_uuid); @@ -382,12 +384,14 @@ CREATE TABLE IF NOT EXISTS api_keys ( CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); -CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, portal_id, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_subscription_uuid ON api_keys(subscription_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) —- add a -- dedicated leading index so single-column api_uuid lookups/joins stay indexed. CREATE INDEX IF NOT EXISTS idx_api_key_api_uuid ON api_keys(api_uuid); +-- Handle is the caller-facing id used to address a key within an API, so it must be +-- unique per (org, portal, api). Enforced here for a race-free guarantee. CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) @@ -426,7 +430,7 @@ CREATE TABLE IF NOT EXISTS api_workflows ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); -CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid); +CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(portal_id, view_uuid); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history @@ -490,29 +494,34 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_session_expire ON sessions(expire); --- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; --- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign --- key, so those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim; referenced by uuid +-- from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so those +-- columns keep pointing at a uuid after the row here is deleted) +-- +-- NOT portal-scoped: The org to idp_ref_id mapping is 1-to-1: all portals serving the +-- same org share the same IdP user base, so the same physical user must resolve to the +-- same uuid regardless of which portal they authenticate through. CREATE TABLE IF NOT EXISTS user_idp_references ( - uuid VARCHAR(40) NOT NULL, - idp_id VARCHAR(255) NOT NULL, - portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (portal_id, uuid) + uuid VARCHAR(40) PRIMARY KEY, + idp_id VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_user_idp_references_idpid_portal ON user_idp_references(idp_id, portal_id); -- User-Organization mappings (live membership record —- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) +-- +-- NOT portal-scoped at the identity level. user_uuid references a global identity in +-- user_idp_references. portal_id is retained here only because org_uuid is portal-specific +-- in organizations. CREATE TABLE IF NOT EXISTS user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - PRIMARY KEY (portal_id, user_uuid, org_uuid), - FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + PRIMARY KEY (user_uuid, org_uuid), + FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid); +CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(portal_id, org_uuid); -- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( diff --git a/portals/api-portal/database/schema.sqlite.sql b/portals/api-portal/database/schema.sqlite.sql index 96a7fdca77..716d5cef72 100644 --- a/portals/api-portal/database/schema.sqlite.sql +++ b/portals/api-portal/database/schema.sqlite.sql @@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS organizations ( UNIQUE(portal_id, handle), UNIQUE(portal_id, display_name) ); +CREATE INDEX IF NOT EXISTS idx_org_idp_ref_id ON organizations(idp_ref_id, portal_id); -- Views table (portal-scoped grouping of APIs for gateway/portal visibility) CREATE TABLE IF NOT EXISTS views ( @@ -77,8 +78,8 @@ CREATE TABLE IF NOT EXISTS organization_assets ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); -CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid); -CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(portal_id, org_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(portal_id, view_uuid); -- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( @@ -126,7 +127,7 @@ CREATE TABLE IF NOT EXISTS view_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); -CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid); +CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid, portal_id); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) -- API is a portal-managed entity: portal_id identifies which portal owns it. @@ -161,10 +162,11 @@ CREATE TABLE IF NOT EXISTS api_metadata ( ); -- org_uuid is nullable — partial indexes prevent NULL-org rows from colliding -- with each other while still enforcing uniqueness among non-NULL org rows. -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id) WHERE org_uuid IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_name_version_org ON api_metadata(name, version, org_uuid, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_org_ref_id ON api_metadata(org_uuid, ref_id, portal_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_metadata_handle_org ON api_metadata(handle, org_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_metadata_status ON api_metadata(status); +CREATE INDEX IF NOT EXISTS idx_api_metadata_org_uuid ON api_metadata(org_uuid, portal_id); -- API Contents table (spec files, docs, icons, etc. attached to an API) CREATE TABLE IF NOT EXISTS api_contents ( @@ -198,7 +200,7 @@ CREATE TABLE IF NOT EXISTS api_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid, portal_id); -- API-Tag mappings (many-to-many: which tags are attached to an API) CREATE TABLE IF NOT EXISTS api_tag_mappings ( @@ -213,7 +215,7 @@ CREATE TABLE IF NOT EXISTS api_tag_mappings ( FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid, portal_id); -- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). @@ -269,7 +271,7 @@ CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); -CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid, portal_id); -- Key Managers table (portal-scoped identity providers used to validate app keys) CREATE TABLE IF NOT EXISTS key_managers ( @@ -323,7 +325,7 @@ CREATE TABLE IF NOT EXISTS app_key_mappings ( FOREIGN KEY (portal_id, app_uuid) REFERENCES applications(portal_id, uuid) ON DELETE NO ACTION, FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid); +CREATE INDEX IF NOT EXISTS idx_app_key_mappings_app_uuid ON app_key_mappings(app_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_app_key_mappings_km_uuid ON app_key_mappings(km_uuid); -- Subscriptions table (portal-scoped application-level subscriptions to an API) @@ -381,7 +383,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); -CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, portal_id, api_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_subscription_uuid ON api_keys(subscription_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a @@ -392,7 +394,6 @@ CREATE INDEX IF NOT EXISTS idx_api_key_api_uuid ON api_keys(api_uuid); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_key_org_api_handle ON api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) --- key_uuid IS the api_keys.uuid — no separate surrogate key on this table. CREATE TABLE IF NOT EXISTS api_key_app_mappings ( key_uuid VARCHAR(40) NOT NULL, app_uuid VARCHAR(40) NOT NULL, @@ -428,7 +429,7 @@ CREATE TABLE IF NOT EXISTS api_workflows ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); -CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid); +CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(portal_id, view_uuid); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history @@ -485,8 +486,6 @@ CREATE INDEX IF NOT EXISTS idx_event_delivery_event_uuid ON event_deliveries(eve CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(portal_id, event_uuid, subscriber_id); -- Sessions table, used by connect-session-sequelize for server-side Express session storage. --- Intentionally excluded from the portal_id composite-PK pattern: portal_id is --- stored inside the sess JSON payload instead of as a schema column. CREATE TABLE IF NOT EXISTS sessions ( sid VARCHAR(255) PRIMARY KEY, sess TEXT NOT NULL, @@ -494,29 +493,34 @@ CREATE TABLE IF NOT EXISTS sessions ( ); CREATE INDEX IF NOT EXISTS idx_session_expire ON sessions(expire); --- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; --- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign --- key, so those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim; referenced by uuid +-- from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so those +-- columns keep pointing at a uuid after the row here is deleted) +-- +-- NOT portal-scoped: The org to idp_ref_id mapping is 1-to-1: all portals serving the +-- same org share the same IdP user base, so the same physical user must resolve to the +-- same uuid regardless of which portal they authenticate through. CREATE TABLE IF NOT EXISTS user_idp_references ( - uuid VARCHAR(40) NOT NULL, - idp_id VARCHAR(255) NOT NULL, - portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (portal_id, uuid) + uuid VARCHAR(40) PRIMARY KEY, + idp_id VARCHAR(255) NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_user_idp_references_idpid_portal ON user_idp_references(idp_id, portal_id); -- User-Organization mappings (live membership record -- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) +-- +-- NOT portal-scoped at the identity level. user_uuid references a global identity in +-- user_idp_references. portal_id is retained here only because org_uuid is portal-specific +-- in organizations. CREATE TABLE IF NOT EXISTS user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - PRIMARY KEY (portal_id, user_uuid, org_uuid), - FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + PRIMARY KEY (user_uuid, org_uuid), + FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid); +CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(portal_id, org_uuid); -- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( diff --git a/portals/api-portal/database/schema.sqlserver.sql b/portals/api-portal/database/schema.sqlserver.sql index 1a7e46f311..12c0ea1764 100644 --- a/portals/api-portal/database/schema.sqlserver.sql +++ b/portals/api-portal/database/schema.sqlserver.sql @@ -40,6 +40,8 @@ CREATE TABLE dbo.organizations ( UNIQUE(portal_id, handle), UNIQUE(portal_id, display_name) ); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_org_idp_ref_id' AND object_id = OBJECT_ID(N'dbo.organizations')) +CREATE INDEX idx_org_idp_ref_id ON dbo.organizations(idp_ref_id, portal_id); -- Views table (portal-scoped grouping of APIs for gateway/portal visibility) IF OBJECT_ID(N'dbo.views', N'U') IS NULL @@ -84,9 +86,9 @@ CREATE TABLE dbo.organization_assets ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_organization_asset_type_name_path_org_view' AND object_id = OBJECT_ID(N'dbo.organization_assets')) CREATE UNIQUE INDEX uq_organization_asset_type_name_path_org_view ON dbo.organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_org_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) -CREATE INDEX idx_organization_asset_org_uuid ON dbo.organization_assets(org_uuid); +CREATE INDEX idx_organization_asset_org_uuid ON dbo.organization_assets(portal_id, org_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_view_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) -CREATE INDEX idx_organization_asset_view_uuid ON dbo.organization_assets(view_uuid); +CREATE INDEX idx_organization_asset_view_uuid ON dbo.organization_assets(portal_id, view_uuid); -- Labels table (portal-scoped labels used for gateway/view assignment) IF OBJECT_ID(N'dbo.labels', N'U') IS NULL @@ -143,7 +145,7 @@ CREATE TABLE dbo.view_label_mappings ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_view_label_mappings_label_view' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) CREATE UNIQUE INDEX uq_view_label_mappings_label_view ON dbo.view_label_mappings(portal_id, label_uuid, view_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_view_label_mappings_view_uuid' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) -CREATE INDEX idx_view_label_mappings_view_uuid ON dbo.view_label_mappings(view_uuid); +CREATE INDEX idx_view_label_mappings_view_uuid ON dbo.view_label_mappings(view_uuid, portal_id); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) -- API is a portal-managed entity: portal_id identifies which portal owns it. @@ -190,6 +192,8 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_metadata_handle_o CREATE UNIQUE INDEX uq_api_metadata_handle_org ON dbo.api_metadata(handle, org_uuid, portal_id) WHERE org_uuid IS NOT NULL; IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_metadata_status' AND object_id = OBJECT_ID(N'dbo.api_metadata')) CREATE INDEX idx_api_metadata_status ON dbo.api_metadata(status); +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_metadata_org_uuid' AND object_id = OBJECT_ID(N'dbo.api_metadata')) +CREATE INDEX idx_api_metadata_org_uuid ON dbo.api_metadata(org_uuid, portal_id); -- API Contents table (spec files, docs, icons, etc. attached to an API) IF OBJECT_ID(N'dbo.api_contents', N'U') IS NULL @@ -231,7 +235,7 @@ CREATE TABLE dbo.api_label_mappings ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_label_mappings_label_api' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) CREATE UNIQUE INDEX uq_api_label_mappings_label_api ON dbo.api_label_mappings(portal_id, label_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_label_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) -CREATE INDEX idx_api_label_mappings_api_uuid ON dbo.api_label_mappings(api_uuid); +CREATE INDEX idx_api_label_mappings_api_uuid ON dbo.api_label_mappings(api_uuid, portal_id); -- API-Tag mappings (many-to-many: which tags are attached to an API) IF OBJECT_ID(N'dbo.api_tag_mappings', N'U') IS NULL @@ -249,7 +253,7 @@ CREATE TABLE dbo.api_tag_mappings ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_tag_mappings_tag_api' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) CREATE UNIQUE INDEX uq_api_tag_mappings_tag_api ON dbo.api_tag_mappings(portal_id, tag_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_tag_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) -CREATE INDEX idx_api_tag_mappings_api_uuid ON dbo.api_tag_mappings(api_uuid); +CREATE INDEX idx_api_tag_mappings_api_uuid ON dbo.api_tag_mappings(api_uuid, portal_id); -- Subscription Plans table (portal-scoped rate/billing plans) -- Throttling limits live in subscription_plan_limits (one row per limit). @@ -313,7 +317,7 @@ CREATE TABLE dbo.api_subscription_plan_mappings ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_subscription_plan_mappings_plan_api' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) CREATE UNIQUE INDEX uq_api_subscription_plan_mappings_plan_api ON dbo.api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_subscription_plan_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) -CREATE INDEX idx_api_subscription_plan_mappings_api_uuid ON dbo.api_subscription_plan_mappings(api_uuid); +CREATE INDEX idx_api_subscription_plan_mappings_api_uuid ON dbo.api_subscription_plan_mappings(api_uuid, portal_id); -- Key Managers table (portal-scoped identity providers used to validate app keys) IF OBJECT_ID(N'dbo.key_managers', N'U') IS NULL @@ -374,7 +378,7 @@ CREATE TABLE dbo.app_key_mappings ( FOREIGN KEY (portal_id, km_uuid) REFERENCES key_managers(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_app_key_mappings_app_uuid' AND object_id = OBJECT_ID(N'dbo.app_key_mappings')) -CREATE INDEX idx_app_key_mappings_app_uuid ON dbo.app_key_mappings(app_uuid); +CREATE INDEX idx_app_key_mappings_app_uuid ON dbo.app_key_mappings(app_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_app_key_mappings_km_uuid' AND object_id = OBJECT_ID(N'dbo.app_key_mappings')) CREATE INDEX idx_app_key_mappings_km_uuid ON dbo.app_key_mappings(km_uuid); @@ -446,7 +450,7 @@ CREATE TABLE dbo.api_keys ( CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_org_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_keys')) -CREATE INDEX idx_api_key_org_api_uuid ON dbo.api_keys(org_uuid, api_uuid); +CREATE INDEX idx_api_key_org_api_uuid ON dbo.api_keys(org_uuid, portal_id, api_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_subscription_uuid' AND object_id = OBJECT_ID(N'dbo.api_keys')) CREATE INDEX idx_api_key_subscription_uuid ON dbo.api_keys(subscription_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_status' AND object_id = OBJECT_ID(N'dbo.api_keys')) @@ -461,7 +465,6 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_key_org_api_handl CREATE UNIQUE INDEX uq_api_key_org_api_handle ON dbo.api_keys(org_uuid, api_uuid, handle, portal_id); -- API Key-Application mappings (which application an API key was issued to) --- key_uuid IS the api_keys.uuid — no separate surrogate key on this table. IF OBJECT_ID(N'dbo.api_key_app_mappings', N'U') IS NULL CREATE TABLE dbo.api_key_app_mappings ( key_uuid VARCHAR(40) NOT NULL, @@ -502,7 +505,7 @@ CREATE TABLE dbo.api_workflows ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_workflow_org_view_handle' AND object_id = OBJECT_ID(N'dbo.api_workflows')) CREATE UNIQUE INDEX uq_api_workflow_org_view_handle ON dbo.api_workflows(org_uuid, view_uuid, handle, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_view_uuid' AND object_id = OBJECT_ID(N'dbo.api_workflows')) -CREATE INDEX idx_api_workflow_view_uuid ON dbo.api_workflows(view_uuid); +CREATE INDEX idx_api_workflow_view_uuid ON dbo.api_workflows(portal_id, view_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_status' AND object_id = OBJECT_ID(N'dbo.api_workflows')) CREATE INDEX idx_api_workflow_status ON dbo.api_workflows(status); @@ -568,8 +571,6 @@ IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_event_delivery_event_ CREATE UNIQUE INDEX uq_event_delivery_event_subscriber ON dbo.event_deliveries(portal_id, event_uuid, subscriber_id); -- Sessions table, used by connect-mssql-v2 (or equivalent) for server-side Express session storage. --- Intentionally excluded from the portal_id composite-PK pattern: portal_id is --- stored inside the sess JSON payload instead of as a schema column. IF OBJECT_ID(N'dbo.sessions', N'U') IS NULL CREATE TABLE dbo.sessions ( sid VARCHAR(255) PRIMARY KEY, @@ -579,33 +580,37 @@ CREATE TABLE dbo.sessions ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_session_expire' AND object_id = OBJECT_ID(N'dbo.sessions')) CREATE INDEX idx_session_expire ON dbo.sessions(expire); --- User IdP References table (one durable record per IdP `sub` claim scoped to a portal; --- referenced by uuid from created_by/updated_by-style columns elsewhere WITHOUT a foreign --- key, so those columns keep pointing at a uuid after the row here is deleted) +-- User IdP References table (one durable record per IdP `sub` claim; referenced by uuid +-- from created_by/updated_by-style columns elsewhere WITHOUT a foreign key, so those +-- columns keep pointing at a uuid after the row here is deleted) +-- +-- NOT portal-scoped: The org to idp_ref_id mapping is 1-to-1: all portals serving the +-- same org share the same IdP user base, so the same physical user must resolve to the +-- same uuid regardless of which portal they authenticate through. IF OBJECT_ID(N'dbo.user_idp_references', N'U') IS NULL CREATE TABLE dbo.user_idp_references ( - uuid VARCHAR(40) NOT NULL, - idp_id VARCHAR(255) NOT NULL, - portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - created_at DATETIME2(7) DEFAULT SYSUTCDATETIME(), - PRIMARY KEY (portal_id, uuid) + uuid VARCHAR(40) PRIMARY KEY, + idp_id VARCHAR(255) NOT NULL UNIQUE, + created_at DATETIME2(7) DEFAULT SYSUTCDATETIME() ); -IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_user_idp_references_idpid_portal' AND object_id = OBJECT_ID(N'dbo.user_idp_references')) -CREATE UNIQUE INDEX uq_user_idp_references_idpid_portal ON dbo.user_idp_references(idp_id, portal_id); -- User-Organization mappings (live membership record -- both sides cascade on delete, -- unlike the "hanging creator" created_by/updated_by pattern used elsewhere) +-- +-- NOT portal-scoped at the identity level. user_uuid references a global identity in +-- user_idp_references. portal_id is retained here only because org_uuid is portal-specific +-- in organizations. IF OBJECT_ID(N'dbo.user_organization_mappings', N'U') IS NULL CREATE TABLE dbo.user_organization_mappings ( user_uuid VARCHAR(40) NOT NULL, org_uuid VARCHAR(40) NOT NULL, portal_id VARCHAR(255) NOT NULL DEFAULT 'portal_id', - PRIMARY KEY (portal_id, user_uuid, org_uuid), - FOREIGN KEY (portal_id, user_uuid) REFERENCES user_idp_references(portal_id, uuid) ON DELETE CASCADE, + PRIMARY KEY (user_uuid, org_uuid), + FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_organization_mappings_org_uuid' AND object_id = OBJECT_ID(N'dbo.user_organization_mappings')) -CREATE INDEX idx_user_organization_mappings_org_uuid ON dbo.user_organization_mappings(org_uuid); +CREATE INDEX idx_user_organization_mappings_org_uuid ON dbo.user_organization_mappings(portal_id, org_uuid); -- Webhook Subscribers table (portal-scoped outbound event subscribers) IF OBJECT_ID(N'dbo.webhook_subscribers', N'U') IS NULL diff --git a/portals/api-portal/src/dao/apiDao.js b/portals/api-portal/src/dao/apiDao.js index 50f0ec7613..eb8131daa2 100644 --- a/portals/api-portal/src/dao/apiDao.js +++ b/portals/api-portal/src/dao/apiDao.js @@ -112,7 +112,7 @@ const SEARCH_APIS_POSTGRES_SQL = ` ) ) GROUP BY - metadata.uuid + metadata.portal_id, metadata.uuid ORDER BY rank_metadata DESC; `; @@ -213,7 +213,7 @@ const create = async (orgId, apiMetadata, createdBy, t) => { technical_owner_email: owners.technicalOwnerEmail, business_owner_email: owners.businessOwnerEmail, business_owner: owners.businessOwner, sandbox_url: apiMetadata.endPoints.sandboxURL, production_url: apiMetadata.endPoints.productionURL, metadata_search: apiMetadata, org_uuid: orgId, - portal_id: portalId, created_by: createdBy, updated_by: createdBy, created_at: now, updated_at: now, + created_by: createdBy, updated_by: createdBy, created_at: now, updated_at: now, }; }; diff --git a/portals/api-portal/src/dao/apiFileDao.js b/portals/api-portal/src/dao/apiFileDao.js index 474ac3adff..19b67521e6 100644 --- a/portals/api-portal/src/dao/apiFileDao.js +++ b/portals/api-portal/src/dao/apiFileDao.js @@ -44,7 +44,7 @@ const store = async (apiFile, fileName, apiId, type, createdBy, t, key) => { [uuid, portalId, content, fileName, apiId, type, key ?? null, createdBy, createdBy] ); return { - uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: createdBy, updated_by: createdBy, }; }; @@ -62,7 +62,7 @@ const storeMany = async (files, apiId, createdBy, t) => { [uuid, portalId, content, file.fileName, file.type, apiId, file.key ?? null, createdBy, createdBy] ); created.push({ - uuid, portal_id: portalId, file_content: content, file_name: file.fileName, type: file.type, + uuid, file_content: content, file_name: file.fileName, type: file.type, api_uuid: apiId, lookup_key: file.key ?? null, created_by: createdBy, updated_by: createdBy, }); } @@ -104,8 +104,8 @@ const getByKey = async (key, apiId, t) => { const deleteByKey = async (key, apiId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ? AND lookup_key = ?`, - [apiId, constants.DOC_TYPES.IMAGES, key] + `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ? AND lookup_key = ? AND portal_id = ?`, + [apiId, constants.DOC_TYPES.IMAGES, key, getPortalId()] ); return rowCount; }; @@ -169,7 +169,7 @@ const upsert = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) [uuid, portalId, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] ); return { - uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: updatedBy, updated_by: updatedBy, }; } @@ -198,7 +198,7 @@ const update = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) [uuid, portalId, content, fileName, apiId, type, key ?? null, updatedBy, updatedBy] ); return { - uuid, portal_id: portalId, file_content: content, file_name: fileName, api_uuid: apiId, type, + uuid, file_content: content, file_name: fileName, api_uuid: apiId, type, lookup_key: key ?? null, created_by: updatedBy, updated_by: updatedBy, }; } @@ -255,8 +255,8 @@ const deleteAll = async (type, orgId, apiId, t) => { const deleteAllByType = async (type, apiId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ?`, - [apiId, type] + `DELETE FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ? AND portal_id = ?`, + [apiId, type, getPortalId()] ); return rowCount; }; diff --git a/portals/api-portal/src/dao/apiKeyDao.js b/portals/api-portal/src/dao/apiKeyDao.js index 1622e87036..ac2d768d10 100644 --- a/portals/api-portal/src/dao/apiKeyDao.js +++ b/portals/api-portal/src/dao/apiKeyDao.js @@ -31,7 +31,7 @@ const APPLICATIONS_TABLE = 'applications'; // Built once at module load — buildUpsert only depends on the (fixed) dialect // and column list, not on any per-call data. Used both by create() (first-time // association, never conflicts) and setApplication() (re-association, may -// conflict on the key_uuid primary key). +// conflict on the composite (portal_id, key_uuid) primary key). const UPSERT_KEY_APP_MAPPING_SQL = db.buildUpsert( APP_KEY_MAPPINGS_TABLE, ['portal_id', 'key_uuid', 'app_uuid', 'created_by', 'created_at'], @@ -189,7 +189,7 @@ async function setApplication(orgId, keyId, appId, updatedBy, transaction, { act if (appId) { await exec.execute(UPSERT_KEY_APP_MAPPING_SQL, [getPortalId(), keyId, appId, updatedBy, new Date()]); } else { - await exec.execute(`DELETE FROM ${APP_KEY_MAPPINGS_TABLE} WHERE key_uuid = ?`, [keyId]); + await exec.execute(`DELETE FROM ${APP_KEY_MAPPINGS_TABLE} WHERE key_uuid = ? AND portal_id = ?`, [keyId, getPortalId()]); } return true; } diff --git a/portals/api-portal/src/dao/apiWorkflowDao.js b/portals/api-portal/src/dao/apiWorkflowDao.js index 0e1c6a89f1..e4fe0f434a 100644 --- a/portals/api-portal/src/dao/apiWorkflowDao.js +++ b/portals/api-portal/src/dao/apiWorkflowDao.js @@ -109,7 +109,7 @@ const update = async (orgId, viewId, apiWorkflowId, apiWorkflowData, updatedBy, if (rowCount === 0) { return [0, []]; } - const updated = await exec.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ?`, [apiWorkflowId]); + const updated = await exec.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ? AND portal_id = ?`, [apiWorkflowId, getPortalId()]); return [rowCount, [mapRow(updated)]]; }; diff --git a/portals/api-portal/src/dao/applicationDao.js b/portals/api-portal/src/dao/applicationDao.js index 7e7581f113..82d96bca63 100644 --- a/portals/api-portal/src/dao/applicationDao.js +++ b/portals/api-portal/src/dao/applicationDao.js @@ -55,7 +55,6 @@ const create = async (orgId, userId, appData) => { display_name: appData.displayName, handle, org_uuid: orgId, - portal_id: portalId, description: appData.description, created_by: userId, updated_by: userId, @@ -153,8 +152,8 @@ const upsertKeyMapping = async (mappingData, t) => { if (existing) { const updatedAt = new Date(); await exec.execute( - `UPDATE ${KEY_MAPPING_TABLE} SET as_client_id = ?, updated_by = ?, updated_at = ? WHERE uuid = ?`, - [mappingData.asClientId, mappingData.createdBy, updatedAt, existing.uuid] + `UPDATE ${KEY_MAPPING_TABLE} SET as_client_id = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND portal_id = ?`, + [mappingData.asClientId, mappingData.createdBy, updatedAt, existing.uuid, getPortalId()] ); return { ...existing, as_client_id: mappingData.asClientId, updated_by: mappingData.createdBy, updated_at: updatedAt }; } @@ -168,7 +167,6 @@ const upsertKeyMapping = async (mappingData, t) => { ); return { uuid, - portal_id: portalId, app_uuid: mappingData.appId, km_uuid: mappingData.kmId || null, as_client_id: mappingData.asClientId, @@ -180,7 +178,7 @@ const upsertKeyMapping = async (mappingData, t) => { const deleteMappings = async (orgId, appId, t) => { const exec = t || db; - const { rowCount } = await exec.execute(`DELETE FROM ${KEY_MAPPING_TABLE} WHERE app_uuid = ?`, [appId]); + const { rowCount } = await exec.execute(`DELETE FROM ${KEY_MAPPING_TABLE} WHERE app_uuid = ? AND portal_id = ?`, [appId, getPortalId()]); if (rowCount < 1) { logger.debug('No Application Key Mapping found', { orgId, @@ -232,8 +230,8 @@ const getKeyMappingById = async (appId, mappingId, t) => { const deleteKeyMappingById = async (appId, mappingId, t) => { const exec = t || db; const { rowCount } = await exec.execute( - `DELETE FROM ${KEY_MAPPING_TABLE} WHERE uuid = ? AND app_uuid = ?`, - [mappingId, appId] + `DELETE FROM ${KEY_MAPPING_TABLE} WHERE uuid = ? AND app_uuid = ? AND portal_id = ?`, + [mappingId, appId, getPortalId()] ); return rowCount; }; @@ -252,7 +250,6 @@ const createKeyMapping = async (mappingData, t) => { ); return { uuid, - portal_id: portalId, app_uuid: mappingData.appId, km_uuid: mappingData.kmId || null, as_client_id: mappingData.asClientId || null, diff --git a/portals/api-portal/src/dao/auditDao.js b/portals/api-portal/src/dao/auditDao.js index b135e6e29d..9067a91cf8 100644 --- a/portals/api-portal/src/dao/auditDao.js +++ b/portals/api-portal/src/dao/auditDao.js @@ -46,7 +46,6 @@ const record = async (action, resourceUuid, resourceType, orgUuid, performedBy) resource_uuid: resourceUuid, resource_type: resourceType, org_uuid: orgUuid, - portal_id: portalId, performed_by: performedBy, }; }; diff --git a/portals/api-portal/src/dao/eventDao.js b/portals/api-portal/src/dao/eventDao.js index 3c8036b677..298df67106 100644 --- a/portals/api-portal/src/dao/eventDao.js +++ b/portals/api-portal/src/dao/eventDao.js @@ -44,11 +44,11 @@ function parseDeliveryRow(row) { async function create({ eventType, orgId, aggregateType, aggregateId, payload }, transaction) { const exec = transaction || db; const uuid = crypto.randomUUID(); + const portalId = getPortalId(); const row = { uuid, type: eventType, org_uuid: orgId, - portal_id: getPortalId(), aggregate_type: aggregateType, aggregate_uuid: aggregateId, payload: payload || {}, @@ -59,7 +59,7 @@ async function create({ eventType, orgId, aggregateType, aggregateId, payload }, await exec.execute( `INSERT INTO ${EVENTS_TABLE} (uuid, type, org_uuid, portal_id, aggregate_type, aggregate_uuid, payload, occurred_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [row.uuid, row.type, row.org_uuid, row.portal_id, row.aggregate_type, row.aggregate_uuid, + [row.uuid, row.type, row.org_uuid, portalId, row.aggregate_type, row.aggregate_uuid, JSON.stringify(row.payload), row.occurred_at, row.status] ); @@ -89,7 +89,6 @@ async function createDeliveries(eventId, subscribers, perSubscriberEncrypted, tr const portalId = getPortalId(); const rows = subscribers.map((sub) => ({ uuid: crypto.randomUUID(), - portal_id: portalId, event_uuid: eventId, subscriber_id: sub.id, target_url: sub.url, @@ -102,7 +101,7 @@ async function createDeliveries(eventId, subscribers, perSubscriberEncrypted, tr `INSERT INTO ${DELIVERIES_TABLE} (uuid, portal_id, event_uuid, subscriber_id, target_url, encrypted_fields, status) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ - row.uuid, row.portal_id, row.event_uuid, row.subscriber_id, row.target_url, + row.uuid, portalId, row.event_uuid, row.subscriber_id, row.target_url, row.encrypted_fields !== null ? JSON.stringify(row.encrypted_fields) : null, row.status, ] diff --git a/portals/api-portal/src/dao/labelDao.js b/portals/api-portal/src/dao/labelDao.js index a4b06c5b9b..9999402588 100644 --- a/portals/api-portal/src/dao/labelDao.js +++ b/portals/api-portal/src/dao/labelDao.js @@ -51,7 +51,6 @@ const create = async (orgId, label, createdBy, t) => { handle: label.handle, display_name: label.displayName, org_uuid: orgId, - portal_id: portalId, created_by: createdBy, updated_by: createdBy, }; @@ -115,7 +114,6 @@ const createMany = async (orgId, labels, createdBy, t) => { handle: label.handle, display_name: label.displayName, org_uuid: orgId, - portal_id: portalId, created_by: createdBy, updated_by: createdBy, }); @@ -160,7 +158,6 @@ const update = async (orgId, label, updatedBy, t) => { handle: label.handle, display_name: label.displayName, org_uuid: orgId, - portal_id: portalId, created_by: updatedBy, updated_by: updatedBy, }; @@ -176,8 +173,8 @@ const update = async (orgId, label, updatedBy, t) => { const updatedAt = new Date(); await exec.execute( - `UPDATE ${LABELS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ?`, - [label.displayName, updatedBy, updatedAt, row.uuid] + `UPDATE ${LABELS_TABLE} SET display_name = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND portal_id = ?`, + [label.displayName, updatedBy, updatedAt, row.uuid, getPortalId()] ); return { ...row, display_name: label.displayName, updated_by: updatedBy, updated_at: updatedAt }; }; @@ -212,8 +209,8 @@ const deleteApiMapping = async (orgId, apiId, labels, t) => { if (idList.length === 0) return 0; const placeholders = idList.map(() => '?').join(', '); const { rowCount } = await exec.execute( - `DELETE FROM ${API_LABELS_TABLE} WHERE label_uuid IN (${placeholders}) AND api_uuid = ?`, - [...idList, apiId] + `DELETE FROM ${API_LABELS_TABLE} WHERE label_uuid IN (${placeholders}) AND api_uuid = ? AND portal_id = ?`, + [...idList, apiId, getPortalId()] ); return rowCount; }; @@ -221,12 +218,14 @@ const deleteApiMapping = async (orgId, apiId, labels, t) => { const addToView = async (orgId, labelId, viewId, createdBy, t) => { const exec = t || db; const portalId = getPortalId(); - return findOrCreateSafe( + const result = await findOrCreateSafe( VIEW_LABELS_TABLE, { label_uuid: labelId, view_uuid: viewId, portal_id: portalId }, { uuid: crypto.randomUUID(), portal_id: portalId, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }, exec ); + const { portal_id: _portalId, ...rest } = result; + return rest; }; module.exports = { diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index e9c14c37f8..7417512e3a 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -48,7 +48,6 @@ const create = async (orgData, t) => { ); return { uuid, - portal_id: portalId, display_name: orgData.displayName, business_owner: orgData.businessOwner, business_owner_contact: orgData.businessOwnerContact, @@ -260,6 +259,8 @@ const deleteOrgDependents = async (orgUuid, t) => { await exec.execute('DELETE FROM key_managers WHERE org_uuid = ?', [orgUuid]); await exec.execute('DELETE FROM api_workflows WHERE org_uuid = ?', [orgUuid]); + // This method is not scoped by portal_id: this is an org-level deletion that + // removes all assets belonging to the org across every portal. await exec.execute(`DELETE FROM ${ORG_CONTENT_TABLE} WHERE org_uuid = ?`, [orgUuid]); // view_label_mappings/api_label_mappings cascade automatically from // views/labels ON DELETE CASCADE. @@ -273,7 +274,9 @@ const deleteOrg = async (orgId, t) => { const exec = t || db; const existing = await get(orgId, t); await deleteOrgDependents(existing.uuid, t); - const { rowCount } = await exec.execute(`DELETE FROM ${ORG_TABLE} WHERE uuid = ? AND portal_id = ?`, [existing.uuid, getPortalId()]); + // Since this is org deletion without any consideration of its portals + // portal_id is not included in the WHERE clause + const { rowCount } = await exec.execute(`DELETE FROM ${ORG_TABLE} WHERE uuid = ?`, [existing.uuid]); if (rowCount < 1) { throw new NotFoundError('Organization not found'); } @@ -302,7 +305,6 @@ const createContent = async (orgData, t) => { file_path: orgData.filePath, org_uuid: orgData.orgId, view_uuid: viewId, - portal_id: getPortalId(), created_by: orgData.createdBy, updated_by: orgData.createdBy, }; diff --git a/portals/api-portal/src/dao/subscriptionDao.js b/portals/api-portal/src/dao/subscriptionDao.js index f58961019e..7ba7be3f5c 100644 --- a/portals/api-portal/src/dao/subscriptionDao.js +++ b/portals/api-portal/src/dao/subscriptionDao.js @@ -241,9 +241,11 @@ async function regenerateToken(orgId, subId, updatedBy, transaction) { async function deleteSubscription(orgId, subId, createdBy, transaction) { const exec = transaction || db; + // Nullify nullable subscription_uuid references before deleting the subscription row. + // The DB constraint is ON DELETE NO ACTION; application code owns the nullification. await exec.execute( - 'UPDATE api_keys SET subscription_uuid = NULL WHERE subscription_uuid = ?', - [subId] + 'UPDATE api_keys SET subscription_uuid = NULL WHERE subscription_uuid = ? AND portal_id = ? AND org_uuid = ?', + [subId, getPortalId(), orgId] ); const where = ['uuid = ?', 'org_uuid = ?', 'portal_id = ?']; const params = [subId, orgId, getPortalId()]; diff --git a/portals/api-portal/src/dao/subscriptionPlanDao.js b/portals/api-portal/src/dao/subscriptionPlanDao.js index 1b1ca1303d..fbc14f36b1 100644 --- a/portals/api-portal/src/dao/subscriptionPlanDao.js +++ b/portals/api-portal/src/dao/subscriptionPlanDao.js @@ -197,6 +197,8 @@ const update = async (orgId, planId, plan, updatedBy, t) => { const deletePlan = async (orgId, planName, t) => { const exec = t || db; + // Nullify nullable plan_uuid references before deleting the subscription_plan row. + // The DB constraint is ON DELETE NO ACTION; application code owns the nullification. await exec.execute( `UPDATE subscriptions SET plan_uuid = NULL WHERE plan_uuid IN ( SELECT uuid FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE handle = ? AND org_uuid = ? AND portal_id = ? @@ -261,7 +263,7 @@ const createApiMapping = async (apiSubscriptionPlans, apiId, createdBy, t) => { VALUES (?, ?, ?, ?, ?, ?)`, [uuid, portalId, plan.planId, apiId, createdBy, now] ); - created.push({ uuid, portal_id: portalId, plan_uuid: plan.planId, api_uuid: apiId, created_by: createdBy, created_at: now }); + created.push({ uuid, plan_uuid: plan.planId, api_uuid: apiId, created_by: createdBy, created_at: now }); } return created; }; diff --git a/portals/api-portal/src/dao/tagDao.js b/portals/api-portal/src/dao/tagDao.js index ff667cbebd..488cec7d61 100644 --- a/portals/api-portal/src/dao/tagDao.js +++ b/portals/api-portal/src/dao/tagDao.js @@ -78,7 +78,7 @@ const createApiMapping = async (orgId, apiId, tagNames, createdBy, t) => { */ const replaceApiMapping = async (orgId, apiId, tagNames, createdBy, t) => { const exec = t || db; - await exec.execute(`DELETE FROM ${API_TAGS_TABLE} WHERE api_uuid = ?`, [apiId]); + await exec.execute(`DELETE FROM ${API_TAGS_TABLE} WHERE api_uuid = ? AND portal_id = ?`, [apiId, getPortalId()]); return createApiMapping(orgId, apiId, tagNames || [], createdBy, t); }; diff --git a/portals/api-portal/src/dao/userIdpReferenceDao.js b/portals/api-portal/src/dao/userIdpReferenceDao.js index 06fcbb1a77..acec49388f 100644 --- a/portals/api-portal/src/dao/userIdpReferenceDao.js +++ b/portals/api-portal/src/dao/userIdpReferenceDao.js @@ -20,23 +20,21 @@ const crypto = require('crypto'); const db = require('../db/driver'); const { findOrCreateSafe } = require('./findOrCreateHelper'); -const { getPortalId } = require('../utils/orgContext'); const TABLE = 'user_idp_references'; const DELETED_USER = 'deleted_user'; /** - * Find-or-create the idp reference row for this (idp_id, portal_id) pair, - * returning its uuid. portal_id is resolved internally — never accepted from - * request input (IDOR prevention). Falls back to a plain lookup on a - * unique-constraint race between concurrent requests for the same pair. + * Find-or-create the idp reference row for this idp_id, returning its uuid. + * user_idp_references is not portal-scoped — one row per IdP sub claim shared + * across all portals of the same org. Falls back to a plain lookup on a + * unique-constraint race between concurrent requests for the same idp_id. */ const resolveUuid = async (idpId) => { - const portalId = getPortalId(); const reference = await findOrCreateSafe( TABLE, - { idp_id: idpId, portal_id: portalId }, - { uuid: crypto.randomUUID(), idp_id: idpId, portal_id: portalId } + { idp_id: idpId }, + { uuid: crypto.randomUUID(), idp_id: idpId } ); return reference.uuid; }; diff --git a/portals/api-portal/src/dao/userOrganizationMappingDao.js b/portals/api-portal/src/dao/userOrganizationMappingDao.js index 49240c9076..769727afb3 100644 --- a/portals/api-portal/src/dao/userOrganizationMappingDao.js +++ b/portals/api-portal/src/dao/userOrganizationMappingDao.js @@ -24,13 +24,13 @@ const TABLE = 'user_organization_mappings'; /** * Record that this user belongs to this org. No-op if already recorded. - * PRIMARY KEY is (portal_id, user_uuid, org_uuid). + * PRIMARY KEY is (user_uuid, org_uuid). */ const ensureMapping = async (userUuid, orgUuid) => { const portalId = getPortalId(); await findOrCreateSafe( TABLE, - { portal_id: portalId, user_uuid: userUuid, org_uuid: orgUuid }, + { user_uuid: userUuid, org_uuid: orgUuid }, { portal_id: portalId, user_uuid: userUuid, org_uuid: orgUuid } ); }; diff --git a/portals/api-portal/src/dao/viewDao.js b/portals/api-portal/src/dao/viewDao.js index 13af7142fe..47f4ceb5df 100644 --- a/portals/api-portal/src/dao/viewDao.js +++ b/portals/api-portal/src/dao/viewDao.js @@ -49,7 +49,6 @@ const create = async (orgId, payload, createdBy, t) => { handle: payload.handle, display_name: displayName, org_uuid: orgId, - portal_id: portalId, created_by: createdBy, updated_by: createdBy, }; @@ -86,7 +85,6 @@ const update = async (orgId, handle, displayName, updatedBy, t) => { handle, display_name: initialDisplayName, org_uuid: orgId, - portal_id: portalId, created_by: updatedBy, updated_by: updatedBy, }; @@ -288,7 +286,7 @@ const addLabels = async (orgId, viewId, labels, createdBy, t) => { `INSERT INTO ${VIEW_LABELS_TABLE} (uuid, portal_id, label_uuid, view_uuid, created_by) VALUES (?, ?, ?, ?, ?)`, [uuid, portalId, labelId, viewId, createdBy] ); - created.push({ uuid, portal_id: portalId, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }); + created.push({ uuid, label_uuid: labelId, view_uuid: viewId, created_by: createdBy }); } return created; }; diff --git a/portals/api-portal/src/dao/webhookSubscriberDao.js b/portals/api-portal/src/dao/webhookSubscriberDao.js index 412c846814..32cf88f9e6 100644 --- a/portals/api-portal/src/dao/webhookSubscriberDao.js +++ b/portals/api-portal/src/dao/webhookSubscriberDao.js @@ -184,8 +184,8 @@ const get = async (orgId, subscriberHandle) => { }; /** - * Get a single webhook subscriber by UUID only, without scoping to an org. - * UUID is a globally unique UUID primary key, so this is safe. + * Get a single webhook subscriber by UUID only, without scoping to an org or a portal. + * UUID is a globally unique UUID, so this is safe. * Used by the delivery worker, which only has the subscriber UUID (from the * delivery row) and not the org UUID in scope. */ From 8c1448e3e8b8345ed724f0b0ecae770d5b66112f Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Mon, 31 Aug 2026 09:27:57 +0530 Subject: [PATCH 3/4] Fix delete revoked api-keys upon api deletion --- portals/api-portal/src/dao/apiKeyDao.js | 11 ++++++++++- portals/api-portal/src/services/apiMetadataService.js | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/portals/api-portal/src/dao/apiKeyDao.js b/portals/api-portal/src/dao/apiKeyDao.js index ac2d768d10..3c2321455f 100644 --- a/portals/api-portal/src/dao/apiKeyDao.js +++ b/portals/api-portal/src/dao/apiKeyDao.js @@ -204,4 +204,13 @@ async function updateExpiry(orgId, keyId, expiresAt, updatedBy, transaction) { return rowCount > 0; } -module.exports = { create, get, getIdByHandle, list, revoke, setApplication, updateExpiry }; +async function deleteByApi(orgId, apiId, t) { + const exec = t || db; + const { rowCount } = await exec.execute( + `DELETE FROM ${API_KEYS_TABLE} WHERE api_uuid = ? AND org_uuid = ? AND portal_id = ?`, + [apiId, orgId, getPortalId()] + ); + return rowCount; +} + +module.exports = { create, get, getIdByHandle, list, revoke, setApplication, updateExpiry, deleteByApi }; diff --git a/portals/api-portal/src/services/apiMetadataService.js b/portals/api-portal/src/services/apiMetadataService.js index b7fccd4d4d..84d6b44a10 100644 --- a/portals/api-portal/src/services/apiMetadataService.js +++ b/portals/api-portal/src/services/apiMetadataService.js @@ -754,6 +754,8 @@ const deleteAPIMetadata = async (req, res) => { if (activeKeys.length > 0) { throw new CustomError(409, constants.ERROR_MESSAGE.ERR_KEY_EXIST, "API has active keys."); } + // Delete revoked/expired keys within the transaction before removing the API row + await apiKeyDao.deleteByApi(orgId, apiId, t); const apiDeleteResponse = await apiDao.delete(orgId, apiId, t); if (apiDeleteResponse === 0) { throw new NotFoundError("Resource not found to delete"); From 1c8cd07bc5eeb5605012c10654221ec0f931b863 Mon Sep 17 00:00:00 2001 From: NethmiRanasinghe Date: Wed, 2 Sep 2026 12:32:32 +0530 Subject: [PATCH 4/4] Fix review comments --- .../api-portal/database/schema.postgres.sql | 26 +++++----- portals/api-portal/database/schema.sqlite.sql | 26 +++++----- .../api-portal/database/schema.sqlserver.sql | 26 +++++----- portals/api-portal/src/dao/apiFileDao.js | 14 +++--- portals/api-portal/src/dao/apiKeyDao.js | 12 ++--- portals/api-portal/src/dao/applicationDao.js | 4 +- portals/api-portal/src/dao/eventDao.js | 28 +++++------ portals/api-portal/src/dao/subscriptionDao.js | 8 +-- .../src/dao/webhookSubscriberDao.js | 8 +-- .../src/middlewares/authMiddleware.js | 21 ++++---- .../src/middlewares/ensureAuthenticated.js | 7 +++ .../src/services/mcpRegistryService.js | 49 ++++++++++--------- .../src/services/webhooks/dispatcher.js | 6 ++- portals/api-portal/src/utils/orgContext.js | 12 ++--- 14 files changed, 128 insertions(+), 119 deletions(-) diff --git a/portals/api-portal/database/schema.postgres.sql b/portals/api-portal/database/schema.postgres.sql index bfca758dcb..aa8909f0e4 100644 --- a/portals/api-portal/database/schema.postgres.sql +++ b/portals/api-portal/database/schema.postgres.sql @@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS organization_assets ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); -CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(portal_id, org_uuid); -CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(portal_id, view_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid, portal_id); -- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( @@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS view_label_mappings ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(label_uuid, view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid, portal_id); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) @@ -199,7 +199,7 @@ CREATE TABLE IF NOT EXISTS api_label_mappings ( FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(label_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid, portal_id); -- API-Tag mappings (many-to-many: which tags are attached to an API) @@ -214,7 +214,7 @@ CREATE TABLE IF NOT EXISTS api_tag_mappings ( FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(tag_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid, portal_id); -- Subscription Plans table (portal-scoped rate/billing plans) @@ -271,7 +271,7 @@ CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api - ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); + ON api_subscription_plan_mappings(plan_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid, portal_id); -- Key Managers table (portal-scoped identity providers used to validate app keys) @@ -307,7 +307,7 @@ CREATE TABLE IF NOT EXISTS applications ( PRIMARY KEY (portal_id, uuid), FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, portal_id, created_by); +CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, created_by, portal_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle, portal_id); -- Application-KeyManager mappings (per-KM OAuth2 client registration for an application) @@ -351,8 +351,8 @@ CREATE TABLE IF NOT EXISTS subscriptions ( FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, UNIQUE(token) ); -CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, portal_id, created_by); -CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, portal_id, api_uuid); +CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, created_by, portal_id); +CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_subscription_plan_uuid ON subscriptions(plan_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_status ON subscriptions(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a @@ -384,7 +384,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); -CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, portal_id, api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_key_subscription_uuid ON api_keys(subscription_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) —- add a @@ -430,7 +430,7 @@ CREATE TABLE IF NOT EXISTS api_workflows ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); -CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(portal_id, view_uuid); +CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history @@ -484,7 +484,7 @@ CREATE TABLE IF NOT EXISTS event_deliveries ( FOREIGN KEY (portal_id, event_uuid) REFERENCES events(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_delivery_event_uuid ON event_deliveries(event_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(portal_id, event_uuid, subscriber_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(event_uuid, subscriber_id, portal_id); -- Sessions table, used by connect-pg-simple for server-side Express session storage. CREATE TABLE IF NOT EXISTS sessions ( @@ -521,7 +521,7 @@ CREATE TABLE IF NOT EXISTS user_organization_mappings ( FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(portal_id, org_uuid); +CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid, portal_id); -- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( diff --git a/portals/api-portal/database/schema.sqlite.sql b/portals/api-portal/database/schema.sqlite.sql index 716d5cef72..b73a6ee703 100644 --- a/portals/api-portal/database/schema.sqlite.sql +++ b/portals/api-portal/database/schema.sqlite.sql @@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS organization_assets ( ); CREATE UNIQUE INDEX IF NOT EXISTS uq_organization_asset_type_name_path_org_view ON organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); -CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(portal_id, org_uuid); -CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(portal_id, view_uuid); +CREATE INDEX IF NOT EXISTS idx_organization_asset_org_uuid ON organization_assets(org_uuid, portal_id); +CREATE INDEX IF NOT EXISTS idx_organization_asset_view_uuid ON organization_assets(view_uuid, portal_id); -- Labels table (portal-scoped labels used for gateway/view assignment) CREATE TABLE IF NOT EXISTS labels ( @@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS view_label_mappings ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(portal_id, label_uuid, view_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_view_label_mappings_label_view ON view_label_mappings(label_uuid, view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_view_label_mappings_view_uuid ON view_label_mappings(view_uuid, portal_id); -- API Metadata table (core record for REST APIs, MCP servers, AI agents, etc.) @@ -199,7 +199,7 @@ CREATE TABLE IF NOT EXISTS api_label_mappings ( FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(portal_id, label_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_label_mappings_label_api ON api_label_mappings(label_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_label_mappings_api_uuid ON api_label_mappings(api_uuid, portal_id); -- API-Tag mappings (many-to-many: which tags are attached to an API) @@ -214,7 +214,7 @@ CREATE TABLE IF NOT EXISTS api_tag_mappings ( FOREIGN KEY (portal_id, api_uuid) REFERENCES api_metadata(portal_id, uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(portal_id, tag_uuid, api_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_tag_mappings_tag_api ON api_tag_mappings(tag_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_tag_mappings_api_uuid ON api_tag_mappings(api_uuid, portal_id); -- Subscription Plans table (portal-scoped rate/billing plans) @@ -270,7 +270,7 @@ CREATE TABLE IF NOT EXISTS api_subscription_plan_mappings ( FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_subscription_plan_mappings_plan_api - ON api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); + ON api_subscription_plan_mappings(plan_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_subscription_plan_mappings_api_uuid ON api_subscription_plan_mappings(api_uuid, portal_id); -- Key Managers table (portal-scoped identity providers used to validate app keys) @@ -306,7 +306,7 @@ CREATE TABLE IF NOT EXISTS applications ( PRIMARY KEY (portal_id, uuid), FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); -CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, portal_id, created_by); +CREATE INDEX IF NOT EXISTS idx_application_org_created_by ON applications(org_uuid, created_by, portal_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_application_org_handle ON applications(org_uuid, handle, portal_id); -- Application-KeyManager mappings (per-KM OAuth2 client registration for an application) @@ -350,8 +350,8 @@ CREATE TABLE IF NOT EXISTS subscriptions ( FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION, UNIQUE(token) ); -CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, portal_id, created_by); -CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, portal_id, api_uuid); +CREATE INDEX IF NOT EXISTS idx_subscription_org_created_by ON subscriptions(org_uuid, created_by, portal_id); +CREATE INDEX IF NOT EXISTS idx_subscription_org_api_uuid ON subscriptions(org_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_subscription_plan_uuid ON subscriptions(plan_uuid); CREATE INDEX IF NOT EXISTS idx_subscription_status ON subscriptions(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a @@ -383,7 +383,7 @@ CREATE TABLE IF NOT EXISTS api_keys ( CONSTRAINT chk_api_key_revoked CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); -CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, portal_id, api_uuid); +CREATE INDEX IF NOT EXISTS idx_api_key_org_api_uuid ON api_keys(org_uuid, api_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_key_subscription_uuid ON api_keys(subscription_uuid); CREATE INDEX IF NOT EXISTS idx_api_key_status ON api_keys(status); -- api_uuid is only ever a trailing column above (org_uuid, api_uuid) -- add a @@ -429,7 +429,7 @@ CREATE TABLE IF NOT EXISTS api_workflows ( FOREIGN KEY (portal_id, view_uuid) REFERENCES views(portal_id, uuid) ON DELETE NO ACTION ); CREATE UNIQUE INDEX IF NOT EXISTS uq_api_workflow_org_view_handle ON api_workflows(org_uuid, view_uuid, handle, portal_id); -CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(portal_id, view_uuid); +CREATE INDEX IF NOT EXISTS idx_api_workflow_view_uuid ON api_workflows(view_uuid, portal_id); CREATE INDEX IF NOT EXISTS idx_api_workflow_status ON api_workflows(status); -- Audit table (write-only mutation trail; no FK on performed_by so history @@ -483,7 +483,7 @@ CREATE TABLE IF NOT EXISTS event_deliveries ( FOREIGN KEY (portal_id, event_uuid) REFERENCES events(portal_id, uuid) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS idx_event_delivery_event_uuid ON event_deliveries(event_uuid); -CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(portal_id, event_uuid, subscriber_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_delivery_event_subscriber ON event_deliveries(event_uuid, subscriber_id, portal_id); -- Sessions table, used by connect-session-sequelize for server-side Express session storage. CREATE TABLE IF NOT EXISTS sessions ( @@ -520,7 +520,7 @@ CREATE TABLE IF NOT EXISTS user_organization_mappings ( FOREIGN KEY (user_uuid) REFERENCES user_idp_references(uuid) ON DELETE CASCADE, FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(portal_id, org_uuid); +CREATE INDEX IF NOT EXISTS idx_user_organization_mappings_org_uuid ON user_organization_mappings(org_uuid, portal_id); -- Webhook Subscribers table (portal-scoped outbound event subscribers) CREATE TABLE IF NOT EXISTS webhook_subscribers ( diff --git a/portals/api-portal/database/schema.sqlserver.sql b/portals/api-portal/database/schema.sqlserver.sql index 12c0ea1764..c7648ab5b4 100644 --- a/portals/api-portal/database/schema.sqlserver.sql +++ b/portals/api-portal/database/schema.sqlserver.sql @@ -86,9 +86,9 @@ CREATE TABLE dbo.organization_assets ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_organization_asset_type_name_path_org_view' AND object_id = OBJECT_ID(N'dbo.organization_assets')) CREATE UNIQUE INDEX uq_organization_asset_type_name_path_org_view ON dbo.organization_assets(file_type, file_name, file_path, org_uuid, view_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_org_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) -CREATE INDEX idx_organization_asset_org_uuid ON dbo.organization_assets(portal_id, org_uuid); +CREATE INDEX idx_organization_asset_org_uuid ON dbo.organization_assets(org_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_organization_asset_view_uuid' AND object_id = OBJECT_ID(N'dbo.organization_assets')) -CREATE INDEX idx_organization_asset_view_uuid ON dbo.organization_assets(portal_id, view_uuid); +CREATE INDEX idx_organization_asset_view_uuid ON dbo.organization_assets(view_uuid, portal_id); -- Labels table (portal-scoped labels used for gateway/view assignment) IF OBJECT_ID(N'dbo.labels', N'U') IS NULL @@ -143,7 +143,7 @@ CREATE TABLE dbo.view_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_view_label_mappings_label_view' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) -CREATE UNIQUE INDEX uq_view_label_mappings_label_view ON dbo.view_label_mappings(portal_id, label_uuid, view_uuid); +CREATE UNIQUE INDEX uq_view_label_mappings_label_view ON dbo.view_label_mappings(label_uuid, view_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_view_label_mappings_view_uuid' AND object_id = OBJECT_ID(N'dbo.view_label_mappings')) CREATE INDEX idx_view_label_mappings_view_uuid ON dbo.view_label_mappings(view_uuid, portal_id); @@ -233,7 +233,7 @@ CREATE TABLE dbo.api_label_mappings ( FOREIGN KEY (portal_id, label_uuid) REFERENCES labels(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_label_mappings_label_api' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) -CREATE UNIQUE INDEX uq_api_label_mappings_label_api ON dbo.api_label_mappings(portal_id, label_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_label_mappings_label_api ON dbo.api_label_mappings(label_uuid, api_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_label_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_label_mappings')) CREATE INDEX idx_api_label_mappings_api_uuid ON dbo.api_label_mappings(api_uuid, portal_id); @@ -251,7 +251,7 @@ CREATE TABLE dbo.api_tag_mappings ( FOREIGN KEY (portal_id, tag_uuid) REFERENCES tags(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_tag_mappings_tag_api' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) -CREATE UNIQUE INDEX uq_api_tag_mappings_tag_api ON dbo.api_tag_mappings(portal_id, tag_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_tag_mappings_tag_api ON dbo.api_tag_mappings(tag_uuid, api_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_tag_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_tag_mappings')) CREATE INDEX idx_api_tag_mappings_api_uuid ON dbo.api_tag_mappings(api_uuid, portal_id); @@ -315,7 +315,7 @@ CREATE TABLE dbo.api_subscription_plan_mappings ( FOREIGN KEY (portal_id, plan_uuid) REFERENCES subscription_plans(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_subscription_plan_mappings_plan_api' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) -CREATE UNIQUE INDEX uq_api_subscription_plan_mappings_plan_api ON dbo.api_subscription_plan_mappings(portal_id, plan_uuid, api_uuid); +CREATE UNIQUE INDEX uq_api_subscription_plan_mappings_plan_api ON dbo.api_subscription_plan_mappings(plan_uuid, api_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_subscription_plan_mappings_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_subscription_plan_mappings')) CREATE INDEX idx_api_subscription_plan_mappings_api_uuid ON dbo.api_subscription_plan_mappings(api_uuid, portal_id); @@ -356,7 +356,7 @@ CREATE TABLE dbo.applications ( FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_application_org_created_by' AND object_id = OBJECT_ID(N'dbo.applications')) -CREATE INDEX idx_application_org_created_by ON dbo.applications(org_uuid, portal_id, created_by); +CREATE INDEX idx_application_org_created_by ON dbo.applications(org_uuid, created_by, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_application_org_handle' AND object_id = OBJECT_ID(N'dbo.applications')) CREATE UNIQUE INDEX uq_application_org_handle ON dbo.applications(org_uuid, handle, portal_id); @@ -405,9 +405,9 @@ CREATE TABLE dbo.subscriptions ( FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE NO ACTION ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_org_created_by' AND object_id = OBJECT_ID(N'dbo.subscriptions')) -CREATE INDEX idx_subscription_org_created_by ON dbo.subscriptions(org_uuid, portal_id, created_by); +CREATE INDEX idx_subscription_org_created_by ON dbo.subscriptions(org_uuid, created_by, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_org_api_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions')) -CREATE INDEX idx_subscription_org_api_uuid ON dbo.subscriptions(org_uuid, portal_id, api_uuid); +CREATE INDEX idx_subscription_org_api_uuid ON dbo.subscriptions(org_uuid, api_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_plan_uuid' AND object_id = OBJECT_ID(N'dbo.subscriptions')) CREATE INDEX idx_subscription_plan_uuid ON dbo.subscriptions(plan_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_subscription_status' AND object_id = OBJECT_ID(N'dbo.subscriptions')) @@ -450,7 +450,7 @@ CREATE TABLE dbo.api_keys ( CHECK ((revoked_at IS NULL AND status != 'REVOKED') OR (revoked_at IS NOT NULL AND status = 'REVOKED')) ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_org_api_uuid' AND object_id = OBJECT_ID(N'dbo.api_keys')) -CREATE INDEX idx_api_key_org_api_uuid ON dbo.api_keys(org_uuid, portal_id, api_uuid); +CREATE INDEX idx_api_key_org_api_uuid ON dbo.api_keys(org_uuid, api_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_subscription_uuid' AND object_id = OBJECT_ID(N'dbo.api_keys')) CREATE INDEX idx_api_key_subscription_uuid ON dbo.api_keys(subscription_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_key_status' AND object_id = OBJECT_ID(N'dbo.api_keys')) @@ -505,7 +505,7 @@ CREATE TABLE dbo.api_workflows ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_api_workflow_org_view_handle' AND object_id = OBJECT_ID(N'dbo.api_workflows')) CREATE UNIQUE INDEX uq_api_workflow_org_view_handle ON dbo.api_workflows(org_uuid, view_uuid, handle, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_view_uuid' AND object_id = OBJECT_ID(N'dbo.api_workflows')) -CREATE INDEX idx_api_workflow_view_uuid ON dbo.api_workflows(portal_id, view_uuid); +CREATE INDEX idx_api_workflow_view_uuid ON dbo.api_workflows(view_uuid, portal_id); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_api_workflow_status' AND object_id = OBJECT_ID(N'dbo.api_workflows')) CREATE INDEX idx_api_workflow_status ON dbo.api_workflows(status); @@ -568,7 +568,7 @@ CREATE TABLE dbo.event_deliveries ( IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_event_delivery_event_uuid' AND object_id = OBJECT_ID(N'dbo.event_deliveries')) CREATE INDEX idx_event_delivery_event_uuid ON dbo.event_deliveries(event_uuid); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_event_delivery_event_subscriber' AND object_id = OBJECT_ID(N'dbo.event_deliveries')) -CREATE UNIQUE INDEX uq_event_delivery_event_subscriber ON dbo.event_deliveries(portal_id, event_uuid, subscriber_id); +CREATE UNIQUE INDEX uq_event_delivery_event_subscriber ON dbo.event_deliveries(event_uuid, subscriber_id, portal_id); -- Sessions table, used by connect-mssql-v2 (or equivalent) for server-side Express session storage. IF OBJECT_ID(N'dbo.sessions', N'U') IS NULL @@ -610,7 +610,7 @@ CREATE TABLE dbo.user_organization_mappings ( FOREIGN KEY (portal_id, org_uuid) REFERENCES organizations(portal_id, uuid) ON DELETE CASCADE ); IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_user_organization_mappings_org_uuid' AND object_id = OBJECT_ID(N'dbo.user_organization_mappings')) -CREATE INDEX idx_user_organization_mappings_org_uuid ON dbo.user_organization_mappings(portal_id, org_uuid); +CREATE INDEX idx_user_organization_mappings_org_uuid ON dbo.user_organization_mappings(org_uuid, portal_id); -- Webhook Subscribers table (portal-scoped outbound event subscribers) IF OBJECT_ID(N'dbo.webhook_subscribers', N'U') IS NULL diff --git a/portals/api-portal/src/dao/apiFileDao.js b/portals/api-portal/src/dao/apiFileDao.js index 19b67521e6..fc6891351f 100644 --- a/portals/api-portal/src/dao/apiFileDao.js +++ b/portals/api-portal/src/dao/apiFileDao.js @@ -29,9 +29,9 @@ const API_METADATA_TABLE = 'api_metadata'; // Every content row is tenant-scoped through the API it belongs to — this // correlated EXISTS clause (not a JOIN alias, which sqlite's UPDATE grammar // doesn't support portably) is appended to UPDATE/DELETE statements that need -// to verify org ownership. Requires org_uuid as the LAST bind param. +// to verify org ownership. Requires org_uuid then getPortalId() as the last two bind params. const TENANT_SCOPE_EXISTS = - `EXISTS (SELECT 1 FROM ${API_METADATA_TABLE} m WHERE m.uuid = ${CONTENT_TABLE}.api_uuid AND m.org_uuid = ? AND m.portal_id = ${CONTENT_TABLE}.portal_id)`; + `EXISTS (SELECT 1 FROM ${API_METADATA_TABLE} m WHERE m.uuid = ${CONTENT_TABLE}.api_uuid AND m.org_uuid = ? AND m.portal_id = ${CONTENT_TABLE}.portal_id AND m.portal_id = ?)`; const store = async (apiFile, fileName, apiId, type, createdBy, t, key) => { const exec = t || db; @@ -93,8 +93,8 @@ const getByType = async (type, orgId, apiId, t) => { const getByKey = async (key, apiId, t) => { const exec = t || db; return exec.queryOne( - `SELECT * FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ? AND lookup_key = ?`, - [apiId, constants.DOC_TYPES.IMAGES, key] + `SELECT * FROM ${CONTENT_TABLE} WHERE api_uuid = ? AND type = ? AND lookup_key = ? AND portal_id = ?`, + [apiId, constants.DOC_TYPES.IMAGES, key, getPortalId()] ); }; @@ -135,7 +135,7 @@ const upsertMany = async (files, apiId, orgId, updatedBy, t) => { WHERE api_uuid = ? AND file_name = ? AND type = ? AND ${TENANT_SCOPE_EXISTS}`, [ toBlobBuffer(file.content), file.fileName, file.key ?? existing.lookup_key, updatedBy, updatedAt, - apiId, existing.file_name, existing.type, orgId, + apiId, existing.file_name, existing.type, orgId, getPortalId(), ] ); if (!rowCount) { @@ -179,7 +179,7 @@ const upsert = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) `UPDATE ${CONTENT_TABLE} SET file_content = ?, file_name = ?, lookup_key = ?, updated_by = ?, updated_at = ? WHERE api_uuid = ? AND type = ? AND ${TENANT_SCOPE_EXISTS}`, - [content, fileName, key ?? existing.lookup_key, updatedBy, updatedAt, apiId, type, orgId] + [content, fileName, key ?? existing.lookup_key, updatedBy, updatedAt, apiId, type, orgId, getPortalId()] ); return rowCount; }; @@ -208,7 +208,7 @@ const update = async (apiFile, fileName, apiId, orgId, type, updatedBy, t, key) `UPDATE ${CONTENT_TABLE} SET file_content = ?, file_name = ?, lookup_key = ?, updated_by = ?, updated_at = ? WHERE api_uuid = ? AND file_name = ? AND type = ? AND ${TENANT_SCOPE_EXISTS}`, - [content, fileName, key ?? existing.lookup_key, updatedBy, updatedAt, apiId, fileName, type, orgId] + [content, fileName, key ?? existing.lookup_key, updatedBy, updatedAt, apiId, fileName, type, orgId, getPortalId()] ); return rowCount; }; diff --git a/portals/api-portal/src/dao/apiKeyDao.js b/portals/api-portal/src/dao/apiKeyDao.js index 3c2321455f..ddbbd127d7 100644 --- a/portals/api-portal/src/dao/apiKeyDao.js +++ b/portals/api-portal/src/dao/apiKeyDao.js @@ -51,8 +51,8 @@ async function attachAssociations(exec, keys) { const apiIds = [...new Set(keys.map((k) => k.api_uuid))]; const metadataRows = apiIds.length ? await exec.query( - `SELECT uuid, name, version, handle, ref_id, type FROM ${API_METADATA_TABLE} WHERE uuid IN (${apiIds.map(() => '?').join(', ')})`, - apiIds + `SELECT uuid, name, version, handle, ref_id, type FROM ${API_METADATA_TABLE} WHERE uuid IN (${apiIds.map(() => '?').join(', ')}) AND portal_id = ?`, + [...apiIds, getPortalId()] ) : []; const metadataByUuid = indexBy(metadataRows, 'uuid'); @@ -60,8 +60,8 @@ async function attachAssociations(exec, keys) { const keyIds = keys.map((k) => k.uuid); const mappingRows = keyIds.length ? await exec.query( - `SELECT * FROM ${APP_KEY_MAPPINGS_TABLE} WHERE key_uuid IN (${keyIds.map(() => '?').join(', ')})`, - keyIds + `SELECT * FROM ${APP_KEY_MAPPINGS_TABLE} WHERE key_uuid IN (${keyIds.map(() => '?').join(', ')}) AND portal_id = ?`, + [...keyIds, getPortalId()] ) : []; const mappingByKeyUuid = indexBy(mappingRows, 'key_uuid'); @@ -69,8 +69,8 @@ async function attachAssociations(exec, keys) { const appIds = [...new Set(mappingRows.map((m) => m.app_uuid))]; const appRows = appIds.length ? await exec.query( - `SELECT uuid, display_name, handle FROM ${APPLICATIONS_TABLE} WHERE uuid IN (${appIds.map(() => '?').join(', ')})`, - appIds + `SELECT uuid, display_name, handle FROM ${APPLICATIONS_TABLE} WHERE uuid IN (${appIds.map(() => '?').join(', ')}) AND portal_id = ?`, + [...appIds, getPortalId()] ) : []; const appByUuid = indexBy(appRows, 'uuid'); diff --git a/portals/api-portal/src/dao/applicationDao.js b/portals/api-portal/src/dao/applicationDao.js index 82d96bca63..ba268dbb3e 100644 --- a/portals/api-portal/src/dao/applicationDao.js +++ b/portals/api-portal/src/dao/applicationDao.js @@ -211,8 +211,8 @@ const deleteMappingsByIds = async (orgId, mappingIds, t) => { const ownedPlaceholders = ownedIds.map(() => '?').join(', '); const { rowCount } = await exec.execute( - `DELETE FROM ${KEY_MAPPING_TABLE} WHERE uuid IN (${ownedPlaceholders})`, - ownedIds + `DELETE FROM ${KEY_MAPPING_TABLE} WHERE uuid IN (${ownedPlaceholders}) AND portal_id = ?`, + [...ownedIds, getPortalId()] ); return rowCount; }; diff --git a/portals/api-portal/src/dao/eventDao.js b/portals/api-portal/src/dao/eventDao.js index 298df67106..71263b37ed 100644 --- a/portals/api-portal/src/dao/eventDao.js +++ b/portals/api-portal/src/dao/eventDao.js @@ -71,7 +71,7 @@ async function create({ eventType, orgId, aggregateType, aggregateId, payload }, // call an explicit DAO update instead. row.save = async (opts) => { const saveExec = (opts && opts.transaction) || exec; - await saveExec.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ?`, [row.status, row.uuid]); + await saveExec.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ? AND portal_id = ?`, [row.status, row.uuid, portalId]); return row; }; @@ -135,8 +135,8 @@ async function claimPending(batchSize, orgUuid) { const ids = events.map((e) => e.uuid); const placeholders = ids.map(() => '?').join(', '); await tx.execute( - `UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid IN (${placeholders})`, - ['DISPATCHED', ...ids] + `UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid IN (${placeholders}) AND portal_id = ?`, + ['DISPATCHED', ...ids, getPortalId()] ); return events.map(parseEventRow); }); @@ -182,8 +182,8 @@ async function claimDueDeliveries(batchSize, orgUuid) { const ids = rows.map((r) => r.uuid); const placeholders = ids.map(() => '?').join(', '); await tx.execute( - `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_attempt_at = ? WHERE uuid IN (${placeholders})`, - ['IN_FLIGHT', new Date(), ...ids] + `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_attempt_at = ? WHERE uuid IN (${placeholders}) AND portal_id = ?`, + ['IN_FLIGHT', new Date(), ...ids, getPortalId()] ); return rows.map(parseDeliveryRow); }); @@ -194,10 +194,10 @@ async function claimDueDeliveries(batchSize, orgUuid) { */ async function markDelivered(deliveryId, httpStatus) { await db.execute( - `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_http_status = ?, delivered_at = ? WHERE uuid = ?`, - ['DELIVERED', httpStatus, new Date(), deliveryId] + `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_http_status = ?, delivered_at = ? WHERE uuid = ? AND portal_id = ?`, + ['DELIVERED', httpStatus, new Date(), deliveryId, getPortalId()] ); - const delivery = await db.queryOne(`SELECT * FROM ${DELIVERIES_TABLE} WHERE uuid = ?`, [deliveryId]); + const delivery = await db.queryOne(`SELECT * FROM ${DELIVERIES_TABLE} WHERE uuid = ? AND portal_id = ?`, [deliveryId, getPortalId()]); await reconcile(parseDeliveryRow(delivery)); } @@ -206,10 +206,10 @@ async function markDelivered(deliveryId, httpStatus) { */ async function markFailed(deliveryId, { httpStatus, error }) { await db.execute( - `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_http_status = ?, last_error = ? WHERE uuid = ?`, - ['FAILED', httpStatus ?? null, error ? String(error).slice(0, 1000) : null, deliveryId] + `UPDATE ${DELIVERIES_TABLE} SET status = ?, last_http_status = ?, last_error = ? WHERE uuid = ? AND portal_id = ?`, + ['FAILED', httpStatus ?? null, error ? String(error).slice(0, 1000) : null, deliveryId, getPortalId()] ); - const delivery = await db.queryOne(`SELECT * FROM ${DELIVERIES_TABLE} WHERE uuid = ?`, [deliveryId]); + const delivery = await db.queryOne(`SELECT * FROM ${DELIVERIES_TABLE} WHERE uuid = ? AND portal_id = ?`, [deliveryId, getPortalId()]); await reconcile(parseDeliveryRow(delivery)); } @@ -225,8 +225,8 @@ async function reconcile(delivery) { if (!terminal) return; const allDelivered = all.every((d) => d.status === 'DELIVERED'); await db.execute( - `UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ?`, - [allDelivered ? 'ALL_DELIVERED' : 'FAILED', delivery.event_uuid] + `UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ? AND portal_id = ?`, + [allDelivered ? 'ALL_DELIVERED' : 'FAILED', delivery.event_uuid, getPortalId()] ); } @@ -277,7 +277,7 @@ async function list({ orgId, status, limit = 50, offset = 0 }) { * Admin: get a single event with all delivery details. */ async function get(eventId) { - const event = await db.queryOne(`SELECT * FROM ${EVENTS_TABLE} WHERE uuid = ?`, [eventId]); + const event = await db.queryOne(`SELECT * FROM ${EVENTS_TABLE} WHERE uuid = ? AND portal_id = ?`, [eventId, getPortalId()]); if (!event) return null; const deliveries = await db.query(`SELECT * FROM ${DELIVERIES_TABLE} WHERE event_uuid = ?`, [eventId]); return parseEventRow({ ...event, event_deliveries: deliveries.map(parseDeliveryRow) }); diff --git a/portals/api-portal/src/dao/subscriptionDao.js b/portals/api-portal/src/dao/subscriptionDao.js index 7ba7be3f5c..b7eebe39e4 100644 --- a/portals/api-portal/src/dao/subscriptionDao.js +++ b/portals/api-portal/src/dao/subscriptionDao.js @@ -80,8 +80,8 @@ async function attachApiAndPlan(subs) { if (apiIds.length > 0) { const placeholders = apiIds.map(() => '?').join(', '); const apis = await db.query( - `SELECT ${API_METADATA_COLUMNS} FROM ${API_METADATA_TABLE} WHERE uuid IN (${placeholders})`, - apiIds + `SELECT ${API_METADATA_COLUMNS} FROM ${API_METADATA_TABLE} WHERE uuid IN (${placeholders}) AND portal_id = ?`, + [...apiIds, getPortalId()] ); apiByUuid = indexBy(apis, 'uuid'); } @@ -90,8 +90,8 @@ async function attachApiAndPlan(subs) { if (planIds.length > 0) { const placeholders = planIds.map(() => '?').join(', '); const plans = await db.query( - `SELECT ${SUBSCRIPTION_PLAN_COLUMNS} FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid IN (${placeholders})`, - planIds + `SELECT ${SUBSCRIPTION_PLAN_COLUMNS} FROM ${SUBSCRIPTION_PLANS_TABLE} WHERE uuid IN (${placeholders}) AND portal_id = ?`, + [...planIds, getPortalId()] ); planByUuid = indexBy(plans, 'uuid'); } diff --git a/portals/api-portal/src/dao/webhookSubscriberDao.js b/portals/api-portal/src/dao/webhookSubscriberDao.js index 32cf88f9e6..7e88252129 100644 --- a/portals/api-portal/src/dao/webhookSubscriberDao.js +++ b/portals/api-portal/src/dao/webhookSubscriberDao.js @@ -184,13 +184,13 @@ const get = async (orgId, subscriberHandle) => { }; /** - * Get a single webhook subscriber by UUID only, without scoping to an org or a portal. - * UUID is a globally unique UUID, so this is safe. + * Get a single webhook subscriber by UUID only, without scoping to an org. * Used by the delivery worker, which only has the subscriber UUID (from the - * delivery row) and not the org UUID in scope. + * delivery row) and not the org UUID in scope. portal_id is still included + * so the query can use the composite PK (portal_id, uuid) index. */ const getById = async (subscriberId) => { - const sub = await db.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ?`, [subscriberId]); + const sub = await db.queryOne(`SELECT * FROM ${TABLE} WHERE uuid = ? AND portal_id = ?`, [subscriberId, getPortalId()]); if (!sub) { throw new NotFoundError('Webhook subscriber not found'); } diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index 20d3e5ebab..e352658077 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -279,16 +279,22 @@ async function resolvePortalOrg(req) { */ async function authResolver(req, res, next) { try { - // 1. Local auth users (platform JWT in session, no IdP configured). - // The session stores the org handle in the same ORGANIZATION_CLAIM slot used by IDP - // sessions, so resolveScopedOrg works via the HANDLE lookup in orgDao.getId. - if (req.isAuthenticated && req.isAuthenticated() && - req.user?.isLocalAuth && config.auth.mode !== 'idp') { + // Portal isolation: any session-authenticated request must have been issued by this + // portal's login flow. + if (req.isAuthenticated && req.isAuthenticated()) { if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { + logger.warn('Rejected cross-portal session', { operation: 'authResolver' }); const err = new Error('Forbidden'); err.status = 403; return next(err); } + } + + // 1. Local auth users (platform JWT in session, no IdP configured). + // The session stores the org handle in the same ORGANIZATION_CLAIM slot used by IDP + // sessions, so resolveScopedOrg works via the HANDLE lookup in orgDao.getId. + if (req.isAuthenticated && req.isAuthenticated() && + req.user?.isLocalAuth && config.auth.mode !== 'idp') { const platformToken = req.user[constants.ACCESS_TOKEN]; const claims = platformToken ? decodePlatformJwtClaims(platformToken) : null; const orgHandle = req.user[constants.ROLES.ORGANIZATION_CLAIM]; @@ -329,11 +335,6 @@ async function authResolver(req, res, next) { // derive dp:* scopes, so the operation-level check is enforced here instead of // bypassed — that is the gap role mode exists to close. if (req.isAuthenticated && req.isAuthenticated() && req.user?.grantedScopes !== undefined && config.auth.mode === 'idp') { - if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { - const err = new Error('Forbidden'); - err.status = 403; - return next(err); - } // The session's org claim is populated at login from // config.auth.claimMappings.organization (see passportConfig) and stored // under ORGANIZATION_CLAIM. Resolve req.orgId from it directly — do NOT diff --git a/portals/api-portal/src/middlewares/ensureAuthenticated.js b/portals/api-portal/src/middlewares/ensureAuthenticated.js index 913e0f5d29..618bb9dffd 100644 --- a/portals/api-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/api-portal/src/middlewares/ensureAuthenticated.js @@ -76,6 +76,13 @@ function enforceSecurity(scope) { if (!errors.isEmpty()) { return res.status(400).json(util.getErrors(errors)); } + // Portal isolation — same guard as authResolver/ensureAuthenticated. + if (req.isAuthenticated && req.isAuthenticated()) { + if (!req.session?.portalId || req.session.portalId !== orgContext.getPortalId()) { + logger.warn('Rejected cross-portal session', { operation: 'enforceSecurity' }); + return util.handleError(res, new CustomError(403, constants.ERROR_CODE[403], constants.ERROR_MESSAGE.FORBIDDEN)); + } + } // Local auth users: validate dp:* scope from platform JWT if (req.isAuthenticated() && req.user && req.user.isLocalAuth && config.auth.mode !== 'idp') { const platformToken = req.user[constants.ACCESS_TOKEN]; diff --git a/portals/api-portal/src/services/mcpRegistryService.js b/portals/api-portal/src/services/mcpRegistryService.js index 430487a871..1ccd85fb53 100644 --- a/portals/api-portal/src/services/mcpRegistryService.js +++ b/portals/api-portal/src/services/mcpRegistryService.js @@ -28,6 +28,7 @@ const constants = require('../utils/constants'); const util = require('../utils/util'); const yaml = require('../utils/yaml'); const { matchesAnyScope } = require('../middlewares/ensureAuthenticated'); +const { getPortalId } = require('../utils/orgContext'); const MCP_STATUSES = ['active', 'deprecated', 'deleted']; const SERVER_NAME_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; @@ -108,8 +109,8 @@ function unescapeParam(str) { async function findRowByServerIdentifier(orgId, serverIdentifier, version, transaction) { const exec = transaction || db; - const baseConditions = ['org_uuid = ?', 'type = ?', 'ref_id IS NULL']; - const baseParams = [orgId, constants.API_TYPE.MCP]; + const baseConditions = ['org_uuid = ?', 'type = ?', 'ref_id IS NULL', 'portal_id = ?']; + const baseParams = [orgId, constants.API_TYPE.MCP, getPortalId()]; if (version) { baseConditions.push('version = ?'); baseParams.push(version); } const baseWhere = baseConditions.join(' AND '); @@ -314,8 +315,8 @@ const listServers = async (req, res) => { } } - const conditions = ['org_uuid = ?', 'type = ?']; - const params = [orgId, constants.API_TYPE.MCP]; + const conditions = ['org_uuid = ?', 'type = ?', 'portal_id = ?']; + const params = [orgId, constants.API_TYPE.MCP, getPortalId()]; if (!includeDeleted) { conditions.push("status != 'DELETED'"); } @@ -359,8 +360,8 @@ const listVersions = async (req, res) => { const serverIdentifier = unescapeParam(decodeURIComponent(req.params.serverName)); const includeDeleted = parseBool(req.query.include_deleted, false); - const baseConditions = ['org_uuid = ?', 'type = ?']; - const baseParams = [orgId, constants.API_TYPE.MCP]; + const baseConditions = ['org_uuid = ?', 'type = ?', 'portal_id = ?']; + const baseParams = [orgId, constants.API_TYPE.MCP, getPortalId()]; if (!includeDeleted) { baseConditions.push("status != 'DELETED'"); } @@ -428,14 +429,14 @@ async function findExistingMcpVersion(exec, orgId, name, version, proxyId) { let existing = null; if (proxyId) { existing = await exec.queryOne( - `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND type = ? AND version = ? AND ${PROXY_ID_EXPR} = ?`, - [orgId, constants.API_TYPE.MCP, version, proxyId] + `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND type = ? AND version = ? AND ${PROXY_ID_EXPR} = ? AND portal_id = ?`, + [orgId, constants.API_TYPE.MCP, version, proxyId, getPortalId()] ); } if (!existing) { existing = await exec.queryOne( - `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND type = ? AND name = ? AND version = ?`, - [orgId, constants.API_TYPE.MCP, name, version] + `SELECT * FROM ${API_METADATA_TABLE} WHERE org_uuid = ? AND type = ? AND name = ? AND version = ? AND portal_id = ?`, + [orgId, constants.API_TYPE.MCP, name, version, getPortalId()] ); } return existing; @@ -608,10 +609,10 @@ const deleteVersion = async (req, res) => { } await db.execute( - `UPDATE ${API_METADATA_TABLE} SET status = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, - ['DELETED', util.resolveActor(req), new Date(), existing.uuid, orgId] + `UPDATE ${API_METADATA_TABLE} SET status = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + ['DELETED', util.resolveActor(req), new Date(), existing.uuid, orgId, getPortalId()] ); - const deleted = await db.queryOne(`SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ?`, [existing.uuid]); + const deleted = await db.queryOne(`SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ? AND portal_id = ?`, [existing.uuid, getPortalId()]); logger.info('MCP server deleted', { serverIdentifier, version, orgHandle }); return res.status(200).json(new ServerResponseDTO(deleted)); } catch (error) { @@ -642,10 +643,10 @@ const updateVersionStatus = async (req, res) => { } await db.execute( - `UPDATE ${API_METADATA_TABLE} SET status = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, - [dbStatus, util.resolveActor(req), new Date(), existing.uuid, orgId] + `UPDATE ${API_METADATA_TABLE} SET status = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ? AND portal_id = ?`, + [dbStatus, util.resolveActor(req), new Date(), existing.uuid, orgId, getPortalId()] ); - const updated = await db.queryOne(`SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ?`, [existing.uuid]); + const updated = await db.queryOne(`SELECT * FROM ${API_METADATA_TABLE} WHERE uuid = ? AND portal_id = ?`, [existing.uuid, getPortalId()]); return res.status(200).json(new ServerResponseDTO(updated)); } catch (error) { return handleUnexpectedError(res, error, 'updateVersionStatus', 'Failed to update server status'); @@ -674,14 +675,14 @@ const updateAllVersionsStatus = async (req, res) => { // (sqlite, which already serializes writes through a single connection). let existing = await t.query( `SELECT * FROM ${LOCKABLE_METADATA_TABLE} - WHERE org_uuid = ? AND type = ? AND ref_id IS NULL AND ${PROXY_ID_EXPR} = ?${FOR_UPDATE_SUFFIX}`, - [orgId, constants.API_TYPE.MCP, serverIdentifier] + WHERE org_uuid = ? AND type = ? AND ref_id IS NULL AND ${PROXY_ID_EXPR} = ? AND portal_id = ?${FOR_UPDATE_SUFFIX}`, + [orgId, constants.API_TYPE.MCP, serverIdentifier, getPortalId()] ); if (existing.length === 0) { existing = await t.query( `SELECT * FROM ${LOCKABLE_METADATA_TABLE} - WHERE org_uuid = ? AND type = ? AND ref_id IS NULL AND name = ?${FOR_UPDATE_SUFFIX}`, - [orgId, constants.API_TYPE.MCP, serverIdentifier] + WHERE org_uuid = ? AND type = ? AND ref_id IS NULL AND name = ? AND portal_id = ?${FOR_UPDATE_SUFFIX}`, + [orgId, constants.API_TYPE.MCP, serverIdentifier, getPortalId()] ); } if (existing.length === 0) return; @@ -690,12 +691,12 @@ const updateAllVersionsStatus = async (req, res) => { const idPlaceholders = ids.map(() => '?').join(', '); await t.execute( `UPDATE ${API_METADATA_TABLE} SET status = ?, updated_by = ?, updated_at = ? - WHERE uuid IN (${idPlaceholders}) AND org_uuid = ?`, - [dbStatus, util.resolveActor(req), new Date(), ...ids, orgId] + WHERE uuid IN (${idPlaceholders}) AND org_uuid = ? AND portal_id = ?`, + [dbStatus, util.resolveActor(req), new Date(), ...ids, orgId, getPortalId()] ); updated = await t.query( - `SELECT * FROM ${API_METADATA_TABLE} WHERE uuid IN (${idPlaceholders})`, - ids + `SELECT * FROM ${API_METADATA_TABLE} WHERE uuid IN (${idPlaceholders}) AND portal_id = ?`, + [...ids, getPortalId()] ); }); diff --git a/portals/api-portal/src/services/webhooks/dispatcher.js b/portals/api-portal/src/services/webhooks/dispatcher.js index 5d306790ce..8318813a3b 100644 --- a/portals/api-portal/src/services/webhooks/dispatcher.js +++ b/portals/api-portal/src/services/webhooks/dispatcher.js @@ -51,7 +51,8 @@ async function runBatch() { const subscribers = await matchSubscribers(event.org_uuid, event.type); if (subscribers.length === 0) { // No matching subscribers — mark as delivered immediately. - await db.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ?`, ['ALL_DELIVERED', event.uuid]); + await db.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ? AND portal_id = ?`, + ['ALL_DELIVERED', event.uuid, orgContext.getPortalId()]); continue; } await eventDao.createDeliveries(event.uuid, subscribers, null, null); @@ -60,7 +61,8 @@ async function runBatch() { eventId: event.uuid, error: err.message }); try { - await db.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ?`, ['PENDING', event.uuid]); + await db.execute(`UPDATE ${EVENTS_TABLE} SET status = ? WHERE uuid = ? AND portal_id = ?`, + ['PENDING', event.uuid, orgContext.getPortalId()]); logger.info('Restored event eligibility after delivery creation failure', { eventId: event.uuid }); diff --git a/portals/api-portal/src/utils/orgContext.js b/portals/api-portal/src/utils/orgContext.js index fb1484d25c..1a97f63541 100644 --- a/portals/api-portal/src/utils/orgContext.js +++ b/portals/api-portal/src/utils/orgContext.js @@ -194,13 +194,11 @@ function resetCache() { * config.organization.portalId is populated by the config.toml template: * portal_id = '{{ env "APIP_AP_ORGANIZATION_PORTAL_ID" "portal_id" }}' * - * so env var resolution and the sentinel fallback are already handled before this - * function runs — mirroring how getHandle() reads config.organization.handle without - * separately checking process.env.APIP_AP_ORGANIZATION_HANDLE. - * - * Synchronous: env vars and config are stable after startup, so no await is needed - * and every DAO method can call this inline. Never accept a portalId from request - * input — that is the same IDOR class as accepting org_id from the request. + * NOTE: Every INSERT into a portal-scoped table MUST supply portal_id from + * getPortalId() explicitly. If a row is ever written without it, it silently + * falls back to its DEFAULT value 'portal_id' and if the configured + * organization.portal_id for this deployment resolves to anything else, + * that row becomes unreachable to every portal-scoped query. * * @returns {string} */