From 633ff3541b18c1855e6f8ddf9b8b9eabe67a43b7 Mon Sep 17 00:00:00 2001 From: Brian Cooper Date: Thu, 19 Mar 2026 02:48:26 -0400 Subject: [PATCH] fix: wire Warden authZ checks, fix CORS credentials, and align entitlement limits Wire up Warden checkPermission across all REST mutation routes (workflows, plugins, subscriptions) with fail-open for availability. Fix CORS config to include credentials and allowed headers for cross-origin API requests. Align free-tier workflow limit to 5 (matching pricing page). Add max_functions and max_subscriptions entitlement keys. Upgrade biome and postgraphile deps. --- biome.jsonc | 2 +- bun.lock | 36 +++++++++++++-------------- knip.config.ts | 4 +-- package.json | 4 +-- src/__tests__/entitlements.test.ts | 12 ++++----- src/__tests__/errorMasking.test.ts | 4 +-- src/api.ts | 40 ++++++++++++++++++++++++++++++ src/data/integrations/catalog.json | 2 +- src/lib/entitlements/constants.ts | 2 ++ src/lib/entitlements/enforce.ts | 4 ++- src/lib/warden/authorize.ts | 38 ++++++++++++++++++++++++++++ src/lib/warden/cache.ts | 2 ++ src/lib/warden/client.ts | 2 ++ src/lib/warden/index.ts | 1 + src/lib/warden/organization.ts | 1 + src/routes/plugins.ts | 40 ++++++++++++++++++++++++++++++ src/routes/subscriptions.ts | 40 ++++++++++++++++++++++++++++++ src/server.ts | 2 ++ 18 files changed, 203 insertions(+), 33 deletions(-) create mode 100644 src/lib/warden/authorize.ts diff --git a/biome.jsonc b/biome.jsonc index 6fb496c..184f18e 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index 97cf5d2..97641cf 100644 --- a/bun.lock +++ b/bun.lock @@ -44,12 +44,12 @@ "jose": "^6.2.2", "ms": "^2.1.3", "pg": "^8.20.0", - "postgraphile": "5.0.0-rc.9", + "postgraphile": "5.0.0-rc.10", "postgraphile-plugin-connection-filter": "3.0.0-rc.3", "ts-pattern": "^5.9.0", }, "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "2.4.8", "@types/bun": "^1.3.11", "@types/ms": "^2.1.0", "drizzle-kit": "^0.31.10", @@ -180,23 +180,23 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.6", "@biomejs/cli-darwin-x64": "2.4.6", "@biomejs/cli-linux-arm64": "2.4.6", "@biomejs/cli-linux-arm64-musl": "2.4.6", "@biomejs/cli-linux-x64": "2.4.6", "@biomejs/cli-linux-x64-musl": "2.4.6", "@biomejs/cli-win32-arm64": "2.4.6", "@biomejs/cli-win32-x64": "2.4.6" }, "bin": { "biome": "bin/biome" } }, "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ=="], + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.6", "", { "os": "linux", "cpu": "x64" }, "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], @@ -812,9 +812,9 @@ "@tanstack/query-core": ["@tanstack/query-core@5.91.0", "", {}, "sha512-FYXN8Kk9Q5VKuV6AIVaNwMThSi0nvAtR4X7HQoigf6ePOtFcavJYVIzgFhOVdtbBQtCJE3KimDIMMJM2DR1hjw=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.21", "", { "dependencies": { "@tanstack/virtual-core": "3.13.21" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.21", "", {}, "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], "@temporalio/client": ["@temporalio/client@1.15.0", "", { "dependencies": { "@grpc/grpc-js": "^1.12.4", "@temporalio/common": "1.15.0", "@temporalio/proto": "1.15.0", "abort-controller": "^3.0.0", "long": "^5.2.3", "uuid": "^11.1.0" } }, "sha512-SxTGqRIa2+Vy4P9+06ZpUf4u7ZZmOXfx/kr9XvNqAApLxTMKjTQIg5OH5Wt4JLUtIR7dFkuHIyhewdRyG+hSsQ=="], @@ -1048,7 +1048,7 @@ "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], - "framer-motion": ["framer-motion@12.35.2", "", { "dependencies": { "motion-dom": "^12.35.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1234,9 +1234,9 @@ "monaco-graphql": ["monaco-graphql@1.7.3", "", { "dependencies": { "graphql-language-service": "^5.5.0", "picomatch-browser": "^2.2.6" }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", "monaco-editor": ">= 0.20.0 < 0.53", "prettier": "^2.8.0 || ^3.0.0" } }, "sha512-6LAIcg/vT2NGLjHnT+5iIZONsZCaCuz2orbg7qD/u4Ry9R7rDotLh0HAzIF/yKdzEA5fTZC+TofSx2O+Zi+0ow=="], - "motion-dom": ["motion-dom@12.35.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1294,7 +1294,7 @@ "pluralize": ["pluralize@7.0.0", "", {}, "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow=="], - "postgraphile": ["postgraphile@5.0.0-rc.9", "", { "dependencies": { "@dataplan/json": "^1.0.0-rc.5", "@dataplan/pg": "^1.0.0-rc.7", "@graphile/lru": "^5.0.0-rc.4", "@types/node": "^22.19.1", "@types/pg": "^8.15.6", "debug": "^4.4.3", "grafast": "^1.0.0-rc.8", "grafserv": "^1.0.0-rc.6", "graphile-build": "^5.0.0-rc.5", "graphile-build-pg": "^5.0.0-rc.7", "graphile-config": "^1.0.0-rc.5", "graphile-utils": "^5.0.0-rc.7", "graphql": "^16.9.0", "iterall": "^1.3.0", "jsonwebtoken": "^9.0.2", "pg": "^8.16.3", "pg-sql2": "^5.0.0-rc.4", "tamedevil": "^0.1.0-rc.5", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "@envelop/core": "^5.0.0" }, "optionalPeers": ["@envelop/core"], "bin": { "postgraphile": "dist/cli-run.js" } }, "sha512-EH5fU9nLh+f7r40gyijWXsHt3jbbIeiuWoQ1KVMipgqGifojX9IfrbBcqhvrpFxZTsx5yC9T5N6mybM3i3BzBA=="], + "postgraphile": ["postgraphile@5.0.0-rc.10", "", { "dependencies": { "@dataplan/json": "^1.0.0-rc.6", "@dataplan/pg": "^1.0.0-rc.8", "@graphile/lru": "^5.0.0-rc.5", "@types/node": "^22.19.1", "@types/pg": "^8.15.6", "debug": "^4.4.3", "grafast": "^1.0.0-rc.9", "grafserv": "^1.0.0-rc.7", "graphile-build": "^5.0.0-rc.6", "graphile-build-pg": "^5.0.0-rc.8", "graphile-config": "^1.0.0-rc.6", "graphile-utils": "^5.0.0-rc.8", "graphql": "^16.9.0", "iterall": "^1.3.0", "jsonwebtoken": "^9.0.2", "pg": "^8.16.3", "pg-sql2": "^5.0.0-rc.5", "tamedevil": "^0.1.0-rc.6", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "@envelop/core": "^5.0.0" }, "optionalPeers": ["@envelop/core"], "bin": { "postgraphile": "dist/cli-run.js" } }, "sha512-BuggxD7SwUOMv6E0DqOaCqPkvnCFzoJtCsCDZ+QP5C/zLn8yk4Xdwt5DMRURX+QT5oq/HTXh6I8VyZ4X8dGCRQ=="], "postgraphile-plugin-connection-filter": ["postgraphile-plugin-connection-filter@3.0.0-rc.3", "", { "dependencies": { "@tsconfig/node20": "^20.1.4", "tslib": "^2.5.0" } }, "sha512-02CFrWzNDYX1Yoaz0hOMtZUQiPlUrSvqEZcHWTwkZKyGkuVK2ZWf5rY04YbUFhTJZIQc+UytTy5SepJzCT0d4A=="], @@ -1486,7 +1486,7 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], diff --git a/knip.config.ts b/knip.config.ts index e9f9b9e..0e1032b 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -20,12 +20,12 @@ const knipConfig: KnipConfig = { "src/lib/crypto/**", // Test files are run via bun test "src/__tests__/**", - // WIP: Warden client (not yet integrated) - "src/lib/warden/**", // Instrumentation loaded via --import flag at runtime "src/instrumentation.ts", // Events client (not yet wired into app entrypoint) "src/lib/events/**", + // Warden barrel re-exports public API surface consumed by IDP webhooks and authorize + "src/lib/warden/index.ts", ], ignoreDependencies: [ // Used by vortex-worker relay (vortex-api pushes via HTTP, but SDK diff --git a/package.json b/package.json index 359ebca..9ac6b9a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@biomejs/biome" ], "devDependencies": { - "@biomejs/biome": "2.4.6", + "@biomejs/biome": "2.4.8", "@types/bun": "^1.3.11", "@types/ms": "^2.1.0", "drizzle-kit": "^0.31.10", @@ -86,7 +86,7 @@ "jose": "^6.2.2", "ms": "^2.1.3", "pg": "^8.20.0", - "postgraphile": "5.0.0-rc.9", + "postgraphile": "5.0.0-rc.10", "postgraphile-plugin-connection-filter": "3.0.0-rc.3", "ts-pattern": "^5.9.0" }, diff --git a/src/__tests__/entitlements.test.ts b/src/__tests__/entitlements.test.ts index 907d267..8bad980 100644 --- a/src/__tests__/entitlements.test.ts +++ b/src/__tests__/entitlements.test.ts @@ -30,25 +30,25 @@ const { assertUnderLimit } = await import("lib/entitlements/enforce"); describe("assertUnderLimit", () => { it("should throw SafeError when count meets limit", () => { - expect(() => assertUnderLimit(3, 3, "workflows")).toThrow(SafeError); + expect(() => assertUnderLimit(5, 5, "workflows")).toThrow(SafeError); }); it("should throw SafeError when count exceeds limit", () => { - expect(() => assertUnderLimit(3, 10, "workflows")).toThrow(SafeError); + expect(() => assertUnderLimit(5, 10, "workflows")).toThrow(SafeError); }); it("should include count and limit in error message", () => { try { - assertUnderLimit(3, 10, "workflows"); + assertUnderLimit(5, 10, "workflows"); expect(true).toBe(false); // Should not reach here } catch (err) { - expect((err as Error).message).toContain("10/3"); + expect((err as Error).message).toContain("10/5"); expect((err as Error).message).toContain("Upgrade your plan"); } }); it("should not throw when count is under limit", () => { - expect(() => assertUnderLimit(3, 2, "workflows")).not.toThrow(); + expect(() => assertUnderLimit(5, 2, "workflows")).not.toThrow(); }); it("should not throw when limit is -1 (unlimited)", () => { @@ -56,6 +56,6 @@ describe("assertUnderLimit", () => { }); it("should not throw when count is 0", () => { - expect(() => assertUnderLimit(3, 0, "workflows")).not.toThrow(); + expect(() => assertUnderLimit(5, 0, "workflows")).not.toThrow(); }); }); diff --git a/src/__tests__/errorMasking.test.ts b/src/__tests__/errorMasking.test.ts index 0edc96a..edc4401 100644 --- a/src/__tests__/errorMasking.test.ts +++ b/src/__tests__/errorMasking.test.ts @@ -27,7 +27,7 @@ const customMaskError = ( describe("error masking", () => { it("should pass through SafeError messages wrapped in GraphQLError", () => { const safeErr = new SafeError( - "Plan limit reached: workflows (10/3). Upgrade your plan to continue.", + "Plan limit reached: workflows (10/5). Upgrade your plan to continue.", ); const gqlErr = new GraphQLError(safeErr.message, { originalError: safeErr, @@ -37,7 +37,7 @@ describe("error masking", () => { expect(result).toBe(gqlErr); expect((result as GraphQLError).message).toBe( - "Plan limit reached: workflows (10/3). Upgrade your plan to continue.", + "Plan limit reached: workflows (10/5). Upgrade your plan to continue.", ); }); diff --git a/src/api.ts b/src/api.ts index 4722a55..354fb14 100644 --- a/src/api.ts +++ b/src/api.ts @@ -21,6 +21,7 @@ import { } from "lib/entitlements/enforce"; import logger from "lib/logger"; import oauthRoutes from "lib/oauth/routes"; +import authorize from "lib/warden/authorize"; import dlqRoutes from "routes/dlq"; import functionRoutes from "routes/functions"; import internalRoutes from "routes/internal"; @@ -432,6 +433,19 @@ const api = new Elysia({ prefix: "/api/v1" }) const { organizationId } = authInfo; const { name } = params; + // Verify Warden authorization (member required for create/update) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + const existing = await db.query.workflowTable.findFirst({ where: and( eq(workflowTable.name, name), @@ -543,6 +557,19 @@ const api = new Elysia({ prefix: "/api/v1" }) const { organizationId } = authInfo; const { workflowId } = params; + // Verify Warden authorization (member required for clone) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + // Fetch workflow and verify ownership const workflow = await db.query.workflowTable.findFirst({ where: and( @@ -616,6 +643,19 @@ const api = new Elysia({ prefix: "/api/v1" }) const { organizationId } = authInfo; const { workflowId } = params; + // Verify Warden authorization (admin required for delete) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "admin", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + // Verify workflow exists and belongs to org const workflow = await db.query.workflowTable.findFirst({ where: and( diff --git a/src/data/integrations/catalog.json b/src/data/integrations/catalog.json index 55ba6ae..6bb6e02 100644 --- a/src/data/integrations/catalog.json +++ b/src/data/integrations/catalog.json @@ -1,6 +1,6 @@ { "$schema": "./catalog.schema.json", - "generatedAt": "2026-03-18T23:45:17.517Z", + "generatedAt": "2026-03-19T06:06:12.988Z", "total": 601, "entries": [ { diff --git a/src/lib/entitlements/constants.ts b/src/lib/entitlements/constants.ts index 3449d0b..7ce3e15 100644 --- a/src/lib/entitlements/constants.ts +++ b/src/lib/entitlements/constants.ts @@ -8,6 +8,8 @@ export const FEATURE_KEYS = { MAX_INTEGRATIONS: "max_integrations", MAX_PLUGINS: "max_plugins", MAX_USERS: "max_users", + MAX_FUNCTIONS: "max_functions", + MAX_SUBSCRIPTIONS: "max_subscriptions", SSO_ENABLED: "sso_enabled", AUDIT_LOGS: "audit_logs", CUSTOM_PLUGINS: "custom_plugins", diff --git a/src/lib/entitlements/enforce.ts b/src/lib/entitlements/enforce.ts index ec0b37f..bd0f04d 100644 --- a/src/lib/entitlements/enforce.ts +++ b/src/lib/entitlements/enforce.ts @@ -25,9 +25,11 @@ const APP_ID = "vortex"; * Prevents hard failures for orgs that haven't been provisioned in Aether. */ const DEFAULT_LIMITS: Record> = { - max_workflows: { free: 3 }, + max_workflows: { free: 5 }, max_integrations: { free: 10 }, max_plugins: { free: 2 }, + max_functions: { free: 5 }, + max_subscriptions: { free: 10 }, max_runs_per_month: { free: 1000 }, max_users: { free: 1 }, audit_logs: { free: 0 }, diff --git a/src/lib/warden/authorize.ts b/src/lib/warden/authorize.ts new file mode 100644 index 0000000..d6f022b --- /dev/null +++ b/src/lib/warden/authorize.ts @@ -0,0 +1,38 @@ +import { AUTHZ_API_URL, AUTHZ_ENABLED } from "lib/config/env.config"; +import logger from "lib/logger"; +import { checkPermission } from "./client"; + +/** + * Check if a user has a relation on a resource via Warden (OpenFGA). + * Returns true if Warden is disabled or unreachable (fail-open for availability). + */ +const authorize = async ( + userId: string, + resourceType: string, + resourceId: string, + relation: string, +): Promise => { + if (!AUTHZ_API_URL || AUTHZ_ENABLED !== "true") return true; + + try { + return await checkPermission( + AUTHZ_ENABLED, + AUTHZ_API_URL, + userId, + resourceType, + resourceId, + relation, + ); + } catch (error) { + logger.warn("Warden authZ check failed, allowing request", { + userId, + resourceType, + resourceId, + relation, + error: error instanceof Error ? error.message : String(error), + }); + return true; + } +}; + +export default authorize; diff --git a/src/lib/warden/cache.ts b/src/lib/warden/cache.ts index 27b9981..142c9ff 100644 --- a/src/lib/warden/cache.ts +++ b/src/lib/warden/cache.ts @@ -85,6 +85,7 @@ export async function setCachedPermission( * - `user123:organization:` - All organization permissions for user * - `user123:` - All permissions for user * - `:organization:org456:` - All permissions for organization + * @knipignore */ export async function invalidatePermissionCache( pattern: string, @@ -119,6 +120,7 @@ export async function invalidatePermissionCache( /** * Clear all cached permissions. * Useful for testing or emergency cache flush. + * @knipignore */ export async function clearPermissionCache(): Promise { if (cacheClient) { diff --git a/src/lib/warden/client.ts b/src/lib/warden/client.ts index 2829249..cb8e610 100644 --- a/src/lib/warden/client.ts +++ b/src/lib/warden/client.ts @@ -11,12 +11,14 @@ import { isSelfHosted } from "lib/config/env.config"; import logger from "lib/logger"; // Re-export for EXPORTABLE compatibility in plugins +/** @knipignore */ export { AUTHZ_API_URL, AUTHZ_ENABLED, isSelfHosted, } from "lib/config/env.config"; // Re-export cache functions for use in plugins +/** @knipignore */ export { buildPermissionCacheKey, getCachedPermission, diff --git a/src/lib/warden/index.ts b/src/lib/warden/index.ts index d3b9199..502ae05 100644 --- a/src/lib/warden/index.ts +++ b/src/lib/warden/index.ts @@ -1,3 +1,4 @@ +export { default as authorize } from "./authorize"; export * from "./cache"; export * from "./client"; export * from "./organization"; diff --git a/src/lib/warden/organization.ts b/src/lib/warden/organization.ts index f7f7302..918ca71 100644 --- a/src/lib/warden/organization.ts +++ b/src/lib/warden/organization.ts @@ -114,6 +114,7 @@ export async function revokeOrganizationRole( * @param organizationId - The organization ID * @param currentOwnerId - The current owner user ID * @param newOwnerId - The new owner user ID + * @knipignore */ export async function transferOrganizationOwnership( organizationId: string, diff --git a/src/routes/plugins.ts b/src/routes/plugins.ts index e4beb3b..90d137c 100644 --- a/src/routes/plugins.ts +++ b/src/routes/plugins.ts @@ -18,6 +18,7 @@ import { pluginTable, pluginUsageTable } from "lib/db/schema"; import { FEATURE_KEYS } from "lib/entitlements/constants"; import { checkFeatureEnabled } from "lib/entitlements/enforce"; import logger from "lib/logger"; +import authorize from "lib/warden/authorize"; const s3 = new S3Client({}); @@ -200,6 +201,19 @@ const pluginRoutes = new Elysia({ prefix: "/plugins" }) const { organizationId } = authInfo; + // Verify Warden authorization (member required for upload) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + const pluginsEnabled = await checkFeatureEnabled( organizationId, FEATURE_KEYS.CUSTOM_PLUGINS, @@ -304,6 +318,19 @@ const pluginRoutes = new Elysia({ prefix: "/plugins" }) const { organizationId } = authInfo; + // Verify Warden authorization (member required for update) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + const existing = await db.query.pluginTable.findFirst({ where: and( eq(pluginTable.id, params.id), @@ -350,6 +377,19 @@ const pluginRoutes = new Elysia({ prefix: "/plugins" }) const { organizationId } = authInfo; + // Verify Warden authorization (admin required for delete) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "admin", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + const existing = await db.query.pluginTable.findFirst({ where: and( eq(pluginTable.id, params.id), diff --git a/src/routes/subscriptions.ts b/src/routes/subscriptions.ts index c8f5ce2..9e4d490 100644 --- a/src/routes/subscriptions.ts +++ b/src/routes/subscriptions.ts @@ -9,6 +9,7 @@ import { } from "lib/db/schema"; import logger from "lib/logger"; import validateTargetUrl from "lib/validation/validateTargetUrl"; +import authorize from "lib/warden/authorize"; /** * Generate a cryptographically random HMAC secret. @@ -41,6 +42,19 @@ const subscriptionRoutes = new Elysia({ prefix: "/subscriptions" }) const { organizationId } = authInfo; + // Verify Warden authorization (member required for create) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + try { await validateTargetUrl(body.targetUrl); } catch (err) { @@ -361,6 +375,19 @@ const subscriptionRoutes = new Elysia({ prefix: "/subscriptions" }) const { organizationId } = authInfo; + // Verify Warden authorization (member required for update) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "member", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + // Verify subscription exists and belongs to org const [existing] = await db .select({ id: eventSubscriptionTable.id }) @@ -476,6 +503,19 @@ const subscriptionRoutes = new Elysia({ prefix: "/subscriptions" }) const { organizationId } = authInfo; + // Verify Warden authorization (admin required for delete) + if (authInfo.userId) { + const allowed = await authorize( + authInfo.userId, + "organization", + organizationId, + "admin", + ); + if (!allowed) { + return status(403, { error: "Forbidden: insufficient permissions" }); + } + } + const [deleted] = await db .delete(eventSubscriptionTable) .where( diff --git a/src/server.ts b/src/server.ts index 84f1e34..300b21a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -94,7 +94,9 @@ const app = new Elysia({ .use( cors({ origin: CORS_ALLOWED_ORIGINS!.split(","), + credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Request-Id"], }), ) // Rate limiting: 100 requests per minute per IP